推荐系统(七)—— 图神经网络与社交推荐

图神经网络推荐系统全面解析:从消息传递的直觉出发,深入 GCN、GAT、GraphSAGE,再到工业级 PinSage、LightGCN、NGCF、社交推荐与图采样、冷启动处理。配 7 张图与完整 PyTorch 实现。

当 Netflix 决定下一个推荐时,它不会只看你的观看历史。背后是一张关系网:哪些电影共享演员、哪些用户口味重叠、评分如何在目录里层层传播。“图"在这里不是比喻——每一张交互矩阵 本身就是 一张图,而把它当成图来处理,会打开一片纯 user/item embedding 不能表达的空间。

图神经网络( Graph Neural Networks,GNN) 就是让我们能在这张图上推理的工具。它把"每个用户和物品独立学一个向量"换成一句更简单的话:你的表示由你身边的邻居塑造。 这一个视角的转变催生了 Pinterest 的十亿节点 PinSage、在协同过滤上击败重模型的极简 LightGCN,以及把"你看了什么"和"朋友看了什么"融合的社交推荐系统。

本文按"先建直觉、再看实现"的顺序把这片地图走一遍:从用户-物品二部图出发,搭起消息传递框架,依次解剖 GCN、GAT、GraphSAGE、PinSage、LightGCN、NGCF、社交 GNN,以及让一切在大规模下仍能跑动的采样技巧。每个模型都配可运行的 PyTorch 代码。


你将学到

  • 推荐数据为什么天然是图,以及这能换来什么
  • 三大基础块 GCN、GAT、GraphSAGE 的原理与取舍
  • 工业模型(PinSage、LightGCN、NGCF)如何把 GNN 推上生产
  • 社交信号能带来 5–15% 提升,以及它什么时候反而会拖后腿
  • 让 GNN 从 MovieLens 跑到 Pinterest 规模的小批量邻居采样
  • 用归纳式聚合干净地处理新用户冷启动

