2018年12月3日 星期一

[ ML 文章收集 ] 以 gensim 訓練中文詞向量

Source From Here 
Preface 
最近正在嘗試幾種文本分類的算法,卻一直苦於沒有結構化的中文語料,原本是打算先爬下大把大把的部落格文章,再依 tag 將它們分門別類,可惜試了一陣子後,我見識到了理想和現實間的鴻溝。 

所以就找上了基於非監督學習的 Word2vec,為了銜接後續的資料處理,這邊採用的是基於 python 的主題模型函式庫 gensim。這篇教學並不會談太多 word2vec 的數學原理,而是考慮如何輕鬆又直覺地訓練中文詞向量,文章裡所有的程式碼都會傳上 github,現在,就讓我們進入正題吧。 

取得語料 
要訓練詞向量,第一步當然是取得資料集。由於 Word2vec 是基於非監督式學習,訓練集一定一定要越大越好,語料涵蓋的越全面,訓練出來的結果也會越漂亮。這邊採用的是維基百科於 2018/11/20 的備份,文章篇數共有 3,210,087 篇。因為維基百科會定期更新備份資料,如果該日期的備份不幸地被刪除了,也可以前往 維基百科:資料庫 下載挑選更近期的資料,不過請特別注意一點,我們要挑選的是以 pages-articles.xml.bz2 結尾的備份,而不是以 pages-articles-multistream.xml.bz2 結尾的備份唷,否則會在清理上出現一些異常,無法正常解析文章。 

在等待下載的這段時間,我們可以先把這次的主角 gensim 配置好: 
# pip3 install --upgrade gensim

// Get the gensim version
# python -c "import gensim; print(gensim.__version__)"
3.6.0

維基百科下載好後,先別急著解壓縮,因為這是一份 xml 文件,裏頭佈滿了各式各樣的標籤,我們得先想辦法送走這群不速之客,不過也別太擔心,gensim 早已看穿了一切,藉由調用 wikiCorpus,我們能很輕鬆的只取出文章的標題和內容。初始化 WikiCorpus 後,能藉由 get_texts() 可迭代每一篇文章,它所回傳的是一個 tokens list,我以空白符將這些 tokens 串接起來,統一輸出到同一份文字檔裡。這邊要注意一件事,get_texts() 受 wikicorpus.py 中的變數 ARTICLE_MIN_WORDS 限制,只會回傳內容長度大於 50 的文章: 
- wiki_xml2txt.py 
  1. # -*- coding: utf-8 -*-  
  2.   
  3. import logging  
  4. import sys  
  5.   
  6. from gensim.corpora import WikiCorpus  
  7.   
  8. def main():  
  9.   
  10.     if len(sys.argv) != 2:  
  11.         print("Usage: python3 " + sys.argv[0] + " wiki_data_path")  
  12.         exit()  
  13.   
  14.     logging.basicConfig(format='%(asctime)s : %(levelname)s : %(message)s', level=logging.INFO)  
  15.     wiki_corpus = WikiCorpus(sys.argv[1], dictionary={})  
  16.     texts_num = 0  
  17.   
  18.     with open("wiki_texts.txt",'w',encoding='utf-8') as output:  
  19.         for text in wiki_corpus.get_texts():  
  20.             str_text = list(map(lambda t:t.decode('utf-8'), text))  
  21.             #print("text={}".format(str_text))  
  22.             output.write(' '.join(str_text) + '\n')  
  23.             texts_num += 1  
  24.             if texts_num % 1000 == 0:  
  25.                 logging.info("Done with %d articles" % texts_num)  
  26.   
  27. if __name__ == "__main__":  
  28.     main()  
接著你可以如下將下載的 Wiki 檔轉換成 wiki_texts.txt: 
# ./wiki_xml2txt.py zhwiki-20181120-pages-articles.xml.bz2
...
2018-12-02 20:21:24,188 : INFO : finished iterating over Wikipedia corpus of 328276 documents with 75051573 positions (total 3210087 articles, 88888913 positions before pruning articles shorter than 50 words)


轉換完畢後,以 vi 打開看起來會是這個樣子: 
# head -n 1 wiki_texts.txt
歐幾里得 西元前三世紀的古希臘數學家 現在被認為是幾何之父 此畫為拉斐爾的作品 雅典學院 数学 是利用符号语言研究數量...


