選自Medium,做者:Thomas Wolf,機器之心編譯。html
Cython 是一個工具包,可使你在 Python 中編譯 C 語言,這就是爲何 numpy 和 pandas 很快的緣由,Cython 就是 Python 的超集。在本文中,做者將爲咱們介紹他的 GitHub 項目 NeuralCoref v3.0,詳解如何利用 spaCy 和 Cython 以約 100 倍於 Python 的速度實現 NLP 項目。
相關 Jupyter Notebook 地址:github.com/huggingface…python
去年咱們發佈 Python 包 coreference resolution package 後,咱們收到了來自社區的精彩反饋,而且人們開始在不少應用中使用它,其中一些與咱們原來的對話用例迥異。git
咱們發現,儘管對話信息的處理速度很是好,但對於長的新聞文章來講,處理速度可能會很是慢。github
我決定詳細研究這一問題,最終成果即 NeuralCoref v3.0,它在相同準確率的狀況下比老版本快 100 倍左右(每秒幾千字),同時兼顧 Python 庫的易用性和兼容性。api
NeuralCoref v3.0 :github.com/huggingface…數組
我想在這篇文章中分享一些關於這個項目的經驗,特別是:bash
因此我在這裏有點做弊,由於咱們會談論 Python,但也談論一些 Cython 的神奇做用。可是,你知道嗎?Cython 是 Python 的超集,因此不要讓它嚇跑你!網絡
你如今的 Python 程序已是 Cython 程序。數據結構
有幾種狀況下你可能須要加速,例如:多線程
再強調一遍:我同步發佈了一個 Jupyter Notebook,其中包含我在本文中討論的例子。試試看!
Jupyter Notebook:github.com/huggingface…
加速第一步:剖析
首先要知道的是,你的大多數代碼在純 Python 環境中可能運行的不錯,可是若是你多用點心,其中一些瓶頸函數可能讓你的代碼快上幾個數量級。
所以,你首先應該分析你的 Python 代碼並找出瓶頸部分的位置。使用以下的 cProfile 是一種選擇:
import cProfile
import pstats
import myslowmodule
cProfile.run('myslowmodule.run()', 'restats')
p = pstats.Stats('restats')
p.sortstats('cumulative').printstats(30)
複製代碼
若是你使用神經網絡,你可能會發現瓶頸部分是幾個循環,而且涉及 Numpy 數組操做。
那麼,咱們如何加速這些循環代碼?
在 Python 中使用一些 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 解釋器在每次迭代時都會作大量工做(尋找類中的求面積方法、打包和解包參數、調用 Python API ...)。
Cython 將幫助咱們加速循環。
Cython 語言是 Python 的超集,它包含兩種對象:
快速循環只是 Cython 程序(只能訪問 Cython C 對象)中的一個循環。
設計這樣一個循環的直接方法是定義 C 結構,它將包含咱們在計算過程當中須要的全部要素:在咱們的例子中,就是矩形的長度和寬度。
而後,咱們能夠將矩形列表存儲在這種結構的 C 數組中,並將這個數組傳遞給咱們的 check_rectangle 函數。此函數如今接受一個 C 數組做爲輸入,所以經過 cdef 關鍵字而不是 def 將其定義爲 Cython 函數(請注意,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 rectangles[i].w * rectangles[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 數組。當 Pool 由 Python 當作垃圾回收時,它會自動釋放咱們使用它分配的內存。
spaCy API 的 Cython Conventions 是 Cython 在 NLP 中的實際運用的一個很好的參考。
spaCy:spacy.io
Cython Conventions:spacy.io/api/cython#…
讓咱們試試這個代碼吧!
有不少方法能夠測試、編譯和發佈 Cython 代碼!Cython 甚至能夠直接用在 Python 這樣的 Jupyter Notebook 中。
Jupyter Notebook:cython.readthedocs.io/en/latest/s…
首先使用 pip install cython 安裝 Cython
在 Jupyter 的第一次測試
使用 %load_ext Cython 將 Cython 插件加載到 Jupyter notebook 中。
如今,你可使用黑魔術命令 %% cython 編寫像 Python 代碼同樣的 Cython 代碼。
若是在執行 Cython 單元時遇到編譯錯誤,請務必檢查 Jupyter 終端輸出以查看完整的信息。
大多數狀況下,在 %% cython 編譯爲 C ++(例如,若是你使用 spaCy Cython API)或者 import numpy(若是編譯器不支持 NumPy)以後,你會丟失 - + 標記。
正如我在開始時提到的,查看這篇文章的同步 Jupyter Notebook,該 Notebook 包含本文討論的全部示例。
編寫、使用和發佈 Cython 代碼
Cython 代碼寫在 .pyx 文件中。這些文件由 Cython 編譯器編譯爲 C 或 C ++ 文件,而後經過系統的 C 編譯器編譯爲字節碼文件。Python 解釋器可使用字節碼文件。
你可使用 pyximport 直接在 Python 中加載 .pyx 文件:
>>> import pyximport; pyximport.install()
>>> import my_cython_module
複製代碼
你還能夠將你的 Cython 代碼構建爲 Python 包,並將其做爲常規 Python 包導入/發佈,詳見下方地址。這可能須要一些時間才能開始工做,尤爲在全平臺上。若是你須要一個有效示例,spaCy』s install script 是一個至關全面的例子。
導入教程:cython.readthedocs.io/en/latest/s…
Before we move to some NLP, let』s quickly talk about the def, cdef and cpdef keywords, because they are the main things you need to grab to start using Cython.
在咱們轉向 NLP 以前,讓咱們先快速討論一下 def、cdef 和 cpdef 關鍵字,由於它們是你開始使用 Cython 須要掌握的主要內容。
你能夠在 Cython 程序中使用三種類型的函數:
cdef 關鍵字有另外一種用途,即在代碼中定義 Cython C / C ++ 對象。除非用這個關鍵字定義對象,不然它們將被視爲 Python 對象(所以訪問速度很慢)。
使用 Cython 與 spaCy 來加速 NLP
這些東西又好又快,可是...... 咱們如今尚未融入 NLP!沒有字符串操做、沒有 unicode 編碼,也沒有咱們在天然語言處理中幸運擁有的微妙聯繫。
官方的 Cython 文檔甚至建議不要使用 C 字符串:
通常來講:除非你知道本身在作什麼,不然應儘量避免使用 C 字符串,而應使用 Python 字符串對象。
那麼咱們如何在使用字符串時在 Cython 中設計快速循環?
spaCy 會幫咱們的。
spaCy 解決這個問題的方式很是聰明。
將全部字符串轉換爲 64 位哈希碼
spaCy 中的全部 unicode 字符串(token 的文本、其小寫文本、引理形式、POS 鍵標籤、解析樹依賴關係標籤、命名實體標籤...)都存儲在叫 StringStore 的單數據結構中,它們在裏面由 64 位散列索引,即 C uint64_t。
StringStore 對象實現了 Python unicode 字符串和 64 位哈希碼之間的查找表。
它能夠經過 spaCy 任意處及任意對象訪問(請參閱上圖),例如 nlp.vocab.strings、doc.vocab.strings 或 span.doc.vocab.string。
當某個模塊須要對某些 token 執行快速處理時,僅使用 C 級別的 64 位哈希碼而不是字符串。調用 StringStore 查找表將返回與哈希碼相關聯的 Python unicode 字符串。
可是,spaCy 作的遠不止這些,它使咱們可以訪問文檔和詞彙表的徹底覆蓋的 C 結構,咱們能夠在 Cython 循環中使用這些結構,而沒必要自定義結構。
spaCy 的內部數據結構
與 spaCy Doc 對象關聯的主要數據結構是 Doc 對象,該對象擁有已處理字符串的 token 序列(「單詞」)以及 C 對象中的全部稱爲 doc.c 的標註,它是一個 TokenC 結構數組。
TokenC 結構包含咱們須要的關於每一個 token 的全部信息。這些信息以 64 位哈希碼的形式存儲,能夠從新關聯到 unicode 字符串,就像咱們剛剛看到的那樣。
要深刻了解這些 C 結構中的內容,只需查看剛建立的 SpaCy 的 Cython API doc。
咱們來看看一個簡單的 NLP 處理示例。
使用 spaCy 和 Cython 進行快速 NLP 處理
假設咱們有一個須要分析的文本數據集
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))
複製代碼
我在左邊寫了一個腳本,它生成用於 spaCy 解析的 10 份文檔的列表,每一個文檔大約 170k 字。咱們也能夠生成每一個文檔 10 個單詞的 170k 份文檔(好比對話數據集),但建立速度較慢,所以咱們堅持使用 10 份文檔。
咱們想要在這個數據集上執行一些 NLP 任務。例如,咱們想要統計數據集中單詞「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。另外,請注意,Cython 也可使用多線程!並且這實際上多是 Cython 最棒的部分,由於 GIL 被釋放,咱們能夠全速運行。Cython 基本上直接調用 OpenMP。
如今咱們嘗試使用 spaCy 和部分 Cython 加速咱們的 Python 代碼。
首先,咱們必須考慮數據結構。咱們將須要一個 C 數組用於數據集,指針指向每一個文檔的 TokenC 數組。咱們還須要將咱們使用的測試字符串(「run」和「NN」)轉換爲 64 位哈希碼。
當咱們所需的數據都在 C 對象中時,咱們能夠在數據集上以 C 的速度進行迭代。
下面是如何使用 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))
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 結構。(若是你在代碼中屢次使用低級結構,使用 C 結構包裝的 Cython 擴展類型來設計咱們的 Python 代碼是比每次填充 C 結構更優雅的選擇。這就是大多數 spaCy 的結構,它是一種結合了快速,低內存以及與外部 Python 庫和函數接口的簡便性的很是優雅的方法。)
但它也快不少!在個人 Jupyter Notebook 中,這個 Cython 代碼的運行時間大約爲 20 毫秒,比咱們的純 Python 循環快大約 80 倍。
Jupyter Notebook cell 中編寫的模塊的絕對速度一樣使人印象深入,而且能夠爲其餘 Python 模塊和函數提供本地接口:在 30ms 內掃描約 1,700 萬字意味着咱們每秒處理高達 8000 萬字。
咱們這就結束了使用 Cython 進行 NLP 的快速介紹。我但願你喜歡它。
Cython 還有不少其餘的東西可講,但這會讓咱們遠離主題。從如今開始,最好的地方可能就是 Cython tutorials 的概述和適用於 NLP 的 spaCy’s Cython page。