基於CBOW網絡手動實現面向中文語料的word2vec


最近在工作之余學習NLP相關的知識,對word2vec的原理進行了研究。在本篇文章中,嘗試使用TensorFlow自行構建、訓練出一個word2vec模型,以強化學習效果,加深理解。

 

.背景知識:

在深度學習實踐中,傳統的詞匯表達方式是使用one-hot向量,其中,向量的維度等於詞匯量的大小。這會導致在語料較為豐富,詞匯量較大的時候,向量的維度過長,進而產生一個相當大的稀疏矩陣,占用不少內存開銷,降低機器運行速度。而word2vec則為這個問題提供了一種解決方案。

word2vec是一個用來產生詞向量的相關模型,使用固定長度(長度較短)的詞向量來代替one-hot向量,從而降低深度學習網絡中的運算復雜度。其基本思想是使用skip-gram網絡/cbow網絡來對語料進行訓練,留下其中間過程所生成的權重矩陣作為詞向量表。這個詞向量表是一個[詞匯量大小*詞向量維度]的矩陣,在使用時,可以使用one-hot向量和詞向量表做矩陣乘法,提取出對應詞匯的詞向量以供后續使用。

除了降低詞向量的維度之外,word2vec可以使兩個含義相近的詞語用於更為接近的詞向量(即兩個詞向量之間的歐式距離更接近)。因此在搭建語言模型時擁有更強大的邏輯相關性。

現在已經有一個較為方便的word2vec模塊(由Google開發)供大家使用,可以通過 pip install word2vec 指令來對其進行安裝使用。為加深理解,強化學習效果,這里我們不使用該模塊,嘗試自己搭建預訓練網絡來生成word2vec詞向量表。

 

.建模思路:

這次的建模、訓練流程如下圖所示,大致分為語料預處理網絡搭建訓練存儲三個階段。

由於這次是對中文語料進行處理,因此預處理過程中較為復雜,相比處理英文語料多一個分詞的步驟。此外,也沒有查找到資料詳細的解釋word2vec是否需要對所有詞匯進行訓練,因此這里在預處理步驟中加入了詞頻統計,僅提取出10000個出現頻率最高的詞語作為常用詞匯進行建模訓練。

 

.語料預處理:

這里我們使用的是搜狐新聞語料庫(1.5GB)來作為我們的數據源。下載地址為:http://www.sogou.com/labs/resource/cs.php

首先我們要進行語料的預處理工作。搜狐新聞語料庫是一個結構化的語料數據文件,其中,新聞標題位於標簽<content-title></content-title>中,新聞內容位於標簽<content></content>中。第一步就是要將新聞內容從標簽中提取出來。

3.1 亂碼問題

在提取新聞內容時,遇到了一個比較棘手的編碼問題。在這個數據文件中,大部分的新聞內容是使用GBK方式進行編碼的,而其中又夾雜着某些字符使用了其他編碼格式,包括Unicode,ISO-8859-1等等。為解決這個問題,這里引入datadec模塊來判斷字符的編碼格式。最初的想法是使用datadec來判斷每一個字符,使用判斷出的格式來對其進行解碼。但實際問題是各種編碼格式所占字節數長度並不一致,如GBK是雙字節編碼,而UTF-8則是可變長字符編碼,從1-6字節不等,如果每次讀取一個固定長度來datadec其編碼類型,往往會判斷出錯。因此,在語料預處理中,讀取文件的每一行,使用datadec.detect()函數來判斷該行文件的編碼格式,之后用對應的格式進行解碼。其中遇到串碼問題無法正確解析的,在decode()函數中使用errors=’ignore’參數來對該問題進行忽略,保證程序的正常運行。但最終生成的文件還是會有部分亂碼,后期會使用詞頻統計的方式盡量將其過濾。

解決亂碼問題的關鍵代碼如下(注意讀文件時要使用二進制方式讀取,即’rb’):

 1 pure_file = open(path[:-4]+"_pure.txt",'w',encoding='utf-8')
 2 with codecs.open(path,'rb') as f_corp:
 3    # TODO 去除雜質編碼
 4     count = 0    # 記錄總共處理了多少行新聞文件
 5     count1 = 0   # 記錄通過非GBK編碼處理了多少行新聞文件
 6     lines = f_corp.readlines()
 7     for word in lines:
 8         try:
 9             count += 1
10             # 此處不設置 errors='ignore' 參數,嘗試使用GBK解碼,遇到解析問題時跳入except處理流程。
11             word_out = word.decode('gbk')
12             pure_file.write(word_out)
13         except:
14             count1 += 1
15             code_name = chardet.detect(word)
16             if code_name['confidence']>0.90:
17                 word_out = word.decode(code_name['encoding'],errors='ignore')
18                 pure_file.write(word_out)
19             else:
20                 continue
21 pure_file.close()

在解決完亂碼問題之后,我們通過匹配<content>標簽來提取所需要的新聞內容,使用邏輯判斷的方式。

1 content_file = open(path[:-4]+"_content.txt",'w',encoding='utf-8')
2 with open(path,'r',encoding='utf-8') as f_corp:
3     content_lines = f_corp.readlines()
4     for item in content_lines:
5         if item[:9]=='<content>' and len(item)>20:     # 需判斷長度,防止有<content></content> 的情形出現
6             content_file.write(item[9:-11]+'\n')
7 content_file.close()

最終獲得純凈的新聞內容供后續使用,類似下圖所示(可以看出仍有部分亂碼,后期會通過詞頻統計進行處理)。

 

 

3.2 分詞與詞頻統計

拿到純凈的新聞內容后,就可以進行分詞與詞頻統計工作。這里使用jieba分詞器來完成我們的分詞工作。jieba分詞器是一個輕量級的中文分詞模塊,可以使用pip install jieba指令來進行安裝。注意,由於jieba是第三方python模塊,因此不能夠使用conda來進行安裝。

考慮到后面制作訓練樣本時,各類詞語容易與標點形成訓練樣本,影響訓練效果。在分詞之前,首先借助unicodedata模塊中的category函數來去除新聞中的標點符號。

1 for ss in c_lines:
2     sentences = ''.join(ch for ch in ss if category(ch)[0]!='P')

對每一行新聞通過上述語句進行掃描,排除掉其中的標點符號。

之后使用jieba分詞器來對語句進行切分,並存入語料文件。

1 words = jieba.cut(sentences,cut_all=False)
2 words = ' '.join(words)  # 使用 空格 將每一個詞匯隔開 此時是str類型變量。
3 array_c.append(words[:-1])   # 去除句末的 '\n'

切分后語料如下:

可以看出,在文本中仍然存在有些許亂碼,這多少為后續的准確訓練埋下隱患,但由各種亂碼常常是孤立字符,可以通過統計常用詞的方式將其進行排除。

為完成詞頻統計工作,我們建立兩個list,array_di(記錄已統計的詞匯)以及array_dn(記錄array_di中各對應位置詞匯的出現次數)。代碼邏輯大致如下:挨個掃描新聞中詞匯,若該詞語已在存在於array_di中,則array_dn的對應位置+1;若該詞匯從未出現過,則將其添加在array_di的末尾,同時array_dn的末尾添加一個1,表示這個詞匯出現了一次。

在分詞與詞頻統計的過程中,會出現程序運行緩慢,CPU使用率較低的情況,可以使用多進程的方法分派工作,待所有子進程完工之后,再進行拼接。具體方法在我的上一篇學習筆記中有描述。(學習筆記-使用多進程、多線程加速文本內容預處理)

截取常用詞的工作可以使用numpy模塊的argsort()函數來對array_dn進行逆序排序,截取數值最大的10000個值所在的索引,提取出對應索引在array_di中的詞匯做成字典,保存在json文件內。

1 dict_list_index_last = np.array(array_dn)
2 word_frequent_list = np.argsort(-dict_list_index_last)    # 降序排序,並獲取其排序的索引順序
3 d_out = dict()            # TODO 最終常用詞詞典列表
4 count = 0
5 for index in word_frequent_list[:10000]:    # 提取10000個常用詞匯
6     d_out[array_di[index]] = count
7     count += 1
8 with open(pured_file[:-4]+'_dict.json','w',encoding='utf-8') as f_dict:
9     json.dump(d_out, f_dict)

最終生成的.json文件如下:

 

其中,key為詞匯的utf-8編碼,value值為其對應的位置,取值從0~9999。為后續構建初始one-hot vector作好了准備。

 

.網絡搭建:

4.1 模型結構設計

數據預處理工作完成以后,可以開展網絡結構的設計。有兩種網絡模型可以用來進行word2vec的訓練,分別是CBOW(Continuous Bag-of-Words Model)和skip-Gram(Continuous Skip-gram Model)。這兩個網絡的區別主要在於訓練樣本的構造。

CBOW構造一個訓練樣本時,樣本的輸入為當前詞匯的前n個詞和后n個詞,其中n表示窗口長度。例如對於句子[寒冷的 冬天 我 愛 在 學校 里 跑步],當窗口長度為2的時候,這個句子可以分解為4個訓練樣本,即[[寒冷的,冬天,愛,在],[我]],[[冬天,我,在,學校],[愛]],[[我,愛,學校,里],[在]],[[愛,在,里,跑步],[學校]]。其中每一個樣本的前半部分為輸入,后半部分為其對應的輸出。

而使用skip-Gram來構造訓練樣本時,同樣取向前n個詞和向后n個詞作為窗口。但輸入與輸出的維度是相等的,即訓練樣本以詞對的形式來展現。同樣對於[寒冷的 冬天 我 愛 在 學校 里 跑步]這個句子,窗口長度n=2。

輸出詞匯為[寒冷的]時,有[[寒冷的],[冬天]],[[寒冷的],[我]]兩個樣本,

輸入詞匯為[冬天]時,有[[冬天],[寒冷的]],[[冬天],[我]],[[冬天],[愛]]三個樣本,

輸入詞匯為[我]時,有[[我], [寒冷的]],[[我], [冬天]],[[我],[愛]],[[我],[在]]這四個樣本。

以此類推,這句話一共可以生成2+3+4+4+4+4+3+2=26個樣本。相對於CBOW網絡來說訓練內容要豐富一些。

考慮到訓練量過大會比較考驗機器性能,這里選擇使用CBOW網絡來完成word2vec的訓練。

現在開始考慮網絡的維度結構,因為選擇的是CBOW網絡,所以說初始的輸入是2*n個詞匯(n表示窗口長度),即2*n個one-hot vector,疊加成的矩陣,由於預處理中截取的詞匯量為10000,所以輸入矩陣的維度為[2n*10000];同樣的,由於輸出僅僅只有一個詞匯,所以樣本的輸出是一個維度為[1*10000]的one-hot vector。這里假設目標詞向量的維度為300,因此詞向量表的維度為[10000*300]。輸入矩陣和詞向量表經過矩陣乘法相乘,可以得到一個維度為[2n*300]的矩陣,即2n個詞匯經過降維所得到的較短的詞向量。為了使其可以正確的和樣本輸出計算進行對應,需將其正確的映射到[1*10000]的維度。這里使用[1*2n]×[2n*300]×[300*10000]的方法,將其轉換為[1*10000]的向量,經過softmax激活函數計算,可以同樣本輸出計算出loss值,並根據loss使用隨機梯度下降法來對網絡進行訓練。

網絡的模型維度設計示意圖如下:

 

如圖所示,搭建此次網絡模型需要初始化tar_weight,front_weight,back_weight三個權重矩陣。

