以 gensim 訓練中文詞向量

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

太麻煩的事我不會做

儘管後來還是搞定了

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

取得語料

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

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

1
pip3 install --upgrade gensim

維基百科下載好後,先別急著解壓縮,因為這是一份 xml 文件,裏頭佈滿了各式各樣的標籤,我們得先想辦法送走這群不速之客,不過也別太擔心,gensim 早已看穿了一切,藉由調用 wikiCorpus,我們能很輕鬆的只取出文章的標題和內容。

初始化WikiCorpus後,能藉由get_texts()可迭代每一篇文章,它所回傳的是一個tokens list,我以空白符將這些 tokens 串接起來,統一輸出到同一份文字檔裡。這邊要注意一件事,get_texts()wikicorpus.py中的變數ARTICLE_MIN_WORDS限制,只會回傳內容長度大於 50 的文章。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
# -*- 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():
output.write(' '.join(text) + '\n')
texts_num += 1
if texts_num % 10000 == 0:
logging.info("已處理 %d 篇文章" % texts_num)

if __name__ == "__main__":
main()

在shell 裡輸入:

1
python3 wiki_to_txt.py zhwiki-20160820-pages-articles.xml.bz2

如果你的資料不是 8 月 20 號的備份,記得把zhwiki-20160820-pages-articles.xml.bz2換成你的備份的檔名唷。這約需花費 20 分鐘來處理,就讓我們先看一下接下來還要做些什麼吧~

開始斷詞

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

1
2
快速安裝結巴
pip3 install jieba
1
2
3
4
5
6
7
8
# 斷詞示例

import jieba

seg_list = jieba.cut("我来到北京清华大学", cut_all=False)
print("Default Mode: " + "/ ".join(seg_list)) # 精确模式

#輸出 Default Mode: 我/ 来到/ 北京/ 清华大学

現在,我們上一階段的檔案也差不多出爐了,以vi打開看起來會是這個樣子:

1
歐幾里得 西元前三世紀的希臘數學家 現在被認為是幾何之父 此畫為拉斐爾的作品 雅典學院 数学 是利用符号语言研究數量 结构 变化以及空间等概念的一門学科 从某种角度看屬於形式科學的一種 數學透過抽象化和邏輯推理的使用 由計數 計算 數學家們拓展這些概念......

Opps!出了一點狀況,我們發現簡體跟繁體混在一起了,比如「数学」與「數學」會被 word2vec 當成兩個不同的詞,所以我們在斷詞前,還需加上一道繁簡轉換的手續。然而我們的語料集相當龐大,一般的繁簡轉換會有些力不從心,建議採用OpenCC,轉換的方式很簡單:

1
opencc -i wiki_texts.txt -o wiki_zh_tw.txt -c s2tw.json

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35

# -*- coding: utf-8 -*-

import jieba
import logging

def main():

logging.basicConfig(format='%(asctime)s : %(levelname)s : %(message)s', level=logging.INFO)

# jieba custom setting.
jieba.set_dictionary('jieba_dict/dict.txt.big')