前置知识

  • Python 与 PyTorch 基础(张量、nn.Module、训练循环)
  • 嵌入与协同过滤(第 2 篇
  • 矩阵记号;不需要谱图论,文中所有公式都从直觉推起

为什么用图神经网络做推荐?

推荐数据本来就是一张图

用户-物品二部图:高亮的边显示 Alice 与 Bob 共享两部电影,构成隐式的协同信号

任意推荐系统的交互矩阵:行是用户,列是物品,每个非零格表示一次点击/观看/购买/评分。把它重画为 二部图(bipartite graph)

  • 节点 是用户和物品。
  • 把用户连向他交互过的每个物品。
  • 协同信号 藏在拓扑里:交互过相似物品的用户,会通过这些共享物品被隐式地连在一起。

上图中,Alice 和 Bob 素未谋面,但被高亮的边告诉我们他们共享两部电影。一个 GNN 可以沿着多跳传播这个信号:“Bob 喜欢《银翼杀手》;Bob 通过共同电影与 Alice 在图上接近;所以 Alice 也很可能喜欢《银翼杀手》。” 这是矩阵分解永远问不到的问题——它只看见单个格子。

传统方法漏掉了什么

方法它捕捉了什么它漏掉了什么
矩阵分解用户/物品的潜在因子,点积打分每个嵌入孤立学习;没有"邻居的邻居”
神经协同过滤 / 自编码器非线性交互仍然按 (user, item) 对处理,没有沿图传播
GNN节点嵌入由其 $L$ 跳邻域塑造(随之而来的扩展性、过平滑——后面会修)

一句话:传统方法孤立地学 embedding;GNN 在上下文中学 embedding。

GNN 带来的能力

  • 显式建模图结构 —— 直接在拓扑上算。
  • 邻域聚合 —— 协同信号作为架构的副产品自然传播。
  • 多跳推理 —— 叠两层就能表达"喜欢与你喜欢的物品相似的物品的用户"。
  • 归纳式(GraphSAGE) —— 不重训也能给新用户/新物品出向量。
  • 异构图 —— 把社交关系、知识图谱、属性塞进同一张图。

图神经网络基础

图的基本概念

图 $G=(V,E)$ 由顶点集合 $V=\{v_1,\dots,v_n\}$ 和边集合 $E\subseteq V\times V$ 组成。邻接矩阵 $A\in\{0,1\}^{n\times n}$ 满足:$A_{ij}=1$ 当且仅当 $v_i$ 和 $v_j$ 之间有边。

推荐里常见的三种图:

类型描述例子
二部图两类节点,边只跨类用户 ↔ 物品
加权图边权代表强度评分、点击次数
属性图节点带特征向量用户画像、物品类目

一张图说清"消息传递"

消息传递的三步:邻居生成消息 → 目标节点聚合 → 用聚合结果更新自身状态

无论包装多花哨,所有 GNN 都是同一个三步配方的变体:

  1. 生成消息(Message)。 每个邻居算一份要发出去的消息。
  2. 聚合(Aggregate)。 目标节点把所有传入的消息合并(求和、平均、注意力加权……)。
  3. 更新(Update)。 目标节点把聚合结果与自己上一时刻的状态融合。

形式化地,在第 $l$ 层,节点 $v$ 的更新为:

$$\mathbf{m}_v^{(l)} = \mathrm{AGGREGATE}^{(l)}\!\bigl(\{\mathbf{h}_u^{(l-1)} : u\in\mathcal{N}(v)\}\bigr)$$$$\mathbf{h}_v^{(l)} = \mathrm{UPDATE}^{(l)}\!\bigl(\mathbf{h}_v^{(l-1)},\; \mathbf{m}_v^{(l)}\bigr)$$

口语化版本:“看看邻居都知道什么,把它总结一下,再和自己已经知道的拌一拌。” 叠 $L$ 层,每个节点就能看见自己的 $L$ 跳邻域。

电话游戏的类比。 想象一种电话游戏:每个人同时悄悄告诉所有朋友自己知道的事。一轮之后,你知道朋友们的想法;两轮之后,你知道朋友的朋友的想法。GNN 用可学习、可微的函数做的就是同一件事。


图卷积网络(GCN)

核心思想

GCN(Kipf & Welling,2017)在图上定义了一种类卷积操作。CNN 把图像里局部窗口的像素聚合起来;GCN 则把节点图邻域里的特征向量聚合起来。

GCN 层

一层 GCN 计算:

$$\mathbf{H}^{(l+1)} = \sigma\!\Bigl(\tilde{D}^{-1/2}\, \tilde{A}\, \tilde{D}^{-1/2}\, \mathbf{H}^{(l)}\, \mathbf{W}^{(l)}\Bigr)$$

公式看起来沉,逐项拆开就轻了:

符号含义
$\tilde{A} = A + I$加了自环的邻接矩阵(每个节点也"听自己说话")
$\tilde{D}$$\tilde{A}$ 的度矩阵
$\tilde{D}^{-1/2}\tilde{A}\tilde{D}^{-1/2}$对称归一化 —— 防止度大的节点淹没其他人
$\mathbf{H}^{(l)}$第 $l$ 层节点特征矩阵(每行是一个节点的嵌入)
$\mathbf{W}^{(l)}$可学习权重矩阵
$\sigma$非线性,通常是 ReLU

对单个节点 $v$ 展开就是:

$$\mathbf{h}_v^{(l+1)} = \sigma\!\left(\sum_{u\in\mathcal{N}(v)\cup\{v\}} \frac{1}{\sqrt{d_v\,d_u}}\; \mathbf{h}_u^{(l)}\, \mathbf{W}^{(l)}\right)$$
  1. 收集 所有邻居(包括自己)的嵌入。
  2. 归一化:每个邻居的贡献乘以 $1/\sqrt{d_v\cdot d_u}$,热门节点的声音被压低。
  3. 变换:乘上共享权重矩阵 $\mathbf{W}$。
  4. 激活:ReLU。

为什么对称归一化有效? 不归一化时,度高的节点(邻居多)每层都会把数值越拽越大,几层之后梯度爆炸或消失。$\tilde{D}^{-1/2}\tilde{A}\tilde{D}^{-1/2}$ 让每条边的权重为 $1/\sqrt{d_v d_u}$,热门节点收发的信息都被均衡,对应于图拉普拉斯的谱分解,理论上数值稳定。

实现:GCN 层

 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
import torch
import torch.nn as nn
import torch.nn.functional as F
from torch_geometric.utils import add_self_loops, degree


class GCNLayer(nn.Module):
    """单层图卷积。"""

    def __init__(self, in_channels, out_channels):
        super().__init__()
        self.linear = nn.Linear(in_channels, out_channels)

    def forward(self, x, edge_index):
        # 1. 加自环,让每个节点也听自己
        edge_index, _ = add_self_loops(edge_index, num_nodes=x.size(0))

        # 2. 对称归一化系数
        row, col = edge_index
        deg = degree(col, x.size(0), dtype=x.dtype)
        deg_inv_sqrt = deg.pow(-0.5)
        deg_inv_sqrt[deg_inv_sqrt == float('inf')] = 0
        norm = deg_inv_sqrt[row] * deg_inv_sqrt[col]

        # 3. 线性变换
        x = self.linear(x)

        # 4. scatter-add 把归一化后的邻居特征聚合到目标节点
        out = torch.zeros_like(x)
        out.scatter_add_(0, col.unsqueeze(1).expand_as(x), norm.unsqueeze(1) * x[row])
        return out

多层 GCN

叠层会扩大感受野:两层 = 两跳邻域,三层 = 三跳。但要警惕 过平滑(over-smoothing):层太多时所有节点的嵌入会塌缩到同一个值。推荐场景的甜蜜点是 2~3 层。

 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
class GCN(nn.Module):
    """多层 GCN。"""

    def __init__(self, num_nodes, in_channels, hidden_channels,
                 out_channels, num_layers=2, dropout=0.5):
        super().__init__()
        self.num_layers = num_layers
        self.dropout = dropout

        # 节点没有原始特征时,用 ID 嵌入兜底
        self.embedding = None
        if in_channels == 0:
            self.embedding = nn.Embedding(num_nodes, hidden_channels)
            in_channels = hidden_channels

        self.convs = nn.ModuleList()
        if num_layers == 1:
            self.convs.append(GCNLayer(in_channels, out_channels))
        else:
            self.convs.append(GCNLayer(in_channels, hidden_channels))
            for _ in range(num_layers - 2):
                self.convs.append(GCNLayer(hidden_channels, hidden_channels))
            self.convs.append(GCNLayer(hidden_channels, out_channels))

    def forward(self, x, edge_index):
        if self.embedding is not None:
            x = self.embedding.weight
        for i, conv in enumerate(self.convs):
            x = conv(x, edge_index)
            if i < self.num_layers - 1:
                x = F.relu(x)
                x = F.dropout(x, p=self.dropout, training=self.training)
        return x

图注意力网络(GAT)

为什么需要注意力

GCN 把每个邻居视作(按度归一化后)同等重要。但邻居并不一样——你最好的朋友的电影口味,比一个普通点头之交的更值得参考。GAT(Veličković 等,2018)通过为每条边学习一个 自适应的注意力权重 来解决这件事。

GAT 怎么算

对从 $j$ 到 $i$ 的每条边,GAT 算一个注意力 logit:

$$e_{ij} = \mathrm{LeakyReLU}\!\bigl(\mathbf{a}^\top [\mathbf{W}\mathbf{h}_i \,\|\, \mathbf{W}\mathbf{h}_j]\bigr)$$

其中 $\mathbf{W}$ 把节点特征投到共享空间,$\|$ 是拼接,$\mathbf{a}$ 是可学习的注意力向量。然后用 softmax 在邻居间归一化:

$$\alpha_{ij} = \frac{\exp(e_{ij})}{\sum_{k\in\mathcal{N}(i)} \exp(e_{ik})}$$

新嵌入是加权和:

$$\mathbf{h}_i^{(l+1)} = \sigma\!\left(\sum_{j\in\mathcal{N}(i)\cup\{i\}} \alpha_{ij}\, \mathbf{W}^{(l)}\, \mathbf{h}_j^{(l)}\right)$$

口语化:“对每个邻居,先学一个相关度分数,再用这个分数加权求邻居特征的均值。”

多头注意力

和 Transformer 一样,GAT 用多头来稳定训练。每个头独立算注意力和聚合,中间层做拼接、最后一层做平均:

$$\mathbf{h}_i^{(l+1)} = \big\|_{k=1}^{K}\; \sigma\!\left(\sum_{j\in\mathcal{N}(i)\cup\{i\}} \alpha_{ij}^{(k)}\, \mathbf{W}^{(k)}\, \mathbf{h}_j^{(l)}\right)$$

实现:GAT 层

 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
import torch
import torch.nn as nn
import torch.nn.functional as F
from torch_geometric.nn import MessagePassing
from torch_geometric.utils import add_self_loops, softmax


class GATLayer(MessagePassing):
    """带多头注意力的 GAT 层。"""

    def __init__(self, in_channels, out_channels, heads=1,
                 dropout=0.0, concat=True):
        super().__init__(aggr='add', node_dim=0)
        self.in_channels = in_channels
        self.out_channels = out_channels
        self.heads = heads
        self.concat = concat
        self.dropout = dropout

        self.lin = nn.Linear(in_channels, heads * out_channels, bias=False)
        self.att = nn.Parameter(torch.empty(1, heads, 2 * out_channels))
        nn.init.xavier_uniform_(self.lin.weight)
        nn.init.xavier_uniform_(self.att)

    def forward(self, x, edge_index):
        edge_index, _ = add_self_loops(edge_index, num_nodes=x.size(0))
        # 投到多头空间: [N, heads, out_channels]
        x = self.lin(x).view(-1, self.heads, self.out_channels)
        out = self.propagate(edge_index, x=x)
        return out.view(-1, self.heads * self.out_channels) if self.concat else out.mean(dim=1)

    def message(self, x_i, x_j, index):
        # x_i 是目标节点, x_j 是源节点
        alpha = (torch.cat([x_i, x_j], dim=-1) * self.att).sum(dim=-1)
        alpha = F.leaky_relu(alpha, negative_slope=0.2)
        alpha = softmax(alpha, index)  # 按目标节点归一化
        alpha = F.dropout(alpha, p=self.dropout, training=self.training)
        return x_j * alpha.unsqueeze(-1)

GraphSAGE:可扩展的归纳式学习

GCN 的痛点

GCN 是 transductive(直推式) 的:训练时需要看见整张图,对训练后才出现的节点没有 embedding。明天注册的新用户,GCN 只能等下一次重训。

GraphSAGE(Hamilton 等,2017)用 inductive learning(归纳式学习) 解决:不再为每个节点死记一个 embedding,而是学 如何聚合邻居。新节点出现时,对它的邻居跑一遍同样的聚合函数,就能现场算出 embedding。

Sample and Aggregate

GraphSAGE 有两个关键想法:

  1. 邻居采样。 不用全部邻居(贵),每跳采一个固定数量 $k$。
  2. 可学习的聚合。 用可训练的函数(mean、LSTM、max-pool)处理采到的邻居。

对节点 $v$,第 $l$ 层:

$$\mathbf{h}_{\mathcal{N}(v)}^{(l)} = \mathrm{AGGREGATE}^{(l)}\!\bigl(\{\mathbf{h}_u^{(l-1)} : u\in\mathcal{N}_{\text{sampled}}(v)\}\bigr)$$$$\mathbf{h}_v^{(l)} = \sigma\!\bigl(\mathbf{W}^{(l)}\cdot[\mathbf{h}_v^{(l-1)} \,\|\, \mathbf{h}_{\mathcal{N}(v)}^{(l)}]\bigr)$$

口语化:“采几个邻居,把他们的特征汇总,和自己的拼起来,再过一层线性。”

聚合器怎么算
Mean邻居 embedding 平均:$\tfrac{1}{
Max-pool共享 MLP 后逐元素取最大:$\max(\sigma(\mathbf{W}_{\text{pool}}\mathbf{h}_u + \mathbf{b}))$
LSTM把邻居当作序列喂给 LSTM(对顺序敏感)

三种聚合方式一览

GCN vs GAT vs GraphSAGE:等权 / 学习注意力 / 采样后聚合

GCN 给所有邻居(按度归一化后)同等的发言权;GAT 学着把重要的邻居权重调高;GraphSAGE 干脆只看一部分邻居,让计算量在大图上仍可控。

实现:GraphSAGE 层

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import torch
import torch.nn as nn
import torch.nn.functional as F
from torch_geometric.nn import MessagePassing
from torch_geometric.utils import add_self_loops


class SAGEConv(MessagePassing):
    """采用均值聚合的 GraphSAGE 层。"""

    def __init__(self, in_channels, out_channels, normalize=False):
        super().__init__(aggr='mean')
        self.normalize = normalize
        self.lin_self = nn.Linear(in_channels, out_channels)   # 自身特征
        self.lin_neigh = nn.Linear(in_channels, out_channels)  # 邻居特征

    def forward(self, x, edge_index):
        edge_index, _ = add_self_loops(edge_index, num_nodes=x.size(0))
        neigh_agg = self.propagate(edge_index, x=x)
        out = self.lin_self(x) + self.lin_neigh(neigh_agg)
        return F.normalize(out, p=2, dim=1) if self.normalize else out

    def message(self, x_j):
        return x_j

能为新节点直接出向量,是 GraphSAGE 在生产中流行的最大原因。Pinterest 的 PinSage 直接是它的后继。


PinSage: Pinterest 的十亿级图推荐

PinSage(Ying 等,2018)是 Pinterest 的生产 GNN,跑在 30 亿节点、180 亿边 的图上。三个创新让它跑得动:

1. 随机游走采样

不再均匀采邻居:对每个节点跑短随机游走,按访问频次取 top-$k$。这样选出的不是"最近"的邻居,而是 最重要 的邻居。

2. 重要性加权聚合

邻居特征按随机游走的访问计数加权:

$$\mathbf{h}_v^{(l)} = \sigma\!\Bigl(\mathbf{W}^{(l)}\cdot \bigl[\mathbf{h}_v^{(l-1)} \,\big\|\, \mathrm{AGG}\bigl(\{\alpha_{uv}\, \mathbf{h}_u^{(l-1)} : u\in\mathcal{N}_{\text{top-}k}(v)\}\bigr)\bigr]\Bigr)$$

3. 困难负样本挖掘

训练时 PinSage 采 hard negatives —— 那些得分高但不是真正正样本的物品,逼模型去做更细的区分,而不是只学会区分明显无关的物品。

实现:PinSage 卷积

 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
import torch
import torch.nn as nn
import numpy as np
from collections import Counter, defaultdict


class PinSageConv(nn.Module):
    """带重要性加权聚合的 PinSage 卷积。"""

    def __init__(self, in_channels, out_channels):
        super().__init__()
        self.aggregator = nn.Sequential(
            nn.Linear(in_channels, out_channels),
            nn.ReLU(),
            nn.Linear(out_channels, out_channels),
        )
        self.combine = nn.Linear(in_channels + out_channels, out_channels)

    def forward(self, x, edge_index, importance_weights=None):
        row, col = edge_index
        neighbor_feats = x[col]
        if importance_weights is not None:
            neighbor_feats = neighbor_feats * importance_weights.unsqueeze(1)

        aggregated = torch.zeros(x.size(0), x.size(1), device=x.device)
        aggregated.scatter_add_(
            0, row.unsqueeze(1).expand_as(neighbor_feats), neighbor_feats
        )
        aggregated = self.aggregator(aggregated)
        return self.combine(torch.cat([x, aggregated], dim=1))


def compute_random_walk_importance(adj_list, num_nodes, num_walks=10,
                                   walk_length=5, top_k=10):
    """用短随机游走估计邻居重要性。"""
    visit_counts = Counter()
    for start in range(num_nodes):
        if start not in adj_list:
            continue
        for _ in range(num_walks):
            current = start
            for _ in range(walk_length):
                neighbors = adj_list.get(current, [])
                if not neighbors:
                    break
                current = np.random.choice(neighbors)
                visit_counts[(start, current)] += 1

    per_source = defaultdict(list)
    for (src, tgt), cnt in visit_counts.items():
        per_source[src].append((tgt, cnt))

    scores = {}
    for src, neighbors in per_source.items():
        neighbors.sort(key=lambda t: t[1], reverse=True)
        top = neighbors[:top_k]
        total = sum(c for _, c in top)
        for tgt, cnt in top:
            scores[(src, tgt)] = cnt / total if total > 0 else 0
    return scores

LightGCN:少即是多

一个出人意料的发现

LightGCN(He 等,2020)抛了一个挑衅性的问题:“如果我把 GCN 里所有可学习权重、非线性、自环都拿掉,只保留邻居聚合,会怎样?”

答案是:在协同过滤上 效果反而更好。图结构本身就携带了足够多的信号;标准 GCN 里的特征变换和激活函数其实在 拖后腿——它们带来不必要的复杂度和过拟合风险。

标准 GCN 保留自环、权重、ReLU;LightGCN 只保留聚合和层组合

架构

LightGCN 用最朴素的聚合:

$$\mathbf{e}_u^{(l+1)} = \sum_{i\in\mathcal{N}(u)} \frac{1}{\sqrt{|\mathcal{N}(u)|\cdot|\mathcal{N}(i)|}}\; \mathbf{e}_i^{(l)}$$$$\mathbf{e}_i^{(l+1)} = \sum_{u\in\mathcal{N}(i)} \frac{1}{\sqrt{|\mathcal{N}(u)|\cdot|\mathcal{N}(i)|}}\; \mathbf{e}_u^{(l)}$$

口语化:“邻居 embedding 的(按度归一化)平均。没有激活,没有权重矩阵。这就是一层。”

最终 embedding 是各层的组合:

$$\mathbf{e}_u = \sum_{l=0}^{L} \alpha_l\, \mathbf{e}_u^{(l)}, \qquad \mathbf{e}_i = \sum_{l=0}^{L} \alpha_l\, \mathbf{e}_i^{(l)}$$

通常取 $\alpha_l = \tfrac{1}{L+1}$(等权)。第 0 层是原始 embedding,第 1 层是一跳,第 2 层是两跳……组合起来就是一种多尺度表示,不必死磕单一深度。

为什么有效

  • 平滑效果。 聚合让相似节点的 embedding 互相靠近,恰好是协同过滤想要的。
  • 层组合。 把各层混合起来,平衡了直接信号(第 0 层)与高阶协同信号(深层),同时缓解过平滑。
  • 简洁即正则。 参数少,意味着在稀疏交互数据上更不易过拟合。

实现:LightGCN

 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 torch_geometric.nn import MessagePassing
from torch_geometric.utils import degree


class LightGCNLayer(MessagePassing):
    """LightGCN 的一层:归一化邻居聚合,仅此而已。"""

    def __init__(self):
        super().__init__(aggr='add')

    def forward(self, x, edge_index):
        row, col = edge_index
        deg = degree(col, x.size(0), dtype=x.dtype)
        deg_inv_sqrt = deg.pow(-0.5)
        deg_inv_sqrt[deg_inv_sqrt == float('inf')] = 0
        norm = deg_inv_sqrt[row] * deg_inv_sqrt[col]
        return self.propagate(edge_index, x=x, norm=norm)

    def message(self, x_j, norm):
        return norm.unsqueeze(1) * x_j


class LightGCN(nn.Module):
    """完整的 LightGCN 协同过滤模型。"""

    def __init__(self, num_users, num_items, embedding_dim=64, num_layers=3):
        super().__init__()
        self.num_users = num_users
        self.num_items = num_items
        self.num_layers = num_layers

        self.user_embedding = nn.Embedding(num_users, embedding_dim)
        self.item_embedding = nn.Embedding(num_items, embedding_dim)
        nn.init.normal_(self.user_embedding.weight, std=0.1)
        nn.init.normal_(self.item_embedding.weight, std=0.1)

        self.convs = nn.ModuleList([LightGCNLayer() for _ in range(num_layers)])

    def forward(self, edge_index):
        x = torch.cat([self.user_embedding.weight, self.item_embedding.weight], dim=0)
        layer_embeddings = [x]  # 第 0 层 = 原始 embedding
        for conv in self.convs:
            x = conv(x, edge_index)
            layer_embeddings.append(x)

        # 各层等权组合
        final = torch.stack(layer_embeddings, dim=0).mean(dim=0)
        return final[:self.num_users], final[self.num_users:]

    def predict(self, user_emb, item_emb, user_ids, item_ids):
        return (user_emb[user_ids] * item_emb[item_ids]).sum(dim=1)

NGCF:神经图协同过滤

与 LightGCN 的取舍

NGCF(Wang 等,2019)走的是另一条路:在消息传递里显式做特征变换和用户-物品交互项,能学到更丰富的 embedding。LightGCN 在做减法,NGCF 在做加法。

消息构造

每层的消息包含一个 特征交互 项:

$$\mathbf{m}_{u\leftarrow i} = \frac{1}{\sqrt{|\mathcal{N}(u)|\cdot|\mathcal{N}(i)|}}\;\bigl(\mathbf{W}_1\, \mathbf{e}_i^{(l)} + \mathbf{W}_2\, (\mathbf{e}_i^{(l)} \odot \mathbf{e}_u^{(l)})\bigr)$$

其中 $\odot$(逐元素乘)刻画用户与物品在每个维度上的"对得上不对得上"。如果用户在"动作片"维度上分高,物品在同一维度上也分高,相乘就会放大这个信号。

聚合后:

$$\mathbf{e}_u^{(l+1)} = \mathrm{LeakyReLU}\!\bigl(\mathbf{m}_{u\leftarrow u} + \sum_{i\in\mathcal{N}(u)} \mathbf{m}_{u\leftarrow i}\bigr)$$

实现:NGCF

 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
import torch
import torch.nn as nn
import torch.nn.functional as F
from torch_geometric.nn import MessagePassing
from torch_geometric.utils import degree


class NGCFLayer(MessagePassing):
    """带特征变换与交互的 NGCF 层。"""

    def __init__(self, in_channels, out_channels, dropout=0.0):
        super().__init__(aggr='add')
        self.W1 = nn.Linear(in_channels, out_channels, bias=False)
        self.W2 = nn.Linear(in_channels, out_channels, bias=False)
        self.W_self = nn.Linear(in_channels, out_channels, bias=False)
        self.dropout = dropout

    def forward(self, x, edge_index):
        row, col = edge_index
        deg = degree(col, x.size(0), dtype=x.dtype)
        deg_inv_sqrt = deg.pow(-0.5)
        deg_inv_sqrt[deg_inv_sqrt == float('inf')] = 0
        norm = deg_inv_sqrt[row] * deg_inv_sqrt[col]

        self_emb = self.W_self(x)
        neighbor_emb = self.propagate(edge_index, x=x, norm=norm)
        out = F.leaky_relu(self_emb + neighbor_emb, negative_slope=0.2)
        return F.dropout(out, p=self.dropout, training=self.training)

    def message(self, x_i, x_j, norm):
        # W1 * 邻居 + W2 * (用户 . 物品) —— 特征交互
        msg = self.W1(x_j) + self.W2(x_i * x_j)
        return norm.unsqueeze(1) * msg


class NGCF(nn.Module):
    """神经图协同过滤。"""

    def __init__(self, num_users, num_items, embedding_dim=64,
                 num_layers=3, dropout=0.1):
        super().__init__()
        self.num_users = num_users
        self.num_items = num_items
        self.user_embedding = nn.Embedding(num_users, embedding_dim)
        self.item_embedding = nn.Embedding(num_items, embedding_dim)
        nn.init.normal_(self.user_embedding.weight, std=0.1)
        nn.init.normal_(self.item_embedding.weight, std=0.1)

        self.convs = nn.ModuleList([
            NGCFLayer(embedding_dim, embedding_dim, dropout)
            for _ in range(num_layers)
        ])

    def forward(self, edge_index):
        x = torch.cat([self.user_embedding.weight, self.item_embedding.weight], dim=0)
        for conv in self.convs:
            x = conv(x, edge_index)
        return x[:self.num_users], x[self.num_users:]

LightGCN vs NGCF:怎么选?

LightGCNNGCF
参数量仅 embeddingembedding + 权重矩阵
效果稀疏数据上常更优特征丰富时更优
过拟合风险较高(参数多)
训练速度慢一些
推荐用于大多数 CF 任务特征密集的任务

默认选 LightGCN。 只有在用户/物品特征足够丰富、数据量也大到能撑起额外参数时,再考虑 NGCF。


为什么要叠层?多跳聚合

多跳邻域:目标用户先汇聚交互过的物品 → 再汇聚相似用户 → 再汇聚候选物品

把跳数画出来,“为什么推荐要叠层"就一目了然了:

  • 第 1 层 —— 目标用户从交互过的物品收集特征。
  • 第 2 层 —— 信号到达相似用户(这就是经典协同过滤的那一跳)。
  • 第 3 层 —— 信号到达相似用户喜欢的物品:候选推荐物。

两层就足以表达"喜欢相似物品的用户"这种矩阵分解写不出来的模式。三层在稀疏图上偶尔有用,但接近过平滑的边缘——若想要更深,就交给 LightGCN 风格的"层组合”。


社交推荐

基本想法

朋友影响你的购买、观影、听歌选择。社交推荐在用户-物品图上加上 社交边(好友、关注、信任)。底层赌注:社交相连的用户口味相似。

图结构

社交推荐图有两类边:

  • 交互边 $(u_i, i_j)$:用户 $u_i$ 与物品 $i_j$ 有交互。
  • 社交边 $(u_i, u_j)$:用户 $u_i$ 与 $u_j$ 是好友。

两种机制

同质性(homophily)。 朋友口味相似 是因为 他们是朋友(共享背景、文化)。

社会影响(social influence)。 朋友 会变得 越来越相似,因为他们彼此影响。

对模型来说两者结论一样:沿着社交边传播偏好。

实现:Social GCN

 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
import torch
import torch.nn as nn
import torch.nn.functional as F
from torch_geometric.nn import MessagePassing


class SocialGCNLayer(MessagePassing):
    """同时处理用户-物品边与社交边的 GCN 层。"""

    def __init__(self, in_channels, out_channels):
        super().__init__(aggr='mean')
        self.linear = nn.Linear(in_channels, out_channels)

    def forward(self, x, edge_index_ui, edge_index_social, num_users):
        x_ui = self.propagate(edge_index_ui, x=x)
        x_social = self.propagate(edge_index_social, x=x[:num_users])
        x_combined = x_ui.clone()
        x_combined[:num_users] = x_combined[:num_users] + x_social
        return self.linear(x_combined)


class SocialGCN(nn.Module):
    """基于 GCN 的社交推荐。"""

    def __init__(self, num_users, num_items, embedding_dim=64, num_layers=2):
        super().__init__()
        self.num_users = num_users
        self.user_embedding = nn.Embedding(num_users, embedding_dim)
        self.item_embedding = nn.Embedding(num_items, embedding_dim)
        self.convs = nn.ModuleList([
            SocialGCNLayer(embedding_dim, embedding_dim)
            for _ in range(num_layers)
        ])

    def forward(self, edge_index_ui, edge_index_social):
        x = torch.cat([self.user_embedding.weight, self.item_embedding.weight], dim=0)
        for conv in self.convs:
            x = conv(x, edge_index_ui, edge_index_social, self.num_users)
            x = F.relu(x)
        return x[:self.num_users], x[self.num_users:]

实证研究里,当社交关系本身有信息量时,NDCG 普遍能拿到 5–15% 的提升。失败模式是:噪声大的社交关系(一堆"互不熟"的关注列表)反而会拖后腿。常用对策是用注意力把无关社交边的权重压低,或者在训练前用交互重叠预过滤社交图。


大规模图采样

问题

全批量训练意味着每次 forward 都要把 整张图 装进显存、给 所有 节点算 embedding。MovieLens(10 万次交互)没问题;Pinterest(180 亿条边)做不到。

完整图 vs 采样小批:选定一个目标节点,采少数一跳邻居,再为每个一跳采少数二跳邻居,只有这些节点参与一次前向

采样策略

策略怎么做例子
邻居采样每层为每个节点采固定数量邻居GraphSAGE:第一跳 10 个,第二跳 5 个
节点采样采一批节点,用它们的邻域FastGCN
子图采样采连通子图Cluster-GCN、GraphSAINT
随机游走采样用随机游走找重要邻居PinSage

邻居采样是工作马:把每个节点的感受野卡住,显存可预测;每个 epoch 重新采样还顺带做了方差削减。

实现:邻居采样器

 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
import numpy as np
import torch


class NeighborSampler:
    """GraphSAGE 风格的邻居采样器,用于小批量训练。"""

    def __init__(self, edge_index, num_nodes, num_neighbors=(10, 5)):
        """num_neighbors: (一跳采样数, 二跳采样数, …)"""
        self.num_nodes = num_nodes
        self.num_neighbors = num_neighbors

        self.adj = {i: [] for i in range(num_nodes)}
        row, col = edge_index.cpu().numpy()
        for r, c in zip(row, col):
            self.adj[r].append(c)

    def sample(self, target_nodes):
        """为目标节点采样多跳邻域。"""
        current_nodes = set(target_nodes.tolist())
        all_layers = []

        for num_nbrs in self.num_neighbors:
            edges, next_nodes = [], set()
            for node in current_nodes:
                neighbors = self.adj[node]
                if not neighbors:
                    continue
                if len(neighbors) > num_nbrs:
                    sampled = np.random.choice(
                        neighbors, num_nbrs, replace=False
                    ).tolist()
                else:
                    sampled = neighbors
                for nbr in sampled:
                    edges.append([nbr, node])
                    next_nodes.add(nbr)

            if edges:
                all_layers.append(torch.tensor(edges).t().contiguous())
            else:
                all_layers.append(torch.empty((2, 0), dtype=torch.long))
            current_nodes = next_nodes
        return all_layers

训练技巧

BPR 损失(贝叶斯个性化排序)

隐式反馈推荐的默认损失:

$$\mathcal{L}_{\text{BPR}} = -\sum_{(u,i,j)} \ln\, \sigma(\hat{r}_{ui} - \hat{r}_{uj}) + \lambda \|\Theta\|^2$$

口语化:“对每个用户 $u$,让正样本 $i$(交互过)的得分比负样本 $j$(没交互过)的得分高。sigmoid 加 log 把它变成一个光滑可微的目标。”

  • $(u,i,j)$:(用户, 正物品, 负物品) 三元组
  • $\hat{r}_{ui} = \mathbf{e}_u^\top \mathbf{e}_i$:点积得分
  • $\lambda \|\Theta\|^2$:L2 正则

负采样策略

策略描述适用
均匀采样随机选未交互物品基线
基于流行度按流行度比例采样解长尾问题
困难负样本高分负样本训练后期精修

训练循环

 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
import torch
import torch.nn as nn
import torch.optim as optim


class BPRLoss(nn.Module):
    """带 L2 正则的 BPR 损失。"""

    def __init__(self, reg_lambda=1e-4):
        super().__init__()
        self.reg_lambda = reg_lambda

    def forward(self, pos_scores, neg_scores, *embeddings):
        bpr = -torch.log(torch.sigmoid(pos_scores - neg_scores) + 1e-10).mean()
        reg = self.reg_lambda * sum(emb.norm(2).pow(2) for emb in embeddings)
        return bpr + reg


def train_lightgcn(model, edge_index, train_pairs, num_items,
                   num_epochs=100, lr=0.001, batch_size=1024):
    """训练 LightGCN 。 train_pairs: [user, pos_item] 张量。"""
    optimizer = optim.Adam(model.parameters(), lr=lr)
    criterion = BPRLoss()

    for epoch in range(num_epochs):
        model.train()
        perm = torch.randperm(len(train_pairs))
        total_loss, num_batches = 0.0, 0

        for start in range(0, len(train_pairs), batch_size):
            batch = train_pairs[perm[start:start + batch_size]]
            user_ids, pos_items = batch[:, 0], batch[:, 1]
            neg_items = torch.randint(0, num_items, (len(batch),))

            optimizer.zero_grad()
            user_emb, item_emb = model(edge_index)

            pos_scores = (user_emb[user_ids] * item_emb[pos_items]).sum(1)
            neg_scores = (user_emb[user_ids] * item_emb[neg_items]).sum(1)

            loss = criterion(pos_scores, neg_scores,
                             user_emb[user_ids],
                             item_emb[pos_items],
                             item_emb[neg_items])
            loss.backward()
            optimizer.step()
            total_loss += loss.item()
            num_batches += 1

        if (epoch + 1) % 10 == 0:
            print(f"Epoch {epoch+1}/{num_epochs}, Loss: {total_loss/num_batches:.4f}")

评估指标

 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
import numpy as np
import torch


def evaluate_topk(model, edge_index, test_user_items, train_user_items, k=10):
    """计算 Recall@K 和 NDCG@K 。"""
    model.eval()
    recalls, ndcgs = [], []

    with torch.no_grad():
        user_emb, item_emb = model(edge_index)
        for user_id, test_items in test_user_items.items():
            scores = (user_emb[user_id] * item_emb).sum(dim=1)

            # 屏蔽训练集物品
            train_items = train_user_items.get(user_id, [])
            scores[train_items] = -float('inf')

            _, top_k = torch.topk(scores, k)
            top_k = set(top_k.cpu().numpy())

            hits = len(top_k & set(test_items))
            recalls.append(hits / len(test_items) if test_items else 0)

            # NDCG:奖励排位靠前的命中
            dcg = sum(1.0 / np.log2(idx + 2)
                      for idx, item in enumerate(top_k) if item in test_items)
            idcg = sum(1.0 / np.log2(idx + 2)
                       for idx in range(min(k, len(test_items))))
            ndcgs.append(dcg / idcg if idcg > 0 else 0)

    return np.mean(recalls), np.mean(ndcgs)

冷启动:图模型的高光时刻

矩阵分解为新用户没有 embedding;GraphSAGE 风格的归纳式聚合可以从一次交互生成 embedding

一个新用户出现时只有一次交互。矩阵分解帮不上忙——它没有这个用户的行,要加就得重训。而像 GraphSAGE 这样的归纳式 GNN 直接对新用户的邻居跑一遍同样的聚合函数:哪怕只有一次交互,也能拿到一个有意义的 embedding,因为聚合把这件物品的其他爱好者的信息全部带了进来。

工程上处理冷启动用户的实用配方:

  1. 归纳式聚合。 用 GraphSAGE/PinSage 这类模型,让任何至少有一个邻居的节点都自动获得 embedding。
  2. 特征初始化。 用画像特征(人口属性、设备、注册渠道)给冷启动节点一个起点 embedding,再让聚合接管。
  3. 相似用户均值兜底。 在交互足够多之前,把新用户的 embedding 与"同画像用户均值"做混合。
  4. 元学习(MAML)。 如果资源允许,让模型本身就具备"几步交互即可适应新用户"的能力。

完整示例:在类 MovieLens 数据上跑 LightGCN

 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
import torch
import numpy as np
from sklearn.model_selection import train_test_split

# --- 1. 合成类 MovieLens 数据 ---
num_users, num_items = 1000, 2000
num_interactions = 10000

users = np.random.randint(0, num_users, num_interactions)
items = np.random.randint(0, num_items, num_interactions)
train_u, test_u, train_i, test_i = train_test_split(
    users, items, test_size=0.2, random_state=42
)

# 二部图边索引(无向)
edge_src = np.concatenate([train_u, train_i + num_users])
edge_dst = np.concatenate([train_i + num_users, train_u])
edge_index = torch.tensor([edge_src, edge_dst], dtype=torch.long)

# --- 2. 训练 LightGCN ---
model = LightGCN(num_users, num_items, embedding_dim=64, num_layers=3)
optimizer = torch.optim.Adam(model.parameters(), lr=0.001)
criterion = BPRLoss()

train_pairs = torch.tensor(np.stack([train_u, train_i], axis=1), dtype=torch.long)

for epoch in range(50):
    model.train()
    perm = torch.randperm(len(train_pairs))
    for start in range(0, len(train_pairs), 512):
        batch = train_pairs[perm[start:start + 512]]
        user_ids, pos_items = batch[:, 0], batch[:, 1]
        neg_items = torch.randint(0, num_items, (len(batch),))

        optimizer.zero_grad()
        user_emb, item_emb = model(edge_index)
        pos_scores = (user_emb[user_ids] * item_emb[pos_items]).sum(1)
        neg_scores = (user_emb[user_ids] * item_emb[neg_items]).sum(1)
        loss = criterion(pos_scores, neg_scores,
                         user_emb[user_ids],
                         item_emb[pos_items],
                         item_emb[neg_items])
        loss.backward()
        optimizer.step()

    if (epoch + 1) % 10 == 0:
        print(f"Epoch {epoch+1}, Loss: {loss.item():.4f}")

print("训练完成!")

常见问题

GNN 相对矩阵分解到底强在哪?

矩阵分解每个 embedding 是孤立学习的——它从不问"我的邻居长什么样"。GNN 显式地让协同信号在图上传播,所以"通过共享物品相连的相似用户"会自然地得到相似的 embedding,这是架构本身的副产品。多层 GNN 还能捕捉"喜欢与你喜欢相似物品的用户喜欢的物品"这种高阶模式,单层点积写不出来。

GCN vs GAT vs GraphSAGE,什么场景用哪个?

GCNGATGraphSAGE
强项简单高效自适应邻居加权可扩展,能处理新节点
弱项需要全图,权重固定大邻域时贵采样带来方差
最适合中小型静态图异构边大规模、动态图

经验法则:协同过滤先选 LightGCN(简化版 GCN);要归纳式学习用 GraphSAGE;不同邻居重要性差异大用 GAT。

GNN 该叠几层?

推荐场景的标准答案是 2~3 层:

  • 1 层 —— 只看直接邻居,缺协同模式。
  • 2 层 —— 朋友的朋友,大多数任务的甜蜜点。
  • 3 层 —— 三跳模式,提升小且要警惕过平滑。
  • 4 层及以上 —— 过平滑主导,embedding 区分度坍塌。

想要"既深又不塌",用 LightGCN 风格的层组合。

怎么处理 GNN 里的冷启动用户?

  1. 归纳式聚合。 任何至少有一个邻居的节点都能直接出 embedding。
  2. 特征初始化。 用人口学/设备等画像特征,或同画像用户的均值,作为 embedding 起点。
  3. 元学习。 用 MAML 思路,让模型从少量交互里快速适配新用户。
  4. 混合模型。 GNN embedding 与基于内容的特征结合,让冷节点也有有用的表示。

可参考上方的冷启动示意图。

计算成本怎么算?

模型前向显存
GCN / LightGCN$O(L\cdotE
GAT$O(L\cdotE
GraphSAGE(采样)$O(L\cdotV

其中 $L$ 层数、$|E|$ 边数、$d$ embedding 维度、$H$ 注意力头数、$k$ 采样邻居数。十亿级边规模下,采样(GraphSAGE/PinSage)或子图划分(Cluster-GCN)是必选项。

社交推荐一定提升吗?

只在社交关系真的反映共同偏好时才提升。实证研究里能拿到 5–15% 的 NDCG 提升;但噪声社交(“互不熟"的关注列表)反而会拖后腿。常用做法是用注意力压低无关边的权重,或在训练前按交互重叠预过滤社交图。

GNN 能处理动态图吗?

可以,需要适配:

  • 时间感知聚合 —— 给最近的邻居更高的权重。
  • 时间编码 —— 在边上加时间嵌入。
  • 增量更新 —— 在新边上微调,而不是从头重训。
  • 专用时序 GNN —— 比如 TGN,原生处理流式图。
1
2
3
4
5
6
def temporal_aggregate(neighbor_embs, timestamps, current_time, decay_rate=0.1):
    """按时近度给邻居加权。"""
    time_diffs = current_time - timestamps
    weights = torch.exp(-decay_rate * time_diffs)
    weights = weights / weights.sum()
    return (neighbor_embs * weights.unsqueeze(1)).sum(dim=0)

怎么防止过平滑?

  1. 限制深度 在 2~3 层。
  2. 层组合(LightGCN 风格) —— 各层等权,最终表示里仍保留原始 embedding。
  3. 残差连接 —— $\mathbf{h}^{(l+1)} = \mathbf{h}^{(l)} + \mathrm{GNN}(\mathbf{h}^{(l)})$。
  4. 边 dropout —— 训练时随机丢边,降低对结构的过度依赖。

关键要点

  • 推荐数据天然就是图。 GNN 通过邻域聚合让协同信号沿图传播,这是矩阵分解做不到的。
  • LightGCN 证明简洁就是赢。 把 GCN 剥到只剩聚合,反而在协同过滤上常常打败更重的架构。
  • GraphSAGE 让归纳式学习成为可能。 在用户/物品不停变动的生产环境里至关重要——也是冷启动最干净的路径。
  • PinSage 证明十亿级图可行。 随机游走采样 + 重要性加权让 Pinterest 规模的 GNN 跑得起来。
  • 社交信号能带来 5–15% 提升 —— 但只有当社交关系真的反映共同偏好时。
  • 2~3 层是甜蜜点。 更深风险过平滑;用层组合既要深度又避坍塌。

系列导航

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

Liked this piece?

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

GitHub