Opps!出了一點狀況,我們發現簡體跟繁體混在一起了,比如「数学」與「數學」會被 word2vec 當成兩個不同的詞,所以我們在斷詞前,還需加上一道繁簡轉換的手續。然而我們的語料集相當龐大,一般的繁簡轉換會有些力不從心,建議採用 OpenCC,轉換的方式很簡單: 
# opencc -i wiki_texts.txt -o wiki_zh_tw.txt -c s2tw.json
# head -n 1 wiki_zh_tw.txt
歐幾裡得 西元前三世紀的古希臘數學家 現在被認為是幾何之父 此畫為拉斐爾的作品 雅典學院 數學 是利用符號語言研究數量...

如果是要將繁體轉為簡體,只要將 config 的參數從 s2tw.json 改成 t2s.json 即可。現在再檢查一次 wiki_zh_tw.txt,的確只剩下繁體字了,終於能進入斷詞。 

開始斷詞 
我們有清完標籤的語料了,第二件事就是要把語料中每個句子,進一步拆解成一個一個詞,這個步驟稱為「斷詞」。中文斷詞的工具比比皆是,這裏我採用的是 jieba,儘管它在繁體中文的斷詞上還是有些不如 CKIP,但他實在太簡單、太方便、太好調用了,足以彌補這一點小缺憾. 首先來安裝 jieba
# pip3 install jieba

jieba 的字典物件檔案 "jieba_dict/dict.txt.big" 一個詞佔一行;每一行分三部分:詞語,詞頻(可省略),詞性(可省略),用空格隔開,不可順序顛倒 file_name 若為路徑或二進制方式打開的文件,則文件必>須為 UTF-8 編碼; 停用詞的檔案 "jieba_dict/stopwords.txt" 則是每行代表一個停用詞. 
- segment.py 
  1. #!/usr/bin/env python3  
  2. # -*- coding: utf-8 -*-  
  3.   
  4. import jieba  
  5. import logging  
  6. import codecs  
  7.   
  8. DICT_PATH = 'jieba_dict/dict.txt.big'  
  9. STOP_PATH = 'jieba_dict/stopwords.txt'  
  10.   
  11. def main():  
  12.     logging.basicConfig(format='%(asctime)s : %(levelname)s : %(message)s', level=logging.INFO)  
  13.   
  14.     # jieba custom setting.  
  15.     jieba.set_dictionary(DICT_PATH)  
  16.   
  17.     # load stopwords set  
  18.     # https://pythonspot.com/nltk-stop-words/  
  19.     stopword_set = set()  
  20.     with open(STOP_PATH,'r', encoding='utf-8') as stopwords:  
  21.         for stopword in stopwords:  
  22.             stopword_set.add(stopword.strip('\n'))  
  23.   
  24.     output = open('wiki_seg.txt''w', encoding='utf-8')  
  25.     with open('wiki_zh_tw.txt''r', encoding='utf-8') as content :  
  26.         for texts_num, line in enumerate(content):  
  27.             line = line.strip('\n')  
  28.             words = jieba.cut(line, cut_all=False)  
  29.             for word in words:  
  30.                 if word not in stopword_set:  
  31.                     output.write(word + ' ')  
  32.             output.write('\n')  
  33.   
  34.             if (texts_num + 1) % 10000 == 0:  
  35.                 logging.info("Completed %d lines" % (texts_num + 1))  
  36.   
  37.     output.close()  
  38.   
  39. if __name__ == '__main__':  
  40.     main()  
執行下面命令進行斷詞: 
# ./segment.py
...

// Check the segment result
# tail wiki_seg.txt
...
韋佳德 面對面 年 由 韋佳德 riccardo moratto 建立 的 一檔 專訪 型 節目...

Stopwords and Window 
除了之前演示的斷詞外,這邊還多做了兩件事,一是調整 jieba 的辭典,讓他對繁體斷詞比較友善,二是引入了停用詞,停用詞就是像英文中的 the,a,this,中文的你我他,與其他詞相比顯得不怎麼重要,對文章主題也無關緊要的,就可以將它視為停用詞。而要排除停用詞的理由,其實與 word2vec 的實作概念大大相關,由於在開頭講明了不深究概念,就讓我舉個例子替代長篇大論。 

很顯然,一個詞的意涵跟他的左右鄰居很有關係,比如「雨越下越大,茶越充越淡」,什麼會「下」?「雨」會下,什麼會「淡」?茶會「淡」,這樣的類比舉不勝舉,那麼,若把思維逆轉過來呢?顯然,我們或多或少能從左右鄰居是誰,猜出中間的是什麼,這很像我們國高中時天天在練的英文克漏字。那麼問題來了,左右鄰居有誰?能更精確地說,你要往左往右看幾個?假設我們以「孔乙己 一到 店 所有 喝酒 的 人 便都 看著 他 笑」為例,如果往左往右各看一個詞: 
Position1: [孔乙己 一到] 店 所有 喝酒 的 人 便 都 看著 他 笑
Position2: [孔乙己 一到 店] 所有 喝酒 的 人 便 都 看著 他 笑
Position3: 孔乙己 [一到  所有] 喝酒 的 人 便 都 看著 他 笑
Position4: 孔乙己 一到 [店 所有 喝酒] 的 人 便 都 看著 他 笑
Position5: ......

這樣就構成了一個 size=1 的 windows,這個 1 是極端的例子,為了讓我們看看有停用詞跟沒停用詞差在哪,這句話去除了停用詞應該會變成: 
孔乙己 一到 店 所有 喝酒 人 看著 笑

我們看看「人」的窗口變化,原本是「的 人 便」,後來是「喝酒 人 看著」,相比原本的情形,去除停用詞後,我們對「人」這個詞有更多認識,比如人會喝酒,人會看東西,當然啦,這是我以口語的表達,機器並不會這麼想,機器知道的是人跟喝酒會有某種關聯,跟看會有某種關聯,但儘管如此,也遠比本來的「的 人 便」好太多太多了。 

訓練詞向量 
這是最簡單的部分,同時也是最困難的部分,簡單的是程式碼,困難的是詞向量效能上的微調與後訓練。對了,如果你已經對詞向量和語言模型有些研究,在輸入 python3 train.py 之前,建議先看一下下面的程式碼: 
- train.py 
  1. #!/usr/bin/env python3  
  2. # -*- coding: utf-8 -*-  
  3.   
  4. import logging  
  5.   
  6. from gensim.models import word2vec  
  7.   
  8. def main():  
  9.     logging.basicConfig(format='%(asctime)s : %(levelname)s : %(message)s', level=logging.INFO)  
  10.     sentences = word2vec.LineSentence("wiki_seg.txt")  
  11.     model = word2vec.Word2Vec(sentences, size=250)  
  12.   
  13.     # Save model into filesystem  
  14.     model.save("word2vec.model")  
  15.   
  16.     # Load back trained model  
  17.     # model = word2vec.Word2Vec.load("your_model_name")  
  18.   
  19. if __name__ == "__main__":  
  20.     main()  
扣掉 logging 與註釋就剩下三行,真是精簡的漂亮。上頭通篇的學問在 model = word2vec.Word2Vec(sentences, size=250),我們先讓它現出原型: 
  1. class gensim.models.word2vec.Word2Vec(sentences=None, corpus_file=None, size=100, alpha=0.025, window=5, min_count=5, max_vocab_size=None, sample=0.001, seed=1, workers=3, min_alpha=0.0001, sg=0, hs=0, negative=5, ns_exponent=0.75, cbow_mean=1, hashfxn=, iter=5, null_word=0, trim_rule=None, sorted_vocab=1, batch_words=10000, compute_loss=False, callbacks=(), max_final_vocab=None)  
這抵得上 train.py 的所有程式碼了。不過也別太擔心,裏頭多是無關緊要的參數,從初學的角度來看,我們會去動到的大概是: 
* sentences: 當然了,這是要訓練的句子集,沒有他就不用跑了
* size: 這表示的是訓練出的詞向量會有幾維
* alpha: 機器學習中的學習率,這東西會逐漸收斂到 min_alpha
* sg: 這個不是三言兩語能說完的,sg=1 表示採用 skip-gram, sg=0 表示採用 cbow
* window: 還記得孔乙己的例子嗎?能往左往右看幾個字的意思
* workers: 執行緒數目,除非電腦不錯,不然建議別超過 4
* min_count: 若這個詞出現的次數小於 min_count,那他就不會被視為訓練對象

等摸清 Word2Vec 背後的原理後,也可以試著調調 hsnegative,看看對效能會有什麼影響。 

