Preface
最近正在嘗試幾種文本分類的算法,卻一直苦於沒有結構化的中文語料,原本是打算先爬下大把大把的部落格文章,再依 tag 將它們分門別類,可惜試了一陣子後,我見識到了理想和現實間的鴻溝。
所以就找上了基於非監督學習的 Word2vec,為了銜接後續的資料處理,這邊採用的是基於 python 的主題模型函式庫 gensim。這篇教學並不會談太多 word2vec 的數學原理,而是考慮如何輕鬆又直覺地訓練中文詞向量,文章裡所有的程式碼都會傳上 github,現在,就讓我們進入正題吧。
取得語料
要訓練詞向量,第一步當然是取得資料集。由於 Word2vec 是基於非監督式學習,訓練集一定一定要越大越好,語料涵蓋的越全面,訓練出來的結果也會越漂亮。這邊採用的是維基百科於 2018/11/20 的備份,文章篇數共有 3,210,087 篇。因為維基百科會定期更新備份資料,如果該日期的備份不幸地被刪除了,也可以前往 維基百科:資料庫 下載挑選更近期的資料,不過請特別注意一點,我們要挑選的是以 pages-articles.xml.bz2 結尾的備份,而不是以 pages-articles-multistream.xml.bz2 結尾的備份唷,否則會在清理上出現一些異常,無法正常解析文章。
在等待下載的這段時間,我們可以先把這次的主角 gensim 配置好:
維基百科下載好後,先別急著解壓縮,因為這是一份 xml 文件,裏頭佈滿了各式各樣的標籤,我們得先想辦法送走這群不速之客,不過也別太擔心,gensim 早已看穿了一切,藉由調用 wikiCorpus,我們能很輕鬆的只取出文章的標題和內容。初始化 WikiCorpus 後,能藉由 get_texts() 可迭代每一篇文章,它所回傳的是一個 tokens list,我以空白符將這些 tokens 串接起來,統一輸出到同一份文字檔裡。這邊要注意一件事,get_texts() 受 wikicorpus.py 中的變數 ARTICLE_MIN_WORDS 限制,只會回傳內容長度大於 50 的文章:
- wiki_xml2txt.py
- # -*- coding: utf-8 -*-
- import logging
- import sys
- from gensim.corpora import WikiCorpus
- def main():
- if len(sys.argv) != 2:
- print("Usage: python3 " + sys.argv[0] + " wiki_data_path")
- exit()
- logging.basicConfig(format='%(asctime)s : %(levelname)s : %(message)s', level=logging.INFO)
- wiki_corpus = WikiCorpus(sys.argv[1], dictionary={})
- texts_num = 0
- with open("wiki_texts.txt",'w',encoding='utf-8') as output:
- for text in wiki_corpus.get_texts():
- str_text = list(map(lambda t:t.decode('utf-8'), text))
- #print("text={}".format(str_text))
- output.write(' '.join(str_text) + '\n')
- texts_num += 1
- if texts_num % 1000 == 0:
- logging.info("Done with %d articles" % texts_num)
- if __name__ == "__main__":
- main()
轉換完畢後,以 vi 打開看起來會是這個樣子:
Opps!出了一點狀況,我們發現簡體跟繁體混在一起了,比如「数学」與「數學」會被 word2vec 當成兩個不同的詞,所以我們在斷詞前,還需加上一道繁簡轉換的手續。然而我們的語料集相當龐大,一般的繁簡轉換會有些力不從心,建議採用 OpenCC,轉換的方式很簡單:
如果是要將繁體轉為簡體,只要將 config 的參數從 s2tw.json 改成 t2s.json 即可。現在再檢查一次 wiki_zh_tw.txt,的確只剩下繁體字了,終於能進入斷詞。
開始斷詞
我們有清完標籤的語料了,第二件事就是要把語料中每個句子,進一步拆解成一個一個詞,這個步驟稱為「斷詞」。中文斷詞的工具比比皆是,這裏我採用的是 jieba,儘管它在繁體中文的斷詞上還是有些不如 CKIP,但他實在太簡單、太方便、太好調用了,足以彌補這一點小缺憾. 首先來安裝 jieba:
jieba 的字典物件檔案 "jieba_dict/dict.txt.big" 一個詞佔一行;每一行分三部分:詞語,詞頻(可省略),詞性(可省略),用空格隔開,不可順序顛倒 file_name 若為路徑或二進制方式打開的文件,則文件必>須為 UTF-8 編碼; 停用詞的檔案 "jieba_dict/stopwords.txt" 則是每行代表一個停用詞.
- segment.py
- #!/usr/bin/env python3
- # -*- coding: utf-8 -*-
- import jieba
- import logging
- import codecs
- DICT_PATH = 'jieba_dict/dict.txt.big'
- STOP_PATH = 'jieba_dict/stopwords.txt'
- def main():
- logging.basicConfig(format='%(asctime)s : %(levelname)s : %(message)s', level=logging.INFO)
- # jieba custom setting.
- jieba.set_dictionary(DICT_PATH)
- # load stopwords set
- # https://pythonspot.com/nltk-stop-words/
- stopword_set = set()
- with open(STOP_PATH,'r', encoding='utf-8') as stopwords:
- for stopword in stopwords:
- stopword_set.add(stopword.strip('\n'))
- output = open('wiki_seg.txt', 'w', encoding='utf-8')
- with open('wiki_zh_tw.txt', 'r', encoding='utf-8') as content :
- for texts_num, line in enumerate(content):
- line = line.strip('\n')
- words = jieba.cut(line, cut_all=False)
- for word in words:
- if word not in stopword_set:
- output.write(word + ' ')
- output.write('\n')
- if (texts_num + 1) % 10000 == 0:
- logging.info("Completed %d lines" % (texts_num + 1))
- output.close()
- if __name__ == '__main__':
- main()
Stopwords and Window
除了之前演示的斷詞外,這邊還多做了兩件事,一是調整 jieba 的辭典,讓他對繁體斷詞比較友善,二是引入了停用詞,停用詞就是像英文中的 the,a,this,中文的你我他,與其他詞相比顯得不怎麼重要,對文章主題也無關緊要的,就可以將它視為停用詞。而要排除停用詞的理由,其實與 word2vec 的實作概念大大相關,由於在開頭講明了不深究概念,就讓我舉個例子替代長篇大論。
很顯然,一個詞的意涵跟他的左右鄰居很有關係,比如「雨越下越大,茶越充越淡」,什麼會「下」?「雨」會下,什麼會「淡」?茶會「淡」,這樣的類比舉不勝舉,那麼,若把思維逆轉過來呢?顯然,我們或多或少能從左右鄰居是誰,猜出中間的是什麼,這很像我們國高中時天天在練的英文克漏字。那麼問題來了,左右鄰居有誰?能更精確地說,你要往左往右看幾個?假設我們以「孔乙己 一到 店 所有 喝酒 的 人 便都 看著 他 笑」為例,如果往左往右各看一個詞:
這樣就構成了一個 size=1 的 windows,這個 1 是極端的例子,為了讓我們看看有停用詞跟沒停用詞差在哪,這句話去除了停用詞應該會變成:
我們看看「人」的窗口變化,原本是「的 人 便」,後來是「喝酒 人 看著」,相比原本的情形,去除停用詞後,我們對「人」這個詞有更多認識,比如人會喝酒,人會看東西,當然啦,這是我以口語的表達,機器並不會這麼想,機器知道的是人跟喝酒會有某種關聯,跟看會有某種關聯,但儘管如此,也遠比本來的「的 人 便」好太多太多了。
訓練詞向量
這是最簡單的部分,同時也是最困難的部分,簡單的是程式碼,困難的是詞向量效能上的微調與後訓練。對了,如果你已經對詞向量和語言模型有些研究,在輸入 python3 train.py 之前,建議先看一下下面的程式碼:
- train.py
- #!/usr/bin/env python3
- # -*- coding: utf-8 -*-
- import logging
- from gensim.models import word2vec
- def main():
- logging.basicConfig(format='%(asctime)s : %(levelname)s : %(message)s', level=logging.INFO)
- sentences = word2vec.LineSentence("wiki_seg.txt")
- model = word2vec.Word2Vec(sentences, size=250)
- # Save model into filesystem
- model.save("word2vec.model")
- # Load back trained model
- # model = word2vec.Word2Vec.load("your_model_name")
- if __name__ == "__main__":
- main()
- 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)
等摸清 Word2Vec 背後的原理後,也可以試著調調 hs、negative,看看對效能會有什麼影響。
詞向量實驗
訓練完成後,讓我們透過 "demo.py" 來測試一下模型的效能:
- demo.py
- #!/usr/bin/env python3
- # -*- coding: utf-8 -*-
- from gensim.models import word2vec
- from gensim import models
- import logging
- def main():
- logging.basicConfig(format='%(asctime)s : %(levelname)s : %(message)s', level=logging.INFO)
- model = models.Word2Vec.load('word2vec.model')
- print("提供 3 種測試模式\n")
- print("輸入一個詞,則去尋找前一百個該詞的相似詞")
- print("輸入兩個詞,則去計算兩個詞的餘弦相似度")
- print("輸入三個詞,進行類比推理")
- print("Enter 'exit' or 'quit' to stop the loop:")
- while True:
- try:
- query = input()
- q_list = query.split()
- if q_list[0].lower() == 'exit' or q_list[0].lower() == 'quit':
- print("Bye!")
- break
- if len(q_list) == 1:
- print("相似詞前 100 排序")
- res = model.most_similar(q_list[0],topn = 100)
- for item in res:
- print(item[0]+","+str(item[1]))
- elif len(q_list) == 2:
- print("計算 Cosine 相似度")
- res = model.similarity(q_list[0],q_list[1])
- print(res)
- else:
- print("%s之於%s,如%s之於" % (q_list[0],q_list[2],q_list[1]))
- res = model.most_similar([q_list[0],q_list[1]], [q_list[2]], topn= 100)
- for item in res:
- print(item[0]+","+str(item[1]))
- print("----------------------------")
- except Exception as e:
- print(repr(e))
- if __name__ == "__main__":
- main()
我們也能調用 model.similarity(word2,word1) 來直接取得兩個詞的相似度:
能稍微區隔出詞與詞之間的主題,整體來說算是可以接受的了。
更上一層樓
如何優化詞向量的表現?這其實有蠻多方法的,大方向是從應用的角度出發,我們能針對應用特化的語料進行再訓練,除此之外,斷詞器的選擇也很重要,它很大程度的決定什麼詞該在什麼地方出現,如果發現 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
沒有留言:
張貼留言