🏠 总目录📚 本教程 09 · 序列推荐
📑 本页目录(点开跳转)

09 · 序列推荐

25 分钟 | ⭐ 核心


🎯 一句话

「你看东西的顺序,本身就是信息。」 序列推荐把「用户是一个静态画像」改成「用户是一串行为的轨迹」,然后预测:下一个是什么?


🧠 为什么顺序重要(三个例子秒懂)

  ① 顺序改变含义
     [手机 → 手机壳]   ✅ 合理,该推手机壳
     [手机壳 → 手机]   ❌ 已经有手机了,别再推手机

  ② 近期行为权重远大于远期
     一年前看了育儿书,昨天连看 5 个健身视频
     → 现在该推健身,不是育儿

  ③ 兴趣会漂移
     3个月前:婚纱、婚礼策划
     现在:  婴儿用品
     → 用户的人生阶段变了,旧兴趣要衰减

传统 CF/MF 完全丢失了这些信息——它们眼里用户只是一个「集合」,不是「序列」。


🔄 换一个问题定义

传统推荐 序列推荐
问题 用户 u 对物品 i 的评分是多少? 用户看了 [i₁, i₂, ..., iₜ],下一个 i₍ₜ₊₁₎ 是什么?
数据 (user, item, rating) (user, [item序列])
像什么 打分预测 语言模型

🔑 关键类比:把「物品 ID」当成「单词」,把「用户行为序列」当成「句子」, 那么推荐 = 预测下一个词 = 一个语言模型问题

这个类比是过去十年推荐算法进步的最大引擎,也直接导向了第 14 节的生成式推荐。


📜 演进:从马尔可夫链到 Transformer

 ①  FPMC (2010)  马尔可夫链 + MF
     只看上一个物品 → 简单,但记不住长期兴趣

 ②  GRU4Rec (2016)  循环神经网络
     用 GRU 把整个序列压成一个隐状态
     ✅ 能记长期  ❌ 串行计算慢,长序列会遗忘

 ③  Caser (2018)  用 CNN 做序列
     把序列当"图片",用卷积核捕捉局部模式
     ✅ 快  ❌ 感受野有限

 ④  SASRec (2018)  ⭐ 单向 Transformer
     自注意力:每个位置能直接看到之前所有位置
     ✅ 并行、长程依赖、可解释
     ✅ 至今仍是最强 baseline 之一

 ⑤  BERT4Rec (2019)  双向 Transformer + 完形填空
     随机遮住序列中的物品,让模型猜
     ✅ 双向上下文,表征更强
     ⚠️ 但线上是"预测下一个",训练/推理有 gap

 ⑥  HSTU / 生成式推荐 (2024+)  ⭐ 见第 14 节
     千亿参数、超长序列、出现 Scaling Law

⭐ SASRec:值得完全搞懂的那一个

SASRec = Self-Attentive Sequential Recommendation

它做的事

  输入序列:  [i₁, i₂, i₃, i₄]
                              ↓ 每个位置预测下一个
  预测目标:  [i₂, i₃, i₄, i₅]

  这叫 "next-item prediction",和 GPT 的训练方式一模一样

关键结构:因果掩码(Causal Mask)

      能看到谁                 位置1  位置2  位置3  位置4
   位置1 只能看自己              ✅    ❌     ❌    ❌
   位置2 能看 1,2                ✅    ✅     ❌    ❌
   位置3 能看 1,2,3              ✅    ✅     ✅    ❌
   位置4 能看 1,2,3,4            ✅    ✅     ✅    ✅

   为什么?防止「偷看未来」——预测第3个时不能看到第3个是什么

注意力权重的可解释性 🎁

SASRec 训练完,注意力权重是可以看的:

  预测下一个物品时,模型关注了:
    3天前的  机械键盘   ████████ 0.42
    1周前的  显示器     █████    0.28
    2个月前的 咖啡杯    █        0.05
    1年前的  运动鞋     ▏        0.01

  → 模型自己学会了「近期 + 同品类」的注意力模式

🔨 完整实现(能跑)

import torch
import torch.nn as nn
import numpy as np

