打开淘宝刚浏览了几件商品,首页的"猜你喜欢"立刻刷新;听完两首周杰伦,音乐 App 紧接着推同风格的曲子。这种"根据最近行为序列预测下一步兴趣"的能力,就是序列推荐(Sequential Recommendation)的看家本领。
传统协同过滤把用户历史当作一袋无序的交互来处理,把"先后顺序"这条信息直接扔掉了。但现实中,用户兴趣随时间漂移,行为之间有强烈的依赖关系——刚看完手机的用户更可能去看手机壳,而不是台式机。如何把这种动态、有序的偏好建模出来,是序列推荐要回答的核心问题。

本文按照模型演进顺序,把马尔可夫链、GRU4Rec、Caser、SASRec、BERT4Rec、BST、SR-GNN 一条线讲透:动机、公式、实现细节、各自的强项与代价。读完应该能在面对一个具体业务问题时,知道"该选哪个、为什么选它、踩坑会踩在哪里"。
你将学到
- 为什么"顺序"是协同过滤丢掉的关键信号
- 马尔可夫链作为最简基线:为什么稀疏,又为什么仍然有用
- GRU4Rec:第一个把 RNN 用到 session-based 推荐里的工作
- Caser:把序列当成"图像"用 CNN 抽多尺度模式
- SASRec / BERT4Rec:Transformer 时代的单向与双向方案
- BST:把品类、品牌、价格这些 side feature 也喂进 Transformer
- SR-GNN:把会话建模成有向图,再用 GNN 编码
- HR@K、NDCG@K、MRR 这三个评估指标在测什么
- 部署到工业系统时的 6 个关键工程细节
前置知识
- 熟悉神经网络基础(RNN、CNN、Transformer)
- PyTorch 基本用法
- 推荐系统基础(参考第 1 篇
)
- Embedding 表示学习(参考第 5 篇
)
序列推荐到底在解什么问题
形式化定义
给定用户 $u$ 的历史交互序列 $S_u = [i_1, i_2, \dots, i_t]$,序列推荐要估计
$$
P(i_{t+1} \mid S_u) = P(i_{t+1} \mid i_1, i_2, \dots, i_t).
$$
通俗讲:把用户已经做过的事按时间顺序铺开,然后猜下一个动作是什么。这里的关键不只是"这些 item 出现过",更是"它们以什么顺序出现"。
为什么这件事重要
序列推荐能在生产系统里站稳脚跟,靠的是四件事:
- 兴趣漂移:上个月迷动作片,这个月在啃纪录片。静态模型抓不住这种迁移,序列模型会跟着调整。
- 局部上下文:刚点完一支预告片,紧接着想看的就是正片本身,不是随机推荐。这种短链条逻辑只有序列模型才装得下。
- 会话意图:电商里用户连看几个笔记本,下一个大概率是笔记本包——这种规律藏在"转移概率"里,不在边缘流行度里。
- 冷启动缓解:哪怕只有 3 条交互,“顺序"本身也是信号。无序模型把这个信号直接丢了。
与传统推荐的对照
| 维度 | 传统协同过滤 | 序列推荐 |
|---|
| 输入 | 无序的交互集合 | 有序的交互序列 |
| 时间建模 | 忽略 | 一等公民 |
| 预测目标 | $P(i \mid u)$ | $P(i_{t+1} \mid S_u)$ |
| 强项 | 长期偏好 | 下一步预测 |
| 典型话术 | “和你相似的人也喜欢……” | “根据你刚才的行为……” |
三种典型形态
- 用户级序列推荐:把用户全部历史拼成一条长序列。适合捕捉慢速的兴趣演化(如音乐 App 的几个月听歌史)。
- 会话级推荐(Session-based):每个会话独立处理。适合匿名用户或短期意图(一次电商浏览)。
- 混合方案:长期 embedding 表达"你是谁”,当前会话表达"你现在想要什么"。工业系统大多落到这一档。
马尔可夫链方法
一阶马尔可夫链
最简单的序列模型是一阶马尔可夫链:假设下一个 item 只依赖当前 item。
$$
P(i_{t+1} \mid i_1, \dots, i_t) = P(i_{t+1} \mid i_t).
$$
类比:好比只用当前这个词去预测下一个词。看到"冰"你可能猜"激凌",但完全不知道前面在聊甜品还是冰球。
实现上是学一个转移矩阵 $M \in \mathbb{R}^{|I| \times |I|}$,$M_{ij} = P(j \mid i)$,从频次估计:
$$
M_{ij} = \frac{\text{count}(i \to j)}{\text{count}(i)}.
$$
高阶马尔可夫链
$k$ 阶链条件在最近 $k$ 个 item 上:
$$
P(i_{t+1} \mid i_1, \dots, i_t) = P(i_{t+1} \mid i_{t-k+1}, \dots, i_t).
$$
阶数越高上下文越多,但状态空间指数级膨胀:1 万个 item 的二阶模型要估 $10^8$ 种转移概率,绝大多数从未在数据里出现过。这就是数据稀疏问题。
FPMC:因子分解马尔可夫链
FPMC(Factorizing Personalized Markov Chains)把矩阵分解和马尔可夫链拼在一起:既考虑个性化(用户向量),也保留序列模式(item 转移)。优点是参数规模远小于纯高阶链,并能跨用户共享统计信号;缺点是表达能力仍然有限。
实现:一阶马尔可夫推荐器
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
| import numpy as np
from collections import defaultdict
from typing import List
class MarkovChainRecommender:
"""一阶马尔可夫链:简单、可解释,是稳定的强基线。"""
def __init__(self, order: int = 1):
self.order = order
self.transition_counts = defaultdict(lambda: defaultdict(int))
self.context_counts = defaultdict(int)
self.items = set()
def fit(self, sequences: List[List[int]]):
for seq in sequences:
self.items.update(seq)
for i in range(len(seq) - self.order):
context = tuple(seq[i:i + self.order])
next_item = seq[i + self.order]
self.transition_counts[context][next_item] += 1
self.context_counts[context] += 1
def predict_next(self, sequence: List[int], top_k: int = 10):
if len(sequence) < self.order:
return []
context = tuple(sequence[-self.order:])
if context not in self.transition_counts:
return []
total = self.context_counts[context]
probs = [(item, count / total)
for item, count in self.transition_counts[context].items()]
probs.sort(key=lambda x: x[1], reverse=True)
return probs[:top_k]
# --- 使用示例 ---
sequences = [
[1, 2, 3, 4], # 笔记本 -> 鼠标 -> 键盘 -> 显示器
[1, 2, 5], # 笔记本 -> 鼠标 -> 鼠标垫
[2, 3, 4], # 鼠标 -> 键盘 -> 显示器
[1, 6, 7], # 笔记本 -> 充电器 -> 电脑包
]
model = MarkovChainRecommender(order=1)
model.fit(sequences)
for item, prob in model.predict_next([1, 2], top_k=3):
print(f" item {item}: {prob:.3f}")
# 鼠标后面跟"键盘"出现了 2 次,所以 keyboard 排第一
|
马尔可夫链的局限
- 数据稀疏:item 越多,绝大多数转移从未出现过;平滑能缓解但治不了根。
- 窗口太短:哪怕上几阶,也只看得见固定窗口,根本表达不出"这个用户最近一个月都在研究摄影"这种长程信号。
- 没有泛化:A 和 B 即便是相似 item,模型也当成完全独立——它没有共享表示。
这三件事正好是神经序列模型要补的洞。
GRU4Rec:用 RNN 建模会话
模型动机
GRU4Rec(Hidasi et al., 2015)是把深度学习正式带进 session-based 推荐的开山之作。它用 Gated Recurrent Unit 一步步读 item,用一个不断更新的隐藏状态来"压缩"会话至今的全部内容。
核心想法:不要把会话当作 item 的袋子,而是按时间顺序一个一个读,让一个隐藏状态承担"短期记忆"的职责。

