推荐系统(十一)—— 对比学习与自监督学习

对比学习在推荐系统里到底怎么用?这一篇把 InfoNCE、温度系数、SimCLR/MoCo 的负样本来源、SGL 的图增广、CL4SRec 的序列增广、XSimGCL 的极简噪声扰动讲清楚,配 PyTorch 实现与原理图。

经典推荐模型只学一种信号:用户有没有点、看完没、买没买。这种监督信号很宝贵,但也极其稀疏——大多数用户接触不到目录里 1% 的物品,大多数物品也接触不到 0.1% 的用户,新上线的用户和物品则干脆没有任何记录。直接拿这种稀疏标签去优化,几乎注定会在头部过拟合、在尾部沉默。

对比学习换了一笔交易:它不再问"这个样本的标签是什么",改问"哪两个样本应该长得像、哪两个应该长得不一样"。这个问题的答案可以从数据本身免费得到——把同一个用户/物品/序列以两种不同方式扰动一下,扰动出的两个版本就是一对正样本,同一个 batch 里其他样本则全是负样本。模型由此学习到几何结构:相似的东西在 embedding 空间里靠近,不相似的远离。等几何结构学好了,下游有监督的推荐头只要轻轻一推就够了。

本文先把对比学习这台机器的核心零件拆开讲清楚——InfoNCE、温度系数、增广策略——再分别介绍推荐里真正用得上的四类做法:SimCLR 风格的 batch 内对比、MoCo 风格的队列对比、SGL 的图增广、CL4SRec 的序列增广。最后我们看一个有点反直觉的结论:XSimGCL 把所有花哨的增广都丢掉,只往 embedding 上加一点噪声,效果反而更好。

你将学到什么

  • 为什么对比学习处理稀疏性、冷启动、流行度偏置的思路和"再多收点数据"完全不同
  • InfoNCE 细讲:损失从哪来、温度系数 $\tau$ 为什么如此关键、它对梯度的形状意味着什么
  • SimCLR vs MoCo:batch 内负样本 vs 动量编码器 + 队列负样本,分别什么时候用
  • SGL(Wu 等,SIGIR 2021):节点丢弃、边丢弃、随机游走子图三种图视图
  • CL4SRec(Xie 等,ICDE 2022):crop / mask / reorder 三种序列增广
  • SimGCL / XSimGCL(Yu 等,2022/2023):为什么一个简单的 embedding 噪声扰动能打平精心设计的图增广
  • 每一块都有可读的 PyTorch 实现