# load stopwords set
stopword_set = set()
with open('jieba_dict/stopwords.txt','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("已完成前 %d 行的斷詞" % (texts_num + 1))
output.close()

if __name__ == '__main__':
main()

Stopwords and Window

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

首先,在word2vec有一個概念叫 windows,我習慣叫他窗口,因為它給我的感覺跟TCP 那個會滑來滑去的東東很像。

Word2Vec

很顯然,一個詞的意涵跟他的左右鄰居很有關係,比如「雨越下越大,茶越充越淡」,什麼會「下」?「雨」會下,什麼會「淡」?茶會「淡」,這樣的類比舉不勝舉,那麼,若把思維逆轉過來呢?

逆轉

顯然,我們或多或少能從左右鄰居是誰,猜出中間的是什麼,這很像我們國高中時天天在練的英文克漏字。那麼問題來了,左右鄰居有誰?能更精確地說,你要往左往右看幾個?假設我們以「孔乙己 一到 店 所有 喝酒 的 人 便都 看著 他 笑」為例,如果往左往右各看一個:

1
2
3
4
5
[孔乙己 一到] 店 所有 喝酒 的 人 便 都 看著 他 笑
[孔乙己 一到 店] 所有 喝酒 的 人 便 都 看著 他 笑
孔乙己 [一到 店 所有] 喝酒 的 人 便 都 看著 他 笑
孔乙己 一到 [店 所有 喝酒] 的 人 便 都 看著 他 笑
......

這樣就構成了一個 size=1 的 windows,這個 1 是極端的例子,為了讓我們看看有停用詞跟沒停用詞差在哪,這句話去除了停用詞應該會變成:

1
孔乙己 一到 店 所有 喝酒 人 看著 笑

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

就在剛剛,我的斷詞已經跑完了,現在,讓我們進入收尾的階段吧

1
2016-08-26 22:27:59,480 : INFO : 已處理 260000 個 token

訓練詞向量

這是最簡單的部分,同時也是最困難的部分,簡單的是程式碼,困難的是詞向量效能上的微調與後訓練。對了,如果你已經對詞向量和語言模型有些研究,在輸入python3 train.py之前,建議先看一下之後的內文,相信我,你會需要的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# -*- 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)

#保存模型,供日後使用
model.save("word2vec.model")

#模型讀取方式
# model = word2vec.Word2Vec.load("your_model_name")

if __name__ == "__main__":
main()

扣掉logging與註釋就剩下三行,真是精簡的漂亮。上頭通篇的學問在model = word2vec.Word2Vec(sentences, size=250),我們先讓它現出原型:

1
class gensim.models.word2vec.Word2Vec(sentences=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, cbow_mean=1, hashfxn=<built-in function hash>, iter=5, null_word=0, trim_rule=None, sorted_vocab=1, batch_words=10000)

這抵得上 train.py 的所有程式碼了。不過也別太擔心,裏頭多是無關緊要的參數,從初學的角度來看,我們會去動到的大概是:

  • sentences:當然了,這是要訓練的句子集,沒有他就不用跑了
  • size:這表示的是訓練出的詞向量會有幾維
  • alpha:機器學習中的學習率,這東西會逐漸收斂到 min_alpha
  • sg:這個不是三言兩語能說完的,sg=1表示採用skip-gram,sg=0 表示採用cbow
  • window:還記得孔乙己的例子嗎?能往左往右看幾個字的意思
  • workers:執行緒數目,除非電腦不錯,不然建議別超過 4
  • min_count:若這個詞出現的次數小於min_count,那他就不會被視為訓練對象

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

詞向量實驗

訓練完成後,讓我們來測試一下模型的效能,運行python3 demo.py。由於 gensim 將整個模型讀了進來,所以記憶體會消耗相當多,如果出現了MemoryError,可能得調整一下min_count或對常用詞作一層快取,這點要注意一下。

先來試試相似詞排序吧!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
飲料

相似詞前 100 排序
飲品,0.8439314365386963
果汁,0.7858869433403015
罐裝,0.7305712699890137
冰淇淋,0.702262818813324
酸奶,0.7007108926773071
口香糖,0.6987590193748474
酒類,0.6967358589172363
可口可樂,0.6885123252868652
酒精類,0.6843742728233337
含酒精,0.6825539469718933
啤酒,0.6816493272781372
薯片,0.6779764294624329
紅茶,0.6656282544136047
奶茶,0.656740128993988
提神,0.6566425561904907
牛奶,0.6556192636489868
檸檬茶,0.6494661569595337

籃球

相似詞前 100 排序
美式足球,0.6463411450386047
橄欖球,0.6382837891578674
男子籃球,0.6187020540237427
冰球,0.6056296825408936
棒球,0.5859025716781616
籃球運動,0.5831792950630188
籃球員,0.5782726407051086
籃球隊,0.576259195804596
排球,0.5743488073348999
黑子,0.5609416961669922
籃球比賽,0.5498511791229248
打球,0.5496408939361572
中國籃球,0.5471529960632324
男籃,0.5460700392723083
ncaa,0.543986439704895
投投,0.5439497232437134
曲棍球,0.5435376167297363
nba,0.5415610671043396

前100太多了,所以只把前幾個結果貼上來,我們也能調用model.similarity(word2,word1)來直接取得兩個詞的相似度:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
冰沙 刨冰
計算 Cosine 相似度
0.631961417455

電腦 飛鏢
計算 Cosine 相似度
0.154503715708

電腦 程式
計算 Cosine 相似度
0.5021829415

衛生紙 漫畫
計算 Cosine 相似度
0.167776641495

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

更上一層樓

如何優化詞向量的表現?這其實有蠻多方法的,大方向是從應用的角度出發,我們能針對應用特化的語料進行再訓練,除此之外,斷詞器的選擇也很重要,它很大程度的決定什麼詞該在什麼地方出現,如果發現 jieba 有些力不能及的,不妨試著採用別的斷詞器,或是試著在 jieba 自訂辭典,調一下每個詞的權重。

應用考慮好了,接著看看模型,我們可以調整 model() 的參數,比方窗口大小、維度、學習率,進一步還能比較 skip-gram 與 cbow 的效能差異,什麼,你說不知道 skip-gram 跟 cbow 是什麼?且看下回分解。

參考資料

Training Word2Vec Model on English Wikipedia by Gensim