GRU 单元的更新方式
GRU 维护一个隐藏状态 $h_t$。给定输入 $x_t$(item $i_t$ 的 embedding)和上一时刻的 $h_{t-1}$:
重置门(决定要丢掉多少历史):
$$r_t = \sigma(W_r x_t + U_r h_{t-1} + b_r)$$
更新门(决定历史与新信息的混合比例):
$$z_t = \sigma(W_z x_t + U_z h_{t-1} + b_z)$$
候选状态(基于"被允许通过"的历史 + 当前输入算出来的新表示):
$$\tilde{h}_t = \tanh(W_h x_t + U_h (r_t \odot h_{t-1}) + b_h)$$
最终隐藏状态(在旧状态和候选状态之间内插):
$$h_t = (1 - z_t) \odot h_{t-1} + z_t \odot \tilde{h}_t$$
直观理解:GRU 把一段会话当作一段记忆——重置门决定"要不要忘记",更新门决定"要不要写入",最后的 $h_t$ 就是整段会话的压缩摘要。
整体流程:Embedding → GRU → 全连接 → Softmax over items,损失用 BPR 或 TOP1 这类 implicit feedback 上的排序损失。
关键工程细节
实现 GRU4Rec 时,几个看似简单的设计往往决定了能不能跑出靠谱的效果:
Session-parallel mini-batch:原论文里特别提到的训练策略。每个 batch 里同时跑多个会话,但会话长度不同。短会话提前结束后,对应位置的隐藏状态停止更新。这样既能利用 GPU 并行,又不会让 padding 把训练目标污染掉。
负采样:物品空间动辄百万,直接对全词表算 softmax 是奢侈。GRU4Rec 用负采样近似——正样本是序列里真正的下一个 item,负样本随机从词表抽几条,配合 BPR 损失最大化正负样本得分差。
变长序列处理:用 PyTorch 的 pack_padded_sequence / pad_packed_sequence 把 padding 排除出 GRU 的实际计算,只在有效位置消耗算力。
Dropout:通常加在 embedding 层之后和 GRU 输出之后。0.2–0.5 是常用范围;数据少就调大些。
实现
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
| import torch
import torch.nn as nn
import torch.nn.functional as F
from torch.utils.data import Dataset, DataLoader
class GRU4Rec(nn.Module):
"""基于 GRU 的会话推荐模型。"""
def __init__(self, num_items: int, embedding_dim: int = 128,
hidden_dim: int = 128, num_layers: int = 1, dropout: float = 0.25):
super().__init__()
self.num_items = num_items
self.item_embedding = nn.Embedding(num_items + 1, embedding_dim, padding_idx=0)
self.gru = nn.GRU(
input_size=embedding_dim,
hidden_size=hidden_dim,
num_layers=num_layers,
batch_first=True,
dropout=dropout if num_layers > 1 else 0,
)
self.output = nn.Linear(hidden_dim, num_items + 1)
self.dropout = nn.Dropout(dropout)
nn.init.normal_(self.item_embedding.weight, mean=0, std=0.01)
nn.init.xavier_uniform_(self.output.weight)
nn.init.zeros_(self.output.bias)
def forward(self, sequences, hidden=None):
x = self.dropout(self.item_embedding(sequences))
gru_out, hidden = self.gru(x, hidden)
return self.output(self.dropout(gru_out)), hidden
def predict_next(self, sequence, top_k=10):
self.eval()
with torch.no_grad():
seq = torch.LongTensor([sequence]).to(next(self.parameters()).device)
logits, _ = self(seq)
scores, indices = torch.topk(logits[0, -1, :], k=min(top_k, self.num_items))
return list(zip(indices.tolist(), scores.tolist()))
class SessionDataset(Dataset):
"""填充/截断到 max_len;输入 = 去掉最后一项,标签 = 去掉第一项。"""
def __init__(self, sessions, max_len=20):
self.sessions = sessions
self.max_len = max_len
def __len__(self):
return len(self.sessions)
def __getitem__(self, idx):
s = self.sessions[idx][-self.max_len:]
s = [0] * (self.max_len - len(s)) + s
return torch.LongTensor(s[:-1]), torch.LongTensor(s[1:])
def train_gru4rec(model, loader, num_epochs=10, lr=1e-3, device="cpu"):
opt = torch.optim.Adam(model.parameters(), lr=lr)
loss_fn = nn.CrossEntropyLoss(ignore_index=0)
model.to(device)
for epoch in range(num_epochs):
model.train()
total = 0.0
for x, y in loader:
x, y = x.to(device), y.to(device)
opt.zero_grad()
logits, _ = model(x)
loss = loss_fn(logits.reshape(-1, logits.size(-1)), y.reshape(-1))
loss.backward()
opt.step()
total += loss.item()
print(f"epoch {epoch + 1}/{num_epochs} loss={total / len(loader):.4f}")
|
优缺点
| 强项 | 弱项 |
|---|
| 天然适合序列依赖建模 | 时间维度无法并行,训练慢 |
| 变长序列开箱即用 | 长程依赖会"忘掉" |
| 结构紧凑,调参成本低 | 隐藏状态固定大小,承载力有限 |
Caser:用 CNN 抽多尺度模式
动机
Caser(Tang & Wang, 2018)换了个思路:把 embedding 后的序列当作一张"图像",用卷积去扫。不同大小的卷积核同时捕获不同长度的模式,全部并行。
类比:GRU4Rec 是逐字读句子,Caser 是把整句铺平,然后用一组不同放大倍率的放大镜同时看不同尺度的模式(双词、三词、四词词组……)。
架构
设输入序列 embedding 矩阵为 $\mathbf{E} \in \mathbb{R}^{t \times d}$(长度 $t$,维度 $d$):
- 水平卷积沿序列方向滑动,捕获联合级 n-gram 模式。卷积核高度取 2、3、4 就分别得到双词、三词、四词检测器。
- 垂直卷积沿 embedding 维度扫,捕获点级潜在特征——把序列里同一维度的特征聚合起来。
$$
\mathbf{c}_h = \text{ReLU}(\text{Conv}_h(\mathbf{E})) \quad\text{(水平,高度为 } h\text{)}
$$$$
\mathbf{c}_v = \text{ReLU}(\text{Conv}_v(\mathbf{E})) \quad\text{(垂直,覆盖整个序列)}
$$
水平 + 垂直输出做 max pooling、拼接,再过全连接头预测下一个 item。
实现
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
| class Caser(nn.Module):
"""基于 CNN 的序列推荐。"""
def __init__(self, num_items: int, embedding_dim: int = 50,
max_len: int = 50, num_horizon: int = 16,
num_vertical: int = 8,
horizon_sizes: list = [2, 3, 4]):
super().__init__()
self.num_items = num_items
self.max_len = max_len
self.item_embedding = nn.Embedding(num_items + 1, embedding_dim, padding_idx=0)
self.horizon_convs = nn.ModuleList([
nn.Conv2d(1, num_horizon, (h, embedding_dim)) for h in horizon_sizes
])
self.vertical_conv = nn.Conv2d(1, num_vertical, (max_len, 1))
total = num_horizon * len(horizon_sizes) + num_vertical
self.fc1 = nn.Linear(total, embedding_dim)
self.fc2 = nn.Linear(embedding_dim, num_items + 1)
self.dropout = nn.Dropout(0.5)
def forward(self, sequences):
emb = self.item_embedding(sequences).unsqueeze(1) # (B, 1, L, D)
h_outs = []
for conv in self.horizon_convs:
o = F.relu(conv(emb))
h_outs.append(F.max_pool2d(o, (o.size(2), 1)).squeeze(-1).squeeze(-1))
h = torch.cat(h_outs, dim=1)
v = F.relu(self.vertical_conv(emb)).squeeze(2).squeeze(2)
x = self.dropout(F.relu(self.fc1(torch.cat([h, v], dim=1))))
return self.fc2(x)
|
Caser 的价值
- 天生并行:CNN 一次前向把整段序列处理完,比 RNN 在 GPU 上快得多。
- 多尺度模式:不同高度的卷积核同时抓双词、三词、四词组合。
- 局部模式专家:在短会话、强局部规律的场景上,性价比比 RNN/Transformer 都高。
SASRec:自注意力的序列推荐
SASRec(Kang & McAuley, 2018)把 Transformer 编码器带进了序列推荐。自注意力一次性解决了 RNN 的两个老问题:任意两个位置之间一步直达(不再衰减),并且所有位置同时算(不再串行)。
关键直觉:RNN 一次只能读一个 item,远距离 item 之间的信号要经过很多步才能传过去;自注意力让每个 item 一次矩阵乘法就能看见之前所有 item,远近一视同仁。

