编辑推荐: |
本文来自于outofmemory,文章主要讲解如何利用
Cython 和 spaCy 让 Python 在自然语言处理任务中的速度提高百倍。 |
|
开始前,我(作者)得承认文章略微有些标题党,因为虽然我们会讨论Python,但也会包含一些Cython技巧。不过,你知道吗?Cython就是Python的超集啊,所以不要被它吓跑!
你当前所写的Python项目已经算是一种Cython项目了 。
下面是一些你可能需要本文所说Python加速策略的情况:
你在用Python开发一款用于NLP任务的产品模块。
你在用Python计算一个大型NLP数据集的分析数据。
你在为PyTorch/TensorFlow这样的深度学习框架预处理大型训练数据集,或你的深度学习模型的批次加载器(batch
loader)采用了非常复杂的处理逻辑,严重减缓了你的训练时间。
实现百倍加速第一步:分析代码
第一件你需要知道的事情就是,你的大部分代码在纯Python环境都能运行良好,但其中的一些性能瓶颈问题,只要你略表“关切”,就能让程序的速度加速几个量级。
因此,你应该着手分析你的Python代码,找到那些运行很慢的部分。解决这个问题的一种方法就是使用cProfile:
import cProfile
import pstats
import my_slow_module
cProfile.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 random
class Rectangle:
def __init__(self, w, h):
self.w = w
self.h = h
def area(self):
return self.w * self.h
def check_rectangles(rectangles, threshold):
n_out = 0
for rectangle in rectangles:
if rectangle.area() > threshold:
n_out += 1
return n_out
def main():
n_rectangles = 10000000
rectangles = 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 Pool
from random import random
cdef struct Rectangle:
float w
float h
cdef 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 explicitly
for rectangle in rectangles[:n_rectangles]:
if rectangle[i].w * rectangle[i].h > threshold:
n_out += 1
return n_out
def main():
cdef:
int n_rectangles = 10000000
float threshold = 0.25
Pool 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文件:
>>>
import pyximport; 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.request
import spacy
with 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 = 0
for doc in doc_list:
for tok in doc:
if tok.lower_ == word and tok.tag_ == tag:
n_out += 1
return n_out
def 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实现的示例:
import numpy
# Sometime we have a fail to import numpy compilation
error if we don't import numpy
from cymem.cymem cimport Pool
from spacy.tokens.doc cimport Doc
from spacy.typedefs cimport hash_t
from spacy.structs cimport TokenC
cdef struct DocElement:
TokenC* c
int length
cdef int fast_loop(DocElement* docs, int n_docs,
hash_t word, hash_t tag):
cdef int n_out = 0
for doc in docs[:n_docs]:
for c in doc.c[:doc.length]:
if c.lex.lower == word and c.tag == tag:
n_out += 1
return n_out
def 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 doc
for i, doc in enumerate(doc_list): # Populate
our database structure
docs[i].c = doc.c
docs[i].length = (<Doc>doc).length
word_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,还有很多需要学习的知识,可以查看 Cython官方教程 获得大致的了解,以及spaCy上
用于处理NLP任务的Cython内容 。
如果你在你的代码中数次使用低级结构,相比每次填充C类型结构,更好的选择是围绕低级结构设计我们的Python代码,使用Cython扩展类型包装C类型结构。这也是大部分spaCy的构建方式,不仅运行速度快,内存消耗小,而且还能让我们很容易的连接外部Python库和函数。
附本文全部代码
|