集智专栏
资源加载中,请稍后...
集智专栏

如何将Python自然语言处理速度提高100倍?

集智小编

参考资料:medium.com


科创公司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 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实现的示例:

%%cython -+
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库和函数。

附本文全部代码


限时折扣中:0806期《人工智能-从零开始到精通》(前25位报名同学可领取¥200优惠券)

您也许喜欢这些文章

集智专栏

数据安全不可不防!三分钟了解何谓SQL注入

发表至趣味项目
SQL注入是一个非常著名的数据库攻击手段,时至今日,不少知名网站或框架依然存在不同层次的数据库注入漏洞与风险。本文将简单介绍SQL注入的原理和实现方法,同时提供几种简单的防护措施。
集智专栏

[Python入门] 01 基本法则

发表至系列教程
Python入门教程系列的第一篇,从最基本的概念与法则开始,面向从未接触过Python甚至没有任何编程基础的读者。 Python是数据科学界最主流的编程语言,相对于传统的Excel等数据处理工具,Python具备处理海量数据的能力,并且可以执行机器学习算法。从数据获取(网络爬虫等工具)、数据清理到数据分析拟合再到最终的可视化呈现,Python都能胜任。
集智专栏

Python的seaborn.kdeplot有什么用

发表至数据科学
原问题出自知乎(https://www.zhihu.com/question/43415333) 本文介绍了核密度估计(kernel density estimation, kde)的统计学意义,用seaborn实现并横向比较不同核函数的表现。

文章评论(0)