class SASRec(nn.Module):
    def __init__(self, n_items, max_len=50, d=64, n_heads=2, n_blocks=2, dropout=0.2):
        super().__init__()
        self.max_len = max_len
        self.item_emb = nn.Embedding(n_items + 1, d, padding_idx=0)
        self.pos_emb  = nn.Embedding(max_len, d)       # 位置编码:告诉模型顺序
        self.dropout = nn.Dropout(dropout)

        self.blocks = nn.ModuleList([
            nn.TransformerEncoderLayer(
                d_model=d, nhead=n_heads, dim_feedforward=d * 4,
                dropout=dropout, batch_first=True, norm_first=True)
            for _ in range(n_blocks)
        ])
        self.norm = nn.LayerNorm(d)

    def forward(self, seq):
        """seq: (B, L) 物品 ID 序列,0 表示 padding"""
        B, L = seq.shape
        pos = torch.arange(L, device=seq.device).unsqueeze(0).expand(B, L)

        x = self.item_emb(seq) * (self.item_emb.embedding_dim ** 0.5)
        x = self.dropout(x + self.pos_emb(pos))

        # ⭐ 因果掩码:位置 i 不能看到 i 之后的内容
        causal = torch.triu(torch.ones(L, L, device=seq.device), diagonal=1).bool()
        pad_mask = (seq == 0)

        for blk in self.blocks:
            x = blk(x, src_mask=causal, src_key_padding_mask=pad_mask)
        return self.norm(x)                        # (B, L, d)

    def loss(self, seq, pos_items, neg_items):
        """
        seq:       (B, L)  历史序列
        pos_items: (B, L)  每个位置的真实下一个物品
        neg_items: (B, L)  随机采的负样本
        """
        h = self.forward(seq)                                 # (B, L, d)
        pos_e = self.item_emb(pos_items)
        neg_e = self.item_emb(neg_items)

        pos_logit = (h * pos_e).sum(-1)                       # (B, L)
        neg_logit = (h * neg_e).sum(-1)

        mask = (pos_items != 0).float()                       # 忽略 padding 位置
        # 二元交叉熵:正样本分数要高,负样本分数要低
        loss = -(torch.log(torch.sigmoid(pos_logit) + 1e-24) * mask
                 + torch.log(1 - torch.sigmoid(neg_logit) + 1e-24) * mask).sum()
        return loss / mask.sum()

    @torch.no_grad()
    def recommend(self, seq, k=10):
        """用最后一个位置的表示,和所有物品 Embedding 点积"""
        h = self.forward(seq)[:, -1, :]                       # (B, d)
        scores = h @ self.item_emb.weight.T                   # (B, n_items+1)
        scores[:, 0] = -np.inf                                # 屏蔽 padding
        # 屏蔽已经看过的
        scores.scatter_(1, seq, -np.inf)
        return scores.topk(k, dim=-1)


# ---- 训练数据怎么造 ----
def make_training_sample(user_seq, max_len=50):
    """
    user_seq = [3, 7, 12, 45, 9]
    →  seq = [0,...,0, 3, 7, 12, 45]   (左 padding)
       pos = [0,...,0, 7, 12, 45, 9]   (每个位置的下一个)
    """
    seq = np.zeros(max_len, dtype=np.int64)
    pos = np.zeros(max_len, dtype=np.int64)
    nxt = user_seq[-1]
    idx = max_len - 1
    for item in reversed(user_seq[:-1]):
        seq[idx], pos[idx] = item, nxt
        nxt = item
        idx -= 1
        if idx < 0: break
    return seq, pos

🎭 BERT4Rec:另一条路(了解即可)

SASRec BERT4Rec
训练方式 预测下一个(自回归) 随机遮住中间的,双向猜(完形填空)
能看到的上下文 只能看左边 左右都能看
和线上任务的一致性 ✅ 完全一致 ⚠️ 有 gap(线上没有"右边")
实际效果 论文里略低,但复现研究表明调参充分后常常反超 论文里更高

⚠️ 一个重要的行业教训:2022 年后多篇复现研究指出,许多序列推荐论文的「超越 SASRec」在公平调参下无法复现。 SASRec 依然是最强的 baseline 之一。 教训:别轻信论文里的 SOTA,自己跑一遍 baseline。


🕐 序列推荐的四个实战难题

① 长序列怎么办?

用户可能有 10 万条历史,Transformer 是 O(L²),扛不住。

方案 做法
截断 只取最近 50–200 条。最简单,损失长期兴趣
SIM (阿里) 两阶段:先用类目/向量检索出相关的历史(GSU),再对这一小部分做注意力(ESU)⭐
长短期分离 长期兴趣用池化/聚类压缩成几个向量,短期用完整序列
线性注意力 / HSTU 改注意力的复杂度,见第 14 节

② 多行为怎么融合?

点击、加购、购买、收藏……不能都当成一样的。 → 给每个行为加 behavior type embedding,或分别建序列后融合。

③ 时间间隔要不要建模?

连续 3 次点击间隔 5 秒 vs 间隔 3 天,含义完全不同。 → TiSASRec:把时间间隔也编码进注意力。 → 简化做法:加「距今时长分桶」的 Embedding。

④ 会话推荐 vs 长期推荐

匿名用户/新会话(电商游客、新闻网站)只有当前会话几个点击。 → 这叫 Session-based Recommendation,GRU4Rec、SR-GNN 就是为它设计的。


📏 序列推荐的评测(⚠️ 有大坑)

标准做法:Leave-One-Out(留一法)

  用户序列: [i₁, i₂, ..., i₄₈, i₄₉, i₅₀]
                              ↑验证  ↑测试
  用前 48 个训练,i₄₉ 验证,i₅₀ 测试

指标:HR@K(命中率)、NDCG@K(考虑位置的命中)

🚨 大坑:负采样评测不可信

很多论文的做法:从没交互的物品里随机采 100 个当负样本,看真实物品能不能排进前 10。

问题:随机采的 100 个基本都是冷门物品,太容易排赢了 → 指标虚高、模型排名会变。

正确做法全量排序评测(把真实物品和全部 N 个物品一起排)。 Krichene & Rendle (KDD 2020) 的论文专门论证了这一点——采样评测会导致错误的模型排名结论

📌 你自己做项目时也遵守这条:能全量排就全量排。


🔗 这一章连到哪里

去哪为什么
ML基础 13GRU4Rec 的底子,也解释了 RNN 为什么最终被自注意力取代
全景导论 02SASRec 就是单向 Transformer:位置编码 + 因果掩码 + 自注意力,一个不少
上线之后 02本章说「采样评测会骗你」,那一章把整个离线评测为什么骗人拆成五道坎。⭐ 坎 ① 和本章是同一个病根的另一半:评估集里全是旧模型曝光过的样本,新模型想推的东西压根不在候选里 —— 务实解法是留 1% 随机流量攒一份无偏评估集
12 · 评估与 AB 实验本章这条结论在板块内的落点:那一章「离线评估的五个致命陷阱」的陷阱 2「负采样评测」就写着「第 9 节讲过」,⭐ 顺带把旁边四个陷阱(随机切分 / 曝光偏差 / 位置偏差 / 只看总体)一起收了
Kaggle 18 · 多兴趣召回⭐ 本章把用户历史压成一个向量。但一个人可以同时喜欢科幻片和纪录片,压成一个向量等于取平均、两头不讨好。MIND / ComiRec 给一个用户产出多个兴趣向量,那边讲了怎么做

✅ 检查点

  1. 序列推荐和传统推荐在「问题定义」上有什么不同?
  2. 为什么说序列推荐 ≈ 语言模型?
  3. SASRec 的因果掩码是干什么的?
  4. SASRec 和 BERT4Rec 的训练方式区别?哪个和线上任务更一致?
  5. 长序列(10 万条历史)怎么处理?SIM 的思路是什么?
  6. 为什么「采样 100 个负样本」的评测方式不可信?
👀 答案 1. 传统:预测 (u,i) 的分数。序列:给定行为序列,预测下一个物品。 2. 物品 ID = 单词,用户行为序列 = 句子,推荐 = 预测下一个词。训练方式(next-token prediction)完全一致。 3. 防止位置 i 看到 i 之后的信息,避免"偷看未来"导致的信息泄漏。 4. SASRec 自回归预测下一个(只看左边),BERT4Rec 双向完形填空。SASRec 和线上任务更一致(线上没有"右边"的信息)。 5. 截断(最简单);SIM 两阶段:先用类目/向量检索出相关历史,再对这个小子集做注意力;长短期分离。 6. 随机采的负样本大多是冷门物品,太容易被排在后面,指标虚高,且会导致模型间的相对排名结论错误。应做全量排序评测。

🛑 可以停在这里

走神救援

序列推荐:物品=单词,行为序列=句子,推荐=预测下一个词。演进:FPMC→GRU4Rec→SASRec(单向Transformer+因果掩码, 最强baseline)→BERT4Rec(双向完形填空)→生成式。长序列用 SIM 两阶段检索。评测要全量排序,别用采样负样本。因果掩码不是实现细节,是 SASRec 好用的原因:把右边掩掉之后,一条长度 L 的序列一次前向就产生了 L 条训练样本(每个位置都预测它的下一个),而且训练目标和线上「只知道过去、预测未来」完全一致;BERT4Rec 的双向完形填空在论文里更高,但线上根本没有「右边」,天然存在 gap。⚠️ 2022 年后多篇复现研究指出,很多「超越 SASRec」在公平调参下复现不出来,它至今仍是最强 baseline 之一——教训是别信论文里的 SOTA,先把 baseline 自己跑通。⭐ 三个实战点常被忽略:多行为不能混成一条序列,要加 behavior type embedding;时间间隔必须建模,连续 3 次点击隔 5 秒和隔 3 天含义完全不同(TiSASRec 或「距今时长分桶」);匿名游客只有当前会话几个点击,那属于 Session-based 场景,GRU4Rec、SR-GNN 就是为它设计的。

下一节 👉 10-向量检索与ANN.md

打卡记录保存在你的浏览器里,首页能看到总进度