最近在工作之余學習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