热图能直观看到三个特征:对角线最深(每个位置当然最关心自己);越近的 item 权重越大(recency bias);右上三角被遮蔽——SASRec 不允许"偷看"未来。
关键组件
1. Self-Attention:每个位置对所有更早的位置算注意力。
$$
\text{Attention}(Q, K, V) = \text{softmax}\!\left(\frac{QK^\top}{\sqrt{d_k}}\right) V.
$$
$\sqrt{d_k}$ 缩放是为了避免点积随维度增大而爆炸,让 softmax 处于敏感区。
2. 因果掩码:位置 $t$ 只能看 $1, \dots, t$。否则模型直接抄"答案",训练就破功了。
3. 位置编码:自注意力本身对位置不敏感——它对输入是置换等变的。要让模型知道"顺序",必须把位置信息注入进去,经典做法是正弦位置编码:
$$
PE_{(p, 2i)} = \sin(p / 10000^{2i/d}), \qquad
PE_{(p, 2i+1)} = \cos(p / 10000^{2i/d}).
$$
下图展示这个加法在做什么。

4. 残差连接 + LayerNorm:标准 Transformer 配件,让深层堆叠能训得动。
实现
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 math
class PositionalEncoding(nn.Module):
def __init__(self, d_model, max_len=5000):
super().__init__()
pe = torch.zeros(max_len, d_model)
pos = torch.arange(0, max_len, dtype=torch.float).unsqueeze(1)
div = torch.exp(torch.arange(0, d_model, 2).float() * (-math.log(10000.0) / d_model))
pe[:, 0::2] = torch.sin(pos * div)
pe[:, 1::2] = torch.cos(pos * div)
self.register_buffer("pe", pe.unsqueeze(0))
def forward(self, x):
return x + self.pe[:, : x.size(1), :]
class TransformerBlock(nn.Module):
def __init__(self, d_model, num_heads, d_ff, dropout=0.1):
super().__init__()
self.attn = nn.MultiheadAttention(d_model, num_heads, dropout=dropout, batch_first=True)
self.norm1 = nn.LayerNorm(d_model)
self.norm2 = nn.LayerNorm(d_model)
self.ff = nn.Sequential(
nn.Linear(d_model, d_ff), nn.ReLU(),
nn.Dropout(dropout),
nn.Linear(d_ff, d_model), nn.Dropout(dropout),
)
def forward(self, x, mask=None):
a, _ = self.attn(x, x, x, attn_mask=mask)
x = self.norm1(x + a)
x = self.norm2(x + self.ff(x))
return x
class SASRec(nn.Module):
def __init__(self, num_items, d_model=128, num_heads=2,
num_layers=2, d_ff=256, max_len=50, dropout=0.1):
super().__init__()
self.num_items = num_items
self.d_model = d_model
self.max_len = max_len
self.item_embedding = nn.Embedding(num_items + 1, d_model, padding_idx=0)
self.pos_encoding = PositionalEncoding(d_model, max_len)
self.blocks = nn.ModuleList([
TransformerBlock(d_model, num_heads, d_ff, dropout) for _ in range(num_layers)
])
self.dropout = nn.Dropout(dropout)
self.output = nn.Linear(d_model, num_items + 1)
def forward(self, sequences):
seq_len = sequences.size(1)
x = self.item_embedding(sequences) * math.sqrt(self.d_model)
x = self.dropout(self.pos_encoding(x))
causal = torch.triu(torch.ones(seq_len, seq_len, device=sequences.device),
diagonal=1).bool()
for block in self.blocks:
x = block(x, mask=causal)
return self.output(x)
|
为什么 SASRec 成了默认选择
- 长程依赖直接打通,没有梯度衰减问题。
- 训练并行,GPU 利用率高。
- 可解释性免费送——注意力权重直接告诉你哪个历史 item 影响了预测。
- 可扩展性好:序列变长、模型变宽,效果通常都跟着涨。
BERT4Rec:双向编码的序列推荐
动机
BERT4Rec(Sun et al., 2019)把 Transformer 主干保留,但训练目标换了——借了 BERT 的完形填空(cloze task):随机掩掉序列里若干 item,让模型用双向上下文把它们补全。
等等,不是不能看未来吗? 训练时确实可以!我们随机遮掉若干位置,让双向编码器把这些位置重建出来。推理时则在序列末尾追加一个 [MASK],让它去填——backbone 一样,pretext task 不同,得到的表示更丰富。

