如何将Python自然语言处理速度提高100倍?
Posted 刹客网络科技资讯
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了如何将Python自然语言处理速度提高100倍?相关的知识,希望对你有一定的参考价值。
关键时刻,第一时间送达!
科创公司Hugging Face机器学习专家Thomas Wolf去年曾带领团队推出一款Python共指解析工具包NeuralCoref,用神经网络解析句子中的共同指代词。
工具包发布以后,Thomas收到了来自技术社区的积极反馈,但也发现了一个大问题:工具包在处理对话信息是反应迅速,但处理文本较长的新闻文章时速度就变得非常缓慢。
最终,Thomas经过种种努力解决了这个问题,推出的NeuralCoref新版在保证准确率的同时,将处理速度提升了100倍!而且,工具包依然易于使用,也符合Python库的生态环境。
Thomas 随后将他解决这个问题的心得总结了出来,把如何将Python自然语言处理速度提高100倍的经验分享给大家,其中涉及:
怎样才能用Python设计出一个高效率模块
怎样利用好 spaCy 的内置数据结构,从而设计出超高效的自然语言处理函数
在本文,Thomas将讲解如何利用 Cython 和 spaCy 让 Python 在自然语言处理任务中的速度提高百倍。
开始前,我(作者Thomas Wolf——译者注)得承认文章略微有些标题党,因为虽然我们会讨论Python,但也会包含一些Cython技巧。不过,你知道吗?Cython就是Python的超集啊,所以不要被它吓跑!你当前所写的Python项目已经算是一种Cython项目了。
下面是一些你可能需要本文所说Python加速策略的情况:
你在用Python开发一款用于NLP任务的产品模块。
你在用Python计算一个大型NLP数据集的分析数据。
你在为PyTorch/TensorFlow这样的深度学习框架预处理大型训练数据集,或你的深度学习模型的批次加载器(batch loader)采用了非常复杂的处理逻辑,严重减缓了你的训练时间。
实现百倍加速第一步:分析代码
第一件你需要知道的事情就是,你的大部分代码在纯Python环境都能运行良好,但其中的一些性能瓶颈问题,只要你略表“关切”,就能让程序的速度加速几个量级。
因此,你应该着手分析你的Python代码,找到那些运行很慢的部分。解决这个问题的一种方法就是使用cProfile:
import cProfileimport pstatsimport my_slow_modulecProfile.run(my_slow_module.run(), restats)p = pstats.Stats(restats)p.sort_stats(cumulative).print_stats(30)
你会发现运行缓慢的部分基本就是一些循环,或者你用的神经网络里有太多的Numpy数组操作(这里就不再详细讨论Numpy的问题了,因为已经有很多这方面的分析资料)。
那么,我们该怎么加速这些循环?
借助一点Cython技巧,为Python中的循环提速
我们以一个简单的例子讲解一下。比方说我们有很多矩形,将它们保存为一列Python对象,比如Rectangle类的实例。我们模块的主要工作就是迭代该列表,计算有多少矩形的面积大于所设阙值。 我们的Python模块会非常简单,就像这样:
from random import randomclass Rectangle:def __init__(self, w, h):self.w = wself.h = hdef area(self):return self.w * self.hdef check_rectangles(rectangles, threshold):n_out = 0for rectangle in rectangles:if rectangle.area() > threshold:n_out += 1return n_outdef main():n_rectangles = 10000000rectangles = list(Rectangle(random(), random()) for i in range(n_rectangles))n_out = check_rectangles(rectangles, threshold=0.25)print(n_out)
这里的Check_rectangles函数就是我们要解决的瓶颈!它循环了大量的Python对象,这会变得非常慢,因为Python迭代器每次迭代时都要在背后做大量工作(查询类中的area方法,打包和解包参数,调取Python API···)。
这里我们可以借助Cython帮我们加快循环速度。
Cython语言是Python的超集,Python包含两种对象:
Python对象就是我们在常规Python中操作的对象,比如数字、字符串、列表、类实例···
Cython C对象是C或C++对象,比如双精度、整型、浮点数、结构和向量,Cython能以运行超快的低级代码编译它们。
这里的循环我们使用Cython循环就能获得更快的运行速度,而我们只需获取Cython C对象。
设计这种循环的一个直接方法就是定义C结构,它会包含我们计算中所需的全部东西:在我们这里所举的例子中,就是矩形的长和宽。
然后我们将矩形列表保存在所定义的C结构的数组中,我们会将数组传入check_rectangle函数中。该函数现在必需接受C数组作为输入,这样就会被定义为Cython函数,使用cdef关键字而非def(cdef也用于定义Cython C对象)。
这里是我们的Python模块的高速Cython版的样子:
from cymem.cymem cimport Poolfrom random import randomcdef struct Rectangle:float wfloat hcdef int check_rectangles(Rectangle* rectangles, int n_rectangles, float threshold):cdef int n_out = 0# C arrays contain no size information => we need to give it explicitlyfor rectangle in rectangles[:n_rectangles]:if rectangle[i].w * rectangle[i].h > threshold:n_out += 1return n_outdef main():cdef:int n_rectangles = 10000000float threshold = 0.25Pool mem = Pool()Rectangle* rectangles = <Rectangle*>mem.alloc(n_rectangles, sizeof(Rectangle))for i in range(n_rectangles):rectangles[i].w = random()rectangles[i].h = random()n_out = check_rectangles(rectangles, n_rectangles, threshold)print(n_out)
这里我们使用C指针的原生数组,但是你也可以选择其他选项,尤其是C++结构,比如向量、二元组、队列之类。在这里的脚本中,我还使用了cymem的很方面的Pool()内存管理对象,避免了必须手动释放所申请的C数组内存空间。当Python不再需要Pool时,它会自动释放我们用它申请时所占的内存。
我们试试代码! 我们有很多种方法可以测试、编辑和分发Cython代码!Cython甚至还能像Python一样直接在Jupyter Notebook中使用。
首先用pip install cython安装Cython。
首先在Jupyter中测试
在Jupyter notebook中用%load_ext Cython加载Cython扩展项。
现在我们就可以用神奇的命令%%cython像写Python代码一样编写Cython代码。
如果你在执行Cython代码块时出现了编译错误,一定要检查一下Jupyter终端输出,看看信息是否完整。
大多数时候你可能会编译成C++时,在 %%cython后面漏掉了 a-+ 标签(例如在你使用spaCy Cython API时),或者如果编译器出现关于Numpy的报错,你可能是遗漏了import Numpy。
编写、使用和分发Cython代码
Cython代码编写为.pyx文件。这些文件被Cython编译器编译为C或C++文件,然后进一步由系统的C编译器编译为字节码文件。接着,字节码文件就能被Python解释器使用了。
你可以在Python里直接用pyximport加载.pyx文件:
importpyximport; pyximport.install()
import my_cython_module
你也可以将自己的Cython代码创建为Python包,将其作为正常Python包导入或分发。这部分工作或花费一点时间。如果你需要一个工作示例,spaCy的安装脚本是比较详细的例子。
在我们讲NLP之前,先快速说说def,cdef和cpdef关键字,因为它们是你着手使用Cython需要理解的主要知识点。
你可以在Cython中使用3种类型的函数:
Python函数是用常见关键字def定义的。它的输入和输出均为Python对象。在函数内部既可以使用Python对象,也能使用C/C++对象,同样能调用Python和Cython函数。
Cython函数是以关键字cdef定义的。可以将Python和C/C++对象作为输入和输出,也能在内部操作它们。Cython函数不能从Python环境中直接访问(Python解释器和其它纯Python模块会导入你的Cython模块),但能被其它Cython模块导入。
Cython 函数用cpdef关键字定义时和cdef定义的函数一样,但它们带有Python包装器,因此从Python环境(Python对象为输入和输出)和其它Cython模块(C/C ++或Python对象为输入)中都能调用它们。
Cdef关键字还有另一个用途,即在代码中输入Cython C/C ++。如果你没有用该关键字输入你的对象,它们会被当成Python对象(这样就会延缓访问速度)。
使用Cython和spaCy加快解决NLP问题的速度
现在一切进行的很好也很快,但是···我们还没涉及自然语言处理任务呢!没有字符串操作,没有Unicode编码,也没有我们在自然语言处理中能够使用的妙计。
总的来说,除非你很清楚自己所做的任务,不然就不要使用C类型字符串,而是使用Python字符串对象。
所以,我们操作字符串时,该怎样设计Cython中的快速循环呢?
spaCy是我们的“护身符”。spaCy解决这个问题的方式非常智能。
将所有字符串转换为64位哈希码
在spaCy中,所有的Unicode字符串(token的文本,它的小写形式文本,POS 标记标签、解析树依赖标签、命名实体标签等等)都被存储在一个叫StringStore的单数据结构中,可以被64位哈希码索引,也就是C类型unit64_t 。
StringStore对象实现了Python unicode 字符串与 64 位哈希码之间的查找映射。
它可以从 spaCy 的任何地方和任意对象进行访问(如下图所示),比如 npl.vocab.strings、doc.vocab.strings 或者 span.doc.vocab.string。
当某个模块需要在某些tokens上获得更快的处理速度时,就可以使用 C 语言类型的 64 位哈希码代替字符串来实现。调用 StringStore 查找表将返回与该哈希码相关联的 Python unicode 字符串。
但是spaCy的作用不止如此,它还能让我们获取文档和词汇表的完全填充的C语言类型结构,我们可以在Cython循环中用到这一点,而不必创建我们自己的结构。
spaCy的内部数据结构 和spaCy相关的主要数据结构是Doc对象,它有被处理的字符串的token序列,它在C语言类型对象中的所有注释都被称为doc.c,是为TokenC结构的数组。
TokenC结构包含了我们关于每个token所需的全部信息。该信息以64位哈希码的形式保存,能够与我们刚刚看到的Unicode字符串重新关联。
如果想看看这些C类型结构中到底有什么,只需查看新建的spaCy的Cython API doc即可。
我们接下来看一个简单的自然语言处理的例子。
使用spaCy和Cython快速执行自然语言处理任务 假设我们有一个文本文档数据集需要分析。
下面是我写的一段脚本,创建一个列表,包含10个由spaCy解析的文档,每个文档包含大约17万个词汇。我们也可以解析17万份文档,每份文档包含10个词汇(就像对话框数据集),但这种创建方式要慢的多,所以我们还是采取10份文档的形式。
import urllib.requestimport spacywith urllib.request.urlopen(https://raw.githubusercontent.com/pytorch/examples/master/word_language_model/data/wikitext-2/valid.txt) as response:text = response.read()nlp = spacy.load(en)doc_list = list(nlp(text[:800000].decode(utf8)) for i in range(10))
我们想用这个数据集执行一些自然语言处理任务。例如,我们想计算词汇“run”在数据集中用作名词的次数(比如,被 spaCy 标记为「NN」词性标签)。
使用Python 循环实现上述分析的过程非常简单直接:
def slow_loop(doc_list, word, tag):n_out = 0for doc in doc_list:for tok in doc:if tok.lower_ == word and tok.tag_ == tag:n_out += 1return n_outdef main_nlp_slow(doc_list):n_out = slow_loop(doc_list, run, NN)print(n_out)
但是它运行的非常慢!在我的笔记本上,这点代码花了1.4秒才得到结果。如果我们有数百万份文档,就需要花费一天多的时间才能得到答案。
我们可以使用多线程处理,但在Python中这通常也不是个很好的解决方法,因为你必须处理GIL问题(GIL即global interpreter lock,全局解释器锁)。而且,Cython也能使用多线程!实际上,这可能是Cython中最棒的部分,因为Cython基本上能在后台直接调用OpenMP。这里不再详细讨论并行性的问题,可以点击这里查看更多信息。
接下来,我们用spaCy和Cython加快我们的Python代码的运行速度。
首先,我们必须考虑好数据结构。我们需要为数据集获取一个C类型数组,并有指针指向每个文档的TokenC数组。我们还需要将所用的测试字符串(“run”和“NN”)转换为64位哈希码。
如果我们处理过程中所需的全部数据都是C类型对象,然后我们可以以纯C语言的速度迭代整个数据集。
下面是可以用Cython和spaCy实现的示例:
%%cython -+import numpy # Sometime we have a fail to import numpy compilation error if we dont import numpyfrom cymem.cymem cimport Poolfrom spacy.tokens.doc cimport Docfrom spacy.typedefs cimport hash_tfrom spacy.structs cimport TokenCcdef struct DocElement:TokenC* cint lengthcdef int fast_loop(DocElement* docs, int n_docs, hash_t word, hash_t tag):cdef int n_out = 0for doc in docs[:n_docs]:for c in doc.c[:doc.length]:if c.lex.lower == word and c.tag == tag:n_out += 1return n_outdef main_nlp_fast(doc_list):cdef int i, n_out, n_docs = len(doc_list)cdef Pool mem = Pool()cdef DocElement* docs = <DocElement*>mem.alloc(n_docs, sizeof(DocElement))cdef Doc docfor i, doc in enumerate(doc_list): # Populate our database structuredocs[i].c = doc.cdocs[i].length = (<Doc>doc).lengthword_hash = doc.vocab.strings.add(run)tag_hash = doc.vocab.strings.add(NN)n_out = fast_loop(docs, n_docs, word_hash, tag_hash)print(n_out)
代码有点长,因为我们必须在调用Cython函数[*]之前在main_nlp_fast之中声明和填充C结构。
但是代码的运行速度快了很多!在我的Jupyter notebook中,这段Cython代码运行速度大概只有20微秒,相比我们此前的完全由Python编写的循环,运行速度快了80倍。
使用Jupyter Notebook编写模块的速度同样令人瞩目,它可以和其它Python模块和函数自然地连接:20微秒内可处理多达170万个词汇,也就是说我们每秒处理的词汇数量高达8000万!
以上就是我们团队如何用Cython处理NLP任务的快速介绍,希望你能喜欢。
结语
关于Cython,还有很多需要学习的知识,如果你在你的代码中数次使用低级结构,相比每次填充C类型结构,更好的选择是围绕低级结构设计我们的Python代码,使用Cython扩展类型包装C类型结构。这也是大部分spaCy的构建方式,不仅运行速度快,内存消耗小,而且还能让我们很容易的连接外部Python库和函数。
以上是关于如何将Python自然语言处理速度提高100倍?的主要内容,如果未能解决你的问题,请参考以下文章