詞向量也稱為詞嵌入,是指將詞轉換成為向量的形式。
為何需要詞向量
對于非結構化的數據:音頻,圖片,文字。前面兩種的數據存儲方式是天然高維和高密度的,而且數據天然的就非常具有實際意義(相近的數據表示顏色或者音頻接近),幾乎可以直接進入模型進行處理。但是對于文字來說不同的詞如果采用類似LabelEncoder來做的,不同的詞ID取值接近并不能有實際的意義表示。而如果采用類似OneHot編碼則會導致向量維度過高(詞匯量少說也要幾萬),也過于稀疏,同時也依然難以在數值上表示出不同詞之間的關系。
所以我們希望能找到一種詞與向量的映射關系,使得向量維度不需要過大,而且詞向量在向量空間中所表示的點具有實際的意義,也就是相似含義的詞在空間中的距離更近。
Word2Vec就是一個可以達到上述要求的一種方法,它可以從原始文本(語料庫)中讀取詞語然后生成詞向量。word2vec從實現方法來看分為兩個大的框架:一、Hierarchical Softmax模型框架;二、Negative Sampling模型框架。
Hierarchical Softmax模型框架
模型大致由輸入層、投影層和輸出層構成。
其中Hierarchical Softmax模型的輸出層由語料庫中詞出現的頻數當作權值構造出的哈夫曼樹作為輸出。具體實現由CBOW模型(Continuous Bag-of-Words Model)或者Skip-gram模型來完成。
假設詞w的上下文窗口長度skip_window為c,那么對于模型的每一次迭代計算有【w之前c個詞,w,w之后c個詞】
- CBOW模型實現
CBOW考慮的主要思想是要P( w | Context(w) )的概率最大化,所以接下來看CBOW模型主要就是看如何定義和計算這個概率。(當然對于語言模型來說,實際的目標函數通常是對語料庫中的每個詞的概率P( w | Context(w) )取對數再累加)
輸入層: 2c個詞向量
投影層:2c個詞向量的累加
輸出層:哈夫曼樹(重點是詞w所在的葉子節點,以及w到根節點的路徑)
接下來就是重點了,也就是怎么計算P( w | Context(w) )
- 2c個上下文詞向量的累加,與根節點的參數(系數theta_0和常數項bias_0)計算概率P(0)
- 計算使用了Sigmoid函數,假設計算結果為S(0)
- 根據上面計算得到的S0以及詞w所在路徑對應的分支,決定P(0)=S(0)或者P0=1-S(0)
- 繼續使用2c個上下文詞向量的累加向量與路徑中下一個節點的參數計算概率P(1)
- 一直計算到w所在的葉子節點直接相連的上面那個節點P(h)
-
P( w | Context(w) ) = P(0) * P(1) * ... * P(h)
計算過程圖示(簡書不好寫公式,所以省略了很多公式記號)
然后對P取對數,求梯度,可以得到兩個部分的更新:
-
詞w的路徑中各個非葉節點的參數(系數theta_i和常數項bias_i)的更新
非葉節點的系數更新 -
詞w的上下文詞向量的更新(所有w的上下文窗內詞統一更新)
所有w的上下文詞向量統一更新
最后當把語料庫遍歷一遍或者幾遍后就得到了全部詞的詞向量。
- Skip-gram模型實現
Skip-gram考慮的主要思想是要P( Context(w) | w )的概率最大化,所以接下來看Skip-gram模型主要就是看如何定義和計算這個概率。(當然對于語言模型來說,實際的目標函數通常是對語料庫中的每個詞的概率P( Context(w) | w )取對數再累加)
輸入層:詞w的向量
投影層:依舊是詞w的向量
輸出層:哈夫曼樹(重點是詞w的上下文窗內2c個詞所在的葉子節點,以及各自到根節點的路徑)
接下來的重點就是怎么定義和計算P( Context(w) | w )
- 對于詞w的上下文窗內的一個詞u(1):
- 計算P( u(1) | w ),其計算過程和前面的類似,都是先計算詞向量w與u(1)到根路徑中非葉節點的系數Sigmoid函數值,然后根據每個具體的分支得到P(i),然后將P(i)累乘得到。
- 遍歷詞w的上下文窗內的每個詞u(i),都計算得到P( u(i) | w )
-
P( Context(w) | w ) = P(u(1) | w) * P(u(2) | w) * ... * P(u(2c) | w)
計算過程簡易圖示
有了P( Context(w) | w )的計算,就可以通過取對數然后求梯度來對兩個部分的參數更新:
-
每個上下文詞u(i)對應的到根路徑的節點的參數的更新:
每個上下文詞u所在路徑的非葉節點參數更新 -
詞w的向量的更新:
中心詞w的詞向量更新
同樣,把語料庫遍歷幾遍后就可以得到全部詞的詞向量。
- 總結
- CBOW模型的一次更新是:輸入2c個詞向量的累加,然后對中心詞w上的路徑節點系數進行更新,然后對所有的上下文詞的詞向量進行整體一致更新。
- Skip-gram模型的一次更新是:輸入中心詞w的詞向量,然后對每個上下文詞u(i)所在的路徑上的節點系數進行更新,然后對詞w的詞向量進行單獨更新。
可見CBOW的一次更新計算量要小,Skip-gram模型計算量大,而且更新的系數也多。
Negative Sampling模型框架
Negative Sampling模型的輸出層顧名思義,由對指定詞的負采樣來作為輸出(與Hierarchical Softmax最大的不同就是用負采樣替代了哈夫曼樹,這樣也就改變了條件概率的計算過程)。具體實現也是由兩個算法模型CBOW和Skip-gram來實現。
為了解決新加入的概念帶來的困擾,我們先看下負采樣
- 負采樣
負采樣的算法思路其實還是比較簡單,就是利用不同詞在語料中出現的頻次多少來決定被采樣到的概率。
簡單說就是每個詞由一個線段構成(線段的長度由詞頻決定),所有的詞構成一個大的線段,然后在這個總線段上用非常細的刻度來進行劃分,采樣的時候就是在這個細刻度的劃分中隨機選取一個,看其屬于哪個詞的線段內就表示本次采用選到了哪個詞。
多說一句,方便接下來的算法理解,其實負采樣的作用就是采用出一些“負”詞(與采樣詞不同即為負),使得原來在哈夫曼樹中需要用到的非葉節點參數以及分支選擇的地方都替換成負采樣出來的詞。
- CBOW模型實現
輸入層: 2c個詞向量
投影層:2c個詞向量的累加
輸出層:負采樣詞集(重點是詞w的負詞詞集的參數(θ),負詞的概率永遠是1-Sigmoid函數值)
接下來我們考慮的重點不是P( w | Context(w) )而是替換成g( w ):
其中:
把上面的公式用通俗的語言表達就是:
- 輸入詞w的上下文詞向量的累加:X_w
- 對詞w進行負采樣(得到一組非w的負詞)
- X_w與詞w的輔助向量(可訓練的參數θ)的Sigmoid函數值越大越好
- X_w與詞w的負詞的輔助向量(可訓練的參數θ)的Sigmoid函數值越小越好
也就是說每個詞除了有自己的詞向量之外,還有一個輔助向量θ
有了g( w )的定義后,就可以計算梯度,然后更新兩個部分的參數:
-
每個詞包括w及其采樣得到的負詞詞集的參數θ更新:
-
詞w的上下文詞向量的更新(所有上下文詞一致更新):
最后把語料庫遍歷幾遍后,就可以得到全部的詞向量。
- Skip-gram模型實現
輸入層:詞w的向量
投影層:依舊是詞w的向量
輸出層:每個上下文詞u的負采樣(重點是詞u的負詞詞集的參數(θ),負詞的概率永遠是1-Sigmoid函數值)
接下來的重點不是P( Context(w) | w ),而是G
這里v(w)為詞w的詞向量。
通俗講解:
- 輸入詞w的詞向量
- 對于詞w的上下文詞u(i)進行負采樣得到詞集z(i),計算g( u(i) )
- 計算P( z(i)_j | w ),z(i)_j = u(i)時使用v(w)和z的θ的Sigmoid函數值,否則1 - Sigmoid函數值
- g( u(i) ) = 對上面j的遍歷后的累積
- 對上下文詞u(i)進行遍歷,得到每個g( u(i) ),最后累積就是G
有了G之后就可以計算梯度進行參數更新:
- 每個負采樣出來的詞系數θ的更新
- 詞w的詞向量更新
同樣把語料庫遍歷幾遍后可以得到所有的詞向量。
算法整體總結
- Hierarchical Softmax主要是通過哈夫曼樹來計算,其中用到了非葉節點的系數θ。
- Negative Sampling主要是對詞進行負采樣,其中每個詞除了有自己的詞向量外還有輔助向量系數θ。
- CBOW的思想是在Context(w)基礎上讓w的條件概率越大越好,輸入是w的上下文詞向量累加,更新也是上下文的詞向量一致更新。同時對于輔助向量θ的更新個數較少
- Skip-gram的思想是在w的條件上讓Context(w)的條件概率越大越好,輸入是w的詞向量,更新的也是w的詞向量。同時對輔助向量θ的更新個數較多
所以CBOW看起來更新的更平滑,適合小量文本集上的詞向量構建,Skip-gram每次更新都更加有針對性,所以對于大文本集上表現更好。
接下來的TF實踐,主要使用的就是Negative Sampling框架下的Skip-gram算法。
TensorFlow實踐
在TensorFlow的教學文檔中有一個關于詞向量的基礎代碼實踐:word2vec_basic.py。接下來提到的代碼也是圍繞這個進行。
-
- 讀取語料構成輸入數據集。
- 讀語料文檔
讀取的細節代碼就不細究了,這里想說下,因為語料第一次是需要下載的,如果直接用代碼下載的話會比較慢,建議先用迅雷把文檔下載下來text8.zip. 然后放入
from tempfile import gettempdir gettempdir()
顯示的臨時文件夾中(程序中默認在這個文件夾中尋找語料文件,也可以手動修改成別的文件夾),再運行代碼。
- 構建輸入數據
def build_dataset(words, n_words): """Process raw inputs into a dataset.""" count = [['UNK', -1]] # 統計單詞和詞頻的二維列表:[[單詞,詞頻], ... ,[單詞,詞頻]] count.extend(collections.Counter(words).most_common(n_words - 1)) dictionary = dict() # 單詞和對應的索引,不常出現的詞(排名在49999之后的),統統索引為0,用'NUK'表示 for word, _ in count: dictionary[word] = len(dictionary) data = list() # 語料中的單詞轉成索引的列表 unk_count = 0 for word in words: index = dictionary.get(word, 0) if index == 0: # dictionary['UNK'] 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
輸入:words是語料庫中的詞組list,以及n_words最大詞數目的限制。
輸出:data是語料文本中詞的Index的list,count是[[詞,詞頻], ... ,]組成的二維list,dictionary是詞->索引,reversed_dictionary是索引->詞。 - 為Skip-gram模型產生batch輸入
def generate_batch(batch_size, num_skips, skip_window):
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 ] 前后skip窗長加上中心詞自己后的個數
buffer = collections.deque(maxlen=span) # 雙端隊列,并設置最大長度
if data_index + span > len(data):
data_index = 0
buffer.extend(data[data_index:data_index + span]) # 接續上次讀入的位置,讀入span長度的文本內容
data_index += span
for i in range(batch_size // num_skips): # 分塊總共采樣batch_size個,其中每塊隨機選取上下文的詞num_skips次,每一塊的中心詞固定
context_words = [w for w in range(span) if w != skip_window] # 得到不包含中心詞的位置索引[0,1,3,4],假如skip窗長為2
words_to_use = random.sample(context_words, num_skips) # 得到隨機選取的作為上下文的詞的位置
for j, context_word in enumerate(words_to_use):
batch[i * num_skips + j] = buffer[skip_window] # 中心詞
labels[i * num_skips + j, 0] = buffer[context_word] # 上下文詞
if data_index == len(data):
buffer[:] = data[:span]
data_index = span
else:
buffer.append(data[data_index]) # 繼續向后讀入一個詞,相當于讀取下一塊,中心詞也向后偏移一個
data_index += 1
# Backtrack a little bit to avoid skipping words in the end of a batch
data_index = (data_index + len(data) - span) % len(data)
return batch, labels
輸入:batch_size為一個batch的大小,num_skips為每個中心詞選取上下文詞的次數(要保證batch_size能整除num_skips,因為batch_size // num_skips是一個batch中會偏移向后取詞的個數),skip_window是中心詞的上下文詞的范圍(比如skip_window=2是指中心詞的前面2個詞和后面2個詞共4個詞作為這個中心詞的上下文詞集)
輸出:batch是shape=(batch_size,)的中心詞Index的np數組,labels是shape=(batch_size,1)的上下文詞Index的np數組
- 構造Skip-gram模型
使用TF構造任何模型核心都是定義計算loss的公式以及具體計算中使用的優化方法。
- 構造Skip-gram模型
with graph.as_default():
# Input data.
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)
# Ops and variables pinned to the CPU because of missing GPU implementation
with tf.device('/cpu:0'):
# Look up embeddings for inputs.
embeddings = tf.Variable(
tf.random_uniform([vocabulary_size, embedding_size], -1.0, 1.0))
embed = tf.nn.embedding_lookup(embeddings, train_inputs)
# Construct the variables for the 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]))
# Compute the average NCE loss for the batch.
# tf.nce_loss automatically draws a new sample of the negative labels each
# time we evaluate the loss.
# Explanation of the meaning of NCE loss:
# http://mccormickml.com/2016/04/19/word2vec-tutorial-the-skip-gram-model/
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))
# Construct the SGD optimizer using a learning rate of 1.0.
optimizer = tf.train.GradientDescentOptimizer(1.0).minimize(loss)
# Compute the cosine similarity between minibatch examples and all embeddings.
norm = tf.sqrt(tf.reduce_sum(tf.square(embeddings), 1, keep_dims=True))
normalized_embeddings = embeddings / norm
valid_embeddings = tf.nn.embedding_lookup(
normalized_embeddings, valid_dataset)
similarity = tf.matmul(
valid_embeddings, normalized_embeddings, transpose_b=True)
# Add variable initializer.
init = tf.global_variables_initializer()
-
tf.nn.embedding_lookup
這個函數功能是根據輸入的Index查找對應的向量,然后返回Tensor出來。喂給下面的計算NCE損失作為Input用。-
tf.nn.nce_loss
這個函數是重中之重,因為它直接作為loss的計算(再套一個tf.reduce_mean而已)。理解這個函數需要用到上面我們講到Negative Sampling框架下的Skip-gram模型算法。
這里我們再回顧下Skip-gram模型都用到了哪些變量來計算:- 中心詞w的詞向量
- 上下文詞u的輔助系數θ(以及bias)
- 對每個上下文詞u進行負采樣得到的其他詞u_neg的輔助系數θ
然后我們再看下函數tf.nn.nce_loss都有哪些輸入:
- weights:shape=[vocabulary_size, embedding_size]的Tensor,是全部詞典的輔助系數θ
- biases:shape=[vocabulary_size]的Tensor,是全部詞典的偏置項bias
- labels:中心詞w對應的上下文詞u的Index
- inputs:中心詞w的詞向量
- num_sampled:每次對于一個上下文詞要采樣多少個負詞
- num_classes:詞典的大?。ㄔ~的類別個數)
通過對比可以發現tf.nn.nce_loss的輸入正好涵蓋了前面講到Skip-gram模型時用到的計算變量。然后具體內部的實現細節可以通過源碼或者查考其他資料。(與上面寫的計算過程略有不同,除了計算Sigmoid的概率值之外,還計算了交叉熵損失,以及最后按行求和)
計算余弦相似度
最后還對詞向量做了正則化(方便后面計算余弦相似度,直接使用矩陣乘積即可,因為除數已經被歸一化了),然后對隨機選取的詞與全字典進行余弦相似度計算。另外需要注意的是不能用GPU來搭建模型,因為tf.nn.sampled_softmax_loss使用了GPU不支持的op
-
- 開始訓練
with tf.Session(graph=graph) as session:
# We must initialize all variables before we use them.
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}
# We perform one update step by evaluating the optimizer op (including it
# in the list of returned values for session.run()
_, loss_val = session.run([optimizer, loss], feed_dict=feed_dict)
average_loss += loss_val
if step % 2000 == 0:
if step > 0:
average_loss /= 2000
# The average loss is an estimate of the loss over the last 2000 batches.
print('Average loss at step ', step, ': ', average_loss)
average_loss = 0
# Note that this is expensive (~20% slowdown if computed every 500 steps)
if step % 10000 == 0:
sim = similarity.eval()
for i in xrange(valid_size):
valid_word = reverse_dictionary[valid_examples[i]]
top_k = 8 # number of nearest neighbors
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 = normalized_embeddings.eval()
訓練過程比較簡單,就是從generate_batch中讀取數據,然后設置好feed_dict后run得到loss,除了每隔2000打印一次平均loss外,還會每隔10000打印一次隨機選取的驗證詞中余弦相似度最接近的詞語。
最后通過normalized_embeddings.eval()得到正則化后的詞向量final_embeddings
- 畫圖展示詞向量
def plot_with_labels(low_dim_embs, labels, filename):
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.pyplot as plt
tsne = TSNE(perplexity=30, n_components=2, init='pca', n_iter=5000, method='exact')
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, os.path.join(gettempdir(), 'tsne.png'))
except ImportError as ex:
print('Please install sklearn, matplotlib, and scipy to show embeddings.')
print(ex)
通過使用t-SNE降維,來畫圖展示詞向量: