TensorFlow實現word2vec(Skip-Gram、CBOW)代碼記錄

最近學習word2vec,發現一些文章寫的有點,略。。(>﹏<),而且有些代碼有錯誤,這裏記錄一些學習代碼過程中的問題,這裏構建的方式是Skip-Gram,代碼不全部寫出,只寫一些覺得重要的地方。
首先,如果想要了解詳細的數學原理,可以移步word2vec中的數學原理,文檔中寫的非常非常詳細,推薦度max。
另外,使用TensorFlow實現這兩種,代碼很大程度是一樣的,這裏主要介紹Skip-Gram方式的實現,CBOW只是簡要註明不同之處。

Skip-Gram

1.構建數據集

這裏主要是對詞語列表進行更進一步的操作,例如生成詞典並編號等等:

def generate_dataset(data):
    num_words = len(data)
    count = [['UNK', -1]]
    count.extend(collections.Counter(data).most_common(vocabulary_size-1))

    dic = {}
    for val, count in count:
        dic[val] = len(dic)

    reverse_dic = dict(zip(dic.values(), dic.keys()))

    num_data = []
    for item in data:
        if item in dic:
            num_data.append(dic[item])
        else:
            num_data.append(0)

    return dic, reverse_dic, num_data, num_words

說明:

  • dic是生成的詞典,按照詞頻從大到小選擇了vocabulary_size個詞語,注意特殊的 UNK表示其他未收錄的詞語,所以真正使用Counter計數的時候添加的應該是vocabulary_size-1個詞語,詞典的組織形式是(詞語–序號)
  • reverse_dic是反轉的詞典,也就是將詞典的組織形式轉化爲(序號–詞典)形式
  • num_data與data的長度一樣,只是將原本data中每個元素由具體單詞(string)轉化爲序號(int)
  • num_words是詞典長度
  • count的類型是collections.Counter
  • count中有一個UNK,主要是對於我們丟棄了一部分詞語,例如之前去掉的高頻停用詞,或者規定很少出現的詞語不納入詞典,將這些詞語記爲 UNK,可以看到在之後將詞語列表轉爲序號列表時,將這些詞標序號記爲0
  • 關於生成dictionary,生成的唯一編號使用的len函數,畢竟每次增加一個詞語,那麼詞典的長度就會增加一,所以就生成了唯一的編號了(這個地方很簡單,但是防止有的時候轉不過彎來還是提一下)

2.生成batch數據

由全部數據生成一個batch的數據:

index_data = 0
def generate_batch(batch_size, n_skips, window_skip):
    global index_data
    assert batch_size % n_skips == 0
    assert n_skips <= 2*window_skip

    batch_x = np.ndarray(shape=(batch_size), dtype=np.int32)
    batch_y = np.ndarray(shape=(batch_size, 1), dtype=np.int32)

    span = 2 * window_skip + 1
    buffer = collections.deque(maxlen=span)
    for _ in range(span):
        buffer.append(num_data[index_data])
        index_data = (index_data + 1)%num_words

    for i in range(batch_size // n_skips):
        target = window_skip
        avoid_target = [window_skip]

        for j in range(n_skips):
            while target in avoid_target:
                target = random.randint(0, span-1)
            avoid_target.append(target)
            batch_x[i*n_skips+j] = buffer[window_skip]
            batch_y[i*n_skips+j] = buffer[target]
        buffer.append(num_data[index_data])
        index_data = (index_data+1) % num_words

    return batch_x, batch_y

說明:

  • 對於傳入參數的理解:n_skip表示我們爲每個中心詞所生成的樣本(或者說是訓練數據)數目,window_skip表示半徑,舉個例子:I will go to school by bus. 假設到了某一步,中心詞是 to ,window_skip是2,也就是使用 to 來預測 will go school by四個詞語,但是我們不一定要生成所有的(to --> will)這樣的數據,而是在中間隨即選出 n_skip條數據,也就是在2window_skip條數據中選出n_skip條數據作爲訓練數據,所以纔會要求 n_skip<=2window_skip,即函數開頭的 assert 所判定的。至於另一個判定,主要是保證每個中心詞所生成的數據條數都一樣(對於每個中心詞,都生成n_skip條數據,所以一個batch數量,應該是n_skip的整數倍)
  • 關於batch_x, batch_y的維數問題,由於訓練數據集其實就是一個詞預測另一個詞,所以它們的維數應該是一樣的。但是可以看到batch_y被處理成了一個二維數組,這主要是因爲下面使用的損失函數 nce_loss 的要求,其實(n, 1)的二維數組跟(n)維的列向量是一樣的。
  • 注意deque的滑動,這是一個隊列,使用一箇中心詞生成一組數據後,就會向後滑動一個單詞,在隊首“擠”出一個單詞,在隊尾添加一個單詞。
  • 具體實現如何在 2*window_skip個詞中隨機選出n_skip個,這裏使用了一個avoid_target數組,實現的很巧妙。

3.負採樣計算

藉助TensorFlow的函數,Skip_Gram網絡的結構非常簡單:

valid_exampled = np.random.choice(valid_window, valid_size, replace=False)

input_x = tf.placeholder(tf.int32, shape=[batch_size])
input_y = tf.placeholder(tf.int32, shape=[batch_size, 1])
valid_data = tf.constant(valid_exampled)

embeddings = tf.Variable(tf.random_uniform([vocabulary_size, embedding_size]))
embed = tf.nn.embedding_lookup(embeddings, input_x)

nce_weight = tf.Variable(tf.truncated_normal([vocabulary_size, embedding_size],
                                              stddev=1.0 / np.sqrt(embedding_size)))
nce_bias = tf.Variable(tf.zeros([vocabulary_size]))

loss = tf.reduce_mean(tf.nn.nce_loss(weights=nce_weight,
                      biases=nce_bias,
                      labels=input_y,
                      inputs=embed,
                      num_classes=vocabulary_size,
                      num_sampled=num_sample))
optimizer = tf.train.GradientDescentOptimizer(learning_rate=learning_rate).minimize(loss)

說明:

  • 帶有valid部分的代碼都是用來生成驗證數據的,主要是訓練完成之後需要一個評價,可以隨便生成一些驗證數據,然後從詞向量中按照相似度選出最相似的向量,看看以我們直觀的感覺是否這些詞是否類似。
  • embeddings 就是詞向量矩陣(從維數上就可以看出來),關於這個embed,使用了tf.nn.embedding_lookup()函數,這個函數的作用原理其實非常簡單,如下面的公式所示,如果如果左邊的大矩陣是詞向量矩陣,也就是embeddings,那麼提供一個索引矩陣,然後按照索引,這裏是0,2,3,把第一個矩陣的中第0,2,3行拿出來組成一個矩陣。所以這個函數的兩個參數分別是embeddings 和 input_x,也就是拿出這一個batch的訓練數據所對應的行向量。

[0.10.20.30.40.50.60.70.80.90.20.10.30.50.40.6][023]=[0.10.20.30.70.80.90.10.20.3] \begin{bmatrix} 0.1 &amp; 0.2 &amp;0.3 \\ 0.4 &amp; 0.5 &amp; 0.6 \\ 0.7 &amp; 0.8 &amp;0.9 \\ 0.2 &amp; 0.1 &amp;0.3 \\ 0.5 &amp; 0.4 &amp; 0.6 \end{bmatrix}\begin{bmatrix} 0 \\ 2 \\ 3 \end{bmatrix}=\begin{bmatrix} 0.1 &amp; 0.2 &amp;0.3 \\ 0.7 &amp; 0.8 &amp;0.9 \\ 0.1 &amp; 0.2 &amp;0.3 \end{bmatrix}

  • 我們所關心的負採樣的計算過程實際上只用了一行代碼,也就是loss的定義,tf.nn.nce_loss()函數完成了這部分工作,這裏我們提供了nce_weights 這與embeddings的維數一樣,之前提到word2vec中每個單詞會使用兩個詞向量表示,這就是另一個,負採樣使用的詞向量就是從這裏面取出來的。在這些參數中,注意num_sampled,這是負採樣的數量,也就是對於一個正例,我們使用多少個單詞爲負例,num_classes是分類數目,在原理中看這個比較清楚,其實對於一個輸入vv,我們實際上是找到p(wv)p(w|v)最大的那個ww作爲預測,這實際上也就是一個分類,正確的類別也就是ww,總類數就是vocabulary_size。接下來具體介紹一下nce_loss()函數的負採樣機制:

在介紹負採樣的文章中,我們總能看到一張圖:
在這裏插入圖片描述
關於這張圖的含義不再贅述,這在文中開始介紹的數學原理的文章中介紹的相當詳細了。如果指定num_sampled的值是64,也就是除了1個正類之外,從其他的N-1類中選擇64個類作爲負類,關於這個負類的選擇,肯定是詞頻越高,選到的可能性就越大,這也是在原理中提到的,也就是負採樣的主要內容了,這一想法tf.nn.nce_loss()函數內部實現,這裏選擇負類的計算公式:
P(k) = (log(k + 2) - log(k + 1)) / log(range_max + 1)
P(k)=log(k+2)log(k+1)log(range_max+1) P(k) = \frac{log(k+2)-log(k+1)}{log(range\_max+1)}
這裏k也就是選擇的某個詞語的序號,觀察這個公式可以發現,k越小,計算的結果越大,也就是選擇的概率越大,這也就是之前創建數據集的時候,爲什麼要按照詞頻降序排序並編號的原因。這樣負採樣的過程真相大白了,接下來的優化,計算梯度在TensorFlow中當然就不需要我們手寫了,直接定義優化器優化即可。

4.關於詞向量相似度

在驗證部分,使用了向量相似度的概念:

norm = tf.sqrt(tf.reduce_sum(tf.square(embeddings), axis=1, keep_dims=True))
norm_embeddings = embeddings / norm

valid_embeddings = tf.nn.embedding_lookup(norm_embeddings, valid_data)
similarity = tf.matmul(valid_embeddings, norm_embeddings, transpose_b=True)

首先進行詞向量的標準化,也就是全部規範到 [0,1] 之間,並且每個分量的和爲1,得益於廣播機制,直接進行矩陣之間的計算即可。
關於向量之間相似的度量,一種普遍的做法就是餘弦相似度,也就是在向量空間中,求向量的餘弦夾角。餘弦的範圍是[-1, 1],如果兩向量餘弦爲-1,表示完全相反,完全不同,如果是1,那麼同向,認爲相似度最高。
所以先對向量進行標準化,然後利用矩陣乘法,直接使用選擇的驗證向量乘以標準化的詞向量,那麼所得得值(一個內積)就是相似得度量,之後可以看到,對這些值進行排序,也就可以比較出那些向量最相似了。

CBOW

CBOW是由上下文預測中心詞的方式,在代碼實現上,主要有兩個不同。
第一處是在生成批次訓練數據時:

data_index = 0
def generate_batch(batch_size, bag_window):
    global data_index
    span = 2 * bag_window + 1
    batch = np.ndarray(shape=(batch_size, span - 1), dtype=np.int32)  # 列維數不再是1,改成span-1
    labels = np.ndarray(shape=(batch_size, 1), dtype=np.int32)
    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):
        buffer_list = list(buffer)
        labels[i, 0] = buffer_list.pop(bag_window)   # 對應的標籤 也就是中心詞 ,並彈出這個中心詞
        batch[i] = buffer_list
        buffer.append(data[data_index])
        data_index = (data_index + 1) % len(data)
    return batch, labels