根據上面設計的網絡維度結果,開始構建CBOW網絡類:

 1 import numpy as np
 2 import tensorflow as tf
 3 
 4 class CBOW_Cell(object):
 5     def __init__(self, window_length=5, word_dim=300):
 6         with tf.variable_scope("matrix_scope") as matrix_scope:
 7             self.tar_weight = tf.get_variable(name='tar_weight',shape=[10000,word_dim],\
 8                 initializer=tf.truncated_normal_initializer(stddev=0.1),dtype=tf.float32)
 9             self.front_weight = tf.get_variable(name='front_weight',shape=[1,2*window_length],\
10                 initializer=tf.truncated_normal_initializer(stddev=0.1),dtype=tf.float32)
11             self.back_weight = tf.get_variable(name='back_weight',shape=[word_dim,10000],\
12                 initializer=tf.truncated_normal_initializer(stddev=0.1),dtype=tf.float32)
13             matrix_scope.reuse_variables()
14         # 上方為tar_weight,front_weight,back_weight 三個權重矩陣的維度設置及初始化。
15         # 下方為偏移量權重的設置 及 變量保存。
16         self.bias = tf.Variable(tf.zeros([1,10000])) # 偏移量,用於加到softmax前的輸出上
17         self.word_dim = word_dim   # 詞向量維度
18         self.window_length = window_length    
19         # 下方為占位符,規定好輸入、輸出的shape
20         self.sample_in = tf.placeholder(tf.float32, [2*window_length, 10000],name='sample_in')
21         self.sample_out = tf.placeholder(tf.float32, [1, 10000],name='sample_out')

除了上面提到的3個權重矩陣需要使用tf.get_variable()進行初始化,還額外的需要兩個占位符sample_in,sample_out來表示訓練樣本輸入及訓練樣本輸出。

下一步來設計前向傳播函數以及損失函數:

 1     def forward_prop(self,s_input):
 2         step_one = tf.matmul(s_input,self.tar_weight)
 3         out_vector = tf.matmul(tf.matmul(self.front_weight,step_one),self.back_weight)+self.bias
 4         return out_vector
 5     
 6     def loss_func(self,lr=0.001):
 7         out_vector = self.forward_prop(self.sample_in)
 8         y_pre = tf.nn.softmax(out_vector,name='y_pre')
 9         cross_entropy = tf.nn.softmax_cross_entropy_with_logits_v2(labels=self.sample_out,logits=y_pre)
10         train_op = tf.train.GradientDescentOptimizer(lr).minimize(cross_entropy)
11         return y_pre,cross_entropy,train_op

前向傳播函數forward_prop()使用占位符sample_in來計算出維度為[1,10000]的輸出向量。而損失函數loss_func()通過softmax來計算出前向預測結果y_pre,並通過交叉熵函數計算出損失值,train_op是定義了隨機梯度下降的權重優化計算圖。在后續的程序中,我們可以通過train_op來對權重進行優化。

 

4.2 制作訓練樣本

在前面分詞工作結束之后,我們獲得了類似[寒冷的 冬天 我 愛 在 學校 里 跑步]的語料樣本。這里要根據這個語料樣本,以及窗口大小n,來制作輸入維度為[2n*10000],輸出維度為[1*10000]的訓練樣本。

制作訓練樣本分為如下幾步:①對於一條新聞來說,首先就是要確定句子的長度,如果句子包含的詞語數≤2*n時,該語句無法拼接成樣本,直接將其進行棄置。②對於長度充足的句子,我們取一個長度為2n+1的滑動窗口,前n個詞和后n個詞做成維度為[1*10000]的one-hot vector,疊加成為[2n*10000]的輸入矩陣,中間詞匯做成[1*10000]的one-hot vector作為樣本輸出。將輸入和輸出組成對進行輸出。③為了使樣本更多一些,對於一個句子開頭和結束的幾個詞語,在目標詞匯前方/后方的詞語數目小於窗口長度時,順着后方/前方窗口額外取數個詞語使輸入詞語數目達到2n,再組成[2n*10000]的輸入矩陣,將目標詞匯做成one-hot vector向量作為樣本輸出。(第③步的樣本制作方法的目標是增加訓練樣本數目,但該方法是否科學合理仍有待論證)

為此,編寫樣本制作函數如下:

 1 def make_samples(crop_lines_all,index_to_word,word_to_index,window_len,i):   #參數中的i指第幾輪語料
 2     # 一次處理5行語料防止內存溢出
 3     crop_lines = crop_lines_all[i*5:(i+1)*5]
 4     sample_in_list = []     # 輸入樣本list
 5     sample_out_list = []    # 輸出樣本list
 6     for line in crop_lines:
 7         line_list = line.split(' ')
 8         line_list = [word for word in line_list if word in index_to_word]
 9         if len(line_list)<window_len*2+1:     # 如果語句詞匯過少,則拋棄這條語句
