推荐系统(八)—— 知识图谱增强推荐系统

知识图谱增强推荐系统全面解析:知识图谱嵌入(TransE/TransR/DistMult),RippleNet 涟漪传播,KGCN 图卷积,KGAT 注意力网络,CKE 多信号融合,附完整 PyTorch 实现。

当你在视频平台搜索《蝙蝠侠:黑暗骑士》时,系统不仅记下你看了这部电影,它还知道:克里斯蒂安·贝尔饰演蝙蝠侠、克里斯托弗·诺兰执导、属于"诺兰蝙蝠侠三部曲",并且与其他烧脑动作片在风格上同源。这张丰富的语义网络就是 知识图谱(Knowledge Graph,KG)——一张由实体(电影、演员、导演、类型)和带类型的关系acted_indirected_bypart_of)组成的结构化网络。

为什么这对推荐重要?因为纯协同过滤有一个致命盲区:它只能推荐已经有交互历史的物品。一部刚上线、播放量为零的新电影,对它而言是"隐身"的。但如果这部电影的导演正是你最喜欢的诺兰,知识图谱当天就能看到这条联系。知识图谱把推荐从"模式匹配"升级成了 语义推理


你将学到什么

  • 知识图谱是什么、如何用三元组编码现实世界事实
  • 知识图谱嵌入(TransE / TransR / DistMult)如何把实体和关系表示成向量
  • 传播类方法:RippleNet 让用户偏好像水波一样向外扩散
  • 图卷积类方法:KGCNKGAT 通过聚合 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}$ 是所有关系类型的集合。

类比。 把知识图谱想象成"维基百科级别的事实数据库",但它不是用文字段落存储,而是用图。每个词条是一个节点,词条之间的每条链接都是一条带标签的边——标签告诉你它们为什么相连。

现实世界的知识图谱

知识图谱实体数量事实数量使用方
Freebase3900 万19 亿Google(已废弃)
Wikidata1 亿+14 亿+Wikipedia、Google
DBpedia600 万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:头向量加上关系向量约等于尾向量;训练时把正样本拉近、把负样本推到边界之外

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_genresame_actor 走得到的结果完全不同,多样性自然就出现了。

具体例子:看完《黑暗骑士》之后

基于小型知识图谱进行推理:从一次观影到四个候选推荐

上图把"用户的一次观影"到"四部候选电影"之间的显式路径全部画了出来:

  • 盗梦空间——黑暗骑士 -> directed_by -> 诺兰 -> directed -> 盗梦空间
  • 致命魔术——通过诺兰和贝尔两条路径同时支撑(双重信号,置信度更高)
  • 侠影之谜(Batman Begins)——共享主演 + 共享类型
  • 系统不仅可以为每个候选物品给出不同的排序,还能给出不同的解释——这是纯协同过滤做不到的。

KG 增强方法的分类

类别思路代表方法
传播类让用户偏好沿 KG 向外扩散RippleNet
图卷积类通过聚合 KG 邻居学习物品嵌入KGCN、KGAT
嵌入类联合学习用户、物品、KG 实体的嵌入CKE
路径类在多跳路径上推理,提供可解释性KPRN、PathRec

RippleNet:偏好的涟漪传播

RippleNet:用户偏好从历史物品出发,沿一跳、二跳邻居像涟漪一样向外扩散

涟漪类比

往池塘里扔一颗石子,水波会一圈一圈向外扩散。RippleNet(Wang 等,CIKM 2018)对用户偏好做了完全相同的事:当用户与某个物品发生交互后,这份偏好会沿着知识图谱像涟漪一样向外扩散,激活越来越远的相关实体。

工作原理

设用户 $u$ 的历史物品集合为 $V_u = \{v_1, v_2, \ldots\}$:

  1. 第 0 跳: 把用户的历史物品作为初始偏好集 $\mathcal{S}_u^0 = V_u$。
  2. 第 1 跳: 找到通过任意关系与 $\mathcal{S}_u^0$ 相连的所有实体,构成 $\mathcal{S}_u^1$。
  3. 第 2 跳: 再找到与 $\mathcal{S}_u^1$ 相连的所有实体,构成 $\mathcal{S}_u^2$。
  4. 聚合: 在每一跳上用注意力为实体加权,得到增强后的用户嵌入。

