07.09 快 100 倍,Python 為自然語言處理加速度!

利用 spaCy 和一點點 Cython 給 NLP 加速。

自去年發佈 Python 的指代消解包(coreference resolution package)之後,很多用戶開始用它來構建許多應用程序,而這些應用與我們最初的對話應用完全不同。

我們發現,儘管在處理對話時這個包的速度完全沒問題,但在處理較大的問題時卻非常慢。

我決定調查一下這個問題,於是就產生了 NeuralCoref v3.0(https://github.com/huggingface/neuralcoref/)這一項目,它比上一個版本快 100 倍(每秒能分析幾千個單詞),同時保持準確度、易用性,並且依然在 Python 庫的生態系統中。

在本文中我想分享一些在這個項目中學習到的經驗,具體來說包括:

怎樣用 Python 設計高速的模塊;怎樣利用 spaCy 的內部數據結構來有效地設計高速的 NLP 函數。

所以其實這裡有點耍花招,雖然我們是在討論 Python,但還要用一些 Cython的魔法。但別忘了,Cython 是 Python 的超集(http://cython.org/),所以別被它嚇住了!

你現在的 Python 程序已經是 Cython 程序了。

幾種情況下你可能會需要這種加速,例如:

用 Python 為生產環境開發 NLP 模塊;用 Python 在大型 NLP 數據集上計算分析結果;為 pyTorch 或 TensorFlow 等深度學習框架預處理一個大型數據集,或者在深度學習的批次加載器中有個很複雜的處理邏輯使得訓練變慢。

在我們開始前要說的最後一件事:這篇文章裡的例子我都放在了Jupyter Notebook(https://github.com/huggingface/100-times-faster-nlp)上。試試看吧!

加速的第一步:性能分析

首先要明確一點,絕大部分純 Python 的代碼是沒有問題的,但有幾個瓶頸函數如果能夠解決,就能給速度帶來數量級上的提升。

因此首先應該用分析工具分析 Python 代碼,找出哪裡慢。一個辦法是使用cProfile(https://docs.python.org/3/library/profile.html):

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,已經有很多文章討論這個問題了:http://cython.readthedocs.io/en/latest/src/userguide/numpy_tutorial.html)。

那麼,應該如何加快循環的速度?

利用 Cython 實現更快的循環

用個簡單的例子來說明。假設我們一個巨大的集合裡包含許多長方形,保存為 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 中操作的對象,如數字、字符串、列表、類實例等。Cython C 對象是 C 或 C++ 對象,如 dobule、int、float、struct、vectors,這些可以被 Cython 編譯成超級快的底層代碼。

高速循環就是 Cython 程序中只訪問 Cython C 對象的循環。

設計這種高速循環最直接的辦法就是,定義一個 C 結構,它包含計算過程需要的一切。在這個例子中,該結構需要包含長方形的長和寬。

然後我們就可以將長方形列表保存在一個 C 數組中,傳遞給 check_rectangle 函數。現在該函數就需要接收一個 C 數組作為輸入,因此它應該用 cdef 關鍵字(而不是 def)定義為 Cython 函數。(注意 cdef 也被用來定義 Cython C 對象。)

下面是 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))/<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 指針數組,不過你也可以用別的方式,如 vectors、pairs、queues 等 C++ 結構(http://cython.readthedocs.io/en/latest/src/userguide/wrapping_CPlusPlus.html#standard-library)。在這段代碼中,我還使用了cymem(https://github.com/explosion/cymem)提供的方便的 Pool() 內存管理對象,這樣就不用手動釋放 C 數組了。在 Python 對 Pool 進行垃圾回收時,就會自動釋放所有通過 Pool 分配的內存。

關於在 NLP 中使用 Cython 的指南請參考 spaCy API 的 Cython Conventions:https://spacy.io/api/cython#conventions。

試一下這段代碼

有許多方法可以測試、編譯併發布 Cython 代碼!Cython 甚至可以像 Python 一樣直接用在 Jupyter Notebook 中(http://cython.readthedocs.io/en/latest/src/reference/compilation.html#compiling-notebook)。

首先用 pip install cython 安裝 Cython:

在 Jupyter 中測試

在 Jupyter notebook 中通過 %load_ext Cython 加載 Cython 擴展。

現在,只需使用魔術命令(http://cython.readthedocs.io/en/latest/src/reference/compilation.html#compiling-with-a-jupyter-notebook)%%cython 就可以像寫 Python 代碼一樣寫 Cython 代碼了。

如果在執行 Cython 單元的時候遇到編譯錯誤,可以在 Jupyter 的終端輸出上看到完整的錯誤信息。

一些常見的錯誤:如果要編譯成 C++(比如使用 spaCy Cython API),需要在 %%cython 後面加入 -+ 標記;如果編譯器抱怨 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包導入或發佈(詳細說明在此:http://cython.readthedocs.io/en/latest/src/tutorial/cython_tutorial.html#)。這項工作比較花費時間,主要是要處理所有平臺上的兼容性問題。如果需要示例的話,spaCy 的安裝腳本(https://github.com/explosion/spaCy/blob/master/setup.py)就是個很好的例子。

在進入 NLP 之前,我們先快速討論下 def、cdef 和 cpdef 關鍵字,這些是學習 Cython 時最關鍵的概念。

Cython 程序中包含三種函數:

Python 函數,由關鍵字 def 定義。它的輸入和輸出都是 Python 對象。內部可以使用 Python 對象,也可以使用 C/C++ 對象,也可以調用 Cython 函數和 Python 函數。Cython 函數,用 cdef 關鍵字定義。Python 對象和 Cython 對象都可以作為它的輸入、輸出和內部對象使用。這些函數無法在 Python 空間(即 Python 解釋器,和其他需要導入 Cython 模塊的純 Python 模塊)中直接訪問,但可以被其他 Cython 模塊導入。用 cpdef 定義的 Cython 函數,類似於用 cdef 定義的 Cython 函數,但它們還提供了 Python 封裝,因此可以直接在 Python 空間中調用(用 Python 對象作為輸入和輸出),也可以在其他 Cython 模塊中調用(用 C/C++ 或 Python 對象作為輸入)。

cdef 關鍵字還有個用法,就是在代碼中給 Cython C/C++ 對象定義類型。沒有用 cdef 定義類型的對象會被當做 Python 對象處理(因此會降低訪問速度)。

通過 spaCy 使用 Cython 加速 NLP

前面說的這些都很好……但這跟 NLP 還沒關係呢!沒有字符串操作,沒有 Unicode 編碼,自然語言處理中的難點都沒有支持啊!

而且 Cython 的官方文檔甚至還反對使用 C 語言級別的字符串(http://cython.readthedocs.io/en/latest/src/tutorial/strings.html):

一般來說,除非你知道你在做什麼,否則儘量不要使用 C 字符串,而應該使用 Python 字符串對象。

那在處理字符串時怎樣才能設計高速的 Cython 循環?

這就輪到 spaCy 出場了。

spaCy 解決這個問題的辦法特別聰明。

將所有字符串轉換成 64 比特 hash

在 spaCy 中,所有 Unicode 字符串(token 的文本,token 的小寫形式,lemma 形式,詞性標註,依存關係樹的標籤,命名實體標籤……)都保存在名為 StringStore 的單一數據結構中,字符串的索引是 64 比特 hash,也就是 C 語言層次上的 unit64_t。

StringStore 對象實現了在 Python unicode 字符串和 64 比特 hash 之間的查找操作。

StringStore 可以從 spaCy 中的任何地方、任何對象中訪問,例如可以通過 nlp.vocab.string、doc.vocab.strings 或 span.doc.vocab.string 等。

當模塊需要在某些 token 上進行快速處理時,它只會使用 C 語言層次上的 64 比特 hash,而不是使用原始字符串。調用 StringStore 的查找表就會返回與該 hash 關聯的 Python unicode 字符串。

但是 spaCy 還做了更多的事情,我們可以通過它訪問完整的 C 語言層次上的文檔和詞彙表結構,因此可以使用 Cython 循環,不需要再自己構建數據結構。

spaCy 的內部數據結構

spaCy 文檔的主要數據結構是 Doc 對象,它擁有被處理字符串的 token 序列(稱為 words)及所有註解(annotation),這些被保存在一個 C 語言對象 doc.c 中,該對象是個 TokenC 結構的數組。

TokenC(https://github.com/explosion/spaCy/blob/master/spacy/structs.pxd)結構包含關於 token 的所有必要信息。這些信息都保存為 64 比特 hash 的形式,可以通過上面的方法重新構成 unicode 字符串。

看看 spaCy 的 Cython API 文檔,就知道這些 C 結構的好處在哪裡了。

我們通過一個簡單例子看看它在 NLP 處理中的實際應用。

通過 spaCy 和 Cython 進行快速 NPL 處理

假設我們有個文本文檔的數據集需要分析。

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))

上面的腳本建立了一個由10個spaCy解析過的文檔組成的列表,每個文檔大約有17萬個詞。也可以使用17萬個文檔,每個文檔有10個詞(比如對話的數據集),但那樣創建速度就會慢很多,所以還是繼續使用10個文檔好了。

我們要在這個數據集上做一些NLP的處理。比如,我們需要計算“run”這個詞在數據集中作為名詞出現的次數(即被spaCy的詞性分析(Part-Of-Speech)標記為“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秒才能得到結果。如果有100萬個文檔,那就要超過一天的時間。

我們可以使用多任務處理,但在Python中通常並不是個好主意(https://youtu.be/yJR3qCUB27I?t=19m29s),因為你得處理GIL(全局解釋器鎖,https://wiki.python.org/moin/GlobalInterpreterLock)!而且,別忘了Cython也支持多線程(https://cython.readthedocs.io/en/latest/src/userguide/parallelism.html)!而且實際上多線程才是Cython最精彩的部分,因為GIL鎖已經被釋放,代碼可以全速運行了。基本上,Cython會直接調用OpenMP。這裡不會介紹並行,更多的細節可以參考這裡(https://cython.readthedocs.io/en/latest/src/userguide/parallelism.html)。

現在試著用 spaCy 和一點 Cython 加速 Python 代碼吧。

首先需要考慮下數據結構。我們需要個C層次的數組來保存數據集,其中的指針指向每個文檔的TokenC數組。還需要將測字符串(“run"和“NN”)轉換成64比特hash。

下面是用spaCy編寫的Cython代碼:

%%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))/<docelement>

cdef Doc doc

for i, doc in enumerate(doc_list): # Populate our database structure

docs[i].c = doc.c

docs[i].length = (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結構。(注:如果在代碼中多次使用低級結構,就不要每次填充C結構,而是設計一段Python代碼,利用Cython擴展類型(http://cython.readthedocs.io/en/latest/src/userguide/extension_types.html)來封裝C語言的低級結構。spaCy的絕大部分數據結構都是這麼做的,能優雅地結合速度、低內存佔用,以及與外部Python庫和函數的接口的簡單性。)

但它也快得多!在我的Jupyter notebook上,這段Cython代碼只需要大約20毫秒,比純Python循環快大約80倍。

要知道它只是Jupyter notebook單元中的一個模塊,還能給其他Python模塊和函數提供原生的接口,考慮到這一點,它的絕對速度也相當出色:20毫秒內掃描1700萬詞,意味著每秒能掃描八千萬詞。

這就是在 NLP 中使用 Cython 的方法,希望你能喜歡。

相關資料

Cython入門教程:http://cython.readthedocs.io/en/latest/src/tutorial/index.htmlspaCy 的 Cython 頁面:https://spacy.io/api/cython

英文:100 Times Faster Natural Language Processing in Python

鏈接:https://medium.com/huggingface/100-times-faster-natural-language-processing-in-python-ee32033bdced

作者:Thomas Wolf,Huggingface的機器學習,自然語言處理和深度學習科學負責人。

“徵稿啦”

CSDN 公眾號秉持著「與千萬技術人共成長」理念,不僅以「極客頭條」、「暢言」欄目在第一時間以技術人的獨特視角描述技術人關心的行業焦點事件,更有「技術頭條」專欄,深度解讀行業內的熱門技術與場景應用,讓所有的開發者緊跟技術潮流,保持警醒的技術嗅覺,對行業趨勢、技術有更為全面的認知。

如果你有優質的文章,或是行業熱點事件、技術趨勢的真知灼見,或是深度的應用實踐、場景方案等的新見解,歡迎聯繫 CSDN 投稿,聯繫方式:微信(guorui_1118,請備註投稿+姓名+公司職位),郵箱(guorui@csdn.net)。