📑 本页目录(点开跳转)
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基础 13 | GRU4Rec 的底子,也解释了 RNN 为什么最终被自注意力取代 |
| 全景导论 02 | SASRec 就是单向 Transformer:位置编码 + 因果掩码 + 自注意力,一个不少 |
| 上线之后 02 | 本章说「采样评测会骗你」,那一章把整个离线评测为什么骗人拆成五道坎。⭐ 坎 ① 和本章是同一个病根的另一半:评估集里全是旧模型曝光过的样本,新模型想推的东西压根不在候选里 —— 务实解法是留 1% 随机流量攒一份无偏评估集 |
| 12 · 评估与 AB 实验 | 本章这条结论在板块内的落点:那一章「离线评估的五个致命陷阱」的陷阱 2「负采样评测」就写着「第 9 节讲过」,⭐ 顺带把旁边四个陷阱(随机切分 / 曝光偏差 / 位置偏差 / 只看总体)一起收了 |
| Kaggle 18 · 多兴趣召回 | ⭐ 本章把用户历史压成一个向量。但一个人可以同时喜欢科幻片和纪录片,压成一个向量等于取平均、两头不讨好。MIND / ComiRec 给一个用户产出多个兴趣向量,那边讲了怎么做 |
✅ 检查点
- 序列推荐和传统推荐在「问题定义」上有什么不同?
- 为什么说序列推荐 ≈ 语言模型?
- SASRec 的因果掩码是干什么的?
- SASRec 和 BERT4Rec 的训练方式区别?哪个和线上任务更一致?
- 长序列(10 万条历史)怎么处理?SIM 的思路是什么?
- 为什么「采样 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