1. 程式人生 > 實用技巧 >word2vec公式推導及python簡單實現

word2vec公式推導及python簡單實現

簡介

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)

  

參考:

【1】Word2vec數學原理全家桶

【2】An implementation guide to Word2Vec using NumPy and Google Sheets

【3】word2vec python實現

【4】Softmax函式求導詳解

【5】詳解softmax函式以及相關求導過程