与 SASRec 的对照
| 特性 | SASRec | BERT4Rec |
|---|
| 注意力方向 | 因果(left → right) | 双向 |
| 训练任务 | 预测下一个 item | 预测被掩码的 item |
| Mask 策略 | 训练用因果掩码 | 训练用随机掩码 |
| 推理方式 | 取最后一个位置的输出 | 末尾追加 [MASK],读它的输出 |
实现
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
| class BERT4Rec(nn.Module):
"""双向 Transformer + 完形填空式训练。"""
def __init__(self, num_items, d_model=128, num_heads=2,
num_layers=2, d_ff=256, max_len=50,
dropout=0.1, mask_prob=0.15):
super().__init__()
self.num_items = num_items
self.max_len = max_len
self.mask_prob = mask_prob
self.mask_token = num_items + 1 # [MASK] id
self.item_embedding = nn.Embedding(num_items + 2, d_model, padding_idx=0)
self.pos_encoding = PositionalEncoding(d_model, max_len)
self.blocks = nn.ModuleList([
TransformerBlock(d_model, num_heads, d_ff, dropout) for _ in range(num_layers)
])
self.dropout = nn.Dropout(dropout)
self.output = nn.Linear(d_model, num_items + 1)
def _mask_sequence(self, sequences):
import random
masked = sequences.clone()
positions = torch.zeros_like(sequences, dtype=torch.bool)
for i in range(sequences.size(0)):
for j in range(sequences.size(1)):
if sequences[i, j] != 0 and random.random() < self.mask_prob:
positions[i, j] = True
r = random.random()
if r < 0.8:
masked[i, j] = self.mask_token # 80%: [MASK]
elif r < 0.9:
masked[i, j] = random.randint(1, self.num_items)# 10%: 随机
# 10%: 保持原样
return masked, positions
def forward(self, sequences, training=True):
if training:
sequences, positions = self._mask_sequence(sequences)
else:
positions = torch.zeros_like(sequences, dtype=torch.bool)
x = self.item_embedding(sequences) * math.sqrt(self.item_embedding.embedding_dim)
x = self.dropout(self.pos_encoding(x))
for block in self.blocks: # 没有因果掩码
x = block(x)
return self.output(x), positions
def predict_next(self, sequence, top_k=10):
self.eval()
with torch.no_grad():
if len(sequence) >= self.max_len:
sequence = sequence[-(self.max_len - 1):]
sequence = sequence + [self.mask_token]
sequence = [0] * (self.max_len - len(sequence)) + sequence
seq = torch.LongTensor([sequence])
logits, _ = self(seq, training=False)
mask_pos = sequence.index(self.mask_token)
scores, indices = torch.topk(logits[0, mask_pos, :],
k=min(top_k, self.num_items))
return list(zip(indices.tolist(), scores.tolist()))
|
取舍
收益:双向上下文得到的表示更丰富;随机掩码训练让模型对噪声、缺失更鲁棒。
代价:推理需要 [MASK] 追加这个小 trick,不如自回归直观;训练流程稍复杂一些;在很多真实数据集上,调好的 SASRec 已经能追平甚至略好。先上 SASRec,确实需要再考虑 BERT4Rec。
它和别人的不同
BST(Chen et al., 2019,阿里)把 Transformer 扩展到了多字段特征场景。真实电商不只有 item ID,还有类目、品牌、价格档、店铺、用户人群——BST 把这些都 embedding 后拼起来塞进 Transformer。
核心想法:真实用户行为不是 ID 序列,是事件序列。每个事件是 item ID + 类目 + 品牌 + 价格 + 时间间隔的组合。BST 把这一束特征当作输入的最小单位。
实现
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
| class FeatureEmbedding(nn.Module):
"""把多个类别字段分别 embedding 再拼接。"""
def __init__(self, feature_dims, embedding_dim):
super().__init__()
self.embeddings = nn.ModuleDict({
name: nn.Embedding(dim + 1, embedding_dim, padding_idx=0)
for name, dim in feature_dims.items()
})
def forward(self, features):
return torch.cat([self.embeddings[n](t) for n, t in features.items()], dim=-1)
class BST(nn.Module):
"""适合特征丰富电商场景的行为序列 Transformer。"""
def __init__(self, item_vocab_size, feature_dims,
embedding_dim=64, num_heads=2, num_layers=2,
d_ff=256, max_len=50, dropout=0.1):
super().__init__()
self.max_len = max_len
self.item_embedding = nn.Embedding(item_vocab_size + 1, embedding_dim, padding_idx=0)
self.feature_embedding = FeatureEmbedding(feature_dims, embedding_dim)
total = embedding_dim * (1 + len(feature_dims))
self.pos_encoding = PositionalEncoding(total, max_len)
self.blocks = nn.ModuleList([
TransformerBlock(total, num_heads, d_ff, dropout) for _ in range(num_layers)
])
self.dropout = nn.Dropout(dropout)
self.mlp = nn.Sequential(
nn.Linear(total, d_ff), nn.ReLU(), nn.Dropout(dropout),
nn.Linear(d_ff, d_ff // 2), nn.ReLU(), nn.Dropout(dropout),
nn.Linear(d_ff // 2, 1),
)
def forward(self, item_ids, features):
x = torch.cat([self.item_embedding(item_ids),
self.feature_embedding(features)], dim=-1)
x = self.dropout(self.pos_encoding(x))
seq_len = item_ids.size(1)
causal = torch.triu(torch.ones(seq_len, seq_len, device=item_ids.device),
diagonal=1).bool()
for block in self.blocks:
x = block(x, mask=causal)
return self.mlp(x).squeeze(-1)
|
会话推荐(Session-based Recommendation)
会话是一段短时间窗口内的交互——一次电商访问、一次播放列表、一次新闻浏览。会话推荐是序列推荐的特例,但带着几条强约束:
- 匿名为常态:用户身份可能不可用。
- 序列短:通常 5–20 条。
- 会话间独立:跨会话历史要么没有、要么主动忽略。
- 实时性要求高:用户每点一下,都得跟得上。
| 场景 | 典型会话 |
|---|
| 电商 | 笔记本 → 笔记本包 → 笔记本支架 |
| 新闻 | 时政 → 体育 → 天气 |
| 音乐 | 爵士 → 深夜爵士 → 古典 |
| 视频 | 一连三个做菜教程 |
SR-GNN:用图神经网络做会话推荐
动机
SR-GNN(Wu et al., 2019)走了条不同的路:把会话建模成有向图,节点是 item,边是转移关系。
为什么用图? 想想会话 $[A, B, C, B, D]$,item $B$ 出现了两次,形成一个回路。图天然能表达这种重复访问,平面序列模型却要费力气才能近似。
图的构造
对会话 $S = [i_1, i_2, \dots, i_t]$:
- 节点:会话中出现过的 item(去重)。
- 边:从每个 item 指向序列中紧随其后的 item,方向感保留。
- 边权:转移频次(自动处理重复访问)。
工作流程
SR-GNN 用**门控图神经网络(Gated Graph Neural Network, GGNN)**在节点间传递信息。更新方程类似 GRU,只是换成了在图邻居上做:
消息传递:
$$
\mathbf{m}_v^{(l)} = \sum_{u \in \mathcal{N}(v)} \mathbf{A}_{uv}\,\mathbf{h}_u^{(l-1)}
$$
门控更新:
$$
\mathbf{z}_v = \sigma(\mathbf{W}_z \mathbf{m}_v + \mathbf{U}_z \mathbf{h}_v),\quad
\mathbf{r}_v = \sigma(\mathbf{W}_r \mathbf{m}_v + \mathbf{U}_r \mathbf{h}_v)
$$$$
\tilde{\mathbf{h}}_v = \tanh(\mathbf{W}_h \mathbf{m}_v + \mathbf{U}_h (\mathbf{r}_v \odot \mathbf{h}_v))
$$$$
\mathbf{h}_v = (1 - \mathbf{z}_v) \odot \mathbf{h}_v + \mathbf{z}_v \odot \tilde{\mathbf{h}}_v
$$
直观理解:每个 item 跟它的"邻居"(紧邻它前后的 item)交换信息。几轮消息传递后,每个 item 的表示都吸收了它在会话图局部邻域的语义。最后用注意力对节点做加权汇总,得到会话表示。
实现(简化版)
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
| class SRGNN(nn.Module):
"""会话即图的推荐模型;简化的 GGNN + 注意力汇总。"""
def __init__(self, num_items, embedding_dim=100, num_gnn_layers=1, dropout=0.2):
super().__init__()
self.num_items = num_items
self.num_gnn_layers = num_gnn_layers
self.item_embedding = nn.Embedding(num_items + 1, embedding_dim, padding_idx=0)
self.W_z = nn.Linear(embedding_dim, embedding_dim)
self.U_z = nn.Linear(embedding_dim, embedding_dim)
self.W_r = nn.Linear(embedding_dim, embedding_dim)
self.U_r = nn.Linear(embedding_dim, embedding_dim)
self.W_h = nn.Linear(embedding_dim, embedding_dim)
self.U_h = nn.Linear(embedding_dim, embedding_dim)
self.attention = nn.Sequential(
nn.Linear(embedding_dim, embedding_dim),
nn.ReLU(),
nn.Linear(embedding_dim, 1),
)
self.output = nn.Linear(embedding_dim, num_items + 1)
self.dropout = nn.Dropout(dropout)
def _gnn_step(self, node_embs, adj):
m = torch.matmul(adj, node_embs)
z = torch.sigmoid(self.W_z(m) + self.U_z(node_embs))
r = torch.sigmoid(self.W_r(m) + self.U_r(node_embs))
h = torch.tanh(self.W_h(m) + self.U_h(r * node_embs))
return (1 - z) * node_embs + z * h
def forward_session(self, session):
unique = list(dict.fromkeys(session)) # 去重保序
idx = {item: i for i, item in enumerate(unique)}
n = len(unique)
adj = torch.zeros(n, n)
for a, b in zip(session, session[1:]):
adj[idx[b], idx[a]] += 1
adj = adj / adj.sum(dim=1, keepdim=True).clamp(min=1)
h = self.item_embedding(torch.LongTensor(unique))
for _ in range(self.num_gnn_layers):
h = self.dropout(self._gnn_step(h, adj))
seq_emb = h[[idx[i] for i in session]]
attn = F.softmax(self.attention(seq_emb), dim=0)
session_emb = (attn * seq_emb).sum(dim=0)
return self.output(session_emb)
|
SR-GNN 的看家本领
- 图结构承载平面序列表达不了的复杂转移模式。
- 重复 item 一等公民——多次出现会汇聚到同一个节点,转移权重叠加。
- 局部消息传递捕获"这个 item 是会话内某个兴趣团簇的中心"这类局部信号。
序列长度该怎么选
不同模型对"序列要多长"的敏感度差别很大,付出的代价也截然不同。下图把这件事画得比较清楚。

三个观察:
- 马尔可夫链很早就饱和:固定窗口的假设决定了它没法吃到更长上下文的好处。
- GRU4Rec 在 50–75 附近见顶:再长就压不住了,定长隐藏状态成为瓶颈。
- SASRec / BERT4Rec 一直在涨:任意两个位置一步直达,没有压缩瓶颈,更长上下文真的能换来更好预测。
代价图正好相反。RNN 时间维度无法并行,开销随序列长度线性走;Transformer 充分利用 GPU 并行,在主流长度区间几乎不涨。两件事合在一起,就解释了为什么 Transformer 成了工业级序列推荐的默认底座。
评估指标
三个必须知道的
Hit Rate (HR@K):测试集中真值出现在 Top-K 推荐里的比例。
$$
\text{HR@K} = \frac{1}{|T|} \sum_{t \in T} \mathbb{1}\!\left[\text{rank}(i_t^*) \leq K\right]
$$
NDCG@K:和 HR@K 类似,但根据排名位置以 $\log$ 折扣。
$$
\text{NDCG@K} = \frac{1}{|T|} \sum_{t \in T} \frac{\mathbb{1}\!\left[\text{rank}(i_t^*) \leq K\right]}{\log_2(\text{rank}(i_t^*) + 1)}
$$
MRR:真值的平均倒数排名。
$$
\text{MRR} = \frac{1}{|T|} \sum_{t \in T} \frac{1}{\text{rank}(i_t^*)}
$$
怎么选? HR@K 最直观(“猜对了没”);NDCG@K 衡量排序质量(“放在多靠前”);MRR 适合每条 query 有且仅有一个相关 item 的场景。
实现
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
| import numpy as np
from typing import List
def hit_rate_at_k(preds: List[List[int]], truth: List[int], k: int = 10) -> float:
return sum(t in p[:k] for p, t in zip(preds, truth)) / len(truth)
def ndcg_at_k(preds: List[List[int]], truth: List[int], k: int = 10) -> float:
scores = []
for p, t in zip(preds, truth):
if t in p[:k]:
scores.append(1.0 / np.log2(p[:k].index(t) + 2))
else:
scores.append(0.0)
return float(np.mean(scores))
def mrr(preds: List[List[int]], truth: List[int]) -> float:
scores = [1.0 / (p.index(t) + 1) if t in p else 0.0 for p, t in zip(preds, truth)]
return float(np.mean(scores))
|
工程实践
数据预处理
填充与截断:短序列前置补 0、长序列保留最近的若干 item 是基线做法。按长度分桶做动态 batch 能减少无效计算;超长历史可以用滑动窗口拆成多个固定长度子序列。
负采样:implicit feedback 只有正样本,必须采负样本。随机采样是基线,按流行度采或做 hard negative mining 能拿到更好的梯度但代价更高(详见第 5 篇
)。
数据增强:三个便宜好用的招数——
- 序列裁剪:把每条会话裁成多个重叠前缀。
- 随机 mask:BERT4Rec 风格,对非 BERT4Rec 模型也有帮助。
- 轻度乱序:扰动非邻接位置,提升鲁棒性。
训练
- 损失:交叉熵、BPR、sampled softmax 三选一。词表上百万时 sampled softmax 几乎是必须。
- 正则:Dropout(0.1–0.5)、L2 weight decay、validation HR@K 上的 early stopping,覆盖大多数情况。
- 优化:Transformer 类模型默认 AdamW + warmup 后 decay;梯度裁剪 norm=1.0 是稳妥的保险。
扩展到百万级 item
按这个顺序加杠杆:
- 负采样:训练时不要对全词表算 softmax。
- 近似最近邻(FAISS、HNSW):在线召回阶段必备。
- 召回 + 精排两阶段:embedding 召回 → 重模型精排几百条候选。
- 蒸馏:训练一个小模型模仿大模型,推理时用小的。
- 量化:FP16 / INT8 推理,挤延迟预算。
- 缓存:item embedding、热门会话、稳定用户的预测都可以缓存。
模型对比与选型建议
| 模型 | 架构 | 可并行 | 长程能力 | Side feature | 最适合 |
|---|
| 马尔可夫链 | 统计 | n/a | 弱 | 无 | 基线 / 极冷启动 |
| GRU4Rec | RNN | 否 | 中 | 无 | 流式更新 / 简单会话 |
| Caser | CNN | 是 | 短 | 无 | 短会话 / 强局部规律 |
| SASRec | Transformer | 是 | 强 | 无 | 通用首选 |
| BERT4Rec | Transformer | 是 | 强 | 无 | 双向上下文真的有帮助时 |
| BST | Transformer | 是 | 强 | 有 | 特征丰富的电商场景 |
| SR-GNN | GNN | 部分 | 中 | 无 | 含重复 item 的会话 |
选型建议:
- 默认 SASRec。在大多数数据集上是单点最强选择。
- 需要在线流式更新就用 GRU4Rec。
- 会话短、局部模式强就用 Caser。
- item side feature 信息量大就用 BST。
- 会话里大量重复 item、转移结构复杂就上 SR-GNN。
- 有大规模预训练资源时再考虑 BERT4Rec。
Q&A
什么时候用会话推荐,什么时候用用户级序列推荐?
会话推荐适合匿名用户、短期意图(电商浏览、新闻)、低延迟场景。用户级序列推荐适合稳定的用户身份、长历史、关心兴趣演化的场景(音乐、视频)。生产系统大多两条腿走路:长期 embedding 表达"你是谁",当前会话表达"你现在想要什么"。
max_len 怎么定?
看会话长度的实际分布,瞄准 90 分位。常用区间:会话级 20–50,用户级 50–200。不是越长越好——多一个位置就多一份算力,且陈旧交互更可能是噪声。
三件事在工程上很重要:训练并行;任意两个位置一步直达,长程依赖不会衰减;注意力权重免费提供可解释性。RNN 仍有它的位置——内存预算紧、需要跨请求维持隐藏状态做真正流式推理时。
BERT4Rec 的双向真的有用吗?
有时候有用。双向上下文确实带来更丰富的表示,对噪声/缺失更鲁棒。但 [MASK] 追加的推理 trick 不直观,训练流程更复杂;很多真实数据集上调好的 SASRec 已经能追平甚至略好。先 SASRec,后 BERT4Rec。
冷启动怎么处理?
新 item:用内容特征(类目、品牌、价格、文本、图像)冷启动;BST 这种 feature-aware 模型天然支持。新用户/新会话:起步推热门和趋势,用不需要历史的 session-based 模型,外加探索策略(epsilon-greedy 或 contextual bandits)。
报哪个评估指标?
线下榜单上HR@10 + NDCG@10通常是最稳妥的组合:HR@10 直观,NDCG@10 衡量排序质量。每条 query 只有一个 ground truth 时,MRR作为补充很合适。生产环境一定要 pair 上线 A/B:CTR、转化率、营收。线下涨不一定线上涨。
序列推荐能和别的方法组合吗?
可以也应该。常见组合:
- 序列 + 协同过滤:时序信号 + 用户-物品相似度。
- 序列 + 内容:BST 这种把内容特征拼进序列。
- 序列 + 知识图谱:注入 item 之间的关系结构。
- 多任务:同时预测下一个 item、类目、停留时长。
当前研究前沿在做什么?
2023–2025 主要几个方向:
- LLM-based 推荐:把用户历史序列化后 prompt 或微调语言模型。
- 对比学习:拉开 item 表示的语义距离。
- 多模态序列:把 ID、文本、图像、音频混着喂。
- 线性/亚二次注意力:处理超长用户历史。
- 因果推断:不止预测"下一步是什么",还要回答"为什么用户这么选"。
总结
序列推荐的核心命题——把"顺序"当作信号,从中学出"下一步"——已经被一连串模型反复打磨。从最简单的马尔可夫链,到 GRU4Rec、Caser,再到 SASRec、BERT4Rec、BST、SR-GNN,每一次迭代都在补上一代的盲区。
关键要点:
- 顺序就是信号:被 bag-of-items 模型扔掉的"先后顺序",里面藏着用户兴趣的真实节奏。
- 架构演进路径:马尔可夫链 → RNN(GRU4Rec)→ CNN(Caser)→ Transformer(SASRec、BERT4Rec)→ GNN(SR-GNN),每一步都在解决前一步的具体瓶颈。
- 会话 vs 用户级:前者擅长匿名短期意图,后者擅长长期兴趣演化;生产系统通常融合二者。
- 选型看四件事:序列长度、side feature 重要性、延迟预算、是否含重复 item。
- 离线 + 在线评估:HR@K、NDCG@K、MRR 是离线常用指标,但最终拍板的是 A/B。
- 工业落地的六个杠杆:负采样、ANN、两阶段召排、蒸馏、量化、缓存——少一个都过不了延迟关。
系列导航
本文是推荐系统系列的第 6 篇,共 16 篇。