第5課: Word2vec和實驗管理(上)
我們已經建立了幾個非常簡單的模型,它們只需要幾分鐘就能訓練完畢。如果要訓練更復雜的模型,我們需要一些更多的工具。在這節(jié)課中,我們將介紹模型庫、變量共享、模型共享以及如何管理你的實驗。我們將會用word2vec作為例子演示這些。
Word2vec
你也許還不了解詞嵌入(word embedding),那么你應該看看Stanford CS 224N的詞向量課程。了解之后,跟一下這兩篇論文是一個好主意:
- Distributed Representations of Words and Phrases and their Compositionality(Mikolov et al., 2013)
- ,
- Efficient Estimation of Word Representations in Vector Space(Mikolov et al., 2013)
在較高的層面上,我們需要找到一個表示文本數據(比如詞匯)的方法來讓我們能將其用于解決自然語言處理任務。在像語言建模、機器翻譯和語義分析等任務的解決方案中詞嵌入是核心。
Tomas Mikolov帶領的研究團隊提出的word2vec是一組用來做詞嵌入的模型,其中有兩種主要的模型:skip-gram和CBOW。
Skip-gram vs CBOW(Continuous Bag-of-Words)
在算法上,這兩個模型十分相似,除了CBOW是從上下文詞(context words)預測中間詞(center words),而skip-gram與其相反是從中間詞預測上下文詞。
比如我們有一句話:"The quick brown fox jumps",以"brown"為中心詞,然后CBOW嘗試從"the"、"quick"、"fox"和"jumps"去預測"brown",而skip-gram嘗試從"brown"去預測"the"、"quick"、"fox"和"jumps"。
統計上看,CBOW平滑了很多分布信息(把整個上下文當成一次觀察),這使它在較小的數據集上很有用。而skip-gram將每個上下文詞當做一個新的觀察,它在大數據集上效果更好。
在這節(jié)課中,我們將會建立word2vec的skip-gram模型。為了獲得詞匯的向量表示,我們訓練一個簡單的單隱層神經網絡來完成一個特定任務(假任務,fake task),但是之后我們不會用我們訓練的這個神經網絡來完成skip-gram的任務。相反,我們只關心假任務訓練完后隱層的權值,這些權值被稱為詞向量(word vector)或者嵌入矩陣(embedding matrix)。
我們要去訓練模型的假任務是通過給定的中心詞去預測上下文詞,在一句話中指定一個中心詞,查看它附近并隨機選取一個詞作為標簽。這個網絡將會告訴我們詞匯表中的每一個詞作為中心詞的鄰居的概率。這里有一個解釋skip-gram模型細節(jié)的精彩教程。
在TensorBoard中用t-SNE將詞向量投影到3D空間上:

Softmax, 負采樣(Negative Sampling)和NCE(Noise Contrastive Estimation)
獲得可能的鄰近詞的分布,在理論上,我們經常用softmax。Softmax將一組隨機值x_i映射成一組和為1的概率值p_i。在這種情況下,softmax(x_i)表示x_i是指定的中心詞的鄰近詞的概率。
softmax(x_i) = exp(x_i) / ∑_i exp(x_i)
然而,分母的標準形式需要我們計算字典中所有詞(可能有幾百萬個)的exp并求和。就算去掉不常用的詞,一個自然語言模型也必須考慮至少成千上萬個最常用詞,標準形式的softmax還是不好計算。
這里有兩個主要的方法可以規(guī)避這個瓶頸:分層softmax(hierarchical softmax)和基于采樣的softmax(sample-based softmax)。Mikolov團隊在他們的論文中展示了負采樣加速了skip-gram模型的訓練,并對比了更復雜的分層softmax。
負采樣顧名思義屬于基于采樣的方法族,這個方法族還包括重要性采樣(importance sampling)和目標采樣(target sampling)。負采樣是一種叫做Noise Contrastive Estimation(NCE)方法的簡化模型。負采樣對產生的噪聲樣本數量k和噪聲樣本分布Q作了一定的假設,例如kQ(w)=1,這樣可以簡化計算。更多的細節(jié)可以看Sebastian Rudder的On word embeddings - Part 2: Approximating the Softmax和Chris Dyer的Notes on Noise Contrastive Estimation and Negative Sampling。
雖然負采樣對于學習詞嵌入是有用的,但它并不能保證其導數趨向于softmax函數的梯度。相對來說NCE在噪聲樣本增加時就能提供這個保證。Mnih and Teh(2012)表明25個噪聲樣本足以使其性能達到正規(guī)的softmax,且伴隨45%的加速。因為這個原因,我們將會使用NCE。
注意例如負采樣和NCE等基于采樣的方法只在訓練時有用,在預測時仍然需要計算完整的softmax以獲得規(guī)范的概率。
數據集(Dataset)
text8是2006年3月3日英語維基百科的文本的前100 MB,我們使用的文本已經花費大量時間進行預處理過,因為在這門課中主要的學習目標是TensorFlow。我們可以在這里下載這個數據集,課程的GitHub中的word_utils.py能夠下載和讀取這個文本。
100MB的文本不足以訓練好的詞嵌入,但是足夠看到一些有趣的聯系。如果你用空格分隔這個文本可以獲得17,005,207個標記,如果想獲得更好的結果你應該使用fil9(維基百科的前10^9個字節(jié)),就像Matt Mahoney的網站上描述的一樣。
實現word2vec
第1階段: 裝配計算圖
- 建立數據集并用它生成樣本
輸入是中心詞,輸出是上下文詞。我們創(chuàng)建一個最常用詞字典,用這些詞的索引輸入模型從而替代輸入詞。比如我們的中心詞是字典中的第1000個詞就輸入999。
每個樣本輸入是一個標量,所以BATCH_SIZE個樣本輸入的形狀為[BATCH_SIZE],BATCH_SIZE個樣本的輸出形狀為[BATCH_SIZE,1]。
dataset = tf.data.Dataset.from_generator(gen,
(tf.int32, tf.int32),
(tf.TensorShape([BATCH_SIZE]), tf.TensorShape([BATCH_SIZE, 1])))
iterator = dataset.make_initializable_iterator()
center_words, target_words = iterator.get_next()
- 定義權重(嵌入矩陣,embedding matrix)
每一行對應一個詞向量,如果一個詞被表示為一個大小為EMBED_SIZE的向量,那么嵌入矩陣的形狀為[VOCAB_SIZE,EMBED_SIZE]。我們用隨機的均勻分布初始化嵌入矩陣。
embed_matrix = tf.get_variable('embed_matrix',
shape=[VOCAB_SIZE, EMBED_SIZE],
initializer=tf.random_uniform_initializer())
- 預測(計算圖的正向傳播)
我們的目的是獲得我們字典中詞的向量表示(嵌入矩陣),記住嵌入矩陣的維度為VOCAB_SIZExEMBED_SIZE,每一行都對應一個詞的向量表示。所以要獲得batch中所有中心詞的向量,只需要對嵌入矩陣相應行進行切片,TensorFlow提供了一個很方便的方法去做這個。
tf.nn.embedding_lookup(
params,
ids,
partition_strategy='mod',
name=None,
validate_indices=True,
max_norm=None
)
這個方法在涉及到和獨熱碼的矩陣相乘時十分有用,因為它避免了我們在無論如何都會返回0的地方做一堆不必要的計算。

所以我們在獲得輸入的中心詞的向量表示時用這個方法:
embed = tf.nn.embedding_lookup(embed_matrix, center_words, name='embed')
- 定義損失函數
NCE很難用純Python實現,TensorFlow已經為我們實現了:
tf.nn.nce_loss(
weights,
biases,
labels,
inputs,
num_sampled,
num_classes,
num_true=1,
sampled_values=None,
remove_accidental_hits=False,
partition_strategy='mod',
name='nce_loss'
)
注意函數已經實現了,但是第四個參數是輸入(input),第三個參數是標簽(label)。這在有些時候帶來了很多麻煩,但是TensorFlow還是一個正在成長的平臺,現在還不是很完美。NCE損失的源代碼可以在這里找到。
為了計算NCE損失我們需要隱層中的weights和biases,它們在訓練時被optimizer更新。在采樣之后,最后的輸出評分會被計算,這些計算會在tf.nn.nce_loss中完成。
tf.matmul(embed, tf.transpose(nce_weight)) + nce_bias
nce_weight = tf.get_variable('nce_weight',
shape=[VOCAB_SIZE, EMBED_SIZE],
initializer=tf.truncated_normal_initializer(stddev=1.0 / (EMBED_SIZE ** 0.5)))
nce_bias = tf.get_variable('nce_bias', initializer=tf.zeros([VOCAB_SIZE]))
之后我們定義損失loss:
loss = tf.reduce_mean(tf.nn.nce_loss(weights=nce_weight,
biases=nce_bias,
labels=target_words,
inputs=embed,
num_sampled=NUM_SAMPLED,
num_classes=VOCAB_SIZE))
- 定義optimizer
我們還是使用梯度下降:
optimizer = tf.train.GradientDescentOptimizer(LEARNING_RATE).minimize(loss)
第2階段: 執(zhí)行運算
我們將會創(chuàng)建一個session來運行optimizer去最小化損失,然后為我們輸出損失值。別忘了重新初始化你的迭代器!
with tf.Session() as sess:
sess.run(iterator.initializer)
sess.run(tf.global_variables_initializer())
writer = tf.summary.FileWriter('graphs/word2vec_simple', sess.graph)
for index in range(NUM_TRAIN_STEPS):
try:
loss_batch, _ = sess.run([loss, optimizer])
except tf.errors.OutOfRangeError:
sess.run(iterator.initializer)
writer.close()
你可以在課程的GitHub上的word2vec.py中看到完整的模型。
接口: 怎樣構建你的TensorFlow模型
至今我們建立的所有的模型或多或少都有著相同的結構。
第1階段: 組裝你的計算圖
- 導入數據(用tf.data或者placeholder)
- 定義權重
- 定義預測模型
- 定義損失函數loss
- 定義優(yōu)化器optimizer
第2階段: 執(zhí)行運算
- 初始化所有的模型變量
- 初始化迭代器/feed_dict
- 執(zhí)行預測模型
- 計算損失loss
- 調整參數最小化loss
下面的圖片是訓練循環(huán)的可視化表示,摘自TensorFlow for Machine Intelligence (Abrahams et al., 2016)。

問題: 怎樣使我們的模型可以重用?
提示: 利于Python的面向對象功能。
回答: 將我們的模型寫成一個類!
我們的模型類應該實現下面的接口,我們合并了第3步和第4步是因為我們想把embed放到命名空間“NCE loss”下。
class SkipGramModel:
""" Build the graph for word2vec model """
def __init__(self, params):
pass
def _import_data(self):
""" Step 1: import data """
pass
def _create_embedding(self):
""" Step 2: in word2vec, it's actually the weights that we care about """
pass
def _create_loss(self):
""" Step 3 + 4: define the inference + the loss function """
pass
def _create_optimizer(self):
""" Step 5: define optimizer """
pass
可視化詞嵌入
t-SNE(維基百科)
t-SNE(t-distributed stochastic neighbor embedding,t-分布隨機近鄰嵌入)是一種Geoffrey Hinton等人發(fā)明的用于降維的機器學習算法。他是一種非線性降維技術,特別適合在將高維數據嵌入的二維或三維空間中,然后放到散點圖中進行可視化。具體地說,它將每一個高維對象建模為一個二維或三維點,其方式是相似的對象建模為鄰近的點,而不相似的對象建模為較遠的點。
t-SNE算法包含兩個主要步驟:
首先對成對的高維對象構建一個概率分布,相似的對象擁有高概率被選中,不同的對象擁有極地的概率被選中。
t-SNE在低維映射中對點定義了相似的概率分布,然后相對于映射中個點的位置最小化兩個分布之間的Kullback-Leibler散度。
注意,雖然原始算法使用對象之間的歐幾里得距離作為其相似性度量的基礎,但這應該根據需要進行修改。
你可以用它來可視化詞嵌入,你可以可視化任何東西的任何向量表示!在Olah的博客中可以看到可視化MNIST的例子(需要科學上網)。

我們也可以使用PCA來可視化詞嵌入。

而且我們用TensorFlow projector和TensorBoard只用不到10行代碼就可以做所有這些可視化。這些可視化文件會被存儲在visualization目錄中,在命令行運行tensorboard --logdir visualization進行查看。
from tensorflow.contrib.tensorboard.plugins import projector
def visualize(self, visual_fld, num_visualize):
# create the list of num_variable most common words to visualize
word2vec_utils.most_common_words(visual_fld, num_visualize)
saver = tf.train.Saver()
with tf.Session() as sess:
sess.run(tf.global_variables_initializer())
ckpt = tf.train.get_checkpoint_state(os.path.dirname('checkpoints/checkpoint'))
# if that checkpoint exists, restore from checkpoint
if ckpt and ckpt.model_checkpoint_path:
saver.restore(sess, ckpt.model_checkpoint_path)
final_embed_matrix = sess.run(self.embed_matrix)
# you have to store embeddings in a new variable
embedding_var = tf.Variable(final_embed_matrix[:num_visualize], name='embeded')
sess.run(embedding_var.initializer)
config = projector.ProjectorConfig()
summary_writer = tf.summary.FileWriter(visual_fld)
# add embedding to the config file
embedding = config.embeddings.add()
embedding.tensor_name = embedding_var.name
# link this tensor to the file with the first NUM_VISUALIZE words of vocab
embedding.metadata_path = os.path.join(visual_fld,[file_of_most_common_words])
# saves a configuration file that TensorBoard will read during startup.
projector.visualize_embeddings(summary_writer, config)
saver_embed = tf.train.Saver([embedding_var])
saver_embed.save(sess, os.path.join(visual_fld, 'model.ckpt'), 1)
請到課程GitHub的examples/04_word2vec_visualize.py中查看完整代碼。
變量共享
命名空間(Name Scope)
讓我們給tensors命名然后看看在TensorBoard中我們的word2vec模型長什么樣。

就像你在圖中看到的,節(jié)點散落的到處都是,使圖非常難讀。TensorFlow并不知道哪些節(jié)點應該分到一組,當您構建具有數百個運算的復雜模型時,這可能會使調試你的計算圖變得十分困難。
TensorFlow使用命名空間(Name Scope)來將運算節(jié)點分組:
with tf.name_scope(name_of_that_scope):
# declare op_1
# declare op_2
# ...
比如你的計算圖有4個命名空間:“data”、“embed”、“l(fā)oss”和“optimizer”
with tf.name_scope('data'):
iterator = dataset.make_initializable_iterator()
center_words, target_words = iterator.get_next()
with tf.name_scope('embed'):
embed_matrix = tf.get_variable('embed_matrix',
shape=[VOCAB_SIZE, EMBED_SIZE],
initializer=tf.random_uniform_initializer())
embed = tf.nn.embedding_lookup(embed_matrix, center_words, name='embedding')
with tf.name_scope('loss'):
nce_weight = tf.get_variable('nce_weight', shape=[VOCAB_SIZE, EMBED_SIZE],
initializer=tf.truncated_normal_initializer())
nce_bias = tf.get_variable('nce_bias', initializer=tf.zeros([VOCAB_SIZE]))
loss = tf.reduce_mean(tf.nn.nce_loss(weights=nce_weight,
biases=nce_bias,
labels=target_words,
inputs=embed,
num_sampled=NUM_SAMPLED,
num_classes=VOCAB_SIZE), name='loss')
with tf.name_scope('optimizer'):
optimizer = tf.train.GradientDescentOptimizer(LEARNING_RATE).minimize(loss)
在TensorBoard中查看計算圖時,你會看到整潔的分組:

你可以雙擊每個命名空間塊展開查看內部的運算。
TensorBoard有三種類型的邊:
- 灰色實線箭頭,表示數據流 - 比如tf.add(x,y)
- 橙色實線箭頭,表示哪個運算可以改變哪個運算 - 比如optimizer在BP中改變nce_weight、nce_bias和embed_matrix。
- 虛線箭頭.表示控制依賴 - 比如 nce_weight只能在init之后被執(zhí)行??刂埔蕾囘€可以用tf.Graph.control_dependencies(control_inputs)聲明。
變量空間(Variable scope)
一個人們常問的問題是:“命名空間和變量空間有什么不同?”。它們全都是創(chuàng)建命名空間,而變量空間做的是有利于參數共享。讓我們看看為什么我們需要變量共享。
假設我們需要創(chuàng)建一個兩個隱層的神經網絡,然后我們用兩個不同的輸入x1和x2去調用這個神經網絡。
x1 = tf.truncated_normal([200, 100], name='x1')
x2 = tf.truncated_normal([200, 100], name='x2')
def two_hidden_layers(x):
assert x.shape.as_list() == [200, 100]
w1 = tf.Variable(tf.random_normal([100, 50]), name="h1_weights")
b1 = tf.Variable(tf.zeros([50]), name="h1_biases")
h1 = tf.matmul(x, w1) + b1
assert h1.shape.as_list() == [200, 50]
w2 = tf.Variable(tf.random_normal([50, 10]), name="h2_weights")
b2 = tf.Variable(tf.zeros([10]), name="h2_biases")
logits = tf.matmul(h1, w2) + b2
return logits
logits1 = two_hidden_layers(x1)
logits2 = two_hidden_layers(x2)
查看TensorBoard中的計算圖:

每次你調用兩個網絡時,TensorFlow都會創(chuàng)建兩組變量,而事實上,你想要網絡為所有的輸入共享相同的變量。要做這個,你首先需要用tf.get_variable()創(chuàng)建變量。當我們用tf.get_variable()創(chuàng)建變量時,它會先檢查這個變量是否存在,如果存在就使用它,否則創(chuàng)建一個新的變量。
def two_hidden_layers_2(x):
assert x.shape.as_list() == [200, 100]
w1 = tf.get_variable("h1_weights", [100, 50], initializer=tf.random_normal_initializer())
b1 = tf.get_variable("h1_biases", [50], initializer=tf.constant_initializer(0.0))
h1 = tf.matmul(x, w1) + b1
assert h1.shape.as_list() == [200, 50]
w2 = tf.get_variable("h2_weights", [50, 10], initializer=tf.random_normal_initializer())
b2 = tf.get_variable("h2_biases", [10], initializer=tf.constant_initializer(0.0))
logits = tf.matmul(h1, w2) + b2
return logits
我們運行會得到下列錯誤:
ValueError: Variable h1_weights already exists, disallowed. Did you mean to set reuse=True or reuse=tf.AUTO_REUSE in VarScope?
要避免錯誤,我們需要將我們要用的所有變量放到變量空間中,然后設置變量空間為可重用的(reusable)。
def fully_connected(x, output_dim, scope):
with tf.variable_scope(scope) as scope:
w = tf.get_variable("weights", [x.shape[1], output_dim], initializer=tf.random_normal_initializer())
b = tf.get_variable("biases", [output_dim], initializer=tf.constant_initializer(0.0))
return tf.matmul(x, w) + b
def two_hidden_layers(x):
h1 = fully_connected(x, 50, 'h1')
h2 = fully_connected(h1, 10, 'h2')
with tf.variable_scope('two_layers') as scope:
logits1 = two_hidden_layers(x1)
scope.reuse_variables() # 設置重用變量
logits2 = two_hidden_layers(x2)
讓我們看看TensorBoard:

現在只有一組變量了,都在變量空間呢two_layers中,它們接受了兩個不同的輸入x1和x2。tf.variable_scope("name")隱式的打開了tf.name_scope("name")。
計算圖集合(Graph collections)
當你創(chuàng)建模型時,你可能想將你們的變量放在計算圖的不同部分中,有時你想要一種簡單的方法存取它們。tf.get_collection使你能夠使用集合的名字作為關鍵字存取特定的變量集合,空間是變量空間。
tf.get_collection(
key,
scope=None
)
默認情況下,所有的變量都被放在集合tf.GraphKeys.GLOBAL_VARIABLES中,要獲取變量空間“my_scope”中的所有的變量,只需要簡單的調用:
tf.get_collection(tf.GraphKeys.GLOBAL_VARIABLES, scope='my_scope')
如果你在建立變量時設置trainable=True(這是默認值),那么這個變量將會被放在集合tf.GraphKeys.TRAINABLE_VARIABLES中。
你可以創(chuàng)建不包含變量的運算的集合,你可以使用tf.add_to_collection(name,value)來創(chuàng)建你自己的集合,例如你可以創(chuàng)建一個initializer的集合然后把所有的init運算都放在里面。
標準庫使用各種眾所周知的名稱來收集和檢索與計算圖相關的值。 tf.train.Optimizer的子類默認優(yōu)化變量集合tf.GraphKeys.TRAINABLE_VARIABLES中的變量,但是也可以顯示設置需要優(yōu)化的變量列表。