選自Medium,作者:Thomas Wolf,機器之心編譯。
Cython 是一個工具包,可以使你在 Python 中編譯 C 語言,這就是為什麼 numpy 和 pandas 很快的原因,Cython 就是 Python 的超集。在本文中,作者將為我們介紹他的 GitHub 專案 NeuralCoref v3.0,詳解如何利用 spaCy 和 Cython 以約 100 倍於 Python 的速度實現 NLP 專案。
相關 Jupyter Notebook 地址:github.com/huggingface…
去年我們釋出 Python 包 coreference resolution package 後,我們收到了來自社群的精彩反饋,並且人們開始在很多應用中使用它,其中一些與我們原來的對話用例迥異。
我們發現,儘管對話資訊的處理速度非常好,但對於長的新聞文章來說,處理速度可能會非常慢。
我決定詳細研究這一問題,最終成果即 NeuralCoref v3.0,它在相同準確率的情況下比老版本快 100 倍左右(每秒幾千字),同時兼顧 Python 庫的易用性和相容性。
NeuralCoref v3.0 :github.com/huggingface…
我想在這篇文章中分享一些關於這個專案的經驗,特別是:
- 如何用 Python 設計一個高速模組;
- 如何利用 spaCy 的內部資料結構來有效地設計超高速 NLP 函式。
所以我在這裡有點作弊,因為我們會談論 Python,但也談論一些 Cython 的神奇作用。但是,你知道嗎?Cython 是 Python 的超集,所以不要讓它嚇跑你!
你現在的 Python 程式已經是 Cython 程式。
有幾種情況下你可能需要加速,例如:
- 你正在使用 Python 開發一個 NLP 的生產模組;
- 你正在使用 Python 計算分析大型 NLP 資料集;
- 你正在為深度學習框架,如 PyTorch / TensorFlow,預處理大型訓練集,或者你的深度學習批處理載入器中的處理邏輯過於繁重,這會降低訓練速度。
再強調一遍:我同步釋出了一個 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 的超集,它包含兩種物件:
- Python 物件是我們在常規 Python 中操作的物件,如數字、字串、列表、類例項...
- Cython C 物件是 C 或 C ++ 物件,比如 double、int、float、struct、vectors。這些可以由 Cython 在超快速的底層程式碼中編譯。
快速迴圈只是 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 程式中使用三種型別的函式:
- Python 函式,用常用的關鍵字 def 定義。它們可作為輸入和輸出的 Python 物件。也可以在內部同時使用 Python 和 C / C ++ 物件,並可以呼叫 Cython 和 Python 函式。
- 用 cdef 關鍵字定義的 Cython 函式。它們可以作為輸入,在內部使用並輸出 Python 和 C / C ++物件。這些函式不能從 Python 空間訪問(即 Python 直譯器和其他可匯入 Cython 模組的純 Python 模組),但可以由其他 Cython 模組匯入。
- 用 cpdef 關鍵字定義的 Cython 函式就像 cdef 定義的 Cython 函式一樣,但它們也提供了一個 Python 封裝器,因此可以從 Python 空間(以 Python 物件作為輸入和輸出)以及其他 Cython 模組(以 C / C ++ 或 Python 物件作為輸入)中呼叫它們。
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。