摘要:Graph embedding
,DeepWalk
,推荐系统
,Word2vec
使用背景
最近有个需求做百万级别实体的相关推荐,使用embedding+LSH实现,embedding基于实体的行为相似度进行构建,一开始直接将实体的行为序列灌入Word2vec,后来试了DeepWalk就图嵌入(Graph embedding)的最基础版本,因此整理一下理论知识和实践
Word2vec复习
Word2vec技术是基础,它本身是一个有监督地神经网络反向求导训练得到词向量的过程,分为CBow和Skip-gram,这里简单地整理一下Skip-gram的原理
(1)Word2vec的算法流程
word2vec的目的是求词向量,他把词的初始化向量也当成模型参数跟着模型一起迭代训练,使得有相同或者类似上下文的词,最后训练出的词向量具有很高的相似度
- 建立embedding_lookup:embedding_lookup是词数量×embedding_size的矩阵,矩阵的值随机生成,每一行对应一个词,所有词通过索引找到自己的随机初始化向量
- 窗口内的词向量相加或者求平均作为输入:窗口值得是Word2vec模型的窗口长度参数,窗口下的词项链向量相加或者相加求平均作为神经网络的输入
- 选择训练模型CBOW,Skip-gram:这一步影响模型的
输入
和标签
,如果是CBOW,输入是两边的词的词向量,标签是中间的词,因此反向迭代是用中间的词修正两边的词的词向量;如果是Skip-gram,输入是中间的词,标签是两边的词,反向迭代是用两边的词修正中间词的词向量 - 模型迭代反向偏导修正输入词向量:有了输入和label,就可以求输出和label的差异进一步反向求导更新输入,这个更新是直接在原始的词向量上更新,而不是求和求平均之后的输入向量,同一个窗口下每个词的词向量的每一个元素跟新同样的值,即以CBOW为例,中间词对两边窗口的词的每一个元素调整是统一的
(2)Word2vec相比于NNLM的通用改进
Word2vec在计算量,网络结构,输入方式上相比于NNLM都有很大的改善,具体是
- 输入考虑前词和后词:不仅使用前面的词也用到了后面的词,增大了特征信息量,而NNLM只使用了前词
- 输入向量维度拼接方式改变:NNLM采用考虑顺序的前后拼接方式,而Word2vec将所有窗口内的词向量全部相加或者求平均,这样不论句子多长输入维度就固定了,这个窗口里面的词不考虑现后顺序,只关心目标词周围出现了什么词
- 网络结构精简:丢弃了没有隐藏层和激活函数(NNLM模型有一个隐藏层激活函数是tanh),直接到输出层,减少了需要训练的w数量
- 输出层softmax优化:输出层损失函数不再使用softmax,而是映射到
霍夫曼树
。如果采用softmax,需要计算词表的每一个词,复杂度是O(V)
,V是词表词的数量,而霍夫曼树采用左右01编码,举例一个4层的完全二叉树,橙色的编码是011,每一个词都被打在一个节点上,拥有自己唯一的编码,则霍夫曼树只需要计算3次,预测第一个是不是0,第二个是不是1,第三个是不是1,转化为3个节点二分logistic预测,概率和loss等于
映射到树结构上后复杂度是O(log2V)
,而如果采用softmax,需要计算出所有样本才能算softmax,需要15次。至于为什么使用霍夫曼树,霍夫曼树是带权最短路径树,他期望权值大的节点越靠近根节点,用词表建立霍夫曼树是根据词频确定权重,这样高频词越接近根节点,生僻词需要走比较长的路径才能找到,因此高频词在计算概率和loss的时候比低频词更快,因为文章中词语生僻词比较少,所以实际平均复杂度达不到O(log2V)
(3)Skip-gram网络结构
Skip-gram用中间词的向量作为输入,用两边的词作为label,会和窗口中的每一个词都做一次训练,每训练一次都会被这个窗口中被选择的那个词调整,更新梯度值在原来的初始向量上,因此skip-gram相比于COW需要训练更多次,如果文章的词数量是V,窗口词的数量是K,则需要训练KV词,而COW只要V次(因为CBOW输入是窗口的求和向量)。skip-gram的梯度值都是两两词之间独一的,不像CBOW是通过向量相加作为特征,更新梯度是统一的,依赖多窗口构造梯度更新多样性,所以Skip-gram更加个性化,尤其是对于生僻词
DeepWalk理论概述
传统的word2vec或者item2vec都是将实体作为元素,形成一个序列
灌入模型进行embedding训练,以用户的购买序列作为推荐的场景为例,则是将同一个用户同一段时间下的购买商品左右序列作为一个输入构建模型,而商品在多个用户之间都有存在,因此单个商品和其他商品的交互应该形成一张网,因此一种更好的表征方式将商品构建成网络图结构
,而不是一条一条的商品序列,如下
由此产生Graph embedding这种对图结构中的节点进行embedding编码的数据,其中DeepWalk是形成最早的Graph embedding技术,简单而言,DeepWalk的思想是在物品形成的网络图中进行随机游走,产生大量的物品序列,然后物品序列灌入Word2vec进行训练得到embedding,由此可见DeepWalk并没有改变算法,只是对序列的生成做了调整,及输入数据不能是生硬的照搬序列,原始序列只是个例,而真实的表征应该从网络图结构中自然地游走生成,另外还有一点,如果直接以原始序列进行输入可能存在序列过短长度不足的问题,而使用随机游走可以将短序列中的某个终点元素跳转到其他序列从而进行补足,所以DeepWalk的输入方式有两个优势
表征更加自然和丰富
解决原始序列太短的问题
因此DeepWalk的关键问题就是怎么游走,及要确定以下问题
(1)游走的起点:比如说网络图中的
随机
一个点作为起点
(2)游走的距离或者长度限制:比如说限制长度最大40
(3)在多出边节点的跳转概率:当游走到的点存在多个出边的时候,选择哪个出边继续游走的概率和
边权
有关,即概率值为该出边的边权占比所有出边的总边权之和,公式如下
DeepWalk工程实现
工程概述
工程实现参考这个github项目https://github.com/phanein/deepwalk
下载后安装项目到本地
cd deepwalk
pip install -r requirements.txt
python setup.py install
安装好后在命令行获得deepwalk
命令
root@ubuntu:~/myproject/deepwalk-master/deepwalk# which deepwalk
/opt/anaconda3/bin/deepwalk
--help一波看一下启动参数
root@ubuntu:~/myproject/deepwalk-master/deepwalk# deepwalk --help
usage: deepwalk [-h] [--debug] [--format FORMAT] --input [INPUT] [-l LOG]
[--matfile-variable-name MATFILE_VARIABLE_NAME]
[--max-memory-data-size MAX_MEMORY_DATA_SIZE]
[--number-walks NUMBER_WALKS] --output OUTPUT
[--representation-size REPRESENTATION_SIZE] [--seed SEED]
[--undirected UNDIRECTED] [--vertex-freq-degree]
[--walk-length WALK_LENGTH] [--window-size WINDOW_SIZE]
[--workers WORKERS]
optional arguments:
-h, --help show this help message and exit
--debug drop a debugger if an exception is raised. (default:
False)
--format FORMAT File format of input file (default: adjlist)
--input [INPUT] Input graph file (default: None)
-l LOG, --log LOG log verbosity level (default: INFO)
--matfile-variable-name MATFILE_VARIABLE_NAME
variable name of adjacency matrix inside a .mat file.
(default: network)
--max-memory-data-size MAX_MEMORY_DATA_SIZE
Size to start dumping walks to disk, instead of
keeping them in memory. (default: 1000000000)
--number-walks NUMBER_WALKS
Number of random walks to start at each node (default:
10)
--output OUTPUT Output representation file (default: None)
--representation-size REPRESENTATION_SIZE
Number of latent dimensions to learn for each node.
(default: 64)
--seed SEED Seed for random walk generator. (default: 0)
--undirected UNDIRECTED
Treat graph as undirected. (default: True)
--vertex-freq-degree Use vertex degree to estimate the frequency of nodes
in the random walks. This option is faster than
calculating the vocabulary. (default: False)
--walk-length WALK_LENGTH
Length of the random walk started at each node
(default: 40)
--window-size WINDOW_SIZE
Window size of skipgram model. (default: 5)
--workers WORKERS Number of parallel processes. (default: 1)
几个关键的参数说一下:
(1)--input
,--output
:这两个必选,分别是输入数据集和输出embedding路径
(2)--format
:输入数据格式,默认是空格分割符的列表
(3)--number-walks
:每个节点作为起点的次数,默认10
(4)--representation-size
:训练出的词向量维度
(5)--undirected
:将图结构视为无项的,默认视为无向
(6)--walk-length
:随机游走长度
(7)--window-size
:Word2vec窗口长度,默认5
数据准备
下面按照输入要求把玩以下数据,首先以用户的商品购买交易数据作为输入,同一个用户的购买商品作为一个序列,以商品ID作为序列的元素,最终的序列如下
root@ubuntu:~/myproject/test_deepwalk# head -5 ent.seq
34505 25587 52544 35715 100297 8224 113409 107807 203804 34936 52220 49718 82598 84594
82585 22761 81479 80910
88433 100519 22762 100529
65753 67893 110100 67767 55288 117793 10041 59003 77396 201083 117792 52658 25700 106853 46688 86414
10575 65363 10378 17055 11388 27547 62563
但是networkx包要求的adjlist格式或者edgelist格式,看看文档怎么说的
他的意思adjlist是一个
头元素
和尾序列
构成的,尾序列中每一个元素和头元素连接一次,因此这样依赖和edgelist意思是一样的,只不过adjlist是横过来摆,edgelist是竖过来摆,所以还要把原始序列修改一下,改成edgelist节点对格式,并且只呈现上三角,节点对不重复(因为代码没有考虑边权,见下文,就算输入不去重,也会在读取后代码内实现去重)
with open("./ent.edgelist", "w") as f:
with open("./ent.seq", "r") as f1:
for line in f1.readlines():
items = line.strip().split(" ")
items = list(set(items))
for i in range(len(items) - 1):
for j in range(i + 1, len(items)):
f.write("{} {}\n".format(items[i], items[j]))
修改之后的edgelist格式数据如下
root@ubuntu:~/myproject/test_deepwalk# head -10 ent.edgelist
107807 52220
107807 8224
107807 34505
107807 52544
107807 34936
107807 49718
107807 84594
107807 35715
107807 100297
107807 25587
然后直接调用deepwalk命令了
root@ubuntu:~/myproject/test_deepwalk# deepwalk --input ent.edgelist --output ent.embedding
Number of nodes: 14314
Number of walks: 143140
Data size (walks*length): 5725600
Walking...
Training...
然后查看在当前目录下生成的embedding文件即可
root@ubuntu:~/myproject/test_deepwalk# head -2 ent.embedding
14314 64
22762 -0.10342245 0.5454641 0.097237885 0.285588 0.39198652 0.100856714 -0.6985724 -0.07474265 -0.10113905 0.22283842 -0.5350741 -0.18605635 -0.5970278 0.5933698 0.29624382 0.07387866 -0.19292854 -0.042278446 -0.47393396 -0.17337433 -0.16001737 0.2812213 -0.35297522 -0.5092071 0.46490508 0.4460463 -0.26035264 -0.44927886 0.1480038 -0.23978643 0.30653024 -0.18184514 0.054582834 0.67260027 0.3213531 0.13739543 -0.12275054 0.08031149 0.35198563 0.57243794 0.40141836 0.20538211 0.12400588 -0.010270502 0.059158817 -0.42928803 -0.06674689 0.28467888 -0.039897256 0.4593809 0.3859278 0.5134783 0.30135667 0.038226638 0.106701836 -0.054739743 0.6058786 -0.20789003 0.45300347 -0.16694298 0.010459241 -0.20857096 0.25154737 -0.32405242
下一步我们使用gensim库导入词向量,进行最近邻检索,并且替换成商品的名称
from gensim.models.keyedvectors import KeyedVectors
import random
new_model = KeyedVectors.load_word2vec_format('./ent_embedding.txt', binary=False)
keys = new_model.vocab.keys()
def test():
input_code = random.choice(list(keys))
print("------------输入的是:{}".format(goods[input_code])) # goods是商品id和商品名称对应字典
res = new_model.most_similar(input_code)
for index, line in enumerate(res):
print("top{}: {}, {}".format(index + 1, goods[line[0]], line[1]))
调用代码查看相似度结果
------------输入的是:上一道胡萝卜味小面条288g
top1: 上一道营养杂粮小面条288g, 0.7310202121734619
top2: 六月鲜柠檬蒸鱼酱油380g, 0.6864343881607056
top3: 尚品尚澳洲草饲牛肉块500g, 0.5991094708442688
top4: 德拉达混合口味爆米花55g, 0.5856773257255554
top5: 爱森精修里脊肉, 0.5784972906112671
top6: 萨酡帕玛森干酪丝226g, 0.5728169679641724
top7: 新西兰佳沛金果六粒装36#(已删除), 0.5616732835769653
top8: 喜多果优森富士特级, 0.5369739532470703
top9: 海天味极鲜特级酱油380ml, 0.512316107749939
top10: 乐高活塞杯汽车大赛L10857, 0.5106619596481323
------------输入的是:长南瓜(X)
top1: 老相食白香干, 0.9607888460159302
top2: 黄豆芽350g, 0.8752710819244385
top3: 老相食老家手工百叶, 0.8622093796730042
top4: 朝天椒(X), 0.8518887758255005
top5: 杏鲍菇200g, 0.8404598832130432
top6: 铁棍山药(X), 0.805856466293335
top7: 牛角椒300g, 0.8022192120552063
top8: 老姜200g, 0.8020031452178955
top9: 香菇200g, 0.8019629716873169
top10: 番茄300g, 0.7880314588546753
------------输入的是:依云矿泉水喷雾50ml
top1: 蔗糖水晶修护面颈膜(盒装)30mlx5片, 0.7566028833389282
top2: 美若康隐形净痘贴日用型12pc, 0.7422661185264587
top3: 发希倍润手霜桃子40ml, 0.7119519710540771
top4: 乐芝牛原味小三角型涂抹奶酪, 0.6642880439758301
top5: 樱花高保湿精华乳液110ml, 0.6506718397140503
top6: 樱花高保湿精华柔肤水110ml, 0.6455968618392944
top7: 樱花水漾清润BB霜30ml, 0.6393434405326843
top8: 夜莺花紧致面颈膜(盒装)30mlx5片, 0.6253387928009033
top9: 樱花复颜遮瑕粉底蜜30ml, 0.6017882823944092
top10: 亚麻籽宝宝浴后身体乳150ml, 0.6001847982406616
DeepWalk源码解析
项目的源码从setup.py的entry_points
进去
entry_points={'console_scripts': ['deepwalk = deepwalk.__main__:main']}
查看deepwalk文件下main.py的main方法,其中为解析传入参数执行process方法
def process(args):
if args.format == "adjlist":
G = graph.load_adjacencylist(args.input, undirected=args.undirected)
elif args.format == "edgelist":
G = graph.load_edgelist(args.input, undirected=args.undirected)
elif args.format == "mat":
G = graph.load_matfile(args.input, variable_name=args.matfile_variable_name, undirected=args.undirected)
else:
raise Exception("Unknown file format: '%s'. Valid formats: 'adjlist', 'edgelist', 'mat'" % args.format)
print("Number of nodes: {}".format(len(G.nodes())))
num_walks = len(G.nodes()) * args.number_walks
print("Number of walks: {}".format(num_walks))
data_size = num_walks * args.walk_length
print("Data size (walks*length): {}".format(data_size))
先对文件格式进行判断,不同输入格式采用不同网络图节点读取函数,然后分别打印出节点数
(实体数),walks数量
,总数据量data_size
,其中walks数量为节点数 × 每个节点出发的游走次数,data_size数量量等于walks数量 × 游走长度
接下来对数据量进行判断,没有超过阈值则在内存中进行计算,进行随机游走和Word2vec训练
if data_size < args.max_memory_data_size:
print("Walking...")
walks = graph.build_deepwalk_corpus(G, num_paths=args.number_walks,
path_length=args.walk_length, alpha=0, rand=random.Random(args.seed))
print("Training...")
model = Word2Vec(walks, size=args.representation_size, window=args.window_size, min_count=0, sg=1, hs=1,
workers=args.workers)
接下来主要看一下build_deepwalk_corpus这个方法,传入参数有图对象,每个节点的游走次数,游走长度,alpha和随机种子
def build_deepwalk_corpus(G, num_paths, path_length, alpha=0,
rand=random.Random(0)):
walks = []
nodes = list(G.nodes())
for cnt in range(num_paths):
rand.shuffle(nodes)
for node in nodes:
walks.append(G.random_walk(path_length, rand=rand, alpha=alpha, start=node))
return walks
build_deepwalk_corpus这个方法初始话一个空的walks列表,重复num_path次数轮,每轮将网络图中每一个进行一次游走,下一步看random_walk这个方法,在这个方法中指定了游走长度,起始节点,随机种子以及alpha
def random_walk(self, path_length, alpha=0, rand=random.Random(), start=None):
""" Returns a truncated random walk.
path_length: Length of the random walk.
alpha: probability of restarts.
start: the start node of the random walk.
"""
G = self
if start:
path = [start]
else:
# Sampling is uniform w.r.t V, and not w.r.t E
path = [rand.choice(list(G.keys()))]
while len(path) < path_length:
cur = path[-1]
if len(G[cur]) > 0:
if rand.random() >= alpha:
path.append(rand.choice(G[cur]))
else:
path.append(path[0])
else:
break
return [str(node) for node in path]
我靠搞了半天发现这个代码真的是随机游走啊,在跳转的时候真的是随机选取一个节点啊,没有考虑任何边权,具体看path = [rand.choice(list(G.keys()))]这一行代码,来我们看一下他的Graph
对象,发现这个作者丝毫没有使用networkx包,而是自己写的Graph、类以及读取txt为Graph灌入数据,但是为啥requirements.txt里面有networkx误导大众
class Graph(defaultdict):
"""Efficient basic implementation of nx `Graph' – Undirected graphs with self loops"""
def __init__(self):
super(Graph, self).__init__(list)
def nodes(self):
return self.keys()
... # 此处省略若干行
可以看到整个Graph是一个字典对象,这个字典有一些属性和方法,先看一下这个字典怎么初始化的吧,我们看一下G = graph.load_edgelist(args.input, undirected=args.undirected)
这一行代码,跟一下load_edgelist
def load_edgelist(file_, undirected=True):
G = Graph()
with open(file_) as f:
for l in f:
x, y = l.strip().split()[:2]
x = int(x)
y = int(y)
G[x].append(y)
if undirected:
G[y].append(x)
G.make_consistent()
return G
看起来他读取了txt文件的第一列和第二列作为两端的节点,以左节点为头元素,右边的节点加入到尾序列中,如果采用无向模式,右节点也会作为头元素,然后他调用make_consistent
,跟一下
def make_consistent(self):
t0 = time()
for k in iterkeys(self): # key
self[k] = list(sorted(set(self[k]))) # 排序一致
t1 = time()
logger.info('make_consistent: made consistent in {}s'.format(t1 - t0))
self.remove_self_loops() # 删除集合list中存在自己的
return self
这下真相大白了,他对每一个key的尾序列做了相同的排序,然后调用了remove_self_loops
def remove_self_loops(self):
removed = 0
t0 = time()
for x in self:
if x in self[x]:
self[x].remove(x)
removed += 1
t1 = time()
logger.info('remove_self_loops: removed {} loops in {}s'.format(removed, (t1 - t0)))
return self
这个也很明显,如果尾序列中存在key,把key自身去除,最终形成一个Map[node, nodeList]的结构,然后在这个Map结构上进行链式遍历
总结
- Deepwalk相比于直接将序列传入word2vec,序列元素会更加丰富且自然,而且可以解决短序列表达不足的问题
- Deepwalk分为带权重和不带权重,如果不带权重,节点游走在出边中随机挑选,如果带有权重,选择概率会直接挂钩边权
- Deepwalk可以支持有向图也可以是无向图,如果推荐场景十分重视时序关系可以考虑有向图
- Deepwalk的主要参数是每个节点作为起点的次数,和一次游走的步长,一般而言图中的每一个节点都会作为起点游走一定的步长
- Deepwalk的性能较差,主要原因是源代码在游走部分没有并行,在每次节点选择时如果采用带权重采样效率极差,需要进行采样优化