Word2Vec原理及代碼
一、分布式詞表示(Distributed Represantation)
Word2Vec 是 Google 於 2013 年開源推出的一個用於獲取 word vector 的工具包,具體思想可由Tomas Mikolov的兩篇論文一探究竟。此文是我對Word2Vec學習的總結。分布式詞表示使用低維、稠密、連續的向量來表示詞。它們通過“有指導”的方法直接學習詞向量,也稱為詞嵌入(Word Embedding)。它的發展歷程由最初的LSA、LDA、NNLM發展到後來的諸如RNNLM、Word2Vec、GloVe,以及現在最新的ELMo和BERT。
我們知道,最簡單的一種詞表示方式是One-Hot表示,它的優點是很容易表示出不同的詞,但是缺點也是巨大的:一、所有詞向量正交,即無法找到詞與詞之間的關系。二、當有非常大的數據量時,向量的維度會因此擴大而造成維度災難。如下所示,如果用One-Hot方式來表示自然界生物,自然界的動物種類數以百萬記,基本不可能用這種方式來表示動物類別,並且也無法表示諸如鯉魚吃河蝦,老虎吃天鵝,鱷魚和天鵝都會下蛋的關系。
鱷魚 [1,0,0,0,0,0,……,0,0,0,0,0,0,0]
天鵝 [0,1,0,0,0,0,……,0,0,0,0,0,0,0]
鯉魚 [0,0,0,0,1,0,……,0,0,0,0,0,0,0]
河蝦 [0,0,0,0,0,0,……,1,0,0,0,0,0,0] … 老虎 [0,0,0,0,0,0,……,0,0,0,0,0,0,1] 分布式詞則可以用較低維度的矩陣去表示一個詞,並且“賦予”矩陣各行列有各自的意義,詞與詞之間的相似度也可以通過矩陣運算來求得。
二、CBOW與skip-gram的區別
CBOW全稱Continuous Bag-of-Words Model,Skip-Gram全稱Continuous skip-gram Model。兩者是Word2Vec裏重要的模型,CBOW模型的訓練輸入是某一個特征詞的上下文相關的詞對應的詞向量,而輸出就是這特定的一個詞的詞向量。Skip-Gram模型和CBOW的思路是反著來的,即輸入是特定的一個詞的詞向量,而輸出是特定詞對應的上下文詞向量。這兩種模型都包含三層,輸入層、投影層、輸出層:
由上圖可見,CBOW是在已知當前詞 wt 的上下文 wt-2,wt-1,wt+1,wt+2 的前提下預測當前詞wt;而Skip-gram則是已知當前詞 wt 的前提下 ,去預測 wt-2,wt-1,wt+1,wt+2 。
三、Hierarchical Softmax框架與Negative Sampling框架
Hierarchical Softmax 和 Negative Sampling是Word2Vec設計的兩種實現框架。Hierachical Softmax的基本思想是:它把訓練語料中的詞當成葉子節點,其在語料中出現的次數當做權值,通對於詞典Dictionary中的任意詞 w,Huffman 樹中必存在一條從根節點到詞 w 對應結點的路徑,由此每個詞對應一個Huffman編碼。而 Negative Sampling 不再使用復雜的 Huffman樹,而是采用隨機負采樣,可以減少訓練時間和大幅度提高性能。
何為負采樣算法呢?訓練一個神經網絡,樣本只要有所改變(添加、修改、刪除)就需要稍微調整所有的神經網絡權重,這樣才能確保訓練結果的準確性。如果有一個巨大數據集,一個樣本的改變都會改變神經網絡的權重,代價是高昂的。而負采樣的好處是,每一個訓練樣本僅僅改變一小部分的權重而不是所有的權重,解決了這個問題。比如,當我要進行對 ”ready“ 這個單詞進行訓練時,使用負采樣,隨機選擇較少數目(小樣本一般為5~20個,大樣本為2~5個)的 ”負“ 樣本進行權重更新,並且仍然為我們的 ”正“ 單詞更新其對應的權重。詞典 Dictionary 中的詞在語料Corpus中出現的次數不同,那麽對於高頻詞而言,被選為負樣本的概率就應該比較大,而對於低頻詞,被選中的概率就應該小。
如果我們的輸出層大約有 300 x 10,000 維度的權重矩陣,對 ”quick“ 一次進行權重更新,加上額外5個 負樣本的權重更新,一共是6個輸出神經元,和1800個權重值。這些值,僅僅只占輸出層 3,000,000 神經元中的 0.06%。
四、代碼實現
此代碼已上傳github,點擊此處查看。網上有很多關於Word2Vec的實現,本文最初參考此代碼以及一些現成代碼,在後邊的參考文章中列舉。
import collections import math import os import random import zipfile import numpy as np import urllib import tensorflow as tf import matplotlib.pyplot as plt from sklearn.manifold import TSNE # Step 1 : 準備數據文檔 url = ‘http://mattmahoney.net/dc/‘ def download_check(filename, expected_bytes): """下載數據集,如果存在就確認跳過.""" if not os.path.exists(filename): print(‘正在下載所需數據包 …‘) filename, _ = urllib.request.urlretrieve(url + filename, filename) statinfo = os.stat(filename) if statinfo.st_size == expected_bytes: print(‘確認為目標文件 ‘, filename) else: print(statinfo.st_size) raise Exception( ‘文件大小不對應 ‘ + filename + ‘請前往 http://mattmahoney.net/dc/text8.zip 獲取數據集‘) return filename filename = download_check(‘text8.zip‘, 31344016) # Step 2 : 解壓文件 def read_data(filename): """讀取zip的第一個文件並且分割單詞為字符串數組""" with zipfile.ZipFile(filename) as f: data = tf.compat.as_str(f.read(f.namelist()[0])).split() return data words = read_data(filename) print(‘數據長度‘, len(words)) vocabulary_size = 50000 # Step 3 : 準備數據集 def build_dataset(words): """在字典第一個位置插入一項“UNK"代表不能識別的單詞,也就是未出現在字典的單詞統一用UNK表示""" # [[‘UNK‘, -1], [‘i‘, 500], [‘the‘, 498], [‘man‘, 312], ...] count = [[‘UNK‘, -1]] # dictionary {‘UNK‘:0, ‘i‘:1, ‘the‘: 2, ‘man‘:3, ...} 收集所有單詞詞頻 count.extend(collections.Counter(words).most_common(vocabulary_size - 1)) # python中K/V的一種數據結構"字典 dictionary = dict() for word, _ in count: dictionary[word] = len(dictionary) data = list() unk_count = 0 for word in words: if word in dictionary: index = dictionary[word] else: index = 0 # dictionary[‘UNK‘] unk_count += 1 data.append(index) count[0][1] = unk_count reverse_dictionary = dict(zip(dictionary.values(), dictionary.keys())) return data, count, dictionary, reverse_dictionary data, count, dictionary, reverse_dictionary = build_dataset(words) del words print(‘詞頻最高的詞‘, count[:5]) print(‘數據樣例‘, data[:10], [reverse_dictionary[i] for i in data[:10]]) data_index = 0 # Step 4 : skip-gram def generate_batch(batch_size, num_skips, skip_window): global data_index #global關鍵字 使data_index 可在其他函數中修改其值 assert batch_size % num_skips == 0 #assert斷言用於判斷後者是否為true,如果返回值為假,處罰異常 assert num_skips <= 2 * skip_window batch = np.ndarray(shape=(batch_size), dtype=np.int32) #ndarray對象用於存放多維數組 labels = np.ndarray(shape=(batch_size, 1), dtype=np.int32) span = 2 * skip_window + 1 # [ skip_window target skip_window] # 初始化最大長度為span的雙端隊列,超過最大長度後再添加數據,會從另一端刪除容不下的數據 # buffer: 1, 21, 124, 438, 11 buffer = collections.deque(maxlen=span) #創建一個隊列,模擬滑動窗口 for _ in range(span): buffer.append(data[data_index]) data_index = (data_index + 1) % len(data) for i in range(batch_size // num_skips): # // 是整數除 # target : 2 target = skip_window # target label at the center of the buffer # target_to_avoid : [2] targets_to_avoid = [ skip_window ] # 需要忽略的詞在當前的span位置 # 更新源單詞為當前5個單詞的中間單詞 source_word = buffer[skip_window] # 隨機選擇的5個span單詞中除了源單詞之外的4個單詞中的兩個 for j in range(num_skips): while target in targets_to_avoid: target = random.randint(0, span - 1) targets_to_avoid.append(target) # 已經經過的target放入targets_to_avoid #batch中添加源單詞 batch[i * num_skips + j] = source_word #labels添加目標單詞,單詞來自隨機選擇的5個span單詞中除了源單詞之外的4個單詞中的兩個 labels[i * num_skips + j, 0] = buffer[target] # 往雙端隊列中添加下一個單詞,雙端隊列會自動將容不下的數據從另一端刪除 buffer.append(data[data_index]) data_index = (data_index + 1) % len(data) return batch, labels # Step 5 : 構建一個包含隱藏層的神經網絡,隱藏層包含300節點,與我們要構造的WordEmbedding維度一致 batch, labels = generate_batch(batch_size=8, num_skips=2, skip_window=1) # 打印數據樣例中的skip-gram樣本 for i in range(8): print(‘(‘,batch[i], reverse_dictionary[batch[i]], ‘,‘, labels[i, 0], reverse_dictionary[labels[i, 0]],‘)‘) """ ( 3081 originated , 12 as ) ( 3081 originated , 5234 anarchism ) ( 12 as , 6 a ) ( 12 as , 3081 originated ) ( 6 a , 12 as ) ( 6 a , 195 term ) ( 195 term , 6 a ) ( 195 term , 2 of ) """ batch_size = 128 embedding_size = 128 # Demension of the embedding vector skip_window = 1 # How many words to consider left and right num_skips = 2 # How many times to reuse an input to generate a label valid_size = 16 # Random set of words to evaluate similarity on valid_window = 100 # Only pick dev samples in the head of the distribution valid_examples = np.random.choice(valid_window, valid_size, replace=False) num_sampled = 64 # Number of negative examples to sample graph = tf.Graph() with graph.as_default(): # 定義輸入輸出 train_inputs = tf.placeholder(tf.int32, shape=[batch_size]) train_labels = tf.placeholder(tf.int32, shape=[batch_size, 1]) valid_dataset = tf.constant(valid_examples, dtype=tf.int32) # 當缺少GPU時,用CPU來進行訓練和操作變量 with tf.device(‘/cpu:0‘): # 初始化embedding矩陣,後邊經過多次訓練後我們得到的結果就放在此embedding矩陣; # tf.Variable是圖變量,tf.radom_uniform產生一個在[-1,1]間均勻分布的size為[vocabulary_size, embedding_size]的矩陣 embeddings = tf.Variable( tf.random_uniform([vocabulary_size, embedding_size], -1.0, 1.0)) # 將輸入序列轉換成embedding表示, [batch_size, embedding_size] # tf.nn.embedding_lookup的作用就是找到要尋找的embedding data中的對應的行下的vector emded = tf.nn.embedding_lookup(embeddings, train_inputs) # 初始化權重,此處使用負例采樣NCE loss損失函數 # tf.truncated_normal(shape, mean, stddev) :shape表示生成張量的維度,mean是均值,stddev是標準差。這個函數產生正太分布, # 均值和標準差自己設定。這是一個截斷的產生正太分布的函數,就是說產生正太分布的值如果與均值的差值大於兩倍的標準差,那就重新生成。 nce_weights = tf.Variable( tf.truncated_normal([vocabulary_size, embedding_size], stddev=1.0 / math.sqrt(embedding_size))) nce_biases = tf.Variable(tf.zeros([vocabulary_size])) # Compute the average NCE loss for the batch # tf.nce_loss automatically draws a new sample of the negative labels each # time we evalute the loss loss = tf.reduce_mean( tf.nn.nce_loss(weights = nce_weights, biases = nce_biases, labels = train_labels, inputs = emded, num_sampled = num_sampled, num_classes = vocabulary_size )) # 使用1.0的速率來構造SGD優化器 optimizer = tf.train.GradientDescentOptimizer(1.0).minimize(loss) # 計算 minibatch 和 all embeddings的余弦相似度 # tf.reduce_sum() 按照行的維度求和 norm = tf.sqrt(tf.reduce_sum(tf.square(embeddings), 1, keep_dims=True)) normalized_embeddings = embeddings / norm valid_embeddings = tf.nn.embedding_lookup(normalized_embeddings, valid_dataset) # tf.matmul 矩陣相乘 similarity = tf.matmul(valid_embeddings, normalized_embeddings, transpose_b=True) # 添加變量初始化程序 init = tf.global_variables_initializer() # Step 6 : 開始訓練 # 訓練次數 num_steps = 100001 # tf.Session 用於運行TensorFlow操作的類 with tf.Session(graph=graph) as session: # 我們必須在使用之前初始化所有變量 init.run() print("Initialized") average_loss = 0 for step in range(num_steps): batch_inputs, batch_labels = generate_batch( batch_size, num_skips, skip_window) feed_dict = {train_inputs : batch_inputs, train_labels : batch_labels} # We perform one update step by evaluating the optimizer op( including it # in the list of returned values for session.run()) _, loss_val = session.run([optimizer, loss], feed_dict=feed_dict) average_loss += loss_val if step % 2000 == 0: if step > 0: average_loss /= 2000 #The average loss is an estimate of the loss over the last 2000 batches. print("Average loss at step", step, ": ", average_loss) average_loss = 0 # Note that this is expensive ( ~20% slowdown if computed every 500 steps) if step % 10000 == 0: sim = similarity.eval() for i in range(valid_size): valid_word = reverse_dictionary[valid_examples[i]] top_k = 8 # number of nearest neighbors nearest = (-sim[i, : ]).argsort()[1:top_k+1] log_str = "與 %s 最接近的詞是:" % valid_word for k in range(top_k): close_word = reverse_dictionary[nearest[k]] log_str = "%s %s," % (log_str, close_word) print(log_str) final_embeddings = normalized_embeddings.eval() # Step 7 : 繪制結果 def plot_with_labels(low_dim_embs, labels, filename=‘TSNE_result.png‘): assert low_dim_embs.shape[0] >= len(labels), "More labels than embeddings" plt.figure(figsize=(18, 18)) # in inches for i, label in enumerate(labels): x, y = low_dim_embs[i,:] plt.scatter(x, y) plt.annotate(label, xy=(x, y), xytext=(5, 2), textcoords=‘offset points‘, ha=‘right‘, va=‘bottom‘) plt.savefig(filename) # 使用T-SNE算法將128維降低到2維 tsne = TSNE(perplexity= 30, n_components = 2, init = ‘pca‘, n_iter = 5000, random_state = 1) # 繪制點的個數 plot_only = 100 low_dim_embs = tsne.fit_transform(final_embeddings[: plot_only, :]) labels = [reverse_dictionary[i] for i in range(plot_only)] plot_with_labels(low_dim_embs, labels) plt.show()
五、訓練結果
參考資料:
1、https://www.jianshu.com/p/471d9bfbd72f
2、https://www.jianshu.com/p/0bb00eed9c63
3、https://blog.csdn.net/itplus/article/details/37998797
4、https://blog.csdn.net/qq_28444159/article/details/77514563
Word2Vec原理及代碼