詞向量實驗 
訓練完成後,讓我們透過 "demo.py" 來測試一下模型的效能: 
- demo.py 
  1. #!/usr/bin/env python3  
  2. # -*- coding: utf-8 -*-  
  3.   
  4. from gensim.models import word2vec  
  5. from gensim import models  
  6. import logging  
  7.   
  8. def main():  
  9.     logging.basicConfig(format='%(asctime)s : %(levelname)s : %(message)s', level=logging.INFO)  
  10.     model = models.Word2Vec.load('word2vec.model')  
  11.   
  12.     print("提供 3 種測試模式\n")  
  13.     print("輸入一個詞,則去尋找前一百個該詞的相似詞")  
  14.     print("輸入兩個詞,則去計算兩個詞的餘弦相似度")  
  15.     print("輸入三個詞,進行類比推理")  
  16.     print("Enter 'exit' or 'quit' to stop the loop:")  
  17.   
  18.     while True:  
  19.         try:  
  20.             query = input()  
  21.             q_list = query.split()  
  22.             if q_list[0].lower() == 'exit' or q_list[0].lower() == 'quit':  
  23.                 print("Bye!")  
  24.                 break  
  25.   
  26.             if len(q_list) == 1:  
  27.                 print("相似詞前 100 排序")  
  28.                 res = model.most_similar(q_list[0],topn = 100)  
  29.                 for item in res:  
  30.                     print(item[0]+","+str(item[1]))  
  31.   
  32.             elif len(q_list) == 2:  
  33.                 print("計算 Cosine 相似度")  
  34.                 res = model.similarity(q_list[0],q_list[1])  
  35.                 print(res)  
  36.             else:  
  37.                 print("%s之於%s,如%s之於" % (q_list[0],q_list[2],q_list[1]))  
  38.                 res = model.most_similar([q_list[0],q_list[1]], [q_list[2]], topn= 100)  
  39.                 for item in res:  
  40.                     print(item[0]+","+str(item[1]))  
  41.             print("----------------------------")  
  42.         except Exception as e:  
  43.             print(repr(e))  
  44.   
  45. if __name__ == "__main__":  
  46.     main()  
先來試試相似詞排序吧: 
# ./demo.py
...
輸入一個詞,則去尋找前一百個該詞的相似詞
輸入兩個詞,則去計算兩個詞的餘弦相似度
輸入三個詞,進行類比推理
Enter 'exit' or 'quit' to stop the loop:

飲料
相似詞前 100 排序
2018-12-03 19:03:16,811 : INFO : precomputing L2-norms of word weight vectors
飲品,0.843640923500061
果汁,0.753295361995697
口香糖,0.6983180642127991
冰淇淋,0.6936781406402588
無糖,0.6878729462623596
罐裝,0.6821489930152893
酸奶,0.6708729863166809
可口可樂,0.6703883409500122
酒精類,0.6676901578903198
...
----------------------------

籃球
相似詞前 100 排序
美式足球,0.6759085655212402
棒球,0.6597086191177368
橄欖球,0.6494096517562866
冰球,0.6414908766746521
排球,0.6409981846809387
網球,0.6269651651382446
籃球運動,0.6239866018295288
...

我們也能調用 model.similarity(word2,word1) 來直接取得兩個詞的相似度: 
冰沙 刨冰
計算 Cosine 相似度
0.35474154591
----------------------------

電腦 飛鏢
計算 Cosine 相似度
0.167575750507
----------------------------

電腦 程式
計算 Cosine 相似度
0.566629023273
----------------------------

卡通 漫畫
計算 Cosine 相似度
0.43411887359
----------------------------

能稍微區隔出詞與詞之間的主題,整體來說算是可以接受的了。 

更上一層樓 
如何優化詞向量的表現?這其實有蠻多方法的,大方向是從應用的角度出發,我們能針對應用特化的語料進行再訓練,除此之外,斷詞器的選擇也很重要,它很大程度的決定什麼詞該在什麼地方出現,如果發現 jieba 有些力不能及的,不妨試著採用別的斷詞器,或是試著在 jieba 自訂辭典,調一下每個詞的權重。應用考慮好了,接著看看模型,我們可以調整 model() 的參數,比方窗口大小、維度、學習率,進一步還能比較 skip-gram 與 cbow 的效能差異... 

Supplement 
Word2vec Tutorial 
Gensim Word2Vec Tutorial – Full Working Example 

How to Compress and Decompress a .bz2 File in Linux 
結巴中文斷詞台灣繁體版本 
中文斷詞:斷句不要悲劇 / Head first Chinese text segmentation 
Github: johnklee/word2vec_lab

沒有留言:

張貼留言

[ Py DS ] Ch3 - Data Manipulation with Pandas (Part5)

Source From  Here   Pivot Tables   We have seen how the  GroupBy  abstraction lets us explore relationships within a dataset. A pivot ta...