詞向量表示:word2vec與詞嵌入

凌逆戰發表於2020-04-25

  在NLP任務中,訓練資料一般是一句話(中文或英文),輸入序列資料的每一步是一個字母。我們需要對資料進行的預處理是:先對這些字母使用獨熱編碼再把它輸入到RNN中,如字母a表示為(1, 0, 0, 0, …,0),字母b表示為(0, 1, 0, 0, …, 0)。如果只考慮小寫字母a~z,那麼每一步輸入的向量的長度是26。如果一句話有1000個單詞,我們需要使用 (1000, ) 維度的獨熱編碼表示每一個單詞。

缺點

  • 每一步輸入的向量維數會非常大
  • 在獨熱表示中,所有的單詞之間都是平等的,單詞間的依賴關係被忽略

解決方法

  • 使用word2vec,學習一種對映關係f,將一個高維詞語(word)變成一個低維向量(vector),vec=f(word)。

實現詞嵌入一般來說有兩種方法:

  • 基於“計數”的方法
    • 在大型語料庫中,計算一個詞語和另一個詞語同時出現的概率,將經常出現的詞對映到向量空間的相似位置。
  • 基於“預測”的方法
    • 從一個詞或幾個詞出發,預測它們可能的相鄰詞,在預測過程中自然而然地學習到了詞嵌入的對映f。

  通常使用的是基於預測的方法。具體來講,又有兩種基於預測的方法,分別叫CBOWSkip-Gram,接下來會分別介紹它們的原理。

CBOW實現詞嵌入的原理

  CBOW(Continuous Bag of Words)連續詞袋模型,利用某個詞語的上下文預測這個詞語。例如:The manfell in love with the woman。如果只看句子的前半部分,即The man fell in love with the_____,也可以大概率猜到橫線處填的是“woman”。CBOW就是要訓練一個模型,用上下文(在上面的句子裡是“The man fell inlove with the”)來預測可能出現的單詞(如woman)。

  先來考慮用一個單詞來預測另外一個單詞的情況,對應的網路結構如下圖所示:

 CBOW模型:用一個單詞預測一個單詞

  在這裡,輸入的單詞還是被獨熱表示為x,經過一個全連線層得到隱含層h,h再經過一個全連線層得到輸出y。

  V是詞彙表中的單詞的數量,因此獨熱表示的x的維度是(V, )。另外輸出y相當於做Softmax操作前的logits,它的形狀也是(V, ),這是用一個單詞預測另一個單詞。隱層的神經元數量為N, N一般設定為小於V的值,訓練完成後,隱層的值被當作是詞的嵌入表示,即word2vec中的“vec”。

  如何用多個詞來預測一個詞呢?答案很簡單,可以先對它們做同樣的全連線操作,將得到的值全部加起來得到隱含層的值。對應的結構如下圖所示。

        

左圖:CBOW模型:用多個單詞預測一個單詞;右圖:CBOW模型的另外一種表示

  在上圖中,上下文是“the cat sits on the”,要預測的單詞為“mat”。圖中的∑g(embeddings)表示將the、cat、sits、on、the這5個單詞的詞嵌入表示加起來(即隱含層的值相加)。

  在上述結構中,整個網路相當於是一個V類的分類器。V是單詞表中單詞的數量,這個值往往非常大,所以比較難以訓練,通常會簡單修改網路的結構,將V類分類變成兩類分類。

  具體來說,設要預測的目標詞彙是“mat”,會在整個單詞表中,隨機地取出一些詞作為“噪聲詞彙”,如“computer”、“boy”、“fork”。模型會做一個兩類分類:判斷一個詞彙是否屬於“噪聲詞彙”。一般地,設上下文為h,該上下文對應的真正目標詞彙為$w_t$,噪聲詞彙為$\tilde{w}$,優化函式是

$$J=\ln Q_{\theta}\left(D=1 | \boldsymbol{w}_{t}, \boldsymbol{h}\right)+k \underset{\tilde{\boldsymbol{w}}-P_{\min }}{E}\left[\ln Q_{\theta}(D=0 | \tilde{\boldsymbol{w}}, \boldsymbol{h})\right]$$

  $Q_{\theta}\left(D=1 | \boldsymbol{w}_{t}, \boldsymbol{h}\right)$代表的是利用真實詞彙$w_t$和上下文$h$對應的詞嵌入向量進行一次Logistic迴歸得到的概率。這樣的Logistic迴歸實際可以看作一層神經網路。因為$w_t$為真實的目標單詞,所以希望對應的D=1。另外噪聲詞彙$\tilde{w}$為與句子沒關係的詞彙,所以希望對應的D=0,即$\ln Q_{\theta}(D=0 | \tilde{w}_{t},{h})$。另外,$\underset{\tilde{w}-P_{noise}}{E}$表示期望,實際計算的時候不可能精確計算這樣一個期望,通常的做法是隨機取一些噪聲單詞去預估這個期望的值。該損失對應的網路結構如下圖所示。