10             continue
11         else:
12             # 詞語大於雙倍窗口的情況下,可以開始拼接樣本
13             for i2 in range(len(line_list)):
14                 # 句子開頭幾個詞語,前側的詞語數量不夠window_len,則后側多取一些詞語攢齊2*window_len的長度
15                 if i2<window_len+1:   
16                     temp_line_list = line_list[:i2]+line_list[i2+1:2*window_len+1]
17                     sample_in_list.append(input_matrix_calc(word_to_index,temp_line_list))
18                     temp_out_sample = np.zeros(10000)
19                     temp_out_sample[word_to_index[line_list[i2]]] = 1.0
20                     sample_out_list.append(temp_out_sample)
21                 # 句子末尾幾個詞語,后側的詞語數量不夠window_len,則前側多取一些詞語攢齊2*window_len的長度
22                 elif i2>=len(line_list)-window_len: 
23                     temp_line_list = line_list[len(line_list)-2*window_len-1:i2]+line_list[i2+1:]
24                     sample_in_list.append(input_matrix_calc(word_to_index,temp_line_list))
25                     temp_out_sample = np.zeros(10000)
26                     temp_out_sample[word_to_index[line_list[i2]]] = 1.0
27                     sample_out_list.append(temp_out_sample)
28                 # 處於中間階段,前窗口和后窗口都不越界
29                 else:
30                     temp_line_list = line_list[i2-window_len:i2]+line_list[i2+1:i2+1+window_len]
31                     sample_in_list.append(input_matrix_calc(word_to_index,temp_line_list))
32                     temp_out_sample = np.zeros(10000)
33                     temp_out_sample[word_to_index[line_list[i2]]] = 1.0
34                     sample_out_list.append(temp_out_sample)
35     return np.array(sample_in_list),np.array(sample_out_list)

參數列中的i是用於分批制作樣本的參數,每次處理5行語料,來防止內存溢出,因為一個樣本對應了一個巨大的稀疏矩陣,因此每次少處理一些語料比較保險。

 

.訓練存儲:

由於需要TensorFlow的Session中完成訓練的步驟,因此訓練及存儲的工作需要在CBOW網絡類中實現。編輯功能函數train_model如下:

 1 def train_model(self, savepath,crop_lines_all,index_to_word,word_to_index,epochs=1000,lr=0.001):
 2         y_pre,cross_entropy,train_op = self.loss_func(lr)  # TODO TODO TODO  這句話千萬不能放到循環里面,會重復繪制計算圖!!!運行很慢!!
 3         with tf.Session() as sess:
 4             sess.run(tf.global_variables_initializer())
 5             for data_num in range(int(len(crop_lines_all)/5)):
 6                 pass # 生成 in_list out_list 
 7                 in_list,out_list = make_samples(crop_lines_all,index_to_word,\
 8                     word_to_index,self.window_length,data_num)   #一次20個行的處理語料樣本
 9                 out_list = out_list.reshape(len(in_list),1,10000)
10                 if (data_num)%50==0:
11                     print('樣本已處理',data_num*5,'/',len(crop_lines_all),'行。 ',datetime.datetime.now().strftime('%H:%M:%S.%f'))
12                 for i in range(epochs):
13                     for j in range(len(in_list)):
14                         sess.run(train_op, feed_dict={self.sample_in:in_list[j], \
15                             self.sample_out:out_list[j]})
16         #下面為存儲模型的代碼
17             tar_weight=self.tar_weight.eval()   # 這個就是詞向量表[10000*詞向量維度],是word2vec的最終目標
18             front_weight=self.front_weight.eval()
19             back_weight=self.back_weight.eval()
20             bias=self.bias.eval()
21             word_dim=self.word_dim
22             window_length=self.window_length
23             np.savez(savepath,tar_weight=tar_weight,front_weight=front_weight,\
24                 back_weight=back_weight,bias=bias,word_dim=word_dim,window_length=window_length)
25             print('model saved in:',savepath)

