2022年4月10日 星期日

機器學習筆記 : 深度學習的 16 堂課 (十一)

本篇繼續整理 "深度學習的16堂課" 這本書的讀後筆記, 本系列之前的文章參考 : 

  1. 自然語言處理首先要將文字轉成電腦能理解的形式, 也就是要將文字轉成數字, 這種文字量化的方法中最常用的是詞向量 (word vector), 以及它所形成的詞向量空間 (word vector space). 

  2. 在建立詞向量空間之前, 須先將語料作預處理, 包含下列六項 :
    (1). 斷句與斷字 (tokenization) :
    將文本拆成獨立的 token (單字, 數字, 符號, 標點符號等).
    (2). 將全部字母轉成小寫 :
    英文字開頭首字大寫是語法問題, 語意幾乎都相同, 故對於小型語料庫而言全部轉小寫可以減少 token 數. 但對於大型語料庫, 可能會同時出現 general 與 General, 這時應保留大小寫> 
    (3). 移除停用字 (stop words) :
    所謂停用字是指經常出現但沒有特殊涵義的詞, 例如 the, a, which, oh 等, 這些字經常出現且通常是因語法需要而存在, 對語意沒有甚麼貢獻, 所以要將其移除, 也就是停用. 決定停用字沒有絕對的標準, 停用字清單並非唯一. 否定詞例如 don't, isn't, 或 wouldn't 對語意有決定性作用, 仔某些應用 (例如情緒分析) 不可列入停用字. 
    (4). 移除標點符號 :
    標點符號對 NLP 模型通常無價值應予移除, 但在問答應用中, 問號可用來標示問句就不可以刪除. 
    (5). 字根提取 (stemming) :
    將字根以外的部分截掉可將意思差不多的字歸為同一個 token, 例如 house 與 housing 的字根為 hous. 對小型語料庫而言 stemming 可能有幫助, 但對大型語料庫可能沒用. 
    (6). 處理 n-gram 詞彙 :
    經常一起出現的詞組合稱為 n-gram 詞, 例如 New York 為 2-gram (或 bi-gram), New York City 是 3-gram.
    這六個預處理並非全部都要做, 視模型任務與所用的資料集而定. 
    本章主要使用 NLTK 與 gensim 這兩個 NLP 套件來做預處理與提供語料庫 (使用 NLTK 內建的 gutenburg 語料庫). NLTK 是開放原始碼的 NLP 套件, 裡面內建了許多語料庫, 例如 brown, treebank, wordnet, words, conll2000, conll2002, ieer, 與 gutenberg 等, 其中 gutenburg 語料庫內含 18 套免費電子書.

  3. 實作資料預處理 :
    以下實作因為在 Colab 上遇到無法下載 gutenberg 語料庫問題, 故改在本機的 JupyterLab (就是新版的 Jupyter Notebook) 執行, 關於 JupyterLab 的安裝參考 :
    Python 學習筆記 : 安裝 JupyterLab
    另外本機也需要先安裝 NLTK 套件與語料庫, 參考 :
    NLTK 學習筆記 (一) : 安裝 NLTK 套件與語料庫
    因後面要用到 Matplotlib 繪製詞向量空間, 故先執行下列指令 :
    %matplotlib inline 

    (1). 匯入模組, 套件, 語料庫 :
    import nltk from nltk import word_tokenize, sent_tokenize from nltk.corpus import stopwords, gutenberg from nltk.stem.porter import * import string import gensim from gensim.models.phrases import Phraser, Phrases from gensim.models.word2vec import Word2Vec from sklearn.manifold import TSNE import pandas as pd from bokeh.io import output_notebook, output_file from bokeh.plotting import show, figure


    (2). 斷句與斷字 :
    上面從 nltk.corpus 匯入了 gutenburg 模組, 呼叫 gutenberg.fileids() 可檢視此語料庫的 18 本電子書之 txt 檔名 :


    可見第一本書是 Jane Austen 的 "Amma" 這本書. 呼叫 gutenberg.words() 會傳回此語料庫所使用的全部 token 組成的串列, 傳給 () 可以得知整個語料庫含有 262 萬個字 (token) :
     

    呼叫 gutenburg.raw() 會傳回語料庫的原始內容 (很長) :


    將此原始內容傳給上面匯入的 sent_tokenize() 函式可執行斷句, 結果會放在傳回串列中 :


    串列的索引 0 是 "Amma" 這本書的扉頁與標題, 索引 1 才是第一句. 將句子傳給 word_tokenize() 函式可執行斷詞, 結果會將該句所有 token 放在串列中傳回 :


    上面分別用 nltk.sent_tokenize() 與 nltk.word_tokenize() 函式來進行斷句與斷詞, 事實上可以用 gutenburg.sents_tokenize() 函式一氣呵成, 一次完成把斷句與斷詞 : 


    不過與上面用 nltk.sent_tokenize() 與 nltk.word_tokenize() 函式來斷句與斷詞的結果在索引上有所不同, 原來正文第一句是在索引 1, 用 gutenberg_sents() 得到的第一句則是在索引 4 (即往前進 3 格) :


    (3). 將全部大寫字母轉成小寫 :
    以上面 "Amma" 的第一句 (索引 4) 為例, 可用串列生成式將句中全部大寫字母轉成小寫 :
    [w.lower() for w in gberg_sents[4]]


    如果要將 gutenberg 全部 18 本書內容中的大寫字母都改成小寫, 則要用兩層串列, 先建立一個空串列來存放每本書的斷句斷詞結果 :

    lower_sents=[]
    for s in gberg_sents:
        lower_sents.append([w.lower() for w in s])
    lower_sents


    由於 18 本書有 262 萬字, 所以運算需要一點時間. 

    (4). 移除停用字與標點符號 :
    由上面斷句斷詞結果可知有許多 token 是標點符號, 這些連同停用字都應該要移除, 先來看一下標點符號與英文停用字的內容 : 


    可見停用字都是小寫字母, 此處的做法是將 string 模組的字串常數 punctuation (所有標點符號串接起來的字串) 與 NLTK 提供的停用字串列 stopwords.words('english') 串接起來, 然後於上面大寫轉小寫的串列生成式中一併將其移除 :

    stpwrds=stopwords.words('english') + list(string.punctuation) 


    可見英語停用字與標點符號加起來共有 211 個 token. 接著先用 "Amma" 這本書第 一個句子 (索引 4) 為例來移除其中的停用字與標點符號, 由上面可知第一句共有 39 個 token, 經過下面的串列生成式過濾後剩下 13 個 token :

    amma_4=[w.lower() for w in gberg_sents[4] if w.lower() not in stpwrds]


    與上面 [w.lower() for w in gberg_sents[4]] 的結果比較, 可知 she, was, the, 等停用字與標點符號都被去除了. 

    (5). 字根提取 (stemming) :
    此處提取字根是使用 NLTK 的 Porter 演算法, 透過呼叫 PorterStemer() 函式建立一個 PorterStemmer 物件, 然後將上面去除停用字與標點符號且全部轉成小寫的 "Amma" 第一句傳給該物件的 stem() 方法就會得到由該句 token 的字根組成的串列 :

    stemmer=PorterStemmer()   # 建立 PorterStemmer 物件
    [stemmer.stem(w.lower()) for w in gberg_sents[4] if w.lower() not in stpwrds]


    可見 daughters 字根為 daughter (複數一律變單數), house 字根為 hous, 而 early 字根為 earli (與 earlier, earliest 等同形) ... 等等, 對小型語料庫做字根提取動作可將相似的單字併成一類而壯大同類之數量, 在詞向量空間會分配到更精確的位置. 但大型語料庫罕見字出現次數相對地多, 做字根提取不見得有利, 反而是將同一字根的單複數視為不同類較能找出措辭的細微差異. 

    (6). 尋找 n-gram 詞彙 :
    所謂 n-gram 就是 n 個常連在一起的配對字組 (collocation), 例如 New York 是 2-gram, 而 New York City 是 3-gram, 以 2-gram 居多數. 尋找語料庫中的 2-gram 可用 gensim 套件中的 Pharses() 與 Phraser() 函式來做, 前者負責 2-gram 詞彙配對 (2-gram collocation), 它會找出語料庫中曾配對出現的 2-gram 詞組並計算其各自與配對出現之頻率. 後者 Phraser() 則是將前者的輸出加工, 建立一個較有效率的物件.
    下面是將 gutenberg 語料庫的 18 本書經 gutenberg.sents() 斷句斷詞後的輸出先餵給 Phrases(), 再把結果傳入 Phraser() :

    phrases=Phrases(gberg_sents)
    bigram=Phraser(phrases)


    可見這兩個函式會分別產生 Phrases 與 Phraser 物件. 檢視 Phraser 物件的 phrasegrams 屬性即可看到 2-gram 詞組配對的次數與分數 (但我實測只出現分數, 可能是新版 gensim 移除次數顯示) :

    bigram.phrasegrams 


    可見 'two daughters' 作為詞組出現的分數是 11.96, 而 'long ago' 則是 63.22 分, 這個分數是 gensim 套件計算出來的, 分數低表示這兩個字作為 2-gram 的可能性低, 反之分數越高越能視為 2-gram, 並不代表出現的頻率高. 有些詞組雖然出現的次數多, 但若它們個別出現的次數更多, 則計算出來的分數就不高, 這種詞組是否要納入 2-gram 就要再斟酌. 
    將斷詞斷句後的 token 串列當索引丟給 Phaser 物件, 它會把 2-gram 詞組用底線串接後放在串列中傳回, 例如 : 

    test_sentence='Miss Taylor has two daughters'.split()
    bigram[test_sentence]


    可見 Miss Taylor 被組成 Miss_Taylor, 而 two daughters 則被組成 two_daughters. 以上轉小寫, 移除標點與停用字, 提取字根, 以及處理 2-gram 詞組都只用 "Amma" 這本書的第一句來測試, 下面要對全部 gutenberg 語料庫做處理 (但不做移除停用字與字根提取), 上面我們已將整個 gutenberg 都斷句斷詞得到 gberg_sents, 只要在走訪此串列轉小寫時在串列生成式中加上過濾停用字條件即可 :

    lower_sents=[]
    for s in gberg_sents:
        lower_sents.append([w.lower() for w in s if w.lower()
                                         not in list(string.punctuation)])

                                               (略)

    可見經過移除標點符號後, 原本 262 萬個 token 就縮減為約 222 萬左右. 此處因為 lower_sents 與 gberg_sents 都是二維串列, 所以使用 sum() 搭配生成式來求總數, 參考 :

    Find length of 2D array Python

    接著把 lower_sents 丟給 Phrases() 與 Phraser() 來產生 2-gram 物件 :

    lower_bigram=Phraser(Phrases(lower_sents))
    lower_bigram.phrasegrams


    全都已改為小寫, 其中 miss taylor 還是拿到 420 的高分. 這些 2-gram 其實並非要照單全收, Phrases() 函式提供 min_count (最低出現次數) 與 threshold (最低分數) 這兩個參數來讓我們過濾 2-gram 詞組, 符合的才入列, 例如將條件設為最低出現 12 次, 分數最低 64 分以上 :

    lower_bigram=Phraser(Phrases(lower_sents, min_count=12, threshold=64))
    lower_bigram.phrasegrams



    可見只有分數超過 64 分的 2-gram 才會放進串列中 (注意, 因為新版 gensim 不會列出出現次數, 所以此處無法看到次數), 但上面的分數與書上跑出來的不一樣, 例如 miss taylor 書上只有 156 分, 此處是 351 分. 有一些詞組例如 great deal 等其實都不應該算是 2-gram. 

    如同前面測試過的, 只要把經過斷詞處理的句子串列當作索引傳給 2-gram 的 Phaser 物件, 它就會傳回組合成 2-gram 的串列, 例如 :

    test_sentence='miss taylor has two daughters'.split()
    lower_bigram[test_sentence]


    可見經過分數過濾後的 Phraser 物件已經沒有把 two daughters 當成 2-gram 了. 最後來將已轉小寫, 且移除標點符號的整個 gutenberg 語料庫 lower_sents 中的每一個句子串列丟給過濾過的 Phraser 物件 lower_bigram, 讓它來組合句子中的 2-gram token :

    clean_sents=[]
    for s in lower_sents:
        clean_sents.append(lower_bigram[s])

                                                                (略)

    可見 Phraser 物件已將傳入串列中的 2-gram 詞組用底線串在一起了. 

  4. 用 gensim 的 Word2vec() 建立詞向量空間 : 
    經過上面的預處理程序得到最終含有 2-gram 詞組的二維 token 串列 clean_sents 後, 就可以將它傳給 gensim 套件的 Word2vec() 類別來實作詞向量空間, gensim 使用神經網路技術, 故其所建立之物件也稱為模型 :

    model=Word2vec(sentences=clean_sents, size=64, sg=1,
                                  window=10, iter=5, min_count=10, workers=4)

    其中參數說明如下 :
    (1). sentences :
    前面預處理的結果 (二維串列)
    (2). size :
    生成的詞向量空間維度, 即用幾個數值來表示一個 token, 一般是用 2 的次方數, 例如 16, 32, 64 等. 維度越大深度學習所需的計算複雜度就越大, 如果加倍無法再提高模型準確度, 維度能少就少. 
    (3). sg :
    是否使用 SG (Skip Gram) 模型架構, 1 表示使用 SG 架構, 0 表示使用 CBOW (Continuous Bag of Words) 架構, SG 架構是用目標字來預測脈絡字, 適合像 gutenberg 這樣的較小的語料庫, 對罕見字較有利; 而 CBOW 則是用脈絡字預測目標字, 計算速度快, 適合處理大型語料庫, 對常見字較有利. CBOW 的概念是使用一個涵蓋目標字與脈絡字的窗口, 先從語料庫的第 0 個字出發, 依序向右滑動窗口直到尾端. 每次滑動時擷取目標字左右兩邊的脈絡字放入詞袋中訓練神經網路 (詞袋中字的順序不重要), 計算詞袋內脈絡字像亮的平均值以估計目標字為何. 
    (4). window : 
    對 SG 架構 window 設成 10 較適合 (即前後共 20 個脈絡字); 對 CBOW 架構來說設成 5 較適合 (即前後共 10 個脈絡字). 
    (5). iter :
    語料庫的訓練週期數, 與訓練神經網路的 epoch 觀念相同. 小型語料庫只要多走訪幾次, 詞向量空間就會調整得很好, 而大型語料庫單字量已經很大, 走太多次進步也很有限. 
    (6). min_count :
    單字被嵌入詞向量空間必須在語料庫中出現的最低次數, 一般標準是要 10 次以上. 
    (7). workers :
    投入運算的 CPU 核心數, 例如 8 個核心將 worker 設成 4 表示開 4 個執行續來跑詞向量訓練, 保留其餘 4 核心給其他程序使用. 
    建立詞向量空間模型物件後, 可用下列屬性查詢詞向量空間中的詞彙數量 :

    len(model.wv.vocab)

    要檢視某個詞彙例如 dog 的詞向量長怎樣, 只要用該字做索引查詢 model.wv :

    model.wv['dog']


    但這個跟書上的似乎不太一樣. 這個詞向量空間可以用 model.wv.most_similar() 來評估與指定單字意義相近的其他單字, 可傳入 topn 參數指定傳回幾個近義詞 :

    model.wv.most_similar('father', topn=3) 
    model.wv.most_similar('dog', topn=3) 


    傳回值中的數值是近似度的分數, 越大越接近 1 則越相似.
    model.wv 物件還有其他三個常用方法 :

    (1). model.wv.doesnt_match(token_list) :
    傳入 token 串列則可找出其中最與眾不同的 token
    (2). model.wv.similarity(word1, word2) : 
    傳入兩個單字會傳回其語意相似度
    (3). model.wv.most_similar(positive, negative) : 
    可將詞向量作加減以找出語料庫中符合之單字, 其中 positive 為加項, negative 為減項, 都是單字組成的字串. 例如 : 

    model.wv.doesnt_match("mother father sister brother dog".split())
    這會傳回與其他 token 最不搭的 "dog".
    model.wv.similarity('father', 'dog')
    'father' 與 'dog' 語意相似度不高, 大約只有 0.4.
    model.wv.similarity('father', 'mother')
    'father' 與 'mother' 語意相似度高, 大約有 0.8.
    model.wv.most_similar(positive=['father', 'woman'], negative=['man'])
    與 'father', 'woma' 相關, 但與 'man' 不相關的字有 'husband', 'daughter' 等.


    此章最後介紹如何用 t-隨機鄰近嵌入法 (t-distributed stochastic neighbor embedding) 方法把高達 64 維, 超出人類理解範圍的詞向量空間降維到可視的 2 維平面, 這樣就能用視覺化方法來描繪詞向量空間了, 降維的程式碼如下 :

    tsne=TSNE(n_components=2, n_iter=1000)
    X_2d=tsne.fit_transform(model.wv[model.wv.vocab])
    coords_df=pd.DataFrame(X_2d, columns=['x', 'y'])
    coords_df['token']=model.wv.vocab.keys()

    此處先呼叫 TSNE() 函式並傳入參數 n_components=2 指定降維成 2 維, n_iter=1000 指定走訪輸入資料之次數. 然後呼叫 TSNE 物件的 fit_transform() 訓練神經網路, 將得到的結果轉成 Pandas 的 DataFrme 物件, 最後將詞向量空間詞彙的 key (即 token) 添加到 DataFrame 物件中, 可用 head() 檢視前五筆資料 :


    這些數字與書上的結果也不同, 但 token 是一樣的. 接下來就可以用 Pandas 來繪製詞向量空間降成二維後的散佈圖 :

    coords_df.plot.scatter('x', 'y', figsize=(12, 12), marker='x', s=10, alpha=0.2)


    下面改用 Bokeh 繪圖, 並且將 token 繪製上去 :

    output_notebook()
    subset_df=coords_df.sample(n=5000)
    p=figure(plot_width=800, plot_height=800)
    _ =p.text(x=subset_df.x, y=subset_df.y, text=subset_df.token)


    看上去是一團烏雲, 但按輸出圖片右上角的放大鏡後圈選要放大的區域就可以看到細部的 2D 詞向量空間了 :



    終於結束了, 哇, 這章內容真多啊! 但這只是在介紹詞向量空間而已, 後兩章 (12/13) 才是主菜 (用 tf.keras 建立 NLP 模型). 總結內容如下 :

    (1). 沒有一個能應付各種應用的萬用詞向量空間, 對於不同領域的語料庫 (文學, 醫學, 科技 ....) 都要建自己的詞向量空間. 實務上可行的做法是針對特定領域的語料庫做預訓練 (pre-training), 再遷移到目標領域來建模. 
    (2). 在後續 12/13 不再使用 gensim 的 Word2Vec() 來建立詞向量空間, 而是直接使用 tf.keras.

    以上演練之 ipynb 筆記本參考 (14MB ) :

    https://github.com/tony1966/colab/blob/main/deep_learning_illustrate_ch11_nlp_word2vec.ipynb (請先下載再用 JupyterLab 開啟)

沒有留言 :