首先,CBOW既然是上下文多個詞語預測中心詞,所以自然需要拿到多個詞語的索引,在這裏就是span個(2 * bag_window,bag_window也就是窗口半徑),其次,在標籤和數據上,可以看到這與Skip-Gram方式就是做了一個反轉,注意要在隊列中彈出中心詞(所在的位置就是bag_window)。

loss = tf.reduce_mean( tf.nn.nce_loss(nce_weights, nce_biases, train_labels,  tf.reduce_sum(embeds, 1), num_sampled, vocabulary_size))

這裏要注意的是tf.reduce_sum(embeds, 1),也就是對batch中一條數據進行了詞向量維度上的求和,這對應CBOW原理中投影層所作的操作,主要是簡化計算。

之外地其他計算與Skip-Gram一致。

補充

對於有些數據集,可能需要手動去除高頻停用詞,這裏提供一個函數:
代碼如下:

def remove_fre_stop_word(words):
    '''
    去掉一些高頻的停用詞 比如 的之類的
    :param words:  詞語列表
    :return: 剔除高頻停用詞之後的詞語列表
    '''
    t = 1e-5  # t 值
    threshold = 0.8  # 剔除概率閾值

    int_word_counts = collections.Counter(words)
    total_count = len(words)
    word_freqs = {w: c / total_count for w, c in int_word_counts.items()}
    # 計算被刪除的概率
    prob_drop = {w: 1 - np.sqrt(t / f) for w, f in word_freqs.items()}   # 計算刪除概率
    train_words = [w for w in words if prob_drop[w] < threshold]
    return train_words

這裏判斷詞語是否符合的計算公式是:
P(wi)=1tf(wi) P(w_{i})=1-\sqrt{\frac{t}{f(w_{i})}}
t是一個閾值,一般是在 1e-31e-8之間,f(w)表示單詞的頻率。

代碼

全部代碼放在cbow+skip-gram,可以參考 Skip_gram_demo代碼,使用得數據集是訓練word2vec得經典數據集 text8。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章