如何將Python自然語言處理速度提升100倍:用spaCy/Cython加速NLP

AI前線發表於2018-07-13

如何將Python自然語言處理速度提升100倍:用spaCy/Cython加速NLP

作者|Thomas Wolf
譯者|王強
編輯|Debra
AI前線導讀:去年我們釋出了基於 Python 的共指解析包之後,社群反饋非常熱烈,大家開始在各式應用中使用它,有些應用場景與我們原來的對話用例非常不一樣。

之後我們發現,雖然這個解析包的效能對於對話訊息來說是足夠的,但涉及到大篇幅新聞文章時就遠遠不夠了。

更多幹貨內容請關注微信公眾號“AI前線”,(ID:ai-front)

所以我決定好好處理這個問題,最後開發出了比之前版本(每秒幾千單詞)效能提升百倍的 NeuralCoref v3.0(https://github.com/huggingface/neuralcoref) ,同時還保持了同樣水準的準確性和易用性。

本文中,我會分享在這個專案上總結的一些經驗,重點包括:

  • 怎樣在 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自然語言處理速度提升100倍:用spaCy/Cython加速NLP

首先你要知道,你的大部分程式碼在純 Python 環境下可能都執行良好,但是其中存在一些 瓶頸函式,如果好好處理它們,執行速度就能提升一個數量級。

所以,應該首先檢查你的 Python 程式碼,找出那些影響效能的部分。其中一種方法就是使用 cProfile(https://docs.python.org/3/library/profile.html) ,像這樣:


如何將Python自然語言處理速度提升100倍:用spaCy/Cython加速NLP

你可能會發現影響效能的是一些迴圈或者使用神經網路時引入的 Numpy 陣列操作。

那麼該如何加速這些迴圈?

使用 Cython來加速 Python迴圈


如何將Python自然語言處理速度提升100倍:用spaCy/Cython加速NLP

讓我們通過一個簡單的例子來解決這個問題。假設有一堆矩形,我們將它們儲存成一個由 Python 物件(例如 Rectangle類例項)構成的列表。我們的模組的主要功能是對該列表進行迭代運算,從而統計出有多少個矩形的面積是大於所設定閾值的。

我們的 Python 模組非常簡單,看起來像這樣:


如何將Python自然語言處理速度提升100倍:用spaCy/Cython加速NLP


這個 check_rectangles 函式就是我們的瓶頸所在!它對大量 Python 物件進行迴圈檢查,而因為 Python 直譯器在每次迭代中都要做很多工作(比如在類中查詢 area 方法、打包和解包引數、呼叫 Python API 等),這個迴圈就會非常影響效能。

這時就該引入 Cython 來幫助我們加速迴圈了。

Cython 語言是 Python 的一個超集,包含兩種型別的物件:

  • Python 物件就是我們在常規 Python 中使用到的那些物件,諸如數值、字串、列表和類例項等;

  • Cython C 物件是 C 或 C++ 物件,諸如雙精度、整型、浮點、結構、向量,它們能夠用 Cython 的高效能底層語言程式碼進行編譯。

所謂快速迴圈,就是在 Cython程式中只訪問 Cython C 物件的迴圈。

設計這種迴圈最直接的辦法就是,定義一個 C結構,其中包含計算過程中需要的所有內容:本例中就是矩形的長度和寬度。

然後我們可以將矩形物件的列表儲存到這種 C 結構陣列中,再將陣列傳遞給 check_rectangle 函式。這個函式現在需要接收一個 C 陣列作為輸入,由此使用 cdef 關鍵字取代了 def(注意 cdef 也可以用於定義 Cython C 物件),將函式定義為一個 Cython 函式。

這是我們的 Python模組用更快的 Cython 版本重寫後的樣子:


如何將Python自然語言處理速度提升100倍:用spaCy/Cython加速NLP

這裡我們使用了 C 指標的原始陣列,但你也可以選擇其它方案,特別是諸如向量、二元組、佇列之類的 C++結構(http://cython.readthedocs.io/en/latest/src/userguide/wrapping_CPlusPlus.html#standard-library) 。在這段程式碼中,我還使用了 cymem(https://github.com/explosion/cymem) 的 Pool() 記憶體管理物件,以自動釋放分配的 C 陣列。當 Pool觸發 Python的垃圾回收時,它會自動釋放所分配物件使用的記憶體。

spaCy API 的 Cython 約定(https://spacy.io/api/cython#conventions)可以作為在實際應用中使用 Cython 執行 NLP任務的參考。

讓我們來執行這些程式碼

有很多辦法可用於測試、編譯和釋出 Cython 程式碼!Cython 甚至可以像 Python 一樣直接用在 Jupyter Notebook 內(http://cython.readthedocs.io/en/latest/src/reference/compilation.html#compiling-notebook )。

首先使用 pip install cython 命令安裝 Cython。


如何將Python自然語言處理速度提升100倍:用spaCy/Cython加速NLP

在 Jupyter 中的最初測試

使用 %load_ext Cython 在 Jupyter notebook 中載入 Cython 擴充套件。

現在就可以使用神奇的命令(http://cython.readthedocs.io/en/latest/src/reference/compilation.html#compiling-with-a-jupyter-notebook ) %%cython 來寫 Cython程式碼了,就像寫 Python程式碼一樣。

如果在執行 Cython 單元時遇到了編譯錯誤,一定要檢查 Jupyter 終端輸出的完整資訊。

大多數情況下,可能是忘記在 %%cython之後加上 -+標籤(比如當你使用 spaCy Cython API 時)。如果編譯器報出了 Numpy相關的錯誤,那就是忘加 import numpy了。

正如我在一開始就提到的,請仔細檢視這個 Jupyter notebook(https://github.com/huggingface/100-times-faster-nlp),它包含了我們討論到的所有示例。

編寫、使用和釋出 Cython 程式碼

Cython 程式碼的檔案字尾是 .pyx,這些檔案被 Cython 編譯器編譯成 C 或 C++ 檔案,再被系統的 C 編譯器編譯成位元組碼。之後 Python 直譯器就能使用這些位元組碼檔案。

可以使用 pyximport將一個 .pyx 檔案直接載入到 Python 裡:


如何將Python自然語言處理速度提升100倍:用spaCy/Cython加速NLP

還可以將 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 函式。

  • 使用 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 編碼,我們在 NLP中用到的技巧一個都沒涉及。

此外 Cython 的官方文件甚至建議不要使用 C 型別的字串:

一般而言,除非你知道自己在做什麼,否則就應該儘可能避免使用 C 字串,而要使用 Python 的字串物件。

那麼我們在處理字串時,要如何在 Cython 中設計高效能的迴圈呢?

spaCy 能解決這個問題。

spaCy 處理該問題的做法就非常明智。

將所有字串轉換為 64 位雜湊值

spaCy 中所有的 unicode 字串(一個節點文字、它的小寫文字、它的引理形式、POS 標記標籤、解析樹依賴標籤、命名實體標籤等)都被儲存在一個稱為 StringStore的資料結構中,用一個 64 位雜湊值進行索引,也就是 C 型別的 uint64_t(https://www.badprog.com/c-type-what-are-uint8-t-uint16-t-uint32-t-and-uint64-t)。


如何將Python自然語言處理速度提升100倍:用spaCy/Cython加速NLP

StringStore物件實現了 Python unicode 字串與 64 位雜湊值之間的對映。

我們可以從 spaCy 的任意位置和任意物件訪問它,例如 npl.vocab.strings、doc.vocab.strings或 span.doc.vocab.string。

當一個模組需要在某些節點上獲得更高的效能時,只要使用 C 型別的 64 位雜湊值代替字串即可。呼叫 StringStore對映表將返回與該雜湊值相關聯的 Python unicode 字串。

但是 spaCy 還能做更多事情,它還能讓我們訪問到文件和詞彙表的完整 C 型別結構,我們可以在 Cython 迴圈中使用這些結構,這樣就不用自己從頭構建了。

SpaCy的內部資料結構

與 spaCy 文件關聯的主要資料結構是 Doc(https://spacy.io/api/cython-classes#section-doc) 物件,它包含經過處理的字串節點序列(“words”)以及它們在 C 型別物件中的所有註解,稱為 doc.c(https://spacy.io/api/cython-classes#token_attributes) ,它是一個 TokenC 結構陣列。

TokenC(https://spacy.io/api/cython-structs#section-tokenc) 結構包含了我們需要的每個節點的所有資訊。這些資訊被儲存為 64 位雜湊值,它可以與之前的 unicode 字串重新關聯。

如果想要準確地瞭解這些 C 結構中的內容,可以檢視最近剛釋出的的 spaCy 的 Cython API 文件(https://spacy.io/api/cython)。

接下來看一個簡單的 NLP示例。

使用 spaCy和 Cython加速 NLP處理

假設有一個文字文件的資料集需要分析。


如何將Python自然語言處理速度提升100倍:用spaCy/Cython加速NLP

我寫了一個指令碼,建立一個包含 10 個文件(經過 spaCy處理)的列表,每個文件有大約 17 萬個單詞。當然,我們也可以做 17 萬個文件(每個文件包含 10 個單詞),但是建立這麼多文件會很慢,所以我們還是選擇 10 個文件。

我們想要在這個資料集上執行一些 NLP任務。例如,我們想要統計資料集中單詞“run”作為名詞出現的次數(也就是被 spaCy 標記為“NN”)。

用 Python 迴圈來處理非常簡單和直觀:


如何將Python自然語言處理速度提升100倍:用spaCy/Cython加速NLP

但它也非常慢!這段程式碼在我的筆記本上需要執行 1.4 秒才能獲得結果。如果我們的資料集中包含數以百萬計的文件,我們也許要花費 一天以上才能看到結果。

我們可以使用多執行緒來提速,但在 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 在底層可以直接呼叫 OpenMP。這裡我沒時間更加深入探討並行處理,可以參考這裡(https://cython.readthedocs.io/en/latest/src/userguide/parallelism.html)獲取更多資訊。

現在我們嘗試使用 spaCy 和 Cython 來加速 Python 程式碼。

首先,我們要確定使用哪種資料結構。我們需要一個 C 型別的陣列存放資料集,其中用指標指向每個文件的 TokenC 陣列。還要將測試字元(“run”和“NN”)轉成 64 位雜湊值。

當所有需要處理的資料都變成了 C 型別物件,我們就能以純 C 語言的速度迭代資料集。

下面展示這個例子如何寫成 Cython 和 spaCy 的形式:


如何將Python自然語言處理速度提升100倍:用spaCy/Cython加速NLP

程式碼有點長,因為我們必須在呼叫 Cython 函式 [*](https://medium.com/huggingface/100-times-faster-natural-language-processing-in-python-ee32033bdced#a220) 之前在 main_nlp_fast中宣告和計算 C 結構。

但它的效能得到大幅提升!在我的 Jupyter notebook中,這部分 Cython 程式碼大概只用 20 毫秒就執行完畢,比之前的純 Python 迴圈快了 大概 80 倍

使用 Jupyter notebook 單元編寫模組的速度很驚人,它可以與其他 Python 模組和函式發生互動:在 20 毫秒內掃描大約 170 萬個單詞,這意味著我們每秒能夠處理高達 8 千萬個單詞

對使用 Cython 加速 NLP的介紹到此為止,希望大家喜歡。

關於 Cython 還有很多其它的東西可以介紹,但是已經大大超出了這篇文章的範圍。接下來最好的參考資料也許是這份 Cython 教程(http://cython.readthedocs.io/en/latest/src/tutorial/index.html),它提供了綜述內容,以及 spaCy 的 Cython 頁面(https://spacy.io/api/cython),它提供了 NLP相關的內容。

如果你在程式碼中需要多次使用底層結構,比每次計算 C 結構更優雅的做法是,在 Python程式碼的底層使用 Cython 擴充套件型別(http://cython.readthedocs.io/en/latest/src/userguide/extension_types.html) 來包裝 C 型別結構。這就是大多數 spaCy 程式碼所採用的結構,它非常優雅,兼具高效、低記憶體開銷和易於互動的特性。

英文原文:

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



相關文章