前置知识

  • PyTorch 基本功(Module、autograd、损失函数)
  • 图神经网络,特别是 LightGCN(第 7 篇
  • Embedding 空间与相似度(第 5 篇

为什么推荐系统需要对比学习

重新审视稀疏性

稀疏性不是简单的"标签太少",它是结构性的:

  1. 冷启动:新用户/新物品的交互数为零,任何依赖它们做特征的模型(矩阵分解、双塔、GNN)都无法把它们放到 embedding 空间里有意义的位置。
  2. 头部过拟合:长尾点击分布下,损失被几个热门物品主导。一个把热门物品背下来的模型在训练集上看起来很好,��尾部物品却毫无判别力。
  3. 流行度偏置:即便候选池里有多样性,最终的打分头依然会把热门物品推到前面——因为这就是训练损失奖励的方向。整个系统会塌缩到"什么都推同样几个东西"。

对比学习换来的是什么

对比学习用一笔不同的交易:放弃昂贵的标签,换来便宜得多的"扰动一致性"信号。设想把一个用户的行为子图随机丢掉 20% 的边再编码一次,再用不同的丢边方式编码第二次。两个视图,同一个用户。规则很简单:这两个 embedding 应该几乎一模一样,而 batch 里其他用户的 embedding 应该和它们都不一样。

这一个目标同时给你三样东西:

  • 一份免费的训练信号,量可以无限大(任何用户都能扰动任意多次)
  • 一种表示先验:模型学到的特征是那些在扰动下还能保持的——根据定义就是鲁棒的特征,恰好是冷启动和长尾物品最需要的
  • 一个对抗塌缩的正则项:损失明确地把不同用户在 embedding 空间里推开,不让它们都挤到热门物品周围

锚点用户被拉近两个增广视图(正样本),同时被推离 batch 内其他用户(负样本)

上图基本上把对比学习的全部直觉讲完了。蓝色锚点是某个用户,两个绿色点是同一用户的两个增广视图,把它们拉到一起;琥珀色点是同一 batch 里其他用户,把它们推开。对每个用户、每个 batch 都做这件事,整个 embedding 空间就会被塑造成"语义相似 = embedding 相似"的几何结构。


InfoNCE:真正在干活的损失函数

几乎所有对比式推荐模型用的都是 InfoNCE 损失(van den Oord 等,2018)的某种变体。给定锚点 $x$、正样本 $x^+$、一组负样本 $\{x_i^-\}$,编码器 $f$,相似度 $\mathrm{sim}(\cdot,\cdot) = z\cdot z'$(在 $\ell_2$ 归一化后的 embedding 上做点积):

$$ \mathcal{L}_{\text{InfoNCE}} = -\log \frac{\exp\!\big(\mathrm{sim}(f(x), f(x^+)) / \tau\big)}{\exp\!\big(\mathrm{sim}(f(x), f(x^+)) / \tau\big) + \sum_{i} \exp\!\big(\mathrm{sim}(f(x), f(x_i^-)) / \tau\big)} $$

这其实就是一个 $(N+1)$ 类分类器的交叉熵,正确类别是"那个正样本"。分子让正样本的概率变大;分母强迫模型把正样本排在所有负样本之前。这种排序压力,就是阻止"所有 embedding 塌缩到同一个向量"这个平凡解的关键。

温度系数:被严重低估的旋钮

温度 $\tau$ 控制 softmax 区分正负的锐利程度。它不是一个装饰性超参,它会改变损失到底在优化什么。

左:不同温度下,InfoNCE 损失随正样本相似度的变化;右:相同情况下损失对正样本相似度的梯度

从右边那张图能直接读出两点结论:

  • 小 $\tau$(0.05):梯度在判决边界附近极其陡峭。模型几乎把全部容量都花在最难的负样本上——那些与正样本相似度几乎一样高的样本。这有利于学细粒度区分,但当那些"困难负样本"其实是被误标的(比如缺失非随机的点击),训练就会不稳定。
  • 大 $\tau$(1.0):梯度在所有负样本上摊薄,连容易的也分到一份。优化平滑,但聚类松散。
  • 推荐场景的甜点区大致在 $\tau \in [0.1, 0.2]$——足够锐利能学到有用对比,又足够柔和不被噪声带跑。

一个简单的脑模型:$1/\tau$ 是"放大倍数"。把 $\tau$ 减半,每对相似度的差距就被放大一倍,梯度推近-正样本和近-负样本分开的力度也翻一倍。

为什么离不开负样本

如果只优化分子——把正样本拉到一起——那所有 embedding 塌缩到同一个常向量,损失就会愉快地降到零。负样本不是配菜,是承重的约束。这也解释了为什么在 SimCLR 风格的训练里 batch size 影响这么大:batch 256 时每个锚点有 510 个负样本,batch 4096 时有 8190 个。负样本越多,分母越密,对比信号越强。


SimCLR vs MoCo:负样本从哪儿来

视觉自监督的两大范式被原封不动地搬进了推荐:

SimCLR 用 batch 内其他视图做负样本(单一编码器);MoCo 维护一个动量更新的 key 编码器和一个 FIFO 队列缓存负样本

SimCLR(Chen 等,ICML 2020)走极简路线:一个编码器、一个投影头、每个样本两次增广,同一 minibatch 里其他视图全是负样本。代价显而易见——负样本数量被 GPU 显存限制住了。

MoCo(He 等,CVPR 2020)把两边解耦:query 编码器用 SGD 正常更新,动量 key 编码器以指数滑动平均跟随 $\xi \leftarrow m\xi + (1-m)\theta$,再维护一个 FIFO 队列保存最近 $K$ 个 key 的编码(典型 $K=65{,}536$)。负样本来自队列,$K$ 与 batch size 完全解耦。

放到推荐里,SimCLR 模式更常见:CTR/CVR 训练本身 batch 就大;编码器通常是 GNN,前向开销主要在图遍历而非 embedding 查表;而维护一个上百万的用户 key 队列其实没什么意义,因为 embedding 表本身一直在被更新,过两步队列里的旧 key 就和新 query 不在同一个空间了。MoCo 风格的队列在长序列检索模型里更常见——那种场景编码器很重,确实需要把它的开销摊到大量锚点上。

SimCLR 损失的参考实现

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
import torch
import torch.nn as nn
import torch.nn.functional as F


def info_nce(z1: torch.Tensor, z2: torch.Tensor, tau: float = 0.2) -> torch.Tensor:
    """两视图对称的 SimCLR 风格 InfoNCE。

    z1、z2 形状 (B, D),已经 L2 归一化。z1[i] 的正样本是 z2[i],反之亦然;
    拼起来的 2B 大 batch 里其他位置都算负样本。
    """
    B = z1.size(0)
    z = torch.cat([z1, z2], dim=0)                  # (2B, D)
    sim = z @ z.T / tau                             # (2B, 2B)
    sim.fill_diagonal_(float("-inf"))               # 排除自己和自己
    # 第 i 行(i<B)的正样本在第 i+B 列;第 i 行(B<=i<2B)的正样本在第 i-B 列
    targets = torch.cat([torch.arange(B, 2 * B), torch.arange(0, B)]).to(z.device)
    return F.cross_entropy(sim, targets)

这十几行里有三处不那么显然的细节值得提一下:

  1. embedding 进来之前必须已经归一化。归一化后的点积就是余弦相似度,温度 $\tau$ 也才有上面那种几何意义。
  2. 对角线用 $-\infty$ 屏蔽,而不是 0。cross_entropy 工作在 log-softmax 空间,$-\infty$ 在分母里直接消失;用 0 会留一个 $e^0=1$ 的项,悄悄改变梯度。
  3. 损失是对称的:$2B$ 行各贡献一个交叉熵。前 $B$ 行把第二组视图当目标,后 $B$ 行把第一组视图当目标。

SGL:在用户-物品图上做对比学习

SGL(Wu 等,SIGIR 2021,Self-supervised Graph Learning for Recommendation)是把对比学习真正带进推荐主流的工作。思路是在 LightGCN 主干上挂一个 InfoNCE 头,把两个被随机扰动过的用户-物品图当作每个节点的一对正样本视图。

三种扰动图的方式

原始用户-物品二部图与 SGL 的三种增广:边丢弃、节点丢弃、随机游走子图

  • 边丢弃(Edge Dropout, ED):每条边以概率 $1-p$ 独立保留。便宜、保持结构、用得最多。
  • 节点丢弃(Node Dropout, ND):每个节点(连同它的全部边)以概率 $p$ 被丢掉。增广更强,但可能让小度节点训练不稳。
  • 随机游走子图(Random Walk, RW):从每个锚点出发做长度 $L$ 的随机游走,把走过的子图作为视图。

SGL 原论文的消融显示,边丢弃在大多数数据集上稳定地匹配甚至略胜另外两种,而且实现最简单。没有特别理由的话直接用 ED

完整的 SGL 训练步

 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


def edge_dropout(edge_index: torch.Tensor, p: float) -> torch.Tensor:
    keep = torch.rand(edge_index.size(1), device=edge_index.device) > p
    return edge_index[:, keep]


class SGL(nn.Module):
    """LightGCN 主干 + 在两个边丢弃视图上的节点级 InfoNCE。"""

    def __init__(self, n_users, n_items, dim=64, n_layers=3,
                 drop_p=0.1, tau=0.2, lam=0.1):
        super().__init__()
        self.n_users, self.n_items = n_users, n_items
        self.n_layers, self.drop_p, self.tau, self.lam = n_layers, drop_p, tau, lam
        self.user_emb = nn.Embedding(n_users, dim)
        self.item_emb = nn.Embedding(n_items, dim)
        nn.init.normal_(self.user_emb.weight, std=0.1)
        nn.init.normal_(self.item_emb.weight, std=0.1)

    def _propagate(self, x, edge_index):
        """对称归一化的 LightGCN 传播,对各层取平均。"""
        row, col = edge_index
        deg = torch.bincount(row, minlength=x.size(0)).clamp(min=1).float()
        norm = deg.pow(-0.5)
        layers = [x]
        for _ in range(self.n_layers):
            msg = x[col] * (norm[row] * norm[col]).unsqueeze(1)
            agg = torch.zeros_like(x).index_add_(0, row, msg)
            x = agg
            layers.append(x)
        return torch.stack(layers, dim=0).mean(dim=0)

    def encode(self, edge_index):
        x = torch.cat([self.user_emb.weight, self.item_emb.weight], dim=0)
        return self._propagate(x, edge_index)

    def cl_loss(self, z1, z2):
        z1, z2 = F.normalize(z1, dim=1), F.normalize(z2, dim=1)
        sim = z1 @ z2.T / self.tau
        targets = torch.arange(z1.size(0), device=z1.device)
        return F.cross_entropy(sim, targets)

    def bpr_loss(self, z, users, pos, neg):
        u, p, n = z[users], z[self.n_users + pos], z[self.n_users + neg]
        return -F.logsigmoid((u * p).sum(-1) - (u * n).sum(-1)).mean()

    def forward(self, edge_index, users, pos_items, neg_items):
        # 两个增广视图,提供对比信号
        z1 = self.encode(edge_dropout(edge_index, self.drop_p))
        z2 = self.encode(edge_dropout(edge_index, self.drop_p))
        # 一次干净的前向,提供推荐信号
        z = self.encode(edge_index)
        return self.bpr_loss(z, users, pos_items, neg_items) \
             + self.lam * self.cl_loss(z1, z2)

几个不那么显然的实现细节:

  • 三次前向传播,不是两次。对比视图是从原图独立丢边出来的,BPR 用的是干净的原图。如果让 BPR 共用某个被丢过边的视图,监督信号会被偶然存活下来的边带偏。
  • 对比损失在节点层面计算,作用对象是用户和物品 embedding 的拼接。这样二部图的两边都能拿到自监督信号。
  • 损失权重 $\lambda$ 很关键。太小($<10^{-2}$)对比信号几乎消失;太大($>1$)BPR 抓不住推荐目标。SGL 论文在 $\{0.005, 0.05, 0.1, 0.5, 1.0\}$ 上扫了一遍,结论是 $0.1$ 是个稳健默认值——从这里起步。

CL4SRec:序列推荐里的对比学习

对于基于序列的推荐器(SASRec、BERT4Rec、GRU4Rec……),同样的问题变成"怎么给同一条行为序列造两个视图"。CL4SRec(Xie 等,ICDE 2022)提出的三种增广已经成为事实上的默认。

同一条行为序列上的三种增广:crop 截取连续片段,mask 把若干位置替换为 [M],reorder 打乱一段连续区间

  • Crop:保留长度为 $\eta L$ 的连续子序列。保住了局部顺序,让模型对"晚一点开始"或"早一点结束"具有不变性。
  • Mask:随机把 $\gamma$ 比例的位置换成特殊 token [M]。和 BERT 的 masked language modelling 一样的思路——编码器必须从上下文反推被遮挡的物品。
  • Reorder:把一段长度 $\beta L$ 的连续区间打乱。教会模型一个 session 内精确位置没那么重要,物品的集合本身才更稳定。

CL4SRec 的做法是每个视图随机抽一种增广,于是 (视图 A, 视图 B) 就有九种组合。这种随机混合本身也是一种正则——编码器没法对单一增广策略过拟合。

 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
import random
import torch
import torch.nn as nn
import torch.nn.functional as F


class SeqAugment:
    """CL4SRec 的三种序列增广。序列是 LongTensor 的物品 ID,
    id=0 保留给 padding/mask。"""

    def __init__(self, crop_eta=0.6, mask_gamma=0.3, reorder_beta=0.6, mask_id=0):
        self.crop_eta = crop_eta
        self.mask_gamma = mask_gamma
        self.reorder_beta = reorder_beta
        self.mask_id = mask_id

    def __call__(self, seq: torch.Tensor) -> torch.Tensor:
        op = random.choice(["crop", "mask", "reorder"])
        L = seq.size(0)
        if op == "crop":
            k = max(1, int(L * self.crop_eta))
            s = random.randint(0, L - k)
            return seq[s:s + k]
        if op == "mask":
            out = seq.clone()
            n = max(1, int(L * self.mask_gamma))
            idx = torch.randperm(L)[:n]
            out[idx] = self.mask_id
            return out
        # reorder
        out = seq.clone()
        k = max(2, int(L * self.reorder_beta))
        s = random.randint(0, L - k)
        chunk = out[s:s + k][torch.randperm(k)]
        out[s:s + k] = chunk
        return out

编码器用你已有的任何序列模型即可(Transformer、GRU 等)。把最后一层隐藏状态池化、投影、归一化,丢进上面写好的 info_nce,再作为辅助损失加到原本的 next-item 预测上就行。


XSimGCL:连增广本身都不重要

Yu 等(2022/2023)的一项颇为反直觉的实证结果值得单独一节。他们问:SGL 的提升究竟有多少来自图增广本身、有多少来自对比损失?答案是:几乎全来自损失。把图丢边换成在传播得到的 embedding 上加��点点均匀噪声,效果就能持平甚至超过 SGL,而且省掉了图扰动的全部实现成本。

具体技巧(SimGCL / XSimGCL,后者是精简版)大致是:

  1. 正常跑 LightGCN 的传播。
  2. 在每一层往 embedding 上加一个小噪声 $\Delta$,方向取单位球面上的随机向量再缩放到很小的 $\epsilon$(比如 0.1),并且强制噪声与原向量同向(避免翻号)。
  3. 用不同的噪声样本再跑一遍传播,得到第二个视图。完全不动图。
1
2
3
4
5
def add_noise(x: torch.Tensor, epsilon: float = 0.1) -> torch.Tensor:
    """SimGCL 的同向噪声扰动。"""
    noise = torch.rand_like(x)
    noise = F.normalize(noise, dim=-1) * torch.sign(x) * epsilon
    return x + noise

更深的洞察来自 SimGCL 的分析:对比损失同时在做两件事——

  • 对齐(alignment):把正样本对拉近(分子在做的事)。
  • 均匀(uniformity):把所有 embedding 在单位超球面上推得越均匀越好(分母在做的事)。

而 uniformity 才是打破流行度偏置的关键正则项,它是主导效应。一旦有了 uniformity,第二个视图具体怎么造(图丢边、embedding 噪声、还是别的合理扰动)几乎都无所谓。


这些方法到底有多大提升

Recall@20 与 NDCG@20 在四个标准数据集上的对比:LightGCN 基线 vs +SGL vs +XSimGCL

上图的具体数值是 SGL(Wu 等,2021)和 SimGCL/XSimGCL(Yu 等,2022/2023)原论文在 Yelp2018、Amazon-Book、Alibaba-iFashion、Gowalla 等数据集上报告幅度的示意。结论一致:给同一个主干加上对比辅助损失,Recall@20 和 NDCG@20 通常能提升 5%–20%,而且增益几乎集中在尾部物品和冷启动用户身上——正是你最在意的地方。

这种增益在 embedding 空间里是什么样:

对比学习训练前后的物品 embedding,t-SNE 投影:从纠缠成一团到清晰分开的兴趣簇

训练前,物品 embedding 几乎只沿着"流行度"这一根主轴变化,语义结构都被埋住了。训练后,物品按兴趣类别分裂成紧凑、彼此分开的小簇。下游打分头的活就好干多了——一个近似线性的边界基本就够。


常见问题

既然能多收数据,为什么还要用对比学习?

冷用户拿不到"更多数据"——他们就是因为没数据才叫冷。尾部物品也拿不到"更多数据"——尾部本身就是因为几乎没人看才在尾部。对比学习造出的训练信号不需要新交互,只需要把已有交互扰动一下。这和"再多收点点击"是完全不同的事情。

怎么在图增广和 embedding 噪声之间选?

默认用 embedding 噪声(XSimGCL):更快、更简单,最近的文献基本一致认为它能持平或超过图增广。真正需要图增广的场景是:你想顺便正则化 GNN 的结构性归纳偏置,或者你对哪些边/节点最关键有可靠的先验。

温度系数怎么定?

从 $\tau = 0.2$ 起步。如果你的"困难负样本"基本上是真正的负样本(比如有可靠的 dwell-time 信号),把 $\tau$ 降到 0.05–0.1 来加强对比;如果负样本噪声大(比如在百万级目录里随机抽 batch 用户做负样本),保持 $\tau \geq 0.2$ 以免被假负样本带偏。在小网格上扫一下就行,损失对 $\tau$ 是平滑的。

投影头还要不要?

SimCLR / SGL 这类在 GNN 上做对比的,要——而且训练完就丢掉。投影头让编码器的中间表示保持通用,把对比度量的特化任务交给投影头。XSimGCL 是个例外,它直接对传播后的 GNN embedding 做对比,不用投影头照样工作。

对比损失和推荐损失怎么组合?

$$\mathcal{L}_{\text{total}} = \mathcal{L}_{\text{rec}} + \lambda \cdot \mathcal{L}_{\text{CL}}$$

从 $\lambda = 0.1$ 起步。线上效果敏感时再在 $\{0.01, 0.05, 0.1, 0.5, 1.0\}$ 里扫一下。$\lambda$ 的敏感度比 $\tau$ 低很多,别在这上面浪费调参预算。

这套方法对隐式反馈管不管用?

管用,而且比对显式反馈更适合。整套框架默认每个交互都是正标签,让 InfoNCE 的结构隐式处理负样本。SGL、SimGCL、XSimGCL、CL4SRec 全是为隐式反馈(点击、播放、购买)设计的。

数据少到什么程度还能用?

对比学习恰恰在监督信号最稀缺的时候帮助最大。SGL 在 Yelp2018(稀疏度约 99.87%)上的提升比稠密数据集上大得多。低于 ~10K 交互时,随机增广的方差会主导,可能需要更强的先验(跨域迁移、内容特征);超过 ~100K 交互时,应该能稳定看到改进。

怎么评估对比式推荐器?

标准 top-K 指标(Recall@K、NDCG@K、HR@K)做基线对齐,然后额外关注:

  • 冷启动切片:按交互数把用户/物品分桶,分桶报告指标。
  • 长尾覆盖:推荐列表里长尾物品的占比(通常长尾定义为按流行度排序的后 80%)。
  • embedding 诊断:t-SNE/UMAP 可视化;按 Wang & Isola(2020)算 uniformity($\log \mathbb{E}\,e^{-2\|z_i - z_j\|^2}$)和 alignment($\mathbb{E}\,\|z - z^+\|^2$)。高 uniformity + 低 alignment 才算健康。

总结

对比学习解决了一个加再多数据也解决不了的问题:它给模型提供了一种从稀疏交互中学几何的方法,方法是要求模型在扰动下保持一致。2024 年的工程配方已经稳定下来:

  1. 挑一种增广,或者干脆不用增广(XSimGCL 噪声)。
  2. 套一个 InfoNCE,$\tau \approx 0.2$。
  3. 作为辅助损失加上去,权重 $\lambda \approx 0.1$,挂在你已有的有监督目标上。

基于图的推荐器,从 XSimGCL 起步——能跑通的最简方案就是它。如果想要一个更可解释的基线,SGL + 边丢弃依然是个稳健选择。序列推荐器,CL4SRec 那三种增广是默认菜单。所有场景下,最大的收益都集中在冷启动和长尾。


系列导航

本文是推荐系统 16 篇系列的第 11 篇

上一篇下一篇
第十篇:深度兴趣网络与注意力机制所有文章第十二篇:大语言模型与推荐系统

Liked this piece?

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

GitHub