以下内容具有主观性,有些问题的理解和回答不一定准确,仅供参考。翻译不确定的后面都有原文。
作者在追溯基于图推荐的系统的进化过程,发现了两大趋势(更快更广)。
趋势一是批处理到实时处理的演变,最初推荐几乎都是大约每隔一天批量生成的,后面发现实时生成推荐才更符合推特的实时性特点(无论是关于名人八卦、世界大事还是亲人的活动,推特主打的就是一个快)。
趋势二是推荐的范围更广,一开始只给用户推荐其可能感兴趣的用户,后面除了推荐用户外,还会推荐推文、广告等。
GraphJet在这两种趋势下应运而生。
有两点贡献。
贡献1:介绍并比较了基于图推荐的连续几代系统(WTF and Cassovary->Hadoop and the RealGraph->MagicRecs->GraphJet),作者说他们思维的演变反映了(在某些情况下,引领了)一般的行业趋势。因此,他们的经验可能会引起广大观众的兴趣。
贡献2:详细介绍了GraphJet。
提出新的系统GraphJet,使得基于图推荐具有实时性,以及能给用户推荐更广泛的信息。
除去论文的通用章节,如摘要、引言、相关工作、总结、致谢和参考文献外,作者还分了五个章节。其中“系统进化”是介绍GraphJet之前的系统,详尽介绍GraphJet的有四章,先是对GraphJet做了概述,然后对涉及的图操作和实时推荐展开说说,最后介绍了其部署和性能。
概括性地说:在2010年一开始推特还没有给用户推荐用户的功能,于是就搞了“WTF”项目,可以向用户推荐用户。2012年,为了利用更加广泛的信息,包括行为日志中包含的信息,就采用了Hadoop。后来发现基于实时信息生成的推荐更吸引人,就推出了实时推荐系统MagicRecs,但是该系统只是推荐用户。基于此,目前是使用的是GraphJet,可以利用更多的信息进行实时的更加广泛的推荐,不仅推荐用户,还可以推荐推文等。
浅浅展开地说,就是——
一开始Twitter并没有向用户推荐用户的功能。于是在2010年春季提出了“WTF(Who To Follow)”的项目,并在2010年夏季上线。“WTF”要做的事情就是向用户推荐用户,进而丰富用户之间的联系。其中Cassovary是该系统的内存图处理引擎,是他们从零开始编写的自定义系统,后来成为开源系统。
实现“WTF”系统快速部署的关键原因之一是一种非常规的设计:假设整个图都放在单个服务器的内存中。说它是非常规是因为当时常规做法是分布式、水平分区、向外扩展等。后期的GrapJet也沿用了该非常规设计。
作者在“WTF”经验之上又继续构建其后续版本,在2012部署。构建的动力就是希望能够利用更广泛的信息进行推荐,特别是行为日志中包含的信息。但是对这样的数据就没法假设图能存在单个服务器内存上。
作者的想法是把在Cassovary上验证有效的算法扩展到更大更丰富的图上,不仅包括用户关注还包括行为信号。当时作者还没有意识到实时处理的重要性,还在沿用批处理的方案。最后在第二代图推荐系统采用了Hadoop解决方案。
基于图的推荐发展在这一节点(2012年),几乎所有的推荐都是大约每隔一天批量生成的,而作者观察到利用最新的信息生成的推荐更吸引人。于是就有了实时生成推荐的MagicRecs。
MagicRecs成功展示了实时推荐的力量,但是它只用来推荐用户。作者认为有必要构建一个更通用的图存储引擎,能够实时支持更加丰富的推荐算法。最终的产品就是GraphJet。
以下开始看图说话。
WTF和Cassovary
在图1中,FlockDB是Twitter为分析关系数据而构建的一个开源的分布式容错图数据库,用来管理宽而浅的网络图。
HDFS(Hadoop Distributed File System)是Hadoop抽象文件系统的一种实现。HDFS的文件分布在集群机器上,同时提供副本进行容错及可靠性保证。
而WTF的核心是Cassovary,这是他们从零开始编写的自定义系统。WTF DB是一个分片的MySQL数据库。
图中的“Fetcher"中文意思是“取物的人;访问者”,我理解的是从两个分支中生成的推荐,“Blender”的中文意思是“混合物,搅拌机”,这里我理解的是把两个分支产生的推荐进行混合,后面再排序。
对图1的每一块简单介绍后,说一下整体的流程。
图中Blender的来源主要有两个分支。
先说右边的分支——
HDFS每天从前端的图数据库FlockDB中读入图快照(graph snapshots),Cassovary处理从HDFS获得的社交图的快照(Cassovary operates on snapshots of the follower graph loaded from HDFS)。
Cassovary的存储层通过基于顶点的查询提供对图的访问,然后推荐引擎生成“关注谁”的推荐。这些信息(生成的推荐)被物化并存储在分片的MySQL 数据库,即WTF DB。
Cassovary服务器不断地向用户生成推荐,这些推荐是从分布式队列中产生的,队列里是按最近一次刷新的时间戳所排序的用户(Cassovary servers constantly generate recommendations for users, consuming from a distributed queue containing users sorted by a “last refresh” timestamp.)。
实际的API终端从WTF DB中获取推荐来响应网页端或者移动端的请求。
因为图完整地存储在每一个服务器中,所以实现起来比较直接:根据需要复制Cassovary服务器实例实现特定的吞吐量,每一个实例从工作队列中读取并把结果写入到WTF DB中。(吞吐量是指对网络、设备、端口、虚电路或其他设施,单位时间内成功地传送数据的数量(以比特、字节、分组等测量))。
再说左边的分支——
由于快照和把进程导入到HDFS对前端图存储造成的负载,内存中的Cassovary图更新频率超过一天一次是不现实的。但是这就对新用户不友好,因为新用户与其他用户的连接性太少了,对新用户生成高质量的推荐也就是“冷启动”问题,这里是通过另一个分支(算法集)实现的,在图1中有一条分支上写的有实时推荐。
此外,作者介绍了两个Cassovary算法。
第一个算法是“信息圈”(Circle of Trust)。
作者说他们的很多推荐算法的基础是一个有用的“原始”(primate),也称为用户的“信任圈”,是自我中心随机游走的结果。Cassovary根据给的参数计算“信任圈”。
第二个算法是基于SALSA(Stochastic Approach for Link-Structure Analysis)的推荐算法。
这个算法是从大量的感兴趣网站中建立一个二部图,一边是“hub”,另一边是“authorities”,如图2。
“hubs”里的用户是用户的信任圈,“authorities”里的是用户的信任圈里的用户所关注的用户。
在构建的二部图上迭代多次“SALSA”算法,得到两边的分数。右边排序后作为用户的推荐,左边排序后衡量的是与用户的相似度。
这个算法是有效的,因为它抓住了用户推荐问题的递归性质。用户 u 更可能会关注那些类似于 u 用户所关注的用户。相应地,如果用户与u关注相同(或相似)的用户,那么这些用户与u相似。
SALSA实现了这个想法——在左边为u提供相似的用户,在右边为u提供与u相似的用户所关注的用户。
Hadoop and RealGraph
第二代推荐系统是构建在Hadoop分析平台之上的,基于Hadoop的推荐流程可见图3。
RealGraph是社交图和从行为日志里得到的交互图的复合表示。(What we call the RealGraph is a composite representation of the follower graph and interaction graphs that are induced from behavior logs.)
该表示是基于原行为数据中通过提取、清洗、转换、拼接而成的多个特征通道上建立的RealGraph存储在HDFS上,因此可以自由地合并尽可能多的信息。
从图3中可以看到RealGraph有两种作用。其一是利用Cassovary算法批量生成推荐,其二是结合RealGraph和日志数据训练关注者预测模型作为分类器。第一阶段是生成候选推荐,输入到分类器中,得到最终的推荐。
MagicRecs
MagicRecs的过程:假如我们向用户A生成推荐列表,检查用户A所关注的用户列表(B1,B2…,Bn),如果他们中在t时间内有超过k个用户关注了C,我们就像A推荐C。
更具体的如图4,这里k=2,在图4中如果建立了B2到C2的边,我们就向A2推荐C2。
具体实现是将在一定时间间隔内识别从B到C的边转为两个邻接表的交集,即保存A顶点的出边和C顶点的入边,然后求A 和C的邻接表的交集作为B的邻接表(That is, if we stored the outgoing edges of the A vertices and the incoming edges of the C vertices, we
can intersect the adjacency lists of A and C to arrive at the B’s and check the cardinality.)
GraphJet 是用 Java 编写的实时图形处理库,是一个维护用户和推文之间的实时二部交互图的内存图处理引擎。
存储引擎实现了一个简单的API,但它具有足够的表达力可以支持基于已经改进的基于随机游走的推荐算法。与Twitter之前开发的图形推荐引擎Cassovaly类似,GraphJet假设整个图形可以保存在单个服务器上的内存中。
该系统将一个滑动时间窗口内的交互图组织成包含邻接列表的时间分区的索引段。索引支持多种图形算法,包括基于协同过滤的个性化推荐算法。这些算法为 Twitter 内部的各种实时推荐服务提供动力,尤其是内容推荐,这些推荐需要在一个异构的、快速发展的图表上进行协同过滤。
GraphJet能够支持边的快速摄取,同时通过结合紧凑边编码和利用图幂律特征的动态内存分配方案进行查询。
每个GraphJet服务器每秒接收多达100万个图边,在稳定状态下,每秒计算多达500个建议,这转化为每秒数百万个边读操作。
Twitter有三块,数据、特征和HOME MIXER。GraphJet主要用在HOME MIXER。HOME MIXER有三个步骤,先是生成候选源(召回),然后是进行粗/精排,最后进行混/重排。系统会将推文与其他非推文内容(如推广告、推关注人)混合在一起,然后再将这些内容返回到设备上进行显示。 GraphJet作用在生成候选源阶段。
生成候选源有两个来源,内部网络和外部网络。内部网络源是查找用户已经关注的用户的所有推文,排序后得到部分推荐;外部网络源是说用户并没有关注某条推文的作者,怎么计算该推文用户有没有兴趣。两个方法,社交图和嵌入空间。
准确地说,GraphJet作用在HOME MIXER的第一步骤即召回阶段中,从外部网络源利用社交图生成候选推荐。
具体是通过分析你关注的人或者有相似兴趣的人的活动来估计什么与你相关。遍历图按步骤回答以下问题:
1.我关注的人最近在Twitter上发了什么推文?
2.谁和我喜欢类似的推文?他们最近喜欢什么?
GraphJet作为一个维护用户和推文之间实时交互图的图形处理引擎,来执行这些遍历。UTEG(user-tweet-entity-graph)使用GraphJet特征(社交图特征)做协同过滤进行推文。(GraphJet特征?)
GraphJet假设整个图形可以保存在单个服务器上的内存中。这种假设很适合交互图,因为交互图变化的很快,实时的推荐算法依赖最新的信息,这意味着只需要在移动的窗口内维护图。
GraphJet管理的是一个动态的、稀疏的、无向二部图G=(U,T,E),其中U是用户,T是推文,E是在时间窗口内的交互。假设边有具体的类型r,r属于R,R的基数相对较少而且是固定的,对应的是推文中的一些互动如“喜欢”、“转发”等。
尽管概念上二部图是无向的,但是在实际将边存储在邻接表时会隐性表明方向性。可以建立从左到右的索引,u属于U,(t,r)表示类型为r的边从u到t。也可以建立从右到左的索引,t属于T,(u,r)表示类型为r的边从t到u。根据数据访问模式,可以构建从左到右的索引,也可以构建从右到左的索引,或者两者都构建。
在存储层,GraphJet实现了主要右五个方法组成的简单API。
这五个方法分别是:
insertEdge(u, t, r): 在二部图的用户u和推文t之间插入类型为r的边。
getLeftVertexEdges(u): 返回与用户u相关的边迭代器,迭代器里是(t,r)元组。
getLeftVertexRandomEdges(u, k): 返回与用户u相关的边中均匀采样的k条边迭代器,迭代器里是(t,r)元组。
getRightVertexEdges(t): 返回与推文t相关的边迭代器,迭代器里是(u,r)元组。
getRightVertexRandomEdges(t, k): 返回与推文t相关的边中均匀采样的k条边迭代器,迭代器里是(u,r)元组。
关于GraphJet的数据模型和API有两点需要注意:
1.API不支持边的删除。这样做的原因是一方面现实中交互是不能被抹掉的,另一方面在实现的时候简化了操作,此外因为是考虑时间窗口内的交互,所以图不会因为不删除边而无限制的增长。
2.不存储边的时间戳。这是推荐质量和内存之间的权衡。(作者说是可作为未来的发展方)
存储引擎维护的是一个由邻接表(该邻接表存储二部图分区)构成的临时排序的索引段列表。在图5可以看到在“存储引擎”上面有很多“索引段”。其中从左到右的索引将属于U的顶点u映射到邻接表,这个邻接表里是任意数量的(t,r)元组,代表与顶点相关的边。从右到左的索引是对称的。存储引擎能够对图进行原始访问,支持推荐引擎。整个系统为外部客户端提供一个API端点用来请求推荐。
GraphJet通过定期的从头开始创建新的索引来临时划分邻接表。如果边的数量达到阈值就会产生新的分区(partition),所以每个分区的大小差不多。每个GraphJet实例维护少量的索引段。在该设计下,只有最新的索引段会被写入,其他的都是只读。为了消除对复杂的同步机制的需求,所有边的插入由一个写线程处理,读是从Kafka队列读取。一旦一个索引段不再接受新边,就会优化其内容支持更有效的读取。如果索引段的时间大于n个小时就会被丢弃,所以内存消耗不会无限制增加。实验表明,图以这种粗粒度的方式修剪不会对结果产生明显的影响。
GraphJet的作用是维护用户与推文的实时二部交互图。那怎么维护?自然就要涉及到图的相关操作,如边的插入,边的采样和查询。
边的插入:
因为采用的单写入器、多读取器的设计所以不用考虑写-写冲突,GraphJet的输入是(u,t,r)三元组形式的边插入流,用户u和推文t是64位的长整型,r是边的类型。难点是系统需要同时支持边的快速摄取(ingestion of edges)和大容量读取,这就需要平衡写优化和读优化的数据结构。这就涉及到id的映射、内存的分配和边的只读优化。
边的插入_id映射:
相对于64位的id空间每个索引段拥有的都是较小的顶点集,可以通过建立外部顶点和内部顶点之间的双向映射来减少内存需求,这种映射在特定的索引段内是唯一的。
是通过哈希外部顶点的id并将哈希值作为内部顶点的id来实现的,使用的是标准的双重哈希技术,这是一种开发寻址方法,通过第一个哈希函数确定初始探测位置,如果发生碰撞,就利用第二个哈希函数确定线性探测之间跳过的间隔。将外部顶点的id存储在哈希表中,以便通过简单的数组查找来恢复这些id。为方便起见,将哈希表的大小设置为2的幂,这样就可以通过简单的位操作完成mod操作。
因为内部顶点的id是哈希值,没法再哈希,所以要注意哈希表的初始大小设置。可以将历史数据作为指导,假设以加载因子f存储n个顶点,则将初始的哈希表大小设置为 2 b 2^{b} 2b,其中 b = ⌈ b=\lceil b=⌈lg(n/f) ⌉ \rceil ⌉。填充哈希表,直到插入f· 2 b 2^{b} 2b个顶点,然后分配另一个大小为 2 b − 1 2^{b-1} 2b−1存放f· 2 b − 1 2^{b-1} 2b−1个顶点的哈希表,对于这些新顶点,内部顶点的id值是新表中的哈希值加上 2 b 2^{b} 2b。这样就可以在执行反向映射时明确地确定外部顶点id。这个过程可以根据需要反复进行,但是可以调优索引段和哈希表的大小使得额外的分配很少发生。
可以通过32位整形对边类型和内部顶点进行位打包(bit-packing)进一步优化。这是安全的因为可以调整索引段的大小使得内存分配不会溢出。这种优化意味着每个索引段内,邻接表仅是32位整数数组。插入新边转换为查找与顶点有关的邻接表并把整数写入下一个可用的位置,因为只有一个插入线程,所以不用考虑一致性问题。
边的插入_内存分配:
顶点的邻接表要么保存在内存中连续的地址,要么是不连续的。如果是连续的话就不用指针跟踪很方便读取,但是当图发生变化时,因为没办法提前预测顶点的度,所以如果不重新定位邻接表就没法维护其连续性。而不维护其连续性的话,虽然读会涉及到指针跟踪,但是边的插入变得很快。在GraphJet中采用的是不连续内存,能够容忍邻接表的不连续性因为边采样是常见操作。
邻接表将被分为连续的片这个结论并没有回答到底怎么为存储边分配内存。那么应该为顶点分配到大的内存?值太大会导致内存利用率低,值太小会导致效率低。
解决办法是为邻接表片分配内存的时候每当空间用完,就以2的幂次方倍数扩大空间。当我们第一次遇到一个顶点,分配两条边的空间(一个包含两个整数的数组),遇到第一条边插入第一个位置,第二条边第二个位置。当遇到第三条边的时候,分配一个新的大小为 2 2 2^{2} 22的片,如果空间又用完了,再分配一个新的大小为 2 3 2^{3} 23的片,以此类推…
这种方法是可取的,因为推特图在很多方面都遵循“幂律定律”,也观察到与顶点相关的边越多,就越可能出现更多的边。因此这种做法是有意义且取2的幂是最方便的。
通过将大小相同的片分组在“边池”(大数组)来组织邻接表切片。第一个边池P1的长度是 2 1 2^{1} 21*n,n是特定段中预期顶点的数量。将第一个边池的第k个槽记为P1(k)。第二个边池P2,保存n/2个邻接表切片,每个片的长度是 2 2 2^{2} 22。第r个边池Pr,保存n/ 2 r − 1 2^{r-1} 2r−1个邻接片,每个片的长度是 2 r 2^{r} 2r。所以,有d度的顶点v的邻接表:
因为片的大小是固定的,所以可以通过顶点的度下一条边插入的位置以及当前片还有多少空间。
图6是一个具体的例子。
假设顶点v1有25个度,它的邻接表如下:
浅灰色区域对应填充的邻接表切片(k从0开始),因为v1的度是5,所以P4填充了11个格子,还有5条边的空间。
包含一个特定大小片的边池的内存分配是惰性的——这些边池只有在需要的时候才被创建,实际中,边池的数量是有限的,因为控制了每个索引段的大小,所以没有溢出的危险。每个边池的初始大小是2n,用完了就每次按10%扩大。
边的插入_只读优化:
GraphJet的存储引擎管理多个索引段,前面也提到过,只有最新的索引段有新边插入,其余的都是不可变的,所以可以对各种数据结构重新组织物理布局来支持高效读取。一旦索引段不再接受新边,后台线程就开始优化这个索引段的存储布局,创建一个影子副本。这个过程完成后,存储引擎会把新的索引段替换上去并把原来的索引段删除。
当前对只读段的优化相对简单:因为图分区现在是不可变的,所以不需要边池结构来存储邻接表。又因为知道每个顶点最终的度,因此可以将每个邻接表端到端的布局在一个没有间隙的大数组中。然后通过保留每个邻接表的头指针实现对边的访问,这种布局保证了在特定顶点的边上的迭代能够触及内存中的连续区域,从而消除指针追踪的开销。
(This layout guarantees that iteration over the edges of a particular vertex will touch contiguous regions in memory, thereby eliminating the overhead of pointer chasing.)
边的插入搞好了,那边的读怎么搞呢?接下来介绍边的查询和采样。
边的查找
getLeftVertexEdges 方法返回迭代器,遍历二部图左边顶点的所有边(getRightVertexEdges对称),该迭代器是从最早到最近的所有索引段的边的迭代器组成的。(The returned iterator is a composition of iterators over edges in all index segments, from earliest to latest. )在每个段中边是按插入顺序存储的,所以迭代也是按该顺序。每个段内,每个顶点是和它的度还有指向边池的指针相连的,这些边池组成了实际的邻接表。
边的采样
getLeftVertexRandomEdges 方法返回与用户u相关的边中均匀采样的k条边迭代器,迭代器里是(t,r)元组。在一个索引段内随机采样很容易,知道顶点的度d,在[0,d-1
]均匀随机采样一个整数,很容易将其映射到边池的数组位置。那怎么在索引段间进行采样呢?存储引擎会跟踪所有段的顶点度,可以将这些度归一化成采样的概率分布,其中选择索引段的概率与该段中的边数成正比。如果以这种方式对索引段进行抽样,然后在每个段内均匀抽样,就相当于从所有的边随机抽取一个样本。
两种类型。一种是内容推荐查询。计算用户感兴趣的推文列表;一种是相似度查询。计算与一个给定推文的相关推文列表。
GraphJet的输出最好作为候选推荐,这些候选者经过机器学习模型进一步排序和过滤。
作者在论文里介绍了三个。
Full SALSA
在GraphJet的交互图上运行个性化SALSA算法,这个算法类似于WTF中提到算法,主要区别在于这里用的是实时信息。对于内容推荐查询,最简单的形式是从用户对应的二部交互图中的顶点开始,运行SALSA。执行以下的随即游走:从左边的u开始,随机选择一条边到达右侧边的t,然后再从t出发,随机选择一条边回到左侧,这个过程重复奇数次。为了引入个性化,在从左到右的遍历中引入了重置步,以一个固定的概率从查询点重新开始确保随机漫步不会与查询点偏离太远。在这个随机游走的最后,可以计算出右侧顶点的访问分布。
但是有些情况下要查询的点可能不在二部图中,为了解决这个问题,那随即游走的初始点不再是查询用户u而是从种子集中随机一个顶点。种子集是可是配置的,一般是从信任圈里选择。
这个完整的SALSA算法的输出是二部交互图右侧的顶点排序列表,这些推文可能与种子集没有任何的交互,对于具有稀疏种子集的用户是个优点(因为用户的活动太少,给他推荐一些内容他可能会感兴趣,所以说是优点,聊胜于无),但对于具有密集种子集的用户来说这些结果并不好(没有任何交互的推文用户可能并不感兴趣,过犹不及)。拥有直接的公共互动(如转发和点赞)能解释为什么会产生特定的推荐。这通常会让用户更好地理解推荐内容,从而提高用户粘性。
Subgraph SALSA
假设只想生成有公共互动的推文,可以通过限制输出集为种子集的邻居来实现。可以从种子集顶点下采样边来绑定顶点度以避免生成过于不平衡的图,这产生了一个完全二部交互图的子图,在该子图上运行SALSA算法。
这个算法接受一个种子集(如信任圈)构建一个子图,运行以下类似于pagerank的算法:
在种子集中将权重均匀分布(和为1),每次从左到右的迭代中,取每个顶点u的权重为w(u),将权重均分给u的邻居t们,每个t分到的权重是w(u)/d(u),其中d(u)是u的度。右侧顶点将从左侧接收到的权重求和。从右侧顶点到左侧相邻顶点的相同权重分布过程构成了对称的从右到左迭代。迭代直到收敛。
这个算法的输出也是二部交互图右侧的顶点排序列表。与Full SALSA相比,两者各有优缺点。这个算法内存消耗更少,速度更快,但是它忽略了二阶交互信息。
Similarity
前两个算法主要是用来进行内容推荐查询,GraphJet还支持相似度查询。返回与查询顶点相似的所有顶点(相同类型,用户返回相似的用户,推文返回相似的推文)。
两个顶点的相似度可以用余弦相似度度量:
其中N(t)表示顶点t的左侧邻居。因此,对于查询顶点t,目标是根据相似度指标构建tweet的排名列表。
这个算法也是基于随机游走的:对于给定的顶点t,对他的邻域N(t)采样得到集合Ns(t),然后对每一个属于Ns(t)的用户u的邻居进行采样得到与t可能相似的候选点,可以在每次采样步骤上设置权重,使得每一个候选者的访问次数期望与Sim(t,t’)成正比。多次重复这个采样过程,可以计算候选点和它们的相似度估计,然后该算法返回按相似性排序的候选对象。
在核心有一组服务器,每个服务器运行一个GraphJet实例。由于每个单独的服务器都持有交互图的完整副本,因此容错由复制处理,这也决定了服务的总体吞吐量。每个GraphJet实例从Kafka的用户tweet交互事件队列中摄取图边。服务发现由ZooKeeper处理:当GraphJet服务器准备好提供流量时,在一个已知的位置向ZooKeeper注册,客户端查询服务注册表以获取请求路由和负载平衡。
待续…
参考文献:
论文本文
https://blog.csdn.net/qq_38737992/article/details/87624948
https://blog.csdn.net/sjmz30071360/article/details/79877846
https://blog.csdn.net/qq_39388410/article/details/129927227?spm=1001.2014.3001.5501
https://www.sohu.com/a/661688819_121207965?scm=1102.xchannel:325:100002.0.6.0