如何將Python天然語言處理速度提升100倍?

本文首發自集智專欄html


參考資料:medium.compython

科創公司Hugging Face機器學習專家Thomas Wolf去年曾帶領團隊推出一款Python共指解析工具包NeuralCoref,用神經網絡解析句子中的共同指代詞。git

工具包發佈之後,Thomas收到了來自技術社區的積極反饋,但也發現了一個大問題:工具包在處理對話信息是反應迅速,但處理文本較長的新聞文章時速度就變得很是緩慢。github

最終,Thomas通過種種努力解決了這個問題,推出的NeuralCoref新版在保證準確率的同時,將處理速度提高了100倍!並且,工具包依然易於使用,也符合Python庫的生態環境。編程

Thomas 隨後將他解決這個問題的心得總結了出來,把如何將Python天然語言處理速度提升100倍的經驗分享給你們,其中涉及:api

  • 怎樣才能用Python設計出一個高效率模塊
  • 怎樣利用好 spaCy 的內置數據結構,從而設計出超高效的天然語言處理函數

在本文,Thomas將講解如何利用 Cython 和 spaCy 讓 Python 在天然語言處理任務中的速度提升百倍。數組

開始前,我(做者Thomas Wolf——譯者注)得認可文章略微有些標題黨,由於雖然咱們會討論Python,但也會包含一些Cython技巧。不過,你知道嗎?Cython就是Python的超集啊,因此不要被它嚇跑!bash

你當前所寫的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庫和函數。

本文所有代碼見集智專欄--如何將Python天然語言處理速度提升100倍?


0806期《人工智能-從零開始到精通》限時折扣中!

戳這裏看詳情

談笑風生 在線編程 瞭解一下?

(前25位同窗還可領取¥200優惠券哦)

相關文章
相關標籤/搜索