tf.keras下的word2vec和attention word2vec
- 2020 年 12 月 21 日
- AI
之前介紹過基於attention的word2vec,好像挺多私信找我要程式碼,不過git上貌似沒有keras的實現,自己寫一個吧:
首先是word2vec的tf.keras版,來自於tensorflow官方文檔
//www.tensorflow.org/tutorials/text/word2vec這裡實現的是skip-gram,優化的方式一般都是用負取樣代替多分類,霍夫曼樹的實現很麻煩速度也沒有負取樣快效果也一般所以大部分的word2vec的nn框架的實現都是採用了負取樣的方式;

word2vec的模型的搭建很簡單,複雜的是樣本的構造上(這一點和時間序列問題類似,模型構造簡單,麻煩的是樣本的構造上)

上圖表示的是skip gram,c代表了上下文窗口,含義就不解釋了,應該都懂的;

原始的skip gram使用了softmax來表示wi到wo的條件概率;其中v和v ''是目標單詞和上下文單詞的向量表示形式,W是embedding的size。
Noise Contrastive Estimation 雜訊對比估計是softmax的高效近似函數,通過使用nce loss來代替,將softmax的目標——建模單詞分布轉化為 單純為了學習單詞嵌入而引入的多個二分類問題,
The simplified negative sampling objective for a target word is to distinguish the context word from num_ns negative samples drawn from noise distribution Pn(w) of words. More precisely, an efficient approximation of full softmax over the vocabulary is, for a skip-gram pair, to pose the loss for a target word as a classification problem between the context word and num_ns negative samples.
A negative sample is defined as a (target_word, context_word) pair such that the context_word does not appear in thewindow_sizeneighborhood of the target_word. For the example sentence, these are few potential negative samples (whenwindow_sizeis 2).
給了示例,負取樣的例子:(下面的例子是假設windows_size等於2的情況下得到的)
(hot, shimmered)
(wide, hot)
(wide, sun)
下面進入程式碼部分:
import io
import itertools
import numpy as np
import os
import re
import string
import tensorflow as tf
import tqdm
from tensorflow.keras import Model, Sequential
from tensorflow.keras.layers import Activation, Dense, Dot, Embedding, Flatten, GlobalAveragePooling1D, Reshape
from tensorflow.keras.layers.experimental.preprocessing import TextVectorization
SEED = 42
AUTOTUNE = tf.data.experimental.AUTOTUNE
sentence = "The wide road shimmered in the hot sun"
tokens = list(sentence.lower().split())
print(len(tokens))
vocab, index = {}, 1 # start indexing from 1
vocab['<pad>'] = 0 # add a padding token
for token in tokens:
if token not in vocab:
vocab[token] = index
index += 1
vocab_size = len(vocab)
print(vocab)
inverse_vocab = {index: token for token, index in vocab.items()}
print(inverse_vocab)
example_sequence = [vocab[word] for word in tokens]
print(example_sequence)
看了百度和Google上的多個word2vec的tf實現,基本上大部分都是實現了skip-gram+負取樣的word2vec,一方面負取樣實現起來很方便而霍夫曼樹用tf實現非常的麻煩速度也沒有前者快,另一方面tf.keras內置了skip-gram的樣本準備方式調用起來非常簡單。
window_size = 2
positive_skip_grams, _ = tf.keras.preprocessing.sequence.skipgrams(
example_sequence,
vocabulary_size=vocab_size,
window_size=window_size,
negative_samples=0)
print(len(positive_skip_grams))我們通過skipgram可以很方便的構造負取樣需要的正負樣本對,

通過改變negative_samples可以比較方便的得到不同大小的正負樣本對,比如設置為4的話則:

for target, context in positive_skip_grams[:5]:
print(f"({target}, {context}): ({inverse_vocab[target]}, {inverse_vocab[context]})")
看一下我們生成的正樣本對。
然後就是一個比較重要的細節,就是我們做負取樣的話是基於全局的語料進行取樣的,比如某個句子是:
'Today is a nice day'則我們進行負樣本取樣的範圍不局限於這一句而是在所有的sentences上隨機抽取某個目標單詞進行負取樣的,這也是一道比較細節的面試題。
# Get target and context words for one positive skip-gram.
target_word, context_word = positive_skip_grams[0]
# Set the number of negative samples per positive context.
num_ns = 4
context_class = tf.reshape(tf.constant(context_word, dtype="int64"), (1, 1))
negative_sampling_candidates, _, _ = tf.random.log_uniform_candidate_sampler(
true_classes=context_class, # class that should be sampled as 'positive'
num_true=1, # each positive skip-gram has 1 positive context class
num_sampled=num_ns, # number of negative context words to sample
unique=True, # all the negative samples should be unique
range_max=vocab_size, # pick index of the samples from [0, vocab_size]
seed=SEED, # seed for reproducibility
name="negative_sampling" # name of this operation
)
print(negative_sampling_candidates)
print([inverse_vocab[index.numpy()] for index in negative_sampling_candidates])
負取樣得到了4個fake的詞如上圖。
上面我們取的是一個句子作為demo,context_class=2,表示是目標詞的在詞表中的index,然後我們通過tf.random.log_uniform_candidate_sampler來進行取樣,上面的注釋寫的比較清楚了,這裡就不贅述了。
然後是第二個細節需要注意的地方:
大型數據集意味著大量的辭彙,也意味著停用詞等更頻繁的單詞數量更多, 通過對常見單詞(例如,the,is,on)進行負取樣而獲得的訓練示例並沒有為模型學習增加很多有用的資訊——Mikolov。 建議對常用詞進行二次抽樣,以提高嵌入品質。

簡單來說就是對出現頻繁的單詞進行降權減少其被取樣到的概率,否則高頻詞如果頻繁被取樣到則我們的訓練樣本中會有大量完全相同label的負樣本,這對於模型的多樣性來說是不利的。
這一點和gensim中的實現不太一樣:

gensim並沒有引入這個工程的trick。
實際上我們的進行word2vec前對停用詞高頻詞等等進行預處理的話不一定需要google的這個工程trick,不過這個工程trick函數確實比較方便。
把這些都合併在一塊:
# Generates skip-gram pairs with negative sampling for a list of sequences
# (int-encoded sentences) based on window size, number of negative samples
# and vocabulary size.
def generate_training_data(sequences, window_size, num_ns, vocab_size, seed):
# Elements of each training example are appended to these lists.
targets, contexts, labels = [], [], []
# Build the sampling table for vocab_size tokens.
sampling_table = tf.keras.preprocessing.sequence.make_sampling_table(vocab_size)
# Iterate over all sequences (sentences) in dataset.
for sequence in tqdm.tqdm(sequences):
# Generate positive skip-gram pairs for a sequence (sentence).
positive_skip_grams, _ = tf.keras.preprocessing.sequence.skipgrams(
sequence,
vocabulary_size=vocab_size,
sampling_table=sampling_table,
window_size=window_size,
negative_samples=0)
# Iterate over each positive skip-gram pair to produce training examples
# with positive context word and negative samples.
for target_word, context_word in positive_skip_grams:
context_class = tf.expand_dims(
tf.constant([context_word], dtype="int64"), 1)
negative_sampling_candidates, _, _ = tf.random.log_uniform_candidate_sampler(
true_classes=context_class,
num_true=1,
num_sampled=num_ns,
unique=True,
range_max=vocab_size,
seed=SEED,
name="negative_sampling")
# Build context and label vectors (for one target word)
negative_sampling_candidates = tf.expand_dims(
negative_sampling_candidates, 1)
context = tf.concat([context_class, negative_sampling_candidates], 0)
label = tf.constant([1] + [0]*num_ns, dtype="int64")
# Append each element from the training example to global lists.
targets.append(target_word)
contexts.append(context)
labels.append(label)
return targets, contexts, labels其它樣本預處理和準備的部分就不詳細描述了,具體的看官網就行了,因為樣本的準備部分可能不同的人有不同的處理方式不一定要按照tensorflow官網給定的api來運行,只要最後的樣本準備成這樣就行,主要是兩個實現方面的細節注意一下就好:

可以直接是numpy格式的也可以是tensor格式的。

整體的處理方式就是這樣了,上圖是skip-gram的樣本準備的demo,cbow基本是一樣的,只不過在模型架構的時候有一些區別。target就是我們的目標詞,context word也就是我們的target words的上下文,需要注意,負取樣針對的是target word,無論是skip gram還是cbow都是針對target word進行負取樣的。
然後直接來看一下模型的構建和巡禮那:
Python">class Word2Vec(Model):
def __init__(self, vocab_size, embedding_dim):
super(Word2Vec, self).__init__()
self.target_embedding = Embedding(vocab_size,
embedding_dim,
input_length=1,
name="w2v_embedding", )
self.context_embedding = Embedding(vocab_size,
embedding_dim,
input_length=num_ns+1)
self.dots = Dot(axes=(3,2))
self.flatten = Flatten()
def call(self, pair):
target, context = pair
we = self.target_embedding(target)
ce = self.context_embedding(context)
dots = self.dots([ce, we])
return self.flatten(dots)
embedding_dim = 128
word2vec = Word2Vec(vocab_size, embedding_dim)
word2vec.compile(optimizer='adam',
loss=tf.keras.losses.CategoricalCrossentropy(from_logits=True),
metrics=['accuracy'])
word2vec.fit(((targets,contexts)),labels, epochs=20)可以看到,模型的結構很簡單:

如果我們要修改為cbow模式的話也不複雜,做一些細節的修改就可以,但是需要注意,一旦選擇使用cbow則樣本組織的方式也要發生相應的變動。。。cbow的樣本準備目前keras沒有看到相應的api可以使用skipgram來間接的實現或者自己寫一個得了:
class Word2Vec(Model):
def __init__(self, vocab_size, embedding_dim,window_size):
super(Word2Vec, self).__init__()
self.target_embedding = Embedding(vocab_size,
embedding_dim,
input_length=1,
)
self.context_embedding = Embedding(vocab_size,
embedding_dim,
input_length=windows_size*2,name="w2v_embedding")
self.dots = Dot(axes=(3,2))
self.flatten = Flatten()
def call(self, pair):
target, context = pair
we = self.target_embedding(target)
ce = self.context_embedding(context)
ce = GlobalAveragePooling1D(ce) ##對上下文的embedding就行簡單平均,使用Global的方式可以很方便
#的形式
dots = self.dots([ce, we])
return self.flatten(dots)
embedding_dim = 128
word2vec = Word2Vec(vocab_size, embedding_dim)
word2vec.compile(optimizer='adam',
loss=tf.keras.losses.CategoricalCrossentropy(from_logits=True),
metrics=['accuracy'])
word2vec.fit(((targets,contexts)),labels, epochs=20)
我們的樣本要變成這樣的形式了:
context_words:['I','you'] target_words:['love'] label:1
context_words:['I','you'] negative_words_1:['XX1'] label:0
context_words:['I','you'] negative_words_2:['XX2'] label:0
context_words:['I','you'] negative_words_2:['XX3'] label:0
最後是attention word2vec了,原文是針對於cbow實現的attention機制,cbow也確實比較直觀和簡單地去實現:
class Word2Vec(Model):
def __init__(self, vocab_size, embedding_dim,window_size):
super(Word2Vec, self).__init__()
self.target_embedding = Embedding(vocab_size,
embedding_dim,
input_length=1,
)
self.context_embedding = Embedding(vocab_size,
embedding_dim,
input_length=windows_size*2,name="w2v_embedding")
self.dots = Dot(axes=(3,2))
self.flatten = Flatten()
def call(self, pair):
target, context = pair
we = self.target_embedding(target)
ce = self.context_embedding(context)
ce = GlobalAveragePooling1D(ce) ##對上下文的embedding就行簡單平均,使用Global的方式可以很方便
#的形式
dots = self.dots([ce, we])
return self.flatten(dots)
embedding_dim = 128
word2vec = Word2Vec(vocab_size, embedding_dim)
word2vec.compile(optimizer='adam',
loss=tf.keras.losses.CategoricalCrossentropy(from_logits=True),
metrics=['accuracy'])
word2vec.fit(((targets,contexts)),labels, epochs=20)
這裡我們在GlobalAveragePooling1D這個地方做修改,首先,ce的輸出是(None,context_words_size,128)的張量,None表示batch的大小不必在意,context_words_size表示目標詞上下文的單詞的數量,128表示embedding的維度,可以看到,這個輸出的形式和LSTM的輸出是非常相似的,我們可以直接把基於LSTM的attention機制應用過來,唯一不同的地方是這裡是many-to-one的attention形式,不過git上正好有這樣的實現了:
//github.com/philipperemy/keras-attention-mechanismfrom tensorflow.keras.layers import Dense, Lambda, dot, Activation, concatenate
from tensorflow.keras.layers import Layer
class Attention(Layer):
def __init__(self, **kwargs):
super().__init__(**kwargs)
def __call__(self, hidden_states):
"""
Many-to-one attention mechanism for Keras.
@param hidden_states: 3D tensor with shape (batch_size, time_steps, input_dim).
@return: 2D tensor with shape (batch_size, 128)
@author: felixhao28.
"""
hidden_size = int(hidden_states.shape[2])
# Inside dense layer
# hidden_states dot W => score_first_part
# (batch_size, time_steps, hidden_size) dot (hidden_size, hidden_size) => (batch_size, time_steps, hidden_size)
# W is the trainable weight matrix of attention Luong's multiplicative style score
score_first_part = Dense(hidden_size, use_bias=False, name='attention_score_vec')(hidden_states)
# score_first_part dot last_hidden_state => attention_weights
# (batch_size, time_steps, hidden_size) dot (batch_size, hidden_size) => (batch_size, time_steps)
h_t = Lambda(lambda x: x[:, -1, :], output_shape=(hidden_size,), name='last_hidden_state')(hidden_states)
score = dot([score_first_part, h_t], [2, 1], name='attention_score')
attention_weights = Activation('softmax', name='attention_weight')(score)
# (batch_size, time_steps, hidden_size) dot (batch_size, time_steps) => (batch_size, hidden_size)
context_vector = dot([hidden_states, attention_weights], [1, 1], name='context_vector')
pre_activation = concatenate([context_vector, h_t], name='attention_output')
attention_vector = Dense(128, use_bias=False, activation='tanh', name='attention_vector')(pre_activation)
return attention_vector這裡實現的是luong style的attention機制,如果要應用於word2vec要做一些修改,後面寫
我們對比一下上述的git給的demo的數據的輸入輸出的形式:
from attention import Attention
from tensorflow.keras.layers import *
from tensorflow.keras.models import *
# [...]
m = Sequential([
LSTM(128, input_shape=(10, 1), return_sequences=True),
Attention(), # <--------- here.
Dense(1, activation='linear')
])其中attention的輸入是:
(batch_size, time_steps, hidden_size)其實就是target的上下文的輸入的形式,只不過這裡的time_steps表示了上下文的word的個數,比如上下位有4個單詞則time_steps=4,hidden_size就是embedding的維度了。
所以我們對原來的cbow實現和attetion的實現做一些簡單的更改就行了:
class Attention(Layer):
def __init__(self, **kwargs):
super().__init__(**kwargs)
def __call__(self,context_embedding,target_embedding):
score = dot([context_embedding, target_embedding], [2, 1], name='attention_score')
### 計算每一個context words是target words的embedding的相似性 by 點積相似性度量
attention_weights = Activation('softmax', name='attention_weight')(score)
## 通過activation得到最終的attention weights
# (batch_size, time_steps, hidden_size) dot (batch_size, time_steps) => (batch_size, hidden_size)
context_vector = dot([context_embedding, attention_weights], [1, 1], name='context_vector')
# 通過attention weights和context embedding實現加權求和得到最終的 attention vector
##pre_activation = concatenate([context_vector, h_t], name='attention_output')
##attention_vector = Dense(128, use_bias=False, activation='tanh', name='attention_vector')(pre_activation)
attention_vector=context_vector
return attention_vector這裡實現的是最簡單直觀的attention,並沒有採用luong's這裡用於nmt的attention機制,aws的原文中也沒有提到,目測應該是這種最原始的attention機制了(沒有開源是真的噁心也沒有參照),另外就是embedding矩陣,原文中是僅僅是用一個共享的embedding層用於target和context 的words的embedding,因此我們下面的實現僅僅使用了一個embedding層。
class Attention_Word2Vec(Model):
def __init__(self, vocab_size, embedding_dim,window_size):
super(Word2Vec, self).__init__()
self.embedding = Embedding(vocab_size,
embedding_dim,'w2v_embedding')
self.dots = Dot(axes=(3,2))
self.flatten = Flatten()
self.attention = Attention()
def call(self, pair):
target, context = pair
we = self.embedding(target)
ce = self.embedding(context)
attn_ce = self.attention(ce,we) ##這裡實現了attention weighs的定義和加權求和的邏輯的實現,最終輸出一個
# embedding size的attention vector作為輸出
#ce = GlobalAveragePooling1D(ce) ##對上下文的embedding就行簡單平均,使用Global的方式可以很方便
#的形式
dots = self.dots([attn_ce, we])
return self.flatten(dots)
embedding_dim = 128
word2vec = Attention_Word2Vec(vocab_size, embedding_dim)
word2vec.compile(optimizer='adam',
loss=tf.keras.losses.CategoricalCrossentropy(from_logits=True),
metrics=['accuracy'])
word2vec.fit(((targets,contexts)),labels, epochs=20)這樣就可以了,非常的easy。
上述程式碼沒有做過debug,可能會有一些shape的輸入輸出的衝突,debug的時候改一下就行了,debug太麻煩了我莫得時間,一般自己真正需要用到才回去仔細設計一下,思路就是這樣了,不複雜,挺easy的。
另外比較麻煩的就是,使用attention來進行加權求和的話,context vector的數量需要是固定的,因為像句首部分或者是句尾部分可能得到的context words會少一點,這個的話在樣本處理的時候需要使用0來進行填充,然後使用mask的方式來進行處理,或者就是用 unk填充然後embedding矩陣使用單獨全0的一行來指定unk的embedding為[0,0,0,0,0....]這樣不會影響到加權求和的結果。
太麻煩了,就寫到這裡吧。