在第 $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 的步骤:

  1. 采样 $K$ 个邻居。
  2. 打分:用一个考虑关系类型的注意力为每个邻居打权重。
  3. 聚合:用权重加权求和邻居嵌入。
  4. 融合:把聚合结果与物品自身的嵌入拼起来。

在第 $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:注意力权重直观地告诉你"对当前用户而言,物品的哪些 KG 邻居最重要”

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:最终对比

MovieLens-1M 上的定量提升,以及推荐列表的定性对比

左图显示 KGAT 在所有标准指标上都比纯 BPR 基线获得了两位数的相对提升——而在冷启动 Recall@20 上提升幅度尤其大,正是协同过滤最薄弱的场景。

右图把"差距"具体化:不加 KG 时,模型只能依赖热度,推荐出来的全是不相关的票房大片;那部刚上线的诺兰新片完全消失(没人看过)。加上 KG 后,模型推荐的电影与你的口味在主题上有具体关联——而且因为图谱已经知道"它的导演是诺兰”,新片在上线当天就能进入推荐列表。


工程实践要点

知识图谱的来源

来源类型示例
Wikidata / DBpedia公开结构化 KG电影元数据、公司信息
产品目录内部数据库品牌、类目、规格
NER + 关系抽取非结构化文本评论、描述

实体对齐

推荐系统里的物品需要和 KG 里的实体对齐起来。常见做法:

  1. 精确字符串匹配——快但脆。
  2. 模糊匹配——能容忍拼写错误和缩写。
  3. 嵌入相似度——双方都学嵌入,做近邻匹配。
  4. 人工标注——质量最高,但成本也最高。

训练策略

策略何时使用
联合训练数据足够多,可以同时优化 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_genresame_actor 走得到的结果完全不同。通过探索不同的关系路径,系统天然地避开了信息茧房

KG 噪声多、不完整怎么办?

  1. 注意力机制(KGAT)天然会降权那些"不靠谱"的连接。
  2. TransR 嵌入在训练时会"学着忽略"前后矛盾的三元组。
  3. 数据增强:用关系推理预测缺失边。
  4. 多任务学习通过共享信息让模型对缺失数据更鲁棒。

计算瓶颈在哪?

最大的瓶颈是大规模 KG(百万级实体)上的邻居聚合。常见手段:

  • 邻居采样(每个节点最多取 $K$ 个邻居)。
  • 分层聚合(先聚合属性,再聚合物品)。
  • 小批量训练 + 子图采样。
  • 预计算 KG 嵌入(CKE 的做法)。

知识图谱真的能给可解释推荐吗?

这是 KG 最大的优势之一。基于路径的方法可以直接生成解释:“向你推荐电影 X,因为它和你打高分的电影 Z 是同一位导演 Y 的作品。” 这样的解释具体、可读、有事实依据,远胜过"和你相似的用户也喜欢这个”。

KG 增强推荐的最新趋势是什么?

最近的研究方向包括:

  1. 时序 KG:建模偏好和事实如何随时间演化。
  2. 多模态 KG:在结构化知识之外结合图像和文本。
  3. 基于 Transformer 的 KG 方法:用自注意力替代 GCN 的聚合。
  4. 大规模预训练的 KG 嵌入:从基础模型获得 KG 表示。
  5. LLM + KG 混合方法:用大语言模型从文本中抽取 KG,并在 KG 上推理。

关键要点

  • 知识图谱给推荐系统加上了语义理解,让它不止依赖协同信号。
  • 冷启动是 KG 最杀手级的应用场景:让零交互的新物品也能被推荐。
  • RippleNet 把用户偏好像涟漪一样沿 KG 向外扩散。
  • KGCN 与 KGAT 通过聚合 KG 邻居为物品构建更丰富的表示。
  • CKE 把三种信号(协同、结构、文本)融合成一个统一的物品嵌入。
  • 基于路径的推理给出可解释性——具体、可读、有据可查的推荐理由。
  • 2 跳是甜区;跳数再多收益边际递减、噪声指数增加。

系列导航

本文是推荐系统系列的第 8 篇,共 16 篇。

Liked this piece?

Follow on GitHub for the next one — usually one a week.

GitHub