簡介
word2vec實現的功能是將詞用$n$維的向量表示出來,即詞向量。一般這個詞向量的維度為100~300。
word2vec有兩種訓練模型: (1) CBOW:根據中心詞$w(t)$周圍的詞來預測中心詞
(2) Skip-gram:根據中心詞$w(t)$來預測周圍詞
word2vec有兩種加速算法: (1) Hierarohical Softmax
(2) Negative Sampling
本文只實現了Skip-gram,所以這里只介紹該模型。
算法推導
Skip-gram的模型如上圖所示,分為Input layer、Hidden layer和Output layer三層。
$$h = W^{T}·X$$
$$z = W'^{T}·h$$
$$a = softmax(z)$$
$$loss=-\sum_{i=0}^{V}y_ilna_i$$
最后$a$向量中概率最大的即為我們所預測的,因為最后的預測詞中只有一位為$1$,其余都是$0$,所以用交叉熵作為損失函數正好。
在對$W$以及$W'$求導之前,首先來看一下$\frac{\partial a_j}{\partial z_i}$的值:
如果$ j = i:$
$$\frac{\partial a_j}{\partial z_i} = \frac{\partial (\frac{e^{z_j}}{\sum_{k}e^{z_k}})}{\partial z_i} = \frac{(e^{z_j})'·\sum_{k}e^{z_k}-e^{z_j}·e^{z_j}}{(\sum_{k}e^{z_k})^2} = \frac{e^{z_j}}{\sum_{k}e^{z_k}}-\frac{e^{z_j}}{\sum_{k}e^{z_k}}·\frac{e^{z_j}}{\sum_{k}e^{z_k}}=a_j(1-a_j)$$
如果$ j ≠ i:$
$$\frac{\partial a_j}{\partial z_i} = \frac{\partial (\frac{e^{z_j}}{\sum_{k}e^{z_k}})}{\partial z_i} = \frac{0·\sum_{k}e^{z_k}-e^{z_j}·e^{z_i}}{(\sum_{k}e^{z_k})^2} = -\frac{e^{z_j}}{\sum_{k}e^{z_k}}·\frac{e^{z_i}}{\sum_{k}e^{z_k}}=-a_ja_i$$
接下來求梯度:
$$\frac{\partial loss}{\partial z_i} = \frac{\partial loss}{\partial a_1}\frac{\partial a_1}{\partial z_i} + \frac{\partial loss}{\partial a_2} \frac{\partial a_2}{\partial z_i} ··· \frac{\partial loss}{\partial a_v}\frac{\partial a_v}{\partial z_i}$$
因為$a$是經過softmax得到的,所以所有的$a$都與$z_i$有關,又目標詞的y中只有一位為1,假設是$j$位,此時$loss =-lna_j$,所以在上式中,只有對$a_j$的偏導不為0,其余的皆為0。所以上式可以簡化為:
$$ \frac{\partial loss}{\partial z_i} = -\frac{1}{a_j}\frac{\partial a_j}{\partial z_i}$$
而$\frac{\partial a_j}{\partial z_i}$的值,我們已經在上面分類討論過了,在乘上$-\frac{1}{a_j}$后,即為:
如果$ j = i:$, $\frac{\partial a_j}{\partial z_i} = a_j - 1$
如果$ j ≠ i:$, $\frac{\partial a_j}{\partial z_i} = a_i$
所以如果現在我們已經求得向量$a$的值,那么向量$z$的偏導就是$a-y$。
然后:
$$\frac{\partial loss}{\partial W'} = \frac{\partial loss}{\partial z}\frac{\partial z}{\partial W'} = h(a-y)^T$$
$$\frac{\partial loss}{\partial W} = \frac{\partial loss}{\partial z}\frac{\partial z}{\partial h}\frac{\partial h}{\partial W} = xW'(a-y)$$
代碼實現
所需要的庫及超參數設置:
import numpy as np from collections import defaultdict settings = {'window_size': 2, 'n': 3, 'epochs': 500, 'learning_rate': 0.01}
使用的語句為
corpus = ['natural language processing and machine learning is fun and exciting']
生成訓練數據:
def generate_training_data(self, corpus): ''' :param settings: 超參數 :param corpus: 語料庫 :return: 訓練樣本 ''' word_counts = defaultdict(int) # 當字典中不存在時返回0 for row in corpus: for word in row.split(' '): word_counts[word] += 1 self.v_count = len(word_counts.keys()) # v_count:不重復單詞數 self.words_list = list(word_counts.keys()) # words_list:單詞列表 self.word_index = dict((word, i) for i, word in enumerate(self.words_list)) # {單詞:索引} self.index_word = dict((i, word) for i, word in enumerate(self.words_list)) # {索引:單詞} training_data = [] for sentence in corpus: tmp_list = sentence.split(' ') # 語句單詞列表 sent_len = len(tmp_list) # 語句長度 for i, word in enumerate(tmp_list): # 依次訪問語句中的詞語 w_target = self.word2onehot(tmp_list[i]) # 中心詞ont-hot表示 w_context = [] # 上下文 for j in range(i - self.window, i + self.window + 1): if j != i and j <= sent_len - 1 and j >= 0: w_context.append(self.word2onehot(tmp_list[j])) training_data.append([w_target, w_context]) # 對應了一個訓練樣本 return training_data
生成one-hot:
def word2onehot(self, word): """ :param word: 單詞 :return: ont-hot """ word_vec = [0 for i in range(0, self.v_count)] # 生成v_count維度的全0向量 word_index = self.word_index[word] # 獲得word所對應的索引 word_vec[word_index] = 1 # 對應位置位1 return word_vec
forward函數:
def forward_pass(self, x): h = np.dot(self.w1.T, x) u = np.dot(self.w2.T, h) y_pred = self.softmax(u) return y_pred, h, u
softmax函數,注意這里要注意溢出的問題,一般來講減去最大值就可以解決該問題。
def softmax(self, x): e_x = np.exp(x - np.max(x)) # 防止上溢和下溢,減去這個數的計算結果不變 return e_x / e_x.sum(axis=0)
反向傳播,這里要特別注意,在更新第二個矩陣時,我們需要全部更新,但是第一個矩陣只需要更新某一行,所以沒必要去更新全部。
第一個矩陣的梯度如下圖所示的那樣:
def back_prop(self, e, h, x): dl_dw2 = np.outer(h, e) dl_dw1 = np.dot(self.w2, e.T).reshape(-1) self.w1[x.index(1)] = self.w1[x.index(1)] - (self.lr * dl_dw1) # x.index(1)獲取x向量中value=1的索引,只需要更新該索引對應的行即可 self.w2 = self.w2 - (self.lr * dl_dw2)
訓練過程:
def train(self, training_data): self.w1 = np.random.uniform(-1, 1, (self.v_count, self.n)) # 隨機生成參數矩陣 self.w2 = np.random.uniform(-1, 1, (self.n, self.v_count)) for i in range(self.epochs): self.loss = 0 for data in training_data: w_t, w_c = data[0], data[1] # w_t是中心詞的one-hot,w_c是window范圍內所要預測此的one-hot y_pred, h, u = self.forward_pass(w_t) train_loss = np.sum([np.subtract(y_pred, word) for word in w_c], axis=0) # 每個預測詞都是一對訓練數據,相加處理 self.back_prop(train_loss, h, w_t) for word in w_c: self.loss += - np.dot(word, np.log(y_pred)) print('Epoch:', i, "Loss:", self.loss)
結果:
分析
可以看到,我們每次需要對第二個矩陣的每個值都進行更新,在數據量巨大時,這是需要花費很長的時間去計算的。而在Hierarchical Softmax 和 Negative Sampling和這兩種優化方法中,不再使用$W'$這個矩陣,所以可以大大減少計算時間。關於這兩個優化方法,下次再去學習了。
完整代碼
import numpy as np from collections import defaultdict settings = {'window_size': 2, 'n': 3, 'epochs': 500, 'learning_rate': 0.01} class word2vec(): def __init__(self): self.n = settings['n'] self.lr = settings['learning_rate'] self.epochs = settings['epochs'] self.window = settings['window_size'] def generate_training_data(self, corpus): ''' :param settings: 超參數 :param corpus: 語料庫 :return: 訓練樣本 ''' word_counts = defaultdict(int) # 當字典中不存在時返回0 for row in corpus: for word in row.split(' '): word_counts[word] += 1 self.v_count = len(word_counts.keys()) # v_count:不重復單詞數 self.words_list = list(word_counts.keys()) # words_list:單詞列表 self.word_index = dict((word, i) for i, word in enumerate(self.words_list)) # {單詞:索引} self.index_word = dict((i, word) for i, word in enumerate(self.words_list)) # {索引:單詞} training_data = [] for sentence in corpus: tmp_list = sentence.split(' ') # 語句單詞列表 sent_len = len(tmp_list) # 語句長度 for i, word in enumerate(tmp_list): # 依次訪問語句中的詞語 w_target = self.word2onehot(tmp_list[i]) # 中心詞ont-hot表示 w_context = [] # 上下文 for j in range(i - self.window, i + self.window + 1): if j != i and j <= sent_len - 1 and j >= 0: w_context.append(self.word2onehot(tmp_list[j])) training_data.append([w_target, w_context]) # 對應了一個訓練樣本 return training_data def word2onehot(self, word): """ :param word: 單詞 :return: ont-hot """ word_vec = [0 for i in range(0, self.v_count)] # 生成v_count維度的全0向量 word_index = self.word_index[word] # 獲得word所對應的索引 word_vec[word_index] = 1 # 對應位置位1 return word_vec def train(self, training_data): self.w1 = np.random.uniform(-1, 1, (self.v_count, self.n)) # 隨機生成參數矩陣 self.w2 = np.random.uniform(-1, 1, (self.n, self.v_count)) for i in range(self.epochs): self.loss = 0 for data in training_data: w_t, w_c = data[0], data[1] # w_t是中心詞的one-hot,w_c是window范圍內所要預測此的one-hot y_pred, h, u = self.forward_pass(w_t) train_loss = np.sum([np.subtract(y_pred, word) for word in w_c], axis=0) # 每個預測詞都是一對訓練數據,相加處理 self.back_prop(train_loss, h, w_t) for word in w_c: self.loss += - np.dot(word, np.log(y_pred)) print('Epoch:', i, "Loss:", self.loss) def forward_pass(self, x): h = np.dot(self.w1.T, x) u = np.dot(self.w2.T, h) y_pred = self.softmax(u) return y_pred, h, u def softmax(self, x): e_x = np.exp(x - np.max(x)) # 防止上溢和下溢。減去這個數的計算結果不變 return e_x / e_x.sum(axis=0) def back_prop(self, e, h, x): dl_dw2 = np.outer(h, e) dl_dw1 = np.dot(self.w2, e.T).reshape(-1) self.w1[x.index(1)] = self.w1[x.index(1)] - (self.lr * dl_dw1) # x.index(1)獲取x向量中value=1的索引,只需要更新該索引對應的行即可 self.w2 = self.w2 - (self.lr * dl_dw2) if __name__ == '__main__': corpus = ['natural language processing and machine learning is fun and exciting'] w2v = word2vec() training_data = w2v.generate_training_data(corpus) w2v.train(training_data)
參考:
【2】An implementation guide to Word2Vec using NumPy and Google Sheets