選取噪聲詞進行兩類分類的CBOW模型

  通過優化二分類損失函式來訓練模型後,最後得到的模型中的隱含層可以看作是word2vec中的“vec”向量。對於一個單詞,先將它獨熱表示輸入模型,隱含層的值是對應的詞嵌入表示。另外,在TensorFlow中,這裡使用的損失被稱為NCE損失,對應的函式為tf.nn.nce_loss。

Skip-Gram實現詞嵌入的原理

  有了CBOW的基礎後,Skip-Gram的原理比較好理解了。在CBOW方法中,是使用上下文來預測出現的詞,如上下文是:“The man fell in love with the”,要預測的詞是“woman”。Skip-Gram方法和CBOW方法正好相反:使用“出現的詞”來預測它“上下文文中詞”。例如:The manfell in love with the woman。如果只看句子的前半部分,即The man fell in love with the_____,Skip-Gram是使用“woman”,來預測“man”、“fell”等單詞。所以,可以把Skip-Gram方法看作從一個單詞預測另一個單詞的問題。

  在損失的選擇上,和CBOW一樣,取出一些“噪聲詞”,訓練一個兩類分類器(即同樣使用NCE損失)。

在TensorFlow中實現詞嵌入

  我們以Skip-Gram方法為例,在TensorFlow中訓練一個詞嵌入模型。

下載資料集

首先匯入一些需要的庫:

import collections
import math
import os
import random
import zipfile

import numpy as np
from six.moves import urllib
from six.moves import xrange  # pylint: disable=redefined-builtin
import tensorflow as tf

  為了用Skip-Gram方法訓練語言模型,需要下載對應語言的語料庫。在網站http://mattmahoney.net/dc/上提供了大量英語語料庫供下載,為了方便學習,使用一個比較小的語料庫http://mattmahoney.net/dc/text8.zip作為示例訓練模型。程式會自動下載這個檔案:

# 第一步: 在下面這個地址下載語料庫
url = 'http://mattmahoney.net/dc/'


def maybe_download(filename, expected_bytes):
    """
    這個函式的功能是:
        如果filename不存在,就在上面的地址下載它。
        如果filename存在,就跳過下載。
        最終會檢查文字的位元組數是否和expected_bytes相同。
    """
    if not os.path.exists(filename):
        print('start downloading...')
        filename, _ = urllib.request.urlretrieve(url + filename, filename)
    statinfo = os.stat(filename)
    if statinfo.st_size == expected_bytes:
        print('Found and verified', filename)
    else:
        print(statinfo.st_size)
        raise Exception(
            'Failed to verify ' + filename + '. Can you get to it with a browser?')
    return filename


# 下載語料庫text8.zip並驗證下載
filename = maybe_download('text8.zip', 31344016)
下載語料庫程式碼

  正如註釋中所說的,這段程式會從地址http://mattmahoney.net/dc/text8.zip下載該語料庫,並儲存為text8.zip檔案。如果在當前目錄中text8.zip已經存在了,則不會去下載。此外,這段程式還會驗證text8.zip的位元組數是否正確。

  如果讀者執行這段程式後,發現沒有辦法正常下載檔案,可以嘗試使用上述的url手動下載,並將下載好的檔案放在當前目錄下。

  下載、驗證完成後,使用下面的程式將語料庫中的資料讀出來:

# 將語料庫解壓,並轉換成一個word的list
def read_data(filename):
    """
    這個函式的功能是:
        將下載好的zip檔案解壓並讀取為word的list
    """
    with zipfile.ZipFile(filename) as f:
        data = tf.compat.as_str(f.read(f.namelist()[0])).split()
    return data


vocabulary = read_data(filename)
print('Data size', len(vocabulary))  # 總長度為1700萬左右
# 輸出前100個詞。
print(vocabulary[0:100])

  這段程式會把text8.zip解壓,並讀取為Python中的列表,列表中的每一個元素是一個單詞,如:

['anarchism', 'originated', 'as',...,'although', 'there', 'are', 'differing']

這個單詞列表原本是一些連續的句子,只是在語料庫的預處理中被去掉了標點。它是原始的語料庫。

製作詞表

  下載並取出語料庫後,來製作一個單詞表,它可以將單詞對映為一個數字,這個數字是該單詞的id。如原來的資料是['anarchism', 'originated', 'as', 'a', 'term', 'of','abuse', 'first', ....., ],那麼對映之後的資料是[5234, 3081,12, 6, 195, 2, 3134, 46, ....],其中5234代表單詞anarchism,3081代表單詞originated,依此類推。

  一般來說,因為在語料庫中有些詞只出現有限的幾次,如果單詞表中包含了語料庫中的所有詞,會過於龐大。所以,單詞表一般只包含最常用的那些詞。對於剩下的不常用的詞,會將它替換為一個罕見詞標記“UNK”。所有的罕見詞都會被對映為同一個單詞id。

  製作詞表並對之前的語料庫進行轉換的程式碼為:

# 第二步: 製作一個詞表,將不常見的詞變成一個UNK識別符號
# 詞表的大小為5萬(即我們只考慮最常出現的5萬個詞)
vocabulary_size = 50000


def build_dataset(words, n_words):
    """
    函式功能:將原始的單詞表示變成index
    """
    count = [['UNK', -1]]
    count.extend(collections.Counter(words).most_common(n_words - 1))
    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  # UNK的index為0
            unk_count += 1
        data.append(index)
    count[0][1] = unk_count
    reversed_dictionary = dict(zip(dictionary.values(), dictionary.keys()))
    return data, count, dictionary, reversed_dictionary


data, count, dictionary, reverse_dictionary = build_dataset(vocabulary,
                                                            vocabulary_size)
del vocabulary  # 刪除已節省記憶體
# 輸出最常出現的5個單詞
print('Most common words (+UNK)', count[:5])
# 輸出轉換後的資料庫data,和原來的單詞(前10個)
print('Sample data', data[:10], [reverse_dictionary[i] for i in data[:10]])
# 我們下面就使用data來製作訓練集
data_index = 0

  在這裡的程式中,單詞表中只包含了最常用的50000個單詞。請注意,在這個實現中,名詞的單複數形式(如boy和boys),動詞的不同時態(如make和made)都被算作是不同的單詞。原來的訓練資料vocabulary是一個單詞的列表,在經過轉換後,它變成了一個單詞id的列表,即程式中的變數data,它的形式是[5234, 3081, 12, 6, 195, 2,3134, 46, ....]。

生成每步的訓練樣本

  上一步中得到的變數data包含了訓練集中所有的資料,現在把它轉換成訓練時使用的batch資料。一個batch可以看作是一些“單詞對”的集合,如woman -> man, woman-> fell,箭頭左邊表示“出現的單詞”,右邊表示該單詞所在的“上下文”中的單詞,這是在第14.2.2節中所說的Skip-Gram方法。

  製作訓練batch的詳細程式如下:

# 第三步:定義一個函式,用於生成skip-gram模型用的batch
def generate_batch(batch_size, num_skips, skip_window):
    # data_index相當於一個指標,初始為0
    # 每次生成一個batch,data_index就會相應地往後推
    global data_index
    assert batch_size % num_skips == 0
    assert num_skips <= 2 * skip_window
    batch = np.ndarray(shape=(batch_size), dtype=np.int32)
    labels = np.ndarray(shape=(batch_size, 1), dtype=np.int32)
    span = 2 * skip_window + 1  # [ skip_window target skip_window ]
    buffer = collections.deque(maxlen=span)
    # data_index是當前資料開始的位置
    # 產生batch後就往後推1位(產生batch)
    for _ in range(span):
        buffer.append(data[data_index])
        data_index = (data_index + 1) % len(data)
    for i in range(batch_size // num_skips):
        # 利用buffer生成batch
        # buffer是一個長度為 2 * skip_window + 1長度的word list
        # 一個buffer生成num_skips個數的樣本
        #     print([reverse_dictionary[i] for i in buffer])
        target = skip_window  # target label at the center of the buffer
        #     targets_to_avoid保證樣本不重複
        targets_to_avoid = [skip_window]
        for j in range(num_skips):
            while target in targets_to_avoid:
                target = random.randint(0, span - 1)
            targets_to_avoid.append(target)
            batch[i * num_skips + j] = buffer[skip_window]
            labels[i * num_skips + j, 0] = buffer[target]
        buffer.append(data[data_index])
        # 每利用buffer生成num_skips個樣本,data_index就向後推進一位
        data_index = (data_index + 1) % len(data)
    data_index = (data_index + len(data) - span) % len(data)
    return batch, labels


# 預設情況下skip_window=1, num_skips=2
# 此時就是從連續的3(3 = skip_window*2 + 1)個詞中生成2(num_skips)個樣本。
# 如連續的三個詞['used', 'against', 'early']
# 生成兩個樣本:against -> used, against -> early
batch, labels = generate_batch(batch_size=8, num_skips=2, skip_window=1)
for i in range(8):
    print(batch[i], reverse_dictionary[batch[i]],
          '->', labels[i, 0], reverse_dictionary[labels[i, 0]])

 

  儘管程式碼中已經給出了註釋,但為了便於讀者理解,還是對這段程式碼做進一步詳細的說明。這裡生成一個batch的語句為:batch, labels=generate_batch(batch_size=8,num_skips=2, skip_window=1),每執行一次generate_batch函式,會產生一個batch以及對應的標籤labels。注意到該函式有三個引數,batch_size、num_skips和skip_window,下面來說明這三個引數的作用。

  引數batch_size應該是最好理解的,它表示一個batch中單詞對的個數。generate_batch返回兩個值batch和labels,前者表示Skip-Gram方法中“出現的單詞”,後者表示“上下文”中的單詞,它們的形狀分別為(batch_size, )和(batch_size, 1)。

  再來看引數num_skips和skip_window。在生成單詞對時,會在語料庫中先取出一個長度為skip_window*2+1連續單詞列表,這個連續的單詞列表是上面程式中的變數buffer。buffer中最中間的那個單詞是Skip-Gram方法中“出現的單詞”,其餘skip_window*2個單詞是它的“上下文”。會在skip_window*2個單詞中隨機選取num_skips個單詞,放入的標籤labels。

  如skip_window=1 , num_skips=2的情況。會首先選取一個長度為3的buffer,假設它是['anarchism', 'originated','as'],此時originated為中心單詞,剩下的兩個單詞為它的上下文。再在這兩個單詞中選擇num_skips形成標籤。由於num_skips=2,所以實際只能將這兩個單詞都選上(標籤不能重複),最後生成的訓練資料為originated ->anarchism和originated -> as。

  又如skip_window=3, num_skips=2,會首先選取一個長度為7的buffer,假設是['anarchism', 'originated', 'as','a', 'term', 'of', 'abuse'],此時中心單詞為a,再在剩下的單詞中隨機選取兩個,構成單詞對。比如選擇term和of,那麼訓練資料是a -> term, a-> of。

  由於每一次都是在skip*2個單詞中選擇num_skips個單詞,並且單詞不能重複,所以要求skip_window*2>=num_skips。這在程式中也有所體現(對應的語句是assert num_skips <=2 * skip_window)。

  在接下來的訓練步驟中,每一步都會呼叫一次generate_batch函式,並用返回的batch和labels作為訓練資料進行訓練。

定義模型

  此處的模型實際可以抽象為:用一個單詞預測另一個單詞,在輸出時,不使用Softmax損失,而使用NCE損失,即再選取一些“噪聲詞”,作為負樣本進行兩類分類。對應的定義模型程式碼為:

# 第四步: 建立模型.

batch_size = 128
embedding_size = 128  # 詞嵌入空間是128維的。即word2vec中的vec是一個128維的向量
skip_window = 1  # skip_window引數和之前保持一致
num_skips = 2  # num_skips引數和之前保持一致

# 在訓練過程中,會對模型進行驗證 
# 驗證的方法就是找出和某個詞最近的詞。
# 只對前valid_window的詞進行驗證,因為這些詞最常出現
valid_size = 16  # 每次驗證16個詞
valid_window = 100  # 這16個詞是在前100個最常見的詞中選出來的
valid_examples = np.random.choice(valid_window, valid_size, replace=False)

# 構造損失時選取的噪聲詞的數量
num_sampled = 64

graph = tf.Graph()

with graph.as_default():
    # 輸入的batch
    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'):
        # 定義1個embeddings變數,相當於一行儲存一個詞的embedding
        embeddings = tf.Variable(
            tf.random_uniform([vocabulary_size, embedding_size], -1.0, 1.0))
        # 利用embedding_lookup可以輕鬆得到一個batch內的所有的詞嵌入
        embed = tf.nn.embedding_lookup(embeddings, train_inputs)

        # 建立兩個變數用於NCE Loss(即選取噪聲詞的二分類損失)
        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]))

    # tf.nn.nce_loss會自動選取噪聲詞,並且形成損失。
    # 隨機選取num_sampled個噪聲詞
    loss = tf.reduce_mean(
        tf.nn.nce_loss(weights=nce_weights,
                       biases=nce_biases,
                       labels=train_labels,
                       inputs=embed,
                       num_sampled=num_sampled,
                       num_classes=vocabulary_size))

    # 得到loss後,我們就可以構造優化器了
    optimizer = tf.train.GradientDescentOptimizer(1.0).minimize(loss)

    # 計算詞和詞的相似度(用於驗證)
    norm = tf.sqrt(tf.reduce_sum(tf.square(embeddings), 1, keep_dims=True))
    normalized_embeddings = embeddings / norm
    # 找出和驗證詞的embedding並計算它們和所有單詞的相似度
    valid_embeddings = tf.nn.embedding_lookup(
        normalized_embeddings, valid_dataset)
    similarity = tf.matmul(
        valid_embeddings, normalized_embeddings, transpose_b=True)

    # 變數初始化步驟
    init = tf.global_variables_initializer()

  先定義了一個embeddings變數,這個變數的形狀是(vocabulary_size, embedding_size),相當於每一行存了一個單詞的嵌入向量。例如,單詞id為0的嵌入是embeddings[0, :],單詞id為1的嵌入是embeddings[1,:],依此類推。對於輸入資料train_inputs,用一個tf.nn.embedding_lookup函式,可以根據embeddings變數將其轉換成對應的詞嵌入向量embed。對比embed和輸入資料的標籤train_labels,用tf.nn.nce_loss函式可以直接定義其NCE損失。

  另外,在訓練模型時,還希望對模型進行驗證。此處採取的方法是選出一些“驗證單詞”,計算在嵌入空間中與其最相近的詞。由於直接得到的embeddings矩陣可能在各個維度上有不同的大小,為了使計算的相似度更合理,先對其做一次歸一化,用歸一化後的normalized_embeddings計算驗證詞和其他單詞的相似度。

執行訓練

  完成了模型定義後,就可以進行訓練了,對應的程式碼比較簡單:

# 第五步:開始訓練
num_steps = 100001

with tf.Session(graph=graph) as session:
    # 初始化變數
    init.run()
    print('Initialized')

    average_loss = 0
    for step in xrange(num_steps):
        batch_inputs, batch_labels = generate_batch(
            batch_size, num_skips, skip_window)
        feed_dict = {train_inputs: batch_inputs, train_labels: batch_labels}

        # 優化一步
        _, loss_val = session.run([optimizer, loss], feed_dict=feed_dict)
        average_loss += loss_val

        if step % 2000 == 0:
            if step > 0:
                average_loss /= 2000
            # 2000個batch的平均損失
            print('Average loss at step ', step, ': ', average_loss)
            average_loss = 0

        # 每1萬步,我們進行一次驗證
        if step % 10000 == 0:
            # sim是驗證詞與所有詞之間的相似度
            sim = similarity.eval()
            # 一共有valid_size個驗證詞
            for i in xrange(valid_size):
                valid_word = reverse_dictionary[valid_examples[i]]
                top_k = 8  # 輸出最相鄰的8個詞語
                nearest = (-sim[i, :]).argsort()[1:top_k + 1]
                log_str = 'Nearest to %s:' % valid_word
                for k in xrange(top_k):
                    close_word = reverse_dictionary[nearest[k]]
                    log_str = '%s %s,' % (log_str, close_word)
                print(log_str)
    # final_embeddings是我們最後得到的embedding向量
    # 它的形狀是[vocabulary_size, embedding_size]
    # 每一行就代表著對應index詞的詞嵌入表示
    final_embeddings = normalized_embeddings.eval()

  每執行1萬步,會執行一次驗證,即選取一些“驗證詞”,選取在當前的嵌入空間中,與其距離最近的幾個詞,並將這些詞輸出。例如,在網路初始化時(step=0),模型的驗證輸出為:

Nearest to they: uniformity, aiding, cei, hutcheson, roca, megawati, ginger, celled,
Nearest to would: scores, amp, ethyl, takes, gopher, agni, somalis, ideogram,
Nearest to nine: anglophones, leland, fdi, scavullo, woven, sepp, tonle, allying,
Nearest to three: geschichte, physically, awarded, walden, idm, drift, devries, sure,
Nearest to but: duplicate, marcel, phosphorus, paths, devout, borrowing, zap, schism,

  可以發現這些輸出完全是隨機的,並沒有特別的意義。

  但訓練到10萬步時,驗證輸出變為:

Nearest to they: we, there, he, you, it, she, not, who,
Nearest to would: will, can, could, may, must, might, should, to,
Nearest to nine: eight, seven, six, five, zero, four, three, circ,
Nearest to three: five, four, two, six, seven, eight, thaler, mico,
Nearest to but: however, and, although, which, microcebus, while, thaler, or,

  此時,embedding空間中的向量表示已經具備了一定含義。例如,和單詞that最相近的是which,與many最相似的為some,與its最相似的是their等。這些相似性都是容易理解的。如果增加訓練的步數,並且合理調節模型中的引數,還會得到更精確的詞嵌入表示。

  最終,得到的詞嵌入向量為final_embeddings,它是歸一化後的詞嵌入向量,形狀為(vocabulary_size,embedding_size), final_embeddings[0, :]是id為0的單詞對應的詞嵌入表示,final_embeddings[1, :]是id為1的單詞對應的詞嵌入表示,依此類推。

視覺化

  其實,程式得到final_embeddings之後就可以結束了,不過可以更進一步,對詞的嵌入空間進行視覺化表示。由於之前設定的embedding_size=128,即每個詞都被表示為一個128維的向量。雖然沒有方法把128維的空間直接畫出來,但下面的程式使用了t-SNE方法把128維空間對映到了2維,並畫出最常使用的500個詞的位置。畫出的圖片儲存為tsne.png檔案:

 

# Step 6: 視覺化
# 視覺化的圖片會儲存為“tsne.png”

def plot_with_labels(low_dim_embs, labels, filename='tsne.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)


try:
    # pylint: disable=g-import-not-at-top
    from sklearn.manifold import TSNE
    import matplotlib

    matplotlib.use('agg')
    import matplotlib.pyplot as plt

    # 因為我們的embedding的大小為128維,沒有辦法直接視覺化
    # 所以我們用t-SNE方法進行降維
    tsne = TSNE(perplexity=30, n_components=2, init='pca', n_iter=5000)
    # 只畫出500個詞的位置
    plot_only = 500
    low_dim_embs = tsne.fit_transform(final_embeddings[:plot_only, :])
    labels = [reverse_dictionary[i] for i in xrange(plot_only)]
    plot_with_labels(low_dim_embs, labels)

except ImportError:
    print('Please install sklearn, matplotlib, and scipy to show embeddings.')

 

  在執行這段程式碼時,如果是通過ssh連線伺服器的方式執行,則可能會出現類似於“RuntimeError: InvalidDISPLAY variable”之類的錯誤。此時只需要在語句“import matplotlib.pyplot as plt”之前加上下面兩條語句即可成功執行:

import matplotlib
    matplotlib.use('agg')   # must be before importing matplotlib.pyplot or pylab

生成的“tsne.jpg”如圖14-5所示。

使用t-SNE方法視覺化詞嵌入

  相似詞之間的距離比較近。如下圖所示為放大後的部分詞嵌入分佈。

 

放大後的部分詞嵌入分佈

  很顯然,his、her、its、their幾個詞性相近的詞被排在了一起。

  除了相似性之外,嵌入空間中還有一些其他的有趣的性質,如圖14-7所示,在詞嵌入空間中往往可以反映出man-woman, king-queen的對應關係,動詞形式的對應關係,國家和首都的對應關係等。

詞嵌入空間中的對應關係

  在第12章訓練Char RNN時,也曾提到對漢字做“embedding”,那麼第12章中的embedding和本章中的word2vec有什麼區別呢?事實上,不管是在訓練CharRNN時,還是在訓練word2vec模型,都是加入了一個“詞嵌入層”,只不過物件有所不同——一個是漢字,一個是英文單詞。這個詞嵌入層可以把輸入的漢字或英文單詞嵌入到一個更稠密的空間中,這有助於模型效能的提升。訓練它們的方式有所不同,在第12章中,是採用CharRNN的損失,通過預測下一個時刻的字元來訓練模型,“順帶”得到了詞嵌入。在本章中,是採用Skip-Gram方法,通過預測單詞的上下文來訓練詞嵌入。

  最後,如果要訓練一個以單詞為輸入單位的CharRNN(即模型的每一步的輸入都是單詞,輸入的每一步也是單詞,而不是字母),那麼可以用本章中訓練得到的詞嵌入作為要訓練的Char RNN的詞嵌入層的初始值,這樣做可以大大提高收斂速度。對於漢字或是漢字詞語,也可以採取類似的方法。

  我把整個程式碼放在這裡

# coding: utf-8

# Copyright 2015 The TensorFlow Authors. All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ==============================================================================
"""Basic word2vec example."""

# 匯入一些需要的庫
# from __future__ import absolute_import
# from __future__ import division
# from __future__ import print_function

import collections
import math
import os
import random
import zipfile

import numpy as np
from six.moves import urllib
from six.moves import xrange  # pylint: disable=redefined-builtin
import tensorflow as tf

# 第一步: 在下面這個地址下載語料庫
url = 'http://mattmahoney.net/dc/'


def maybe_download(filename, expected_bytes):
    """
    這個函式的功能是:
        如果filename不存在,就在上面的地址下載它。
        如果filename存在,就跳過下載。
        最終會檢查文字的位元組數是否和expected_bytes相同。
    """
    if not os.path.exists(filename):
        print('start downloading...')
        filename, _ = urllib.request.urlretrieve(url + filename, filename)
    statinfo = os.stat(filename)
    if statinfo.st_size == expected_bytes:
        print('Found and verified', filename)
    else:
        print(statinfo.st_size)
        raise Exception(
            'Failed to verify ' + filename + '. Can you get to it with a browser?')
    return filename


# 下載語料庫text8.zip並驗證下載
filename = maybe_download('text8.zip', 31344016)


# 將語料庫解壓,並轉換成一個word的list
def read_data(filename):
    """
    這個函式的功能是:
        將下載好的zip檔案解壓並讀取為word的list
    """
    with zipfile.ZipFile(filename) as f:
        data = tf.compat.as_str(f.read(f.namelist()[0])).split()
    return data


vocabulary = read_data(filename)
print('Data size', len(vocabulary))  # 總長度為1700萬左右
# 輸出前100個詞。
print(vocabulary[0:100])

# 第二步: 製作一個詞表,將不常見的詞變成一個UNK識別符號
# 詞表的大小為5萬(即我們只考慮最常出現的5萬個詞)
vocabulary_size = 50000


def build_dataset(words, n_words):
    """
    函式功能:將原始的單詞表示變成index
    """
    count = [['UNK', -1]]
    count.extend(collections.Counter(words).most_common(n_words - 1))
    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  # UNK的index為0
            unk_count += 1
        data.append(index)
    count[0][1] = unk_count
    reversed_dictionary = dict(zip(dictionary.values(), dictionary.keys()))
    return data, count, dictionary, reversed_dictionary


data, count, dictionary, reverse_dictionary = build_dataset(vocabulary,
                                                            vocabulary_size)
del vocabulary  # 刪除已節省記憶體
# 輸出最常出現的5個單詞
print('Most common words (+UNK)', count[:5])
# 輸出轉換後的資料庫data,和原來的單詞(前10個)
print('Sample data', data[:10], [reverse_dictionary[i] for i in data[:10]])
# 我們下面就使用data來製作訓練集
data_index = 0


# 第三步:定義一個函式,用於生成skip-gram模型用的batch
def generate_batch(batch_size, num_skips, skip_window):
    # data_index相當於一個指標,初始為0
    # 每次生成一個batch,data_index就會相應地往後推
    global data_index
    assert batch_size % num_skips == 0
    assert num_skips <= 2 * skip_window
    batch = np.ndarray(shape=(batch_size), dtype=np.int32)
    labels = np.ndarray(shape=(batch_size, 1), dtype=np.int32)
    span = 2 * skip_window + 1  # [ skip_window target skip_window ]
    buffer = collections.deque(maxlen=span)
    # data_index是當前資料開始的位置
    # 產生batch後就往後推1位(產生batch)
    for _ in range(span):
        buffer.append(data[data_index])
        data_index = (data_index + 1) % len(data)
    for i in range(batch_size // num_skips):
        # 利用buffer生成batch
        # buffer是一個長度為 2 * skip_window + 1長度的word list
        # 一個buffer生成num_skips個數的樣本
        #     print([reverse_dictionary[i] for i in buffer])
        target = skip_window  # target label at the center of the buffer
        #     targets_to_avoid保證樣本不重複
        targets_to_avoid = [skip_window]
        for j in range(num_skips):
            while target in targets_to_avoid:
                target = random.randint(0, span - 1)
            targets_to_avoid.append(target)
            batch[i * num_skips + j] = buffer[skip_window]
            labels[i * num_skips + j, 0] = buffer[target]
        buffer.append(data[data_index])
        # 每利用buffer生成num_skips個樣本,data_index就向後推進一位
        data_index = (data_index + 1) % len(data)
    data_index = (data_index + len(data) - span) % len(data)
    return batch, labels


# 預設情況下skip_window=1, num_skips=2
# 此時就是從連續的3(3 = skip_window*2 + 1)個詞中生成2(num_skips)個樣本。
# 如連續的三個詞['used', 'against', 'early']
# 生成兩個樣本:against -> used, against -> early
batch, labels = generate_batch(batch_size=8, num_skips=2, skip_window=1)
for i in range(8):
    print(batch[i], reverse_dictionary[batch[i]],
          '->', labels[i, 0], reverse_dictionary[labels[i, 0]])

# 第四步: 建立模型.

batch_size = 128
embedding_size = 128  # 詞嵌入空間是128維的。即word2vec中的vec是一個128維的向量
skip_window = 1  # skip_window引數和之前保持一致
num_skips = 2  # num_skips引數和之前保持一致

# 在訓練過程中,會對模型進行驗證 
# 驗證的方法就是找出和某個詞最近的詞。
# 只對前valid_window的詞進行驗證,因為這些詞最常出現
valid_size = 16  # 每次驗證16個詞
valid_window = 100  # 這16個詞是在前100個最常見的詞中選出來的
valid_examples = np.random.choice(valid_window, valid_size, replace=False)

# 構造損失時選取的噪聲詞的數量
num_sampled = 64

graph = tf.Graph()

with graph.as_default():
    # 輸入的batch
    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'):
        # 定義1個embeddings變數,相當於一行儲存一個詞的embedding
        embeddings = tf.Variable(
            tf.random_uniform([vocabulary_size, embedding_size], -1.0, 1.0))
        # 利用embedding_lookup可以輕鬆得到一個batch內的所有的詞嵌入
        embed = tf.nn.embedding_lookup(embeddings, train_inputs)

        # 建立兩個變數用於NCE Loss(即選取噪聲詞的二分類損失)
        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]))

    # tf.nn.nce_loss會自動選取噪聲詞,並且形成損失。
    # 隨機選取num_sampled個噪聲詞
    loss = tf.reduce_mean(
        tf.nn.nce_loss(weights=nce_weights,
                       biases=nce_biases,
                       labels=train_labels,
                       inputs=embed,
                       num_sampled=num_sampled,
                       num_classes=vocabulary_size))

    # 得到loss後,我們就可以構造優化器了
    optimizer = tf.train.GradientDescentOptimizer(1.0).minimize(loss)

    # 計算詞和詞的相似度(用於驗證)
    norm = tf.sqrt(tf.reduce_sum(tf.square(embeddings), 1, keep_dims=True))
    normalized_embeddings = embeddings / norm
    # 找出和驗證詞的embedding並計算它們和所有單詞的相似度
    valid_embeddings = tf.nn.embedding_lookup(
        normalized_embeddings, valid_dataset)
    similarity = tf.matmul(
        valid_embeddings, normalized_embeddings, transpose_b=True)

    # 變數初始化步驟
    init = tf.global_variables_initializer()

# 第五步:開始訓練
num_steps = 100001

with tf.Session(graph=graph) as session:
    # 初始化變數
    init.run()
    print('Initialized')

    average_loss = 0
    for step in xrange(num_steps):
        batch_inputs, batch_labels = generate_batch(
            batch_size, num_skips, skip_window)
        feed_dict = {train_inputs: batch_inputs, train_labels: batch_labels}

        # 優化一步
        _, loss_val = session.run([optimizer, loss], feed_dict=feed_dict)
        average_loss += loss_val

        if step % 2000 == 0:
            if step > 0:
                average_loss /= 2000
            # 2000個batch的平均損失
            print('Average loss at step ', step, ': ', average_loss)
            average_loss = 0

        # 每1萬步,我們進行一次驗證
        if step % 10000 == 0:
            # sim是驗證詞與所有詞之間的相似度
            sim = similarity.eval()
            # 一共有valid_size個驗證詞
            for i in xrange(valid_size):
                valid_word = reverse_dictionary[valid_examples[i]]
                top_k = 8  # 輸出最相鄰的8個詞語
                nearest = (-sim[i, :]).argsort()[1:top_k + 1]
                log_str = 'Nearest to %s:' % valid_word
                for k in xrange(top_k):
                    close_word = reverse_dictionary[nearest[k]]
                    log_str = '%s %s,' % (log_str, close_word)
                print(log_str)
    # final_embeddings是我們最後得到的embedding向量
    # 它的形狀是[vocabulary_size, embedding_size]
    # 每一行就代表著對應index詞的詞嵌入表示
    final_embeddings = normalized_embeddings.eval()


# Step 6: 視覺化
# 視覺化的圖片會儲存為“tsne.png”

def plot_with_labels(low_dim_embs, labels, filename='tsne.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)


try:
    # pylint: disable=g-import-not-at-top
    from sklearn.manifold import TSNE
    import matplotlib
    matplotlib.use('agg')   # must be before importing matplotlib.pyplot or pylab
    import matplotlib.pyplot as plt

    # 因為我們的embedding的大小為128維,沒有辦法直接視覺化
    # 所以我們用t-SNE方法進行降維
    tsne = TSNE(perplexity=30, n_components=2, init='pca', n_iter=5000)
    # 只畫出500個詞的位置
    plot_only = 500
    low_dim_embs = tsne.fit_transform(final_embeddings[:plot_only, :])
    labels = [reverse_dictionary[i] for i in xrange(plot_only)]
    plot_with_labels(low_dim_embs, labels)

except ImportError:
    print('Please install sklearn, matplotlib, and scipy to show embeddings.')
View Code

 

參考

論文《Efficient Estimation of WordRepresentations in Vector Space》CBOW模型和Skip-Gram模型

相關文章