在該函數中,首先獲取損失函數所返回的三個計算圖:y_pre,cross_entropy,train_op。之后建立Session,初始化權重矩陣,通過制作訓練樣本的函數獲取訓練樣本列表,對於每個樣本分別使用train_op進行訓練,優化權重矩陣。經過了數輪訓練,將權重矩陣及CBOW網絡類的參數存入.npz文件,這個文件以字典形式保存權重矩陣,其中tar_weight是我們最終目標的詞向量表。

 

.實踐與結果驗證:

6.1 詞向量表調用:

使用np.load()函數便可以加載.npz文件,並獲取詞向量表tar_weight。

param_dict = np.load(filepath)
tar_weight = param_dict['tar_weight']

我們也可以通過np.linalg.norm()函數來計算兩個詞向量之間的歐氏距離,通過下面數個詞匯來觀察詞向量距離變化。

dist = np.linalg.norm(w2v[word_to_index['車輛']] - w2v[word_to_index['車子']]) 
print('\"車輛\" 與 \"車子\" 之間的歐式距離為:',dist,'!!')
dist = np.linalg.norm(w2v[word_to_index['機械']] - w2v[word_to_index['工業化']]) 
print('\"機械\" 與 \"工業化\" 之間的歐式距離為:',dist,'!!')
dist = np.linalg.norm(w2v[word_to_index['車輛']] - w2v[word_to_index['茶葉']]) 
print('\"車輛\" 與 \"茶葉\" 之間的歐式距離為:',dist,'!!')
dist = np.linalg.norm(w2v[word_to_index['糧食']] - w2v[word_to_index['手表']]) 
print('\"糧食\" 與 \"手表\" 之間的歐式距離為:',dist,'!!')

 

6.2 效果呈現:

最初,經過4000條新聞的訓練,詞匯之間的關系還比較散亂,詞語之間的關系隨機性較為明顯(下圖左)。后經過10萬條新聞的訓練(花了大約24小時….),隨着網絡內部參數的調整,[車輛,車子],[機械,工業化]這些意義接近的詞組之間的歐式距離變小,而[車輛,茶葉][糧食,手表]這些意義較遠的詞匯歐氏距離變大(下圖右)。

          

但是訓練的速度還是較慢,與谷歌提供的word2vec模塊依然有較大差距。

 

.后記

這次自己手動實現word2vec,主要是為了鞏固前期的學習成果,在實踐的過程中仍然發現了不少待探究的細節。

第一個處理不到位的地方就是對不常用的詞的處理方法。本次實踐中,我們將其做了刪除操作,將非常用詞排除在樣本制作之外。這樣做有可能會丟失部分信息。一種處理了思路是使用unknown標簽來將不常用的詞進行概括,作為詞匯表的一部分。

還有一個疑問就是對於標點符號的處理是否妥當。本次實踐中,我們將語料中的標點進行了刪除,之后再進行分詞操作,主要目的是提高處理效率,但標點符號對語句結構的影響會被忽略。

當然,這次我所構架的僅僅是一個簡單的結構,還有部分優化策略沒有使用,導致訓練速度特別的慢。一方面原因是負采樣策略的缺失。如果使用采樣數為5的負采樣策略的話,每一次隨機梯度下降過程將會只調整6個(5個負樣本和一個正樣本)權重值,計算量僅相當於現有情況的萬分之六,訓練速度也會飛速提升。(直至網絡搭建完成之后,經查閱手冊才發現有個tf.nn. nce-loss()函數可以實現負采樣功能,后期需要繼續對其進行深入學習研究。)

另一個待優化的區域是訓練樣本制作方面,本次實踐所采用的訓練樣本制作方法仍較為笨拙。(該網絡搭建完成后,查閱有關資料,發現tf.nn.embedding_lookup()函數可以進行查表操作,因此省去第一步one-hot向量的制作。)我在本機使用Google開發的word2vec模塊,可以在幾分鍾之內將30多萬行經過分詞的語料訓練完畢,而我這個手擼的CBOW網絡模型訓練10萬行語句就消耗了24小時,其中大部分時間都消耗在了樣本制作上。后續可參考word2vec的源碼繼續深入學習研究。

 

 

參考資料:

https://mp.weixin.qq.com/s/u2IumPRlzr4uHStrWXM87A

http://www.dataguru.cn/article-13488-1.html

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM