1.来源及定义
embedding的出现是为了弥补one-hot在表示物品时的不足,当物品的数量变的很多时用one-hot表示的物品的向量就会变的很长,而且很稀疏,这不仅不利于存储而且对于神经网络的输入来说也是不适合的,另一个缺点就是one-hot向量不能很好的表示出两个物品之间的联系,因为任意两个向量的内积为0。
embedding的出现就弥补了这种不足,他将one-hot的高维稀疏向量转换成低维稠密向量,这不仅减少了神经网络的输入维度,而且利用两个向量之间的余弦相似度还能推算出这两个物品的相似度。
embedding和one-hot都是一种将我们人类总结出的抽象的表示的物品,转换成数值型的输入能被计算机所识别,也就是将它嵌入到一个数学空间中去。这也即词嵌入(word embedding)。下面我们要介绍词嵌入的一种方法-word2vec。
2.word2vec原理及代码
word2vec模型是谷歌在2013年提出的,这是一个生成对“词”的向量表达的模型。它有两种实现的方式CBOW和Skip-gram,如下图所示:
其中CBOW模型是用上下文单词来预测中心单词,输入是这个词的上下文单词,输出是预测的词。
Skip-gram模型是用中心单词来预测上下文单词,输入是中心单词,输出是上下文单词。
而在模型之中,这些单词的初始输入是用one-hot向量表示的,而你可能要说:我们如何确定上下文单词的数量哪?这个时候就要引出模型中的滑动窗口的概念了,它规定了我们要预测的上下文单词的数量。例如说滑动窗口为1,就是认为中心单词之和他的前后两个单词有关,那么这个窗口之中总共就会有三个单词,如果使用的是Skip-gram模型,那么输入的就是中心这个单词的one-hot向量,输出就是预测的上下文两个单词的概率。
因为我们的目标是去预测一个词,我们就希望这个词出现的概率最大,我们用来表示一个词出现的概率,也就是希望这些概率之积最大,word2vec的目标函数是:
其中c是滑动窗口的大小,t代表这一时刻的输入。下面我们就是要如何定义,作为一个多分类问题,最直接的方法就是使用softmax函数,word2vec的“愿景”是用一个词向量表示词用词之间的内积距离表示语义的接近程度因此概率被定义为:
其中代表表示输出词,代表称为输入词。
下面我们以代码为例子来讲解Skip-gram模型,其中代码包括一下几个部分:
1.数据的准备和超参数的设定
2.模型的训练
3.模型的使用
我们先看第一步:
settings = {
'window_size': 2, #窗口尺寸 m
#单词嵌入(word embedding)的维度,维度也是隐藏层的大小。
'n': 10,
'epochs': 50, #表示遍历整个样本的次数。在每个epoch中,我们循环通过一遍训练集的样本。
'learning_rate':0.01 #学习率
}
text = "natural language processing and machine learning is fun and exciting"
这里给出了训练时的超参数例如,窗口大小为2表示训练时寻找目标单词前后两个单词,也就是窗口中一共有5个单词。嵌入的维度也就是最后单词的embedding向量有十项。
text = "natural language processing and machine learning is fun and exciting"
这是我们输入的一个句子,接下来要把每个不同的单词都转换成one-hot向量表示。
def word2onehot(self, word):
#将词用onehot编码
word_vec = [0 for i in range(0, self.v_count)] #生成一个大小为v_count的全为0的列表
word_index = self.word_index[word] #索引词得到序号,将他的那一项置为1
word_vec[word_index] = 1
return word_vec
这是一个将输入进来的单词转换成one-hot向量的函数,首先就是生成一个全为0的列表,他的大小为句子中不重复单词的个数,然后得到这个单词的索引,将它对应的列表的位置置1得到它的one-hot向量。
def generate_training_data(self, settings, corpus):
"""
得到训练数据
"""
#defaultdict(int) 一个字典,当所访问的键不存在时,用int类型实例化一个默认值
word_counts = defaultdict(int)
#遍历语料库corpus
for row in corpus:
for word in row:
#统计每个单词出现的次数
word_counts[word] += 1
#print(word_counts)
# 词汇表的长度,不重复单词的长度
self.v_count = len(word_counts.keys())
# 在词汇表中的单词组成的列表
self.words_list = list(word_counts.keys())
# 以词汇表中单词为key,索引为value的字典数据
self.word_index = dict((word, i) for i, word in enumerate(self.words_list)) #enumerate函数返回序号和对应的内容相当于range(len(list))
#以索引为key,以词汇表中单词为value的字典数据
self.index_word = dict((i, word) for i, word in enumerate(self.words_list))
training_data = []
for sentence in corpus:
sent_len = len(sentence)
print(sentence)
print("%%%%")
for i, word in enumerate(sentence):
w_target = self.word2onehot(sentence[i])
w_context = []
for j in range(i - self.window, i + self.window): #得到中心单词本身和他前后两个单词的编码,取不到最后一个
if j != i and j <= sent_len - 1 and j >= 0:
w_context.append(self.word2onehot(sentence[j]))
training_data.append([w_target, w_context]) #训练数据第一个为其自身的编码,第二个为他相邻词的编码
return np.array(training_data)
这是对训练数据的处理,首先创建一个字典统计每个单词出现的次数,然后得到这个列表的长度。
之后得到每个单词的one-hot向量表示,然后将一个窗口里的单词的one-hot向量放在一起组成一个训练样本,其中中心词的向量为一个列表,背景词的向量为一个列表。但这里因为for循环取不到最后一个单词所以窗口内最大有四个单词。
[[list([1, 0, 0, 0, 0, 0, 0, 0, 0]) list([[0, 1, 0, 0, 0, 0, 0, 0, 0]])]
[list([0, 1, 0, 0, 0, 0, 0, 0, 0])
list([[1, 0, 0, 0, 0, 0, 0, 0, 0], [0, 0, 1, 0, 0, 0, 0, 0, 0]])]
[list([0, 0, 1, 0, 0, 0, 0, 0, 0])
list([[1, 0, 0, 0, 0, 0, 0, 0, 0], [0, 1, 0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 1, 0, 0, 0, 0, 0]])]
[list([0, 0, 0, 1, 0, 0, 0, 0, 0])
list([[0, 1, 0, 0, 0, 0, 0, 0, 0], [0, 0, 1, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 1, 0, 0, 0, 0]])]
[list([0, 0, 0, 0, 1, 0, 0, 0, 0])
list([[0, 0, 1, 0, 0, 0, 0, 0, 0], [0, 0, 0, 1, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 1, 0, 0, 0]])]
[list([0, 0, 0, 0, 0, 1, 0, 0, 0])
list([[0, 0, 0, 1, 0, 0, 0, 0, 0], [0, 0, 0, 0, 1, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 1, 0, 0]])]
[list([0, 0, 0, 0, 0, 0, 1, 0, 0])
list([[0, 0, 0, 0, 1, 0, 0, 0, 0], [0, 0, 0, 0, 0, 1, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0, 1, 0]])]
[list([0, 0, 0, 0, 0, 0, 0, 1, 0])
list([[0, 0, 0, 0, 0, 1, 0, 0, 0], [0, 0, 0, 0, 0, 0, 1, 0, 0], [0, 0, 0, 1, 0, 0, 0, 0, 0]])]
[list([0, 0, 0, 1, 0, 0, 0, 0, 0])
list([[0, 0, 0, 0, 0, 0, 1, 0, 0], [0, 0, 0, 0, 0, 0, 0, 1, 0], [0, 0, 0, 0, 0, 0, 0, 0, 1]])]
[list([0, 0, 0, 0, 0, 0, 0, 0, 1])
list([[0, 0, 0, 0, 0, 0, 0, 1, 0], [0, 0, 0, 1, 0, 0, 0, 0, 0]])]]
这是处理完后的所有的训练样本,其中第一行为一个训练样本,第一个list表示中心词的向量表示,第二个list为背景词的向量表示,因为第一个词只有后边才有相邻的词,而我们的循环中又少取了一个单词,所以说第一个训练样本只有两个向量,下面的情况类似。
def train(self, training_data):
#随机化参数w1,w2
self.w1 = np.random.uniform(-1, 1, (self.v_count, self.n))
self.w2 = np.random.uniform(-1, 1, (self.n, self.v_count))
for i in range(self.epochs):
self.loss = 0
# w_t 是表示目标词的one-hot向量
#w_t -> w_target,w_c ->w_context
for w_t, w_c in training_data:
#前向传播
y_pred, h, u = self.forward(w_t)
#print(y_pred)
#计算误差
EI = np.sum([np.subtract(y_pred, word) for word in w_c], axis=0)
#反向传播,更新参数
self.backprop(EI, h, w_t)
#计算总损失
self.loss += -np.sum([u[word.index(1)] for word in w_c]) + len(w_c) * np.log(np.sum(np.exp(u))) #索引1元素所在的位置
#for word in w_c:
# print(word.index(1))
print('Epoch:', i, "Loss:", self.loss)
这是训练的过程,首先初始化隐藏层和输出层的权重矩阵,然后对于每一轮训练,首先从训练数据中选取一个中心词向量和背景词向量,将中心词的向量输入到神经网络中,得到预测的输出,然后计算它和真实输出也就是背景词的向量的差值进行累加,然后反向传播更新梯度,记录每一轮的总损失,然后输出。
def forward(self, x):
"""
前向传播
"""
#print("######")
#print(self.w1.T)
#print(type(self.w1.T))
#print(x)
#print(type(x))
h = np.dot(self.w1.T, x) #x:1*9 w1.T:10*9 h:1*10 数组和列表相乘,数组的每一行和列表的对应位置相乘得到第一个数,符合矩阵乘法时执行矩阵乘法
#print(h)
#print(type(h))
u = np.dot(self.w2.T, h) #w2.T:9*10 h:1*10 u:1*9
y_c = self.softmax(u)
return y_c, h, u
这是前项传播的过程,其中要注意在做矩阵的乘法时用到了矩阵的广播运算,即让矩阵的一行和另一个矩阵的一行相乘,最终的输出用softmax函数来归一化表示概率,这和上面理论推导的公式是一致的。
def softmax(self, x):
"""
"""
e_x = np.exp(x - np.max(x))
return e_x / np.sum(e_x)
softmax函数的定义。
def backprop(self, e, h, x):
d1_dw2 = np.outer(h, e)
d1_dw1 = np.outer(x, np.dot(self.w2, e.T))
self.w1 = self.w1 - (self.lr * d1_dw1)
self.w2 = self.w2 - (self.lr * d1_dw2)
反向传播梯度的更新。(这里感觉反向传播时用的是交叉熵损失函数吗?但是训练时的损失为啥为y_pred-yword)。可能是没标出来。。。
因为交叉熵损失对于多分类问题的梯度就是这样,可以看【深度学习】:超详细的Softmax求导_BQW_的博客-CSDN博客_softmax的导数
def vec_sim(self, word, top_n):
"""
找相似的词
"""
v_w1 = self.word_vec(word)
word_sim = {}
for i in range(self.v_count):
v_w2 = self.w1[i]
theta_sum = np.dot(v_w1, v_w2)
#np.linalg.norm(v_w1) 求范数 默认为2范数,即平方和的二次开方 余弦相似度
theta_den = np.linalg.norm(v_w1) * np.linalg.norm(v_w2)
theta = theta_sum / theta_den
word = self.index_word[i]
word_sim[word] = theta
words_sorted = sorted(word_sim.items(), key=lambda kv: kv[1], reverse=True)
for word, sim in words_sorted[:top_n]:
print(word, sim)
Skip-gram模型实际上是为了得到w1矩阵(其实也可以用w2矩阵来代表词向量),这个矩阵的每一行就代表了一个单词的嵌入向量。所以查找出两个单词之间的向量然后计算他们的余弦相似度就可以找到相似的单词了。
总的代码:
import numpy as np
from collections import defaultdict
class word2vec():
def __init__(self):
self.n = settings['n']
self.lr = settings['learning_rate']
self.epochs = settings['epochs']
self.window = settings['window_size']
def generate_training_data(self, settings, corpus):
"""
得到训练数据
"""
#defaultdict(int) 一个字典,当所访问的键不存在时,用int类型实例化一个默认值
word_counts = defaultdict(int)
print(word_counts)
#遍历语料库corpus
for row in corpus:
for word in row:
#统计每个单词出现的次数
word_counts[word] += 1
#print(word_counts)
# 词汇表的长度,不重复单词的长度
self.v_count = len(word_counts.keys())
# 在词汇表中的单词组成的列表
self.words_list = list(word_counts.keys())
# 以词汇表中单词为key,索引为value的字典数据
self.word_index = dict((word, i) for i, word in enumerate(self.words_list)) #enumerate函数返回序号和对应的内容相当于range(len(list))
#以索引为key,以词汇表中单词为value的字典数据
self.index_word = dict((i, word) for i, word in enumerate(self.words_list))
training_data = []
for sentence in corpus:
sent_len = len(sentence)
print(sentence)
print("%%%%")
for i, word in enumerate(sentence):
w_target = self.word2onehot(sentence[i])
w_context = []
for j in range(i - self.window, i + self.window): #得到中心单词本身和他前后两个单词的编码,取不到最后一个
if j != i and j <= sent_len - 1 and j >= 0:
w_context.append(self.word2onehot(sentence[j]))
training_data.append([w_target, w_context]) #训练数据第一个为其自身的编码,第二个为他相邻词的编码
return np.array(training_data)
def word2onehot(self, word):
#将词用onehot编码
word_vec = [0 for i in range(0, self.v_count)] #生成一个大小为v_count的全为0的列表
word_index = self.word_index[word] #索引词得到序号,将他的那一项置为1
word_vec[word_index] = 1
return word_vec
def train(self, training_data):
#随机化参数w1,w2
self.w1 = np.random.uniform(-1, 1, (self.v_count, self.n))
self.w2 = np.random.uniform(-1, 1, (self.n, self.v_count))
for i in range(self.epochs):
self.loss = 0
# w_t 是表示目标词的one-hot向量
#w_t -> w_target,w_c ->w_context
for w_t, w_c in training_data:
#前向传播
y_pred, h, u = self.forward(w_t)
#print(y_pred)
#计算误差
EI = np.sum([np.subtract(y_pred, word) for word in w_c], axis=0)
#反向传播,更新参数
self.backprop(EI, h, w_t)
#计算总损失
self.loss += -np.sum([u[word.index(1)] for word in w_c]) + len(w_c) * np.log(np.sum(np.exp(u))) #索引1元素所在的位置
#for word in w_c:
# print(word.index(1))
print('Epoch:', i, "Loss:", self.loss)
def forward(self, x):
"""
前向传播
"""
#print("######")
#print(self.w1.T)
#print(type(self.w1.T))
#print(x)
#print(type(x))
h = np.dot(self.w1.T, x) #x:1*9 w1.T:10*9 h:1*10 数组和列表相乘,数组的每一行和列表的对应位置相乘得到第一个数,符合矩阵乘法时执行矩阵乘法
#print(h)
#print(type(h))
u = np.dot(self.w2.T, h) #w2.T:9*10 h:1*10 u:1*9
y_c = self.softmax(u)
return y_c, h, u
def softmax(self, x):
"""
"""
e_x = np.exp(x - np.max(x))
#print(np.max(x))
return e_x / np.sum(e_x)
def backprop(self, e, h, x):
d1_dw2 = np.outer(h, e)
d1_dw1 = np.outer(x, np.dot(self.w2, e.T))
self.w1 = self.w1 - (self.lr * d1_dw1)
self.w2 = self.w2 - (self.lr * d1_dw2)
def word_vec(self, word):
"""
获取词向量
通过获取词的索引直接在权重向量中找
"""
w_index = self.word_index[word]
v_w = self.w1[w_index]
return v_w
def vec_sim(self, word, top_n):
"""
找相似的词
"""
v_w1 = self.word_vec(word)
word_sim = {}
for i in range(self.v_count):
v_w2 = self.w1[i]
theta_sum = np.dot(v_w1, v_w2)
#np.linalg.norm(v_w1) 求范数 默认为2范数,即平方和的二次开方 余弦相似度
theta_den = np.linalg.norm(v_w1) * np.linalg.norm(v_w2)
theta = theta_sum / theta_den
word = self.index_word[i]
word_sim[word] = theta
words_sorted = sorted(word_sim.items(), key=lambda kv: kv[1], reverse=True)
for word, sim in words_sorted[:top_n]:
print(word, sim)
def get_w(self):
w1 = self.w1
return w1
#超参数
settings = {
'window_size': 2, #窗口尺寸 m
#单词嵌入(word embedding)的维度,维度也是隐藏层的大小。
'n': 10,
'epochs': 50, #表示遍历整个样本的次数。在每个epoch中,我们循环通过一遍训练集的样本。
'learning_rate':0.01 #学习率
}
#数据准备
text = "natural language processing and machine learning is fun and exciting"
#按照单词间的空格对我们的语料库进行分词,并转换成小写
corpus = [[word.lower() for word in text.split()]]
print(corpus)
#初始化一个word2vec对象
w2v = word2vec()
training_data = w2v.generate_training_data(settings,corpus)
print(training_data)
#训练
w2v.train(training_data)
print("######")
print(w2v.w1)
# 获取词的向量
word = "machine"
vec = w2v.word_vec(word)
print(word, vec)
# 找相似的词
w2v.vec_sim("machine", 3)
3.总结
对于CBOW模型只是选择的中心词不一样,也就是输入数据时输入数据的list为多个向量而中心单词的list只有一个向量。前向传播的过程为将每个中心词分别输入到网络中得到几个输入,用这几个输入和同一个背景词做误差,反向传播更新参数。
参考博客:
Word2vec代码实现_SUNNY小飞的博客-CSDN博客_word2vec代码实现
4.补充
4.1.item2vec
既然词语能用嵌入向量来表示,而在实际的推荐中我们接触到的数据大多都是一个用户和物品序列,能不能对这些东西进行嵌入式向量表示哪?
为此微软在2016年提出了计算物品Embedding向量的方法 Item2vec.
item2vec就是将用户的物品交互的序列看作是一个词序列然后,将他作为输入得到物品的嵌入向量。它利用的物品的序列是由特定用户的浏览,购买等行为产生的历史行为记录序列。他的优化目标如下:
其中K代表用户,w代表物品和Skip-gram中的词类似。
你会发现它和word2vec相比最大的不同在于缺少了时间窗口,它认为序列中任意两个物品都相关,因此它计算的概率是这一个物品和其他所有物品的概率,其他的训练过程都和word2vec相同。
在得到物品的Embedding之后我们可以简单的对他取平均作为用户的Embedding。
4.2.Graph Embedding
前面提到的word和item都是一种序列样本,但在互联网场景下,数据对象之间更多的呈现出图结构,如知识图谱等,如何对图的这些节点生成嵌入向量,这是一个值得研究的问题。
下面我们介绍一种deepwalk算法,他是一种图嵌入的方法之一,他的基本思想是在物品构成的关系图中随机游走产生许多物品的序列,将这些序列作为word2vec的样本输入,得到物品的嵌入向量。
这幅图很好的展示了deepwalk的算法流程,首先a是用户对于物品的交互的序列,b是构建的物品关系图,从A到B有一条有向边表示用户先后购买了物品A和物品B。
接下来就是随机游走生成大量的序列样本,然后输入到word2vec中进行训练。
上图展示了deepwalk算法的过程,可以看到随机游走的序列的长度是设定好的为t。w是skip-gram模型中的窗口大小,d是生成的嵌入向量的维度。
第二步是构建一个二叉树关于顶点V。
第三步定义了循环的次数,即对每个顶点做随机游走的次数。
第四步打乱顶点的顺序。
第五步之后对于每个顶点,采用一次随机游走,将生成的序列输入到skip-gram模型中。
这里的代码对应算法流程里的第六行
def deepwalk_walk(self, walk_length, start_node):
walk = [start_node] #生成的walk序列
while len(walk) < walk_length:
cur = walk[-1] #取最后一个当作起始节点
cur_nbrs = list(self.G.neighbors(cur)) #获取它的邻居节点
if len(cur_nbrs) > 0:
walk.append(random.choice(cur_nbrs)) #将他添加到列表中
else:
break
return walk
这里的代码对应第三行的外层循环
def _simulate_walks(self, nodes, num_walks, walk_length,):
walks = []
for _ in range(num_walks):
random.shuffle(nodes) #打乱所有的节点
for v in nodes:
walks.append(self.deepwalk_walk(alk_length=walk_length, start_node=v))
#将这个节点输入到deepwalk算法中,得到一个序列
return walks
最后还要说明一下当到达一个顶点后是如何跳转到下一个顶点的,我们定义这样一个公式来表示节点Vi到节点Vj的概率:
表示物品关系图中所有边的集合,是节点的所有出边的集合是节点到节点边的权重,这个概率就被表述为要跳转的边的权重占所有出边的权重的比例。对于无权无向图,=1,此时的边也不再分为出边和入边了。
当然跳转概率也可以被定义为是均等的。