当你在视频平台搜索《蝙蝠侠:黑暗骑士》时,系统不仅记下你看了这部电影,它还知道:克里斯蒂安·贝尔饰演蝙蝠侠、克里斯托弗·诺兰执导、属于"诺兰蝙蝠侠三部曲",并且与其他烧脑动作片在风格上同源。这张丰富的语义网络就是 知识图谱(Knowledge Graph,KG) ——一张由实体(电影、演员、导演、类型)和带类型的关系 (acted_in、directed_by、part_of)组成的结构化网络。
为什么这对推荐重要?因为纯协同过滤有一个致命盲区:它只能推荐已经有交互历史的物品 。一部刚上线、播放量为零的新电影,对它而言是"隐身"的。但如果这部电影的导演正是你最喜欢的诺兰,知识图谱当天 就能看到这条联系。知识图谱把推荐从"模式匹配"升级成了 语义推理 。
你将学到什么 知识图谱是什么、如何用三元组编码现实世界事实 知识图谱嵌入(TransE / TransR / DistMult)如何把实体和关系表示成向量 传播类方法:RippleNet 让用户偏好像水波一样向外扩散 图卷积类方法:KGCN 与 KGAT 通过聚合 KG 邻居学习物品表示 嵌入融合:CKE 同时融合协同信号、结构信号与文本信号 基于路径的可解释推荐推理 每种主流方法都配有可运行的 PyTorch 代码 前置知识 基础 Python 与 PyTorch(张量、nn.Module、训练循环) 熟悉图神经网络(本系列第 7 篇
) 理解嵌入概念(本系列第 5 篇
) 知识图谱基础 什么是知识图谱
知识图谱用 (头实体,关系,尾实体) 三元组的形式存储事实。每条三元组表达一条原子事实:
(黑暗骑士,directed_by ,克里斯托弗·诺兰) (黑暗骑士,starred ,克里斯蒂安·贝尔) (黑暗骑士,has_genre ,动作) (克里斯蒂安·贝尔,acted_in ,致命魔术) 形式化地,知识图谱是一个集合 $\mathcal{G} = \{(h, r, t)\}$,其中 $h \in \mathcal{E}$ 是头实体 ,$r \in \mathcal{R}$ 是关系类型 ,$t \in \mathcal{E}$ 是尾实体 。$\mathcal{E}$ 是所有实体的集合,$\mathcal{R}$ 是所有关系类型的集合。
类比。 把知识图谱想象成"维基百科级别的事实数据库",但它不是用文字段落存储,而是用图。每个词条是一个节点,词条之间的每条链接都是一条带标签的边 ——标签告诉你它们为什么 相连。
现实世界的知识图谱 知识图谱 实体数量 事实数量 使用方 Freebase 3900 万 19 亿 Google(已废弃) Wikidata 1 亿+ 14 亿+ Wikipedia、Google DBpedia 600 万 5.8 亿 学术研究 Amazon Product Graph 数十亿 数万亿 亚马逊推荐
推荐系统中的知识图谱结构 推荐系统使用的 KG 通常是异构图 ——既有多种实体类型,也有多种关系类型。
实体类型
用户:$U = \{u_1, u_2, \ldots, u_m\}$ 物品:$I = \{i_1, i_2, \ldots, i_n\}$ 属性:类型、演员、导演、品牌…… 关系类型
用户—物品:(用户, 交互, 物品) 物品—属性:(电影, has_genre, 动作)、(电影, directed_by, 诺兰) 属性—属性:(诺兰, 合作过, 贝尔) 知识图谱嵌入 把知识图谱送进推荐模型之前,需要先把实体和关系变成稠密向量。关键问题是:如何训练这些嵌入,让它们尊重图谱的结构?
TransE 是最简单也最直观的方法:对于任意一条合法的三元组 $(h, r, t)$,都应当满足向量等式 $\mathbf{h} + \mathbf{r} \approx \mathbf{t}$。
$$\mathcal{L} = \sum_{(h,r,t) \in \mathcal{G}}\; \sum_{(h',r,t') \notin \mathcal{G}} \bigl[\gamma + \|\mathbf{h} + \mathbf{r} - \mathbf{t}\|_2 - \|\mathbf{h}' + \mathbf{r} - \mathbf{t}'\|_2\bigr]_+$$
白话翻译。 “把合法三元组拉近(让 $\mathbf{h} + \mathbf{r} \approx \mathbf{t}$),把非法三元组推远;间隔 $\gamma$ 控制你想要的最小分离度。“上图右侧形象地展示了这一过程:绿色点(合法尾实体)被拉进 margin 圆内,灰色负样本被推到圆外。
方法 评分函数 适用场景 TransE $\|\mathbf{h} + \mathbf{r} - \mathbf{t}\|$ 简单的 1-to-1 关系 TransR $\|\mathbf{h} M_r + \mathbf{r} - \mathbf{t} M_r\|$ 不同关系需要不同向量空间 DistMult $\mathbf{h}^T \text{diag}(\mathbf{r})\, \mathbf{t}$ 对称关系 ComplEx $\text{Re}(\mathbf{h}^T \text{diag}(\mathbf{r})\, \bar{\mathbf{t}})$ 非对称关系
TransE 类比。 想象地图上的城市。“巴黎” + “向东飞 2000 公里"应当落在"莫斯科"附近;“巴黎” + “向南飞 1500 公里"应当落在"阿尔及尔"附近。关系向量就像地图上的位移。
实现:构建知识图谱 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
import torch
import torch.nn as nn
from collections import defaultdict
from typing import List , Tuple
class KnowledgeGraph :
"""用于推荐系统的简易知识图谱。"""
def __init__ ( self ):
self . entities = {} # entity_name -> entity_id
self . relations = {} # relation_name -> relation_id
self . triples = [] # [(h_id, r_id, t_id), ...]
self . entity_adj = defaultdict ( list ) # entity_id -> [(r_id, t_id), ...]
def add_entity ( self , name : str ) -> int :
if name not in self . entities :
self . entities [ name ] = len ( self . entities )
return self . entities [ name ]
def add_relation ( self , name : str ) -> int :
if name not in self . relations :
self . relations [ name ] = len ( self . relations )
return self . relations [ name ]
def add_triple ( self , head : str , relation : str , tail : str ):
h_id = self . add_entity ( head )
r_id = self . add_relation ( relation )
t_id = self . add_entity ( tail )
self . triples . append (( h_id , r_id , t_id ))
self . entity_adj [ h_id ] . append (( r_id , t_id ))
def get_neighbors ( self , entity_id : int ) -> List [ Tuple [ int , int ]]:
return self . entity_adj [ entity_id ]
# 构建一个小型电影知识图谱
kg = KnowledgeGraph ()
kg . add_triple ( "黑暗骑士" , "directed_by" , "诺兰" )
kg . add_triple ( "黑暗骑士" , "starred" , "贝尔" )
kg . add_triple ( "黑暗骑士" , "has_genre" , "动作" )
kg . add_triple ( "黑暗骑士" , "has_genre" , "犯罪" )
kg . add_triple ( "盗梦空间" , "directed_by" , "诺兰" )
kg . add_triple ( "盗梦空间" , "starred" , "迪卡普里奥" )
kg . add_triple ( "盗梦空间" , "has_genre" , "科幻" )
kg . add_triple ( "致命魔术" , "directed_by" , "诺兰" )
kg . add_triple ( "致命魔术" , "starred" , "贝尔" )
kg . add_triple ( "致命魔术" , "has_genre" , "剧情" )
# 查看图谱
dk_id = kg . entities [ "黑暗骑士" ]
print ( f "黑暗骑士的邻居: { kg . get_neighbors ( dk_id ) } " )
# (directed_by, 诺兰), (starred, 贝尔), (has_genre, 动作), (has_genre, 犯罪)
知识图谱为什么能帮助推荐 知识图谱解决了纯协同过滤难以处理的四个老大难问题。
1. 冷启动 问题。 新电影没有任何交互记录,协同过滤完全看不见它。
KG 解法。 即使在上线第一天,新电影也已经拥有属性(导演、演员、类型),通过这些属性它已经和图谱里的其他物品连成一片。如果你喜欢诺兰的旧片,KG 完全可以当天 把他的新片推给你。
2. 数据稀疏 问题。 大多数用户只与极少量物品交互,交互矩阵 99% 以上都是零。
KG 解法。 知识图谱用稠密的语义连接填补了这些空白。即便两个物品没有任何共同用户,它们也可能共享导演、类型或制作公司——这条语义边本身就是有用的信号。
3. 可解释性 问题。 “和你相似的用户也喜欢 X"不是一个让人信服的解释。
KG 解法。 “向你推荐《盗梦空间》,因为它的导演是克里斯托弗·诺兰,他执导的《黑暗骑士》你打了 5 星。"——具体、可读、有据可查的推理路径。
4. 多样性 问题。 协同过滤天然倾向于制造"信息茧房”,反复推荐同质化的内容。
KG 解法。 不同的关系路径会引出不同类型的推荐:沿着 same_director 走和沿着 same_genre、same_actor 走得到的结果完全不同,多样性自然就出现了。
具体例子:看完《黑暗骑士》之后
上图把"用户的一次观影"到"四部候选电影"之间的显式路径全部画了出来:
盗梦空间 ——黑暗骑士 -> directed_by -> 诺兰 -> directed -> 盗梦空间致命魔术 ——通过诺兰和贝尔两条路径同时支撑(双重信号 ,置信度更高)侠影之谜 (Batman Begins)——共享主演 + 共享类型系统不仅可以为每个候选物品给出不同的排序 ,还能给出不同的解释 ——这是纯协同过滤做不到的。 KG 增强方法的分类 类别 思路 代表方法 传播类 让用户偏好沿 KG 向外扩散 RippleNet 图卷积类 通过聚合 KG 邻居学习物品嵌入 KGCN、KGAT 嵌入类 联合学习用户、物品、KG 实体的嵌入 CKE 路径类 在多跳路径上推理,提供可解释性 KPRN、PathRec
RippleNet:偏好的涟漪传播
涟漪类比 往池塘里扔一颗石子,水波会一圈一圈向外扩散。RippleNet(Wang 等,CIKM 2018)对用户偏好做了完全相同的事:当用户与某个物品发生交互后,这份偏好会沿着知识图谱像涟漪一样 向外扩散,激活越来越远的相关实体。
工作原理 设用户 $u$ 的历史物品集合为 $V_u = \{v_1, v_2, \ldots\}$:
第 0 跳: 把用户的历史物品作为初始偏好集 $\mathcal{S}_u^0 = V_u$。第 1 跳: 找到通过任意关系与 $\mathcal{S}_u^0$ 相连的所有实体,构成 $\mathcal{S}_u^1$。第 2 跳: 再找到与 $\mathcal{S}_u^1$ 相连的所有实体,构成 $\mathcal{S}_u^2$。聚合: 在每一跳上用注意力为实体加权,得到增强后的用户嵌入。在第 $h$ 跳,候选物品 $v$ 与某个尾实体 $t$ 的相关度为:
$$p_i^h = \text{softmax}(\mathbf{v}^T \mathbf{R}_i\, \mathbf{t})$$
白话翻译。 “这个 KG 实体和我们要打分的候选物品有多相关?用关系矩阵 $\mathbf{R}$ 来衡量它们的兼容性。”
第 $h$ 跳的偏好向量为:
$$\mathbf{o}_u^h = \sum_{(h,r,t) \in \mathcal{S}_u^h} p_i^h\, \mathbf{t}$$
最终用户嵌入是各跳偏好的加权和:
$$\mathbf{u} = \sum_{h=0}^{H} \alpha_h\, \mathbf{o}_u^h$$
走一遍流程。 用户看了《黑暗骑士》:
第 0 跳:{黑暗骑士} 第 1 跳:{诺兰、贝尔、动作、犯罪} 第 2 跳:{盗梦空间、致命魔术、侠影之谜、迪卡普里奥、剧情……} 涟漪发现:用户可能喜欢《盗梦空间》(通过诺兰相连)和《致命魔术》(通过诺兰和 贝尔同时相连——双重支撑,权重更高)。
实现:RippleNet 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
import torch
import torch.nn as nn
import torch.nn.functional as F
class RippleNet ( nn . Module ):
"""RippleNet:在知识图谱上传播用户偏好。"""
def __init__ ( self , num_users , num_items , num_entities , num_relations ,
embedding_dim = 64 , n_hop = 2 , n_memory = 32 ):
super () . __init__ ()
self . n_hop = n_hop
self . n_memory = n_memory
self . embedding_dim = embedding_dim
self . user_emb = nn . Embedding ( num_users , embedding_dim )
self . item_emb = nn . Embedding ( num_items , embedding_dim )
self . entity_emb = nn . Embedding ( num_entities , embedding_dim )
self . relation_emb = nn . Embedding ( num_relations , embedding_dim )
self . hop_weights = nn . Parameter ( torch . ones ( n_hop + 1 ) / ( n_hop + 1 ))
for emb in [ self . user_emb , self . item_emb ,
self . entity_emb , self . relation_emb ]:
nn . init . xavier_uniform_ ( emb . weight )
def forward ( self , user_ids , item_ids , ripple_sets ):
"""
Args:
user_ids: [batch_size]
item_ids: [batch_size]
ripple_sets: ripple_sets[i][h] 是用户 i 在第 h 跳的
(relation_id, tail_id) 列表。
"""
user_base = self . user_emb ( user_ids ) # [B, d]
item_vec = self . item_emb ( item_ids ) # [B, d]
user_enhanced = self . _propagate ( user_base , item_vec , ripple_sets )
scores = ( user_enhanced * item_vec ) . sum ( dim = 1 )
return scores
def _propagate ( self , user_base , item_vec , ripple_sets ):
batch_size = user_base . size ( 0 )
hop_memories = [ user_base ]
for hop in range ( self . n_hop ):
hop_embs = []
for i in range ( batch_size ):
rs = ripple_sets [ i ][ hop ] if hop < len ( ripple_sets [ i ]) else []
if not rs :
hop_embs . append ( torch . zeros ( self . embedding_dim ))
continue
rs = rs [: self . n_memory ] # 限制 memory
rels = torch . LongTensor ([ r for r , _ in rs ])
tails = torch . LongTensor ([ t for _ , t in rs ])
rel_e = self . relation_emb ( rels ) # [K, d]
tail_e = self . entity_emb ( tails ) # [K, d]
# 用候选物品对每个 KG 邻居打分
scores = ( item_vec [ i ] . unsqueeze ( 0 ) * rel_e * tail_e ) . sum ( 1 )
probs = F . softmax ( scores , dim = 0 ) # [K]
hop_embs . append (( probs . unsqueeze ( 1 ) * tail_e ) . sum ( 0 ))
hop_memories . append ( torch . stack ( hop_embs ))
# 多跳加权融合
stacked = torch . stack ( hop_memories , dim = 1 ) # [B, H+1, d]
weights = F . softmax ( self . hop_weights , dim = 0 )
return ( weights . unsqueeze ( 0 ) . unsqueeze ( 2 ) * stacked ) . sum ( dim = 1 )
@staticmethod
def build_ripple_sets ( user_items , kg_adj , max_hops = 2 ):
"""通过 BFS 为单个用户构建 ripple sets。"""
ripple_sets = []
current = set ( user_items )
for _ in range ( max_hops ):
next_hop = []
for entity in current :
for r_id , t_id in kg_adj . get ( entity , []):
next_hop . append (( r_id , t_id ))
ripple_sets . append ( next_hop )
current = { t for _ , t in next_hop }
return ripple_sets
KGCN:知识图谱卷积网络 换一个视角 RippleNet 是把用户偏好 沿 KG 向外扩散;KGCN(Wang 等,WWW 2019)反过来做:通过聚合每个物品在 KG 中的邻居,为物品构建更好的表示 。
类比。 RippleNet 问的是"从你喜欢的东西出发,KG 上还有什么?";KGCN 问的是"对于每个候选物品,它在 KG 中的邻域告诉了我们什么?”
KGCN 怎么算 对于物品 $i$,它在 KG 上的邻居集合为 $\mathcal{N}_i$,KGCN 的步骤:
采样 $K$ 个邻居。打分 :用一个考虑关系类型的注意力为每个邻居打权重。聚合 :用权重加权求和邻居嵌入。融合 :把聚合结果与物品自身的嵌入拼起来。在第 $l$ 层:
$$\mathbf{e}_{\mathcal{N}_i}^{(l)} = \sum_{(r,e) \in \mathcal{N}_i} \pi_r(i, e)\; \mathbf{e}_e^{(l-1)}$$
其中 $\pi_r(i, e)$ 是关系感知的注意力权重。物品嵌入按下式更新:
$$\mathbf{e}_i^{(l)} = \sigma\!\bigl(\mathbf{W}^{(l)}[\mathbf{e}_i^{(l-1)} \,\|\, \mathbf{e}_{\mathcal{N}_i}^{(l)}] + \mathbf{b}^{(l)}\bigr)$$
白话翻译。 “看看 KG 里这个物品都连着谁(演员、导演、类型);按关系类型决定每条连接的重要程度;最后把邻域信号和物品自身嵌入混合。”
实现:KGCN 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
import torch
import torch.nn as nn
import torch.nn.functional as F
class KGCNLayer ( nn . Module ):
"""单层 KGCN,带关系感知聚合。"""
def __init__ ( self , embedding_dim , num_relations ):
super () . __init__ ()
self . embedding_dim = embedding_dim
self . rel_transforms = nn . ModuleList ([
nn . Linear ( embedding_dim , embedding_dim , bias = False )
for _ in range ( num_relations )
])
self . attn = nn . ModuleList ([
nn . Linear ( embedding_dim , 1 )
for _ in range ( num_relations )
])
self . combine = nn . Linear ( embedding_dim * 2 , embedding_dim )
def forward ( self , item_embs , neighbors , relation_embs ):
num_items = item_embs . size ( 0 )
aggregated = []
for i in range ( num_items ):
nbrs = neighbors . get ( i , [])
if not nbrs :
aggregated . append ( torch . zeros ( self . embedding_dim ))
continue
nbr_embs , scores = [], []
for r_id , n_id in nbrs :
transformed = self . rel_transforms [ r_id ]( item_embs [ n_id ])
nbr_embs . append ( transformed )
compatibility = item_embs [ i ] + relation_embs [ r_id ]
scores . append ( self . attn [ r_id ]( compatibility ))
nbr_embs = torch . stack ( nbr_embs )
scores = torch . cat ( scores )
weights = F . softmax ( scores , dim = 0 )
aggregated . append (( weights . unsqueeze ( 1 ) * nbr_embs ) . sum ( 0 ))
aggregated = torch . stack ( aggregated )
combined = torch . cat ([ item_embs , aggregated ], dim = 1 )
return self . combine ( combined )
class KGCN ( nn . Module ):
"""用于推荐的知识图谱卷积网络。"""
def __init__ ( self , num_users , num_items , num_entities , num_relations ,
embedding_dim = 64 , n_layers = 2 ):
super () . __init__ ()
self . num_users = num_users
self . num_items = num_items
self . user_emb = nn . Embedding ( num_users , embedding_dim )
self . entity_emb = nn . Embedding ( num_entities , embedding_dim )
self . relation_emb = nn . Embedding ( num_relations , embedding_dim )
self . layers = nn . ModuleList ([
KGCNLayer ( embedding_dim , num_relations )
for _ in range ( n_layers )
])
for emb in [ self . user_emb , self . entity_emb , self . relation_emb ]:
nn . init . xavier_uniform_ ( emb . weight )
def forward ( self , user_ids , item_ids , item_neighbors ):
user_vec = self . user_emb ( user_ids )
item_vec = self . entity_emb . weight [: self . num_items ]
rel_vec = self . relation_emb . weight
for idx , layer in enumerate ( self . layers ):
if idx < len ( item_neighbors ):
item_vec = layer ( item_vec , item_neighbors [ idx ], rel_vec )
item_vec = F . relu ( item_vec )
item_final = item_vec [ item_ids ]
return ( user_vec * item_final ) . sum ( dim = 1 )
KGAT:知识图谱注意力网络
KGAT 多了什么 KGAT(Wang 等,KDD 2019)比 KGCN 走得更远:它把用户—物品交互和知识图谱合并成一张统一的图 ,称为 协同知识图谱(Collaborative Knowledge Graph,CKG) ,再在这张图上做基于注意力的聚合。
$$\mathcal{G}_\text{CKG} = \underbrace{\{(u, \text{interact}, i)\}}_{\text{用户-物品边}} \;\cup\; \underbrace{\{(h, r, t)\}}_{\text{KG 边}}$$
为什么重要? 在 KGCN 里,用户嵌入和物品嵌入活在两个不同的空间;在 KGAT 里,它们都是同一张图上的节点,于是协同信号和语义信号能够同时传播 。
注意力机制 对于实体 $e$(可能是用户、物品或属性),KGAT 在它的邻居上计算注意力:
$$\pi(e, r, e') = \frac{\exp\!\bigl(\text{LeakyReLU}(\mathbf{a}^T [\mathbf{e}_e \,\|\, \mathbf{e}_r \,\|\, \mathbf{e}_{e'}])\bigr)}{\sum_{(r'', e'') \in \mathcal{N}(e)} \exp\!\bigl(\text{LeakyReLU}(\mathbf{a}^T [\mathbf{e}_e \,\|\, \mathbf{e}_{r''} \,\|\, \mathbf{e}_{e''}])\bigr)}$$
白话翻译。 “对每个邻居,把实体、关系、邻居三个嵌入拼起来,通过一个可学习的打分函数,再用 softmax 归一化——这告诉模型哪些连接最重要。“上图清晰地展示了这一点:通向"诺兰"和"迪卡普里奥"的边很粗(高注意力),而通向"2010”(上映年份)的边很细。
实体更新:
$$\mathbf{e}_e^{(l)} = \sigma\!\bigl(\mathbf{W}^{(l)}[\mathbf{e}_e^{(l-1)} \,\|\, \mathbf{e}_{\mathcal{N}(e)}^{(l-1)}] + \mathbf{b}^{(l)}\bigr)$$
多头注意力 和 GAT(第 7 篇)一样,KGAT 用多个注意力头来捕捉关系的不同侧面:
$$\mathbf{e}_{\mathcal{N}(e)} = \big\|_{k=1}^{K}\; \sum_{(r,e') \in \mathcal{N}(e)} \pi^{(k)}(e, r, e')\, \mathbf{e}_{e'}^{(k)}$$
实现:KGAT 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
import torch
import torch.nn as nn
import torch.nn.functional as F
class KGATLayer ( nn . Module ):
"""单层 KGAT,带关系感知注意力。"""
def __init__ ( self , embedding_dim , num_heads = 1 , dropout = 0.1 ):
super () . __init__ ()
self . embedding_dim = embedding_dim
self . num_heads = num_heads
self . dropout = dropout
# 在 [entity || relation || neighbor] 上打分
self . attention = nn . Linear ( embedding_dim * 3 , num_heads )
self . W_v = nn . Linear ( embedding_dim , embedding_dim )
self . output = nn . Linear ( embedding_dim , embedding_dim )
def forward ( self , entity_embs , relation_embs , neighbors ):
outputs = []
for i in range ( entity_embs . size ( 0 )):
nbrs = neighbors . get ( i , [])
if not nbrs :
outputs . append ( entity_embs [ i ])
continue
scores , values = [], []
for r_id , n_id in nbrs :
combined = torch . cat ([
entity_embs [ i ], relation_embs [ r_id ], entity_embs [ n_id ]
])
scores . append ( self . attention ( combined ))
values . append ( self . W_v ( entity_embs [ n_id ]))
scores = F . softmax ( torch . stack ( scores ), dim = 0 ) # [K, heads]
scores = F . dropout ( scores , p = self . dropout , training = self . training )
values = torch . stack ( values ) # [K, d]
aggregated = ( scores . mean ( dim = 1 , keepdim = True ) * values ) . sum ( 0 )
out = entity_embs [ i ] + aggregated
outputs . append ( F . relu ( self . output ( out )))
return torch . stack ( outputs )
class KGAT ( nn . Module ):
"""用于推荐的知识图谱注意力网络。"""
def __init__ ( self , num_users , num_items , num_entities , num_relations ,
embedding_dim = 64 , n_layers = 2 , num_heads = 2 , dropout = 0.1 ):
super () . __init__ ()
self . num_users = num_users
self . entity_emb = nn . Embedding ( num_entities , embedding_dim )
self . user_emb = nn . Embedding ( num_users , embedding_dim )
self . relation_emb = nn . Embedding ( num_relations , embedding_dim )
self . layers = nn . ModuleList ([
KGATLayer ( embedding_dim , num_heads , dropout )
for _ in range ( n_layers )
])
for emb in [ self . entity_emb , self . user_emb , self . relation_emb ]:
nn . init . xavier_uniform_ ( emb . weight )
def forward ( self , user_ids , item_ids , ckg_neighbors ):
# 合并嵌入表:[users | entities]
all_embs = self . entity_emb . weight . clone ()
all_embs [: self . num_users ] = self . user_emb . weight
rel_embs = self . relation_emb . weight
for idx , layer in enumerate ( self . layers ):
if idx < len ( ckg_neighbors ):
all_embs = layer ( all_embs , rel_embs , ckg_neighbors [ idx ])
user_final = all_embs [ user_ids ]
item_final = all_embs [ self . num_users + item_ids ]
return ( user_final * item_final ) . sum ( dim = 1 )
CKE:协同知识库嵌入 多信号融合的思路 CKE(Zhang 等,KDD 2016)走了完全不同的路线:不在推理时在图上传播,而是预先 从三种互补的来源学习嵌入,最后相加融合 。
$$\mathbf{i} = \mathbf{i}_\text{CF} + \mathbf{i}_\text{KG} + \mathbf{i}_\text{text}$$
分量 来源 学习方法 $\mathbf{i}_\text{CF}$ 用户—物品交互 矩阵分解 $\mathbf{i}_\text{KG}$ 知识图谱结构 TransR $\mathbf{i}_\text{text}$ 物品文本描述 文本 CNN
为什么要三种信号? 因为它们各自抓住了别人抓不到的东西:
CF 知道谁喜欢什么 ,但不知道为什么 。 KG 知道语义关系 ,但不知道用户行为。 文本捕捉的是结构化三元组难以表达的细腻描述 。 联合训练 CKE 用一个综合损失同时训练三个组件:
$$\mathcal{L} = \mathcal{L}_\text{CF} + \lambda_1 \mathcal{L}_\text{KG} + \lambda_2 \mathcal{L}_\text{text} + \lambda_3 \mathcal{L}_\text{reg}$$
CF 损失就是标准的矩阵分解 $\hat{r}_{ui} = \mathbf{u}^T \mathbf{i}$。KG 损失采用 TransR:把合法三元组拉近、把负样本推远。
实现:CKE 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
import torch
import torch.nn as nn
import torch.nn.functional as F
class TransR ( nn . Module ):
"""TransR:每个关系自带一套实体投影矩阵。"""
def __init__ ( self , num_entities , num_relations , entity_dim , relation_dim ):
super () . __init__ ()
self . entity_emb = nn . Embedding ( num_entities , entity_dim )
self . relation_emb = nn . Embedding ( num_relations , relation_dim )
self . proj_matrices = nn . Embedding ( num_relations ,
entity_dim * relation_dim )
self . entity_dim = entity_dim
self . relation_dim = relation_dim
for emb in [ self . entity_emb , self . relation_emb , self . proj_matrices ]:
nn . init . xavier_uniform_ ( emb . weight )
def forward ( self , heads , relations , tails ,
neg_heads = None , neg_tails = None ):
h = self . entity_emb ( heads )
r = self . relation_emb ( relations )
t = self . entity_emb ( tails )
proj = self . proj_matrices ( relations ) . view (
- 1 , self . entity_dim , self . relation_dim
)
h_proj = torch . bmm ( h . unsqueeze ( 1 ), proj ) . squeeze ( 1 )
t_proj = torch . bmm ( t . unsqueeze ( 1 ), proj ) . squeeze ( 1 )
pos_score = ( h_proj + r - t_proj ) . norm ( p = 2 , dim = 1 ) ** 2
if neg_heads is not None and neg_tails is not None :
nh = self . entity_emb ( neg_heads )
nt = self . entity_emb ( neg_tails )
nh_proj = torch . bmm ( nh . unsqueeze ( 1 ), proj ) . squeeze ( 1 )
nt_proj = torch . bmm ( nt . unsqueeze ( 1 ), proj ) . squeeze ( 1 )
neg_score = ( nh_proj + r - nt_proj ) . norm ( p = 2 , dim = 1 ) ** 2
return F . relu ( 1.0 + pos_score - neg_score ) . mean ()
return pos_score
class CKE ( nn . Module ):
"""协同知识库嵌入。"""
def __init__ ( self , num_users , num_items , num_entities , num_relations ,
embedding_dim = 64 , relation_dim = 32 ,
kg_lambda = 0.1 , reg_lambda = 0.01 ):
super () . __init__ ()
self . num_entities = num_entities
self . kg_lambda = kg_lambda
self . reg_lambda = reg_lambda
# CF 分量
self . user_emb = nn . Embedding ( num_users , embedding_dim )
self . item_cf_emb = nn . Embedding ( num_items , embedding_dim )
# KG 分量(TransR)
self . transr = TransR ( num_entities , num_relations ,
embedding_dim , relation_dim )
# 把 KG 嵌入投影到 CF 空间
self . kg_proj = nn . Linear ( embedding_dim , embedding_dim )
nn . init . xavier_uniform_ ( self . user_emb . weight )
nn . init . xavier_uniform_ ( self . item_cf_emb . weight )
def forward ( self , user_ids , item_ids ):
user_vec = self . user_emb ( user_ids )
item_cf = self . item_cf_emb ( item_ids )
item_kg = self . kg_proj ( self . transr . entity_emb ( item_ids ))
item_combined = item_cf + item_kg
return ( user_vec * item_combined ) . sum ( dim = 1 )
def compute_loss ( self , user_ids , item_ids , ratings ,
kg_heads = None , kg_rels = None , kg_tails = None ):
# CF 损失
preds = self . forward ( user_ids , item_ids )
cf_loss = F . mse_loss ( preds , ratings . float ())
# KG 损失
kg_loss = torch . tensor ( 0.0 )
if kg_heads is not None :
neg_heads = torch . randint ( 0 , self . num_entities , kg_heads . shape )
neg_tails = torch . randint ( 0 , self . num_entities , kg_tails . shape )
kg_loss = self . transr ( kg_heads , kg_rels , kg_tails ,
neg_heads , neg_tails )
# 正则
reg = ( self . user_emb . weight . norm ( 2 ) ** 2 +
self . item_cf_emb . weight . norm ( 2 ) ** 2 )
return cf_loss + self . kg_lambda * kg_loss + self . reg_lambda * reg
基于路径的可解释推荐 为什么路径很重要 前面的方法学到的都是嵌入 ——稠密、强大但不透明的向量。基于路径的推理走另一条路:从用户的历史物品出发,到候选物品之间,找出一条具体的 KG 路径 ,把它同时 当作特征和解释。
示例路径。 你给《黑暗骑士》打了高分 -> 黑暗骑士 directed_by 诺兰 -> 诺兰 directed 盗梦空间 -> 推荐《盗梦空间》 。
这条路径既是模型可以利用的特征,也是用户能直接读懂的解释。
实现:多跳路径推理 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
import torch
import torch.nn as nn
import torch.nn.functional as F
from collections import deque
class PathReasoning ( nn . Module ):
"""寻找并打分知识图谱路径,用于可解释推荐。"""
def __init__ ( self , num_entities , num_relations , embedding_dim = 64 ):
super () . __init__ ()
self . entity_emb = nn . Embedding ( num_entities , embedding_dim )
self . relation_emb = nn . Embedding ( num_relations , embedding_dim )
self . path_scorer = nn . Sequential (
nn . Linear ( embedding_dim * 3 , embedding_dim ),
nn . ReLU (),
nn . Linear ( embedding_dim , 1 ),
)
nn . init . xavier_uniform_ ( self . entity_emb . weight )
nn . init . xavier_uniform_ ( self . relation_emb . weight )
def find_paths ( self , kg_adj , start , end , max_hops = 3 , max_paths = 10 ):
"""BFS 查找从 start 到 end 的路径。"""
paths = []
queue = deque ([( start , [], [])])
visited = set ()
while queue and len ( paths ) < max_paths :
current , rel_path , ent_path = queue . popleft ()
if current == end and rel_path :
paths . append (( rel_path , ent_path + [ end ]))
continue
if len ( rel_path ) >= max_hops :
continue
for r_id , t_id in kg_adj . get ( current , []):
if ( current , r_id , t_id ) not in visited :
visited . add (( current , r_id , t_id ))
queue . append (( t_id ,
rel_path + [ r_id ],
ent_path + [ current ]))
return paths [: max_paths ]
def score_path ( self , path ):
"""用嵌入对单条路径打分。"""
rel_ids , ent_ids = path
if not rel_ids :
return torch . tensor ( 0.0 )
start = self . entity_emb ( torch . tensor ([ ent_ids [ 0 ]]))
rels = self . relation_emb ( torch . tensor ( rel_ids )) . mean ( 0 , keepdim = True )
end = self . entity_emb ( torch . tensor ([ ent_ids [ - 1 ]]))
combined = torch . cat ([ start , rels , end ], dim = 1 )
return self . path_scorer ( combined ) . squeeze ()
def recommend_with_explanations ( self , user_items , kg_adj ,
candidates , max_hops = 3 ):
"""返回 [(item_id, score, best_path)],按分数倒序。"""
results = []
for item_id in candidates :
best_score = float ( '-inf' )
best_path = None
for src_item in user_items :
for path in self . find_paths ( kg_adj , src_item , item_id ,
max_hops = max_hops ):
score = self . score_path ( path ) . item ()
if score > best_score :
best_score = score
best_path = path
if best_path is not None :
results . append (( item_id , best_score , best_path ))
results . sort ( key = lambda x : x [ 1 ], reverse = True )
return results
加 KG 与不加 KG:最终对比
左图显示 KGAT 在所有标准指标上都比纯 BPR 基线获得了两位数的相对提升 ——而在冷启动 Recall@20 上提升幅度尤其大,正是协同过滤最薄弱的场景。
右图把"差距"具体化:不加 KG 时,模型只能依赖热度,推荐出来的全是不相关的票房大片;那部刚上线的诺兰新片完全消失(没人看过)。加上 KG 后,模型推荐的电影与你的口味在主题上有具体关联 ——而且因为图谱已经知道"它的导演是诺兰”,新片在上线当天 就能进入推荐列表。
工程实践要点 知识图谱的来源 来源 类型 示例 Wikidata / DBpedia 公开结构化 KG 电影元数据、公司信息 产品目录 内部数据库 品牌、类目、规格 NER + 关系抽取 非结构化文本 评论、描述
实体对齐 推荐系统里的物品需要和 KG 里的实体对齐起来。常见做法:
精确字符串匹配 ——快但脆。模糊匹配 ——能容忍拼写错误和缩写。嵌入相似度 ——双方都学嵌入,做近邻匹配。人工标注 ——质量最高,但成本也最高。训练策略 策略 何时使用 联合训练 数据足够多,可以同时优化 KG 和推荐目标 预训练 + 微调 KG 数据远大于交互数据 多任务学习 希望多个任务共享表示
跳数的选择 1 跳: 只用直接属性,快但浅。2 跳: 大多数任务的甜区 。能捕捉到"同导演”、“同演员"这类模式。3 跳: 路径更丰富,但噪声也变大。需要靠注意力筛选。4 跳及以上: 收益基本消失,信噪比急剧下降。完整训练流水线 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
import torch
import torch.optim as optim
from torch.utils.data import Dataset , DataLoader
import numpy as np
class KGRecDataset ( Dataset ):
"""KG 增强推荐的数据集。"""
def __init__ ( self , user_ids , item_ids , ratings ):
self . user_ids = torch . LongTensor ( user_ids )
self . item_ids = torch . LongTensor ( item_ids )
self . ratings = torch . FloatTensor ( ratings )
def __len__ ( self ):
return len ( self . user_ids )
def __getitem__ ( self , idx ):
return {
'user_id' : self . user_ids [ idx ],
'item_id' : self . item_ids [ idx ],
'rating' : self . ratings [ idx ],
}
def train_cke ( model , train_loader , val_loader , kg_triples = None ,
num_epochs = 10 , lr = 0.001 ):
"""训练 CKE 模型,可选地加入 KG 损失。"""
optimizer = optim . Adam ( model . parameters (), lr = lr )
best_val = float ( 'inf' )
for epoch in range ( num_epochs ):
model . train ()
train_loss = 0
for batch in train_loader :
optimizer . zero_grad ()
loss = model . compute_loss (
batch [ 'user_id' ], batch [ 'item_id' ], batch [ 'rating' ],
* kg_triples if kg_triples else ( None , None , None )
)
loss . backward ()
optimizer . step ()
train_loss += loss . item ()
model . eval ()
val_loss = 0
with torch . no_grad ():
for batch in val_loader :
preds = model ( batch [ 'user_id' ], batch [ 'item_id' ])
val_loss += F . mse_loss ( preds , batch [ 'rating' ] . float ()) . item ()
train_loss /= len ( train_loader )
val_loss /= len ( val_loader )
print ( f "Epoch { epoch + 1 } / { num_epochs } | "
f "Train: { train_loss : .4f } | Val: { val_loss : .4f } " )
if val_loss < best_val :
best_val = val_loss
torch . save ( model . state_dict (), 'best_kg_model.pt' )
if __name__ == "__main__" :
num_users , num_items = 1000 , 500
num_entities , num_relations = 2000 , 10
train_data = KGRecDataset (
np . random . randint ( 0 , num_users , 8000 ),
np . random . randint ( 0 , num_items , 8000 ),
np . random . uniform ( 1 , 5 , 8000 ),
)
val_data = KGRecDataset (
np . random . randint ( 0 , num_users , 2000 ),
np . random . randint ( 0 , num_items , 2000 ),
np . random . uniform ( 1 , 5 , 2000 ),
)
model = CKE ( num_users , num_items , num_entities , num_relations )
train_cke (
model ,
DataLoader ( train_data , batch_size = 64 , shuffle = True ),
DataLoader ( val_data , batch_size = 64 ),
num_epochs = 10 ,
)
常见问题 Q&A 知识图谱怎么帮助解决冷启动? 新物品即便没有任何交互,它在 KG 里依然有属性(类型、导演、演员)。这些属性把它和那些有 交互历史的物品连了起来。如果你喜欢诺兰的旧片,KG 完全可以在他的新片上线当天 就把它推给你,根本不需要任何交互数据。
RippleNet 与 KGCN 有什么区别? RippleNet 是用户中心 的:从用户历史出发沿 KG 向外扩散。
KGCN 是物品中心 的:通过聚合物品 KG 邻居来构建增强的物品表示。
工程上 KGCN 通常更可扩展,因为物品的邻域比用户偏好"涟漪"更稳定。
什么时候应该用 KGAT 而不是 KGCN? 当你想让协同信号和语义信号在同一张图里相互作用 时,就用 KGAT。它的协同知识图谱(CKG)把用户—物品边和 KG 边合并,注意力可以在两类边上学习;KGCN 则把它们分开处理。
CKE 和图卷积类方法相比怎么样? CKE 更简单、更快——预先学好 KG 嵌入,然后相加融合 即可。图卷积类(KGCN、KGAT)更强大,因为推理时还会在图上传播信息,但代价也更高。先用 CKE 跑个 baseline,再升到 KGAT 是常见的工程节奏。
知识图谱能改善推荐多样性吗? 可以。不同关系类型自然会引出不同类型的连接:沿 same_director 走和沿 same_genre、same_actor 走得到的结果完全不同。通过探索不同的关系路径,系统天然地避开了信息茧房 。
KG 噪声多、不完整怎么办? 注意力机制 (KGAT)天然会降权那些"不靠谱"的连接。TransR 嵌入 在训练时会"学着忽略"前后矛盾的三元组。数据增强 :用关系推理预测缺失边。多任务学习 通过共享信息让模型对缺失数据更鲁棒。计算瓶颈在哪? 最大的瓶颈是大规模 KG(百万级实体)上的邻居聚合。常见手段:
邻居采样 (每个节点最多取 $K$ 个邻居)。分层聚合 (先聚合属性,再聚合物品)。小批量训练 + 子图采样。预计算 KG 嵌入 (CKE 的做法)。知识图谱真的能给可解释推荐吗? 这是 KG 最大的优势之一。基于路径的方法可以直接生成解释:“向你推荐电影 X,因为它和你打高分的电影 Z 是同一位导演 Y 的作品。” 这样的解释具体、可读、有事实依据,远胜过"和你相似的用户也喜欢这个”。
KG 增强推荐的最新趋势是什么? 最近的研究方向包括:
时序 KG :建模偏好和事实如何随时间演化。多模态 KG :在结构化知识之外结合图像和文本。基于 Transformer 的 KG 方法 :用自注意力替代 GCN 的聚合。大规模预训练的 KG 嵌入 :从基础模型获得 KG 表示。LLM + KG 混合方法 :用大语言模型从文本中抽取 KG,并在 KG 上推理。关键要点 知识图谱给推荐系统加上了语义理解 ,让它不止依赖协同信号。冷启动是 KG 最杀手级的应用场景 :让零交互的新物品也能被推荐。RippleNet 把用户偏好像涟漪一样 沿 KG 向外扩散。KGCN 与 KGAT 通过聚合 KG 邻居 为物品构建更丰富的表示。CKE 把三种信号 (协同、结构、文本)融合成一个统一的物品嵌入。基于路径的推理给出可解释性 ——具体、可读、有据可查的推荐理由。2 跳是甜区 ;跳数再多收益边际递减、噪声指数增加。系列导航
本文是推荐系统系列的第 8 篇 ,共 16 篇。