刷 TikTok 时,每次推荐都精准得让人有点毛骨悚然——不是因为它能读懂你的想法,而是因为它捕捉到了你刚刚观看内容的时间顺序。先看一个做饭视频,再看一个旅行 vlog,和反过来的顺序,传递的信息完全不同。这种顺序,正是序列推荐系统要捕捉的核心信号。
举个例子,两个朋友给你推荐电影。第一个只知道你喜欢什么类型,但从不问你上周看了什么。第二个说:“你刚连续看了三部科幻惊悚片,试试这部吧。”传统协同过滤就像第一个朋友,而序列推荐则是第二个。

这篇文章会按照模型发展的脉络,把马尔可夫链、GRU4Rec、Caser、SASRec、BERT4Rec、BST、SR-GNN 这条线讲清楚:每个模型的动机、公式、实现细节、优势和代价。读完后,面对具体业务问题,你应该能知道“选哪个模型、为什么选它、可能会踩哪些坑”。
你将学到什么#
- 为什么顺序重要,序列模型如何突破基于集合的协同过滤局限
- 马尔可夫链:最简单的序列基线模型,稀疏但解释性强,效果出人意料地稳健
- GRU4Rec:第一个认真对待 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 篇
)
序列推荐到底在做什么?#
序列推荐的核心是利用用户交互的时间顺序来建模用户偏好。传统协同过滤将用户历史视为无序的物品集合,而序列推荐则将其建模为具有时间顺序的行为序列——这条序列中蕴含了更多信息。
$$P(i_{t+1} \mid S_u) = P(i_{t+1} \mid i_1, i_2, \dots, i_t).$$简言之:根据用户历史行为的时间顺序,预测其下一个交互物品。这里的关键不仅是“哪些物品出现过”,更是“它们以什么时间顺序出现”。
为什么重要?#
序列推荐能在实际系统中发挥作用,主要靠以下四点:
- 兴趣漂移:上个月还在看动作片,这个月可能已经转向纪录片。静态模型抓不住这种变化,但序列模型可以动态调整。
- 局部上下文:刚看完一部预告片,用户接下来最可能想看的是正片,而不是随机推荐的内容。这种短时依赖只有序列模型能捕捉。
- 会话意图:在电商场景中,用户连续浏览了几款笔记本电脑后,大概率会去看笔记本包。这种规律隐藏在行为转移中,而不是物品本身的流行度里。
- 冷启动缓解:即使用户只有 3 次交互,这些交互的顺序本身也是一种信号。传统无序模型会直接丢弃这种信息。
序列推荐 vs 传统推荐#
| 维度 | 传统协同过滤 | 序列推荐 |
|---|
| 输入 | 无序交互集合 | 有序交互序列 |
| 时间建模 | 忽略 | 核心 |
| 预测目标 | $P(i \mid u)$ | $P(i_{t+1} \mid S_u)$ |
| 强项 | 长期偏好 | 下一步预测 |
| 典型话术 | “和你相似的人也喜欢……” | “根据你刚才的行为……” |
三种典型形态#
- 用户级序列推荐:将用户的所有历史行为拼成一条长序列。适合捕捉缓慢的兴趣演化,比如音乐 App 中几个月的听歌记录。
- 会话级推荐(Session-based):每个会话独立处理。适合匿名用户或短期意图,比如一次电商浏览会话。
- 混合方案:用长期 embedding 表示“你是谁”,用当前会话表示“你现在想要什么”。工业界系统大多采用这种方案。
马尔可夫链方法#
一阶马尔可夫链#
$$P(i_{t+1} \mid i_1, \dots, i_t) = P(i_{t+1} \mid i_t).$$类比:就像用当前词预测下一个词。看到“冰”,你可能猜“激凌”,但完全不知道前面在聊甜品还是冰球。
$$M_{ij} = \frac{\text{count}(i \to j)}{\text{count}(i)}.$$简单说,就是统计 $j$
跟在 $i$
后面的次数,再除以 $i$
出现的总次数(不包括末尾)。

高阶马尔可夫链#
$$P(i_{t+1} \mid i_1, \dots, i_t) = P(i_{t+1} \mid i_{t-k+1}, \dots, i_t).$$高阶链能捕捉更多上下文,但状态空间会指数级膨胀。比如,1 万个物品的二阶模型有 $10^8$
种可能的转移,大多数从未出现过。这就是维度灾难。
实现:一阶马尔可夫推荐器#
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
| 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 排第一
|
马尔可夫链的局限#
- 数据稀疏:物品越多,绝大多数转移从未出现过。平滑只能缓解,无法根治。
- 窗口太短:即使高阶链也只能看固定窗口,根本表达不了“用户最近一个月都在研究摄影”这种长程信号。
- 没有泛化:A 和 B 即使功能相似,模型也当成完全独立——没有共享表示。
这三点正好是神经序列模型要解决的问题。
GRU4Rec:基于 RNN 的序列推荐模型#
模型架构概览#
GRU4Rec(Hidasi et al., 2015)是第一个认真对待会话推荐的深度学习模型。它用 Gated Recurrent Units 逐个处理物品,同时维护一个动态更新的会话摘要。
核心思想:不把会话看作物品的集合,而是按顺序逐一处理,用隐藏状态压缩所有已见信息。

GRU 如何更新状态#
GRU 单元维护一个隐藏状态 $h_t$
。给定输入 $x_t$
(物品 $i_t$
的嵌入)和上一时刻的隐藏状态 $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 逐个读取物品并维护一个“记忆”向量。重置门决定丢弃多少旧记忆,更新门决定加入多少新信息。最终隐藏状态是对整个会话的压缩表示。
完整流程是:嵌入 → GRU → 线性投影 → 物品词汇表上的 Softmax,使用排序损失(BPR 或 TOP1)进行隐式反馈训练。
实现细节#
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
| 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:卷积序列嵌入推荐模型#
核心动机#
Caser(Tang & Wang, 2018)彻底改变了序列建模的思路。它把嵌入后的序列铺平成一张“图像”,然后用卷积操作提取特征。不同大小的卷积核并行捕捉不同长度的模式。
类比:如果 GRU4Rec 是逐字读句子,Caser 就像是把整句话摊开,用一组不同放大倍率的放大镜同时观察双词、三词和更长的短语组合。
模型架构#
输入是一个 $t \times d$
的嵌入矩阵 $\mathbf{E}$
(序列长度 $t$
,嵌入维度 $d$
)。Caser 在这个矩阵上堆叠了两类卷积:
- 水平卷积沿序列方向滑动,捕获联合级 n-gram 模式。卷积核高度设为 2、3、4,分别对应双词、三词和四词检测器。
- 垂直卷积沿嵌入维度扫描,捕获点级潜在特征——即单个物品在时间维度上的隐向量聚合。
$
\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{(垂直卷积,覆盖整个序列)}
$$水平和垂直卷积的输出经过池化、拼接后,送入全连接层进行预测。

实现代码#
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 一次前向计算就能处理完整序列,GPU 上比 RNN 快得多。
- 多尺度模式捕捉:不同大小的卷积核同时提取双词、三词和更长的短语模式。
- 局部模式专家:擅长短序列中的强局部规律场景,RNN 和 Transformer 在这种场景下显得冗余或浪费。
SASRec:序列推荐中的自注意力#
SASRec(Kang & McAuley, 2018)将 Transformer 编码器引入了序列推荐。自注意力一举解决了 RNN 的两个痛点:每个位置可以直接连接到所有之前的位置(没有梯度消失问题),并且所有位置可以并行处理。
核心原因:RNN 每次只能处理一个 item,速度慢,且难以捕捉时间间隔较远的关联。自注意力通过一次矩阵乘法,让每个 item 直接“看到”所有之前的 item,同时捕捉近距离和远距离的关系。

这张热图展示了典型的注意力模式。每一行是一个位置作为 query,列是它关注的 item。对角线颜色最深(每个位置最关注自己),近期 item 权重更高,右上角斜线区域被遮挡——这些位置被因果掩码屏蔽,防止模型提前看到未来信息。
核心组件#
$$
\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$
。如果没有这个掩码,模型会直接“作弊”,通过未来信息完成预测。
$$
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
| 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)
nn.init.normal_(self.item_embedding.weight, mean=0, std=0.01)
nn.init.xavier_uniform_(self.output.weight)
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 利用率高。
- 自带可解释性——注意力权重直接揭示哪些历史行为影响了预测。
- 扩展性强:序列长度增加或模型规模扩大时,效果通常同步提升。
BERT4Rec:序列推荐中的双向编码器#
BERT4Rec(Sun et al., 2019)沿用了 Transformer 的主干结构,但改变了训练目标。它借鉴了 BERT 的完形填空任务(cloze task):随机遮掩序列中的部分物品,让模型利用左右两侧的上下文来预测这些被遮掩的物品。
等等,未来的信息也能用? 训练时确实可以!我们会随机隐藏一些位置,让双向编码器根据其他位置的信息重建它们。推理时,则在序列末尾追加一个 [MASK] 标记,预测它的值。同样的主干,不同的预训练任务,学到的表示更丰富。

与 SASRec 的对比#
| 特性 | SASRec | BERT4Rec |
|---|
| 注意力方向 | 因果(左 → 右) | 双向 |
| 训练任务 | 预测下一个物品 | 预测被遮掩的物品 |
| 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] 标记,不如自回归预测直观;训练流程稍复杂;在很多实际场景中,调优后的 SASRec 已经能接近甚至超过 BERT4Rec 的效果。优先尝试 SASRec,必要时再考虑 BERT4Rec。
BST 的独特之处#
BST(Chen et al., 2019,阿里)在 Transformer 的基础上做了扩展,支持处理丰富的侧边特征,而不仅仅是 item ID。在真实的电商系统中,除了商品 ID,还有类目、品牌、价格、店铺 ID、用户画像等信息。BST 把这些特征都嵌入后拼接起来,再输入到 Transformer 中。
核心洞察:用户的实际行为不是简单的 ID 序列,而是事件序列。每个事件包含商品的 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
| 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)另辟蹊径,不再把会话看作简单的序列,而是建模为有向图。节点是物品,边是物品间的转移关系。
为什么选择图? 比如会话 $[A, B, C, B, D]$
,物品 $B$
出现了两次,形成了一个回路。图结构天然适合表达这种重复访问,而平面序列模型则需要额外努力才能近似。

图的构建#
对于会话 $S = [i_1, i_2, \dots, i_t]$
:
- 节点:会话中出现的唯一物品。
- 边:从每个物品指向序列中紧随其后的物品,保留顺序。
- 边权重:记录每次转移的频次,自动处理重复访问。
SR-GNN 的工作原理#
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
$$通俗解释:每个物品跟它前后相邻的物品交换信息。经过几轮消息传递,每个物品的表示都吸收了它在会话图中局部邻域的信息。最后用注意力机制对节点嵌入加权汇总,得到会话表示。
实现(简化版)#
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 的亮点#
- 图结构捕捉平面序列无法表达的复杂转移模式。
- 重复物品优先处理——多次出现的物品汇聚到同一节点,转移权重叠加。
- 局部消息传递捕获“某个物品是会话内兴趣团簇中心”这类局部信号。
序列长度该怎么选?#
不同模型对序列长度的敏感度差别很大,付出的代价也完全不同。下图展示了这个问题的关键点。

有三点值得注意:
- 马尔可夫链很早就到顶了。固定窗口假设让它无法利用更长的上下文。
- GRU4Rec 在 50–75 个物品附近达到峰值,再长反而下降。隐藏状态难以把更多历史压缩到固定大小的向量里。
- SASRec 和 BERT4Rec 持续提升。任意两个位置直接交互,没有压缩瓶颈,更长的上下文确实能带来更好的预测质量。
代价的情况正好相反。RNN 时间维度无法并行,训练成本随序列长度线性增长。Transformer 充分利用 GPU 并行,在中等长度范围内几乎不增加额外开销。这两个因素共同解释了为什么 Transformer 成为工业级序列推荐系统的默认选择。
评估指标#
必须掌握的三个指标#
$$
\text{HR@K} = \frac{1}{|T|} \sum_{t \in T} \mathbb{1}\!\left[\text{rank}(i_t^*) \leq K\right]
$$
$$
\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)}
$$
$$
\text{MRR} = \frac{1}{|T|} \sum_{t \in T} \frac{1}{\text{rank}(i_t^*)}
$$如何选择? HR@K 最直观(“有没有猜对”)。NDCG@K 更适合衡量排序质量(“排得多靠前”)。MRR 则适用于每条查询只有一个相关物品的场景。
实现代码#
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 能减少计算浪费。如果用户历史特别长,可以用滑动窗口切成多个固定长度的子序列。
负采样:隐式反馈数据只有正样本,所以需要采负样本。随机采样是最简单的基线方法。按流行度加权采样或者挖掘难例负样本(hard negative mining)能获得更好的梯度,但代价也更高(详见第 5 篇
)。
数据增强:三个简单又有效的技巧——
- 序列裁剪:把每个会话拆成多个重叠的前缀。
- 随机 mask:类似 BERT4Rec 的做法,即使不用 BERT4Rec 模型也能用这个方法。
- 轻度乱序:打乱非相邻的 item 顺序,提升模型的鲁棒性。
- 损失函数:交叉熵、BPR 和 sampled softmax 是常用选择。当词表规模达到百万级别时,sampled softmax 几乎是必选项。
- 正则化:Dropout(0.1–0.5)、L2 正则化和基于验证集 HR@K 的早停策略,基本能覆盖大部分场景。
- 优化器:Transformer 类模型默认用 AdamW,配合 warmup 后衰减的学习率调度策略。梯度裁剪(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 侧特征丰富且有意义,用 BST。
- 如果会话中包含大量重复 item 且转移关系复杂,用 SR-GNN。
- 如果有大规模语料支持预训练,再考虑 BERT4Rec。
问答环节#
Q1. 什么时候该选会话推荐,而不是用户级序列推荐?#
会话推荐适合匿名用户、短期意图明确的场景(比如电商浏览、新闻阅读),以及需要实时低延迟的情况。用户级序列推荐则更适合有稳定用户 ID、历史数据较长、关注兴趣演化的场景(比如音乐流媒体、视频平台)。大多数生产系统会同时用两种方法:长期嵌入表示“你是谁”,当前会话表示“你现在想要什么”。
Q2. max_len 怎么定?#
看会话长度的实际分布,取 90% 分位数作为参考。常见范围是:会话 20–50,用户历史 50–200。不是越长越好——每多一个位置就多一份计算开销,而且老的交互数据往往噪声更多,价值更低。
实际中有三个关键原因:训练可以并行化;任意两个位置之间直接建立联系,没有梯度消失问题;注意力权重天然提供可解释性。RNN 也有它的用武之地——内存预算有限时,或者需要跨请求维持状态做真正流式推理时。
Q4. BERT4Rec 的双向注意力真的有用吗?#
有时候有用。双向上下文确实能让物品表示更丰富,模型对缺失或噪声也更鲁棒。但 [MASK] 推理技巧不够直观,训练流程更复杂;在很多真实数据集上,调好的 SASRec 已经能追平甚至略胜一筹。建议先试 SASRec,再考虑 BERT4Rec,尤其是当你有大规模预训练语料或噪声较多的日志时。
Q5. 冷启动怎么处理?#
新物品:用内容特征(类目、品牌、价格、文本、图像)冷启动;像 BST 这种 feature-aware 模型天然支持。新用户/新会话:起步推热门和趋势物品,用不需要历史的会话推荐模型,或者在外层加探索策略(epsilon-greedy 或 contextual bandits)。
Q6. 应该报告哪些评估指标?#
线下榜单上,HR@10 + NDCG@10 是最稳妥的组合:HR@10 直观易懂,NDCG@10 衡量排序质量。如果每条查询只有一个正确答案,MRR 是不错的补充指标。在线上生产环境,一定要结合 A/B 测试,看点击率、转化率和营收——线下效果好不一定线上效果好。
Q7. 序列推荐能和其他方法结合吗?#
可以,而且通常应该结合。常见组合包括:
- 序列 + 协同过滤:时序信号 + 用户-物品相似度。
- 序列 + 内容:把内容特征拼进序列(如 BST、content-aware SASRec)。
- 序列 + 知识图谱:注入物品之间的关系结构。
- 多任务:同时预测下一个物品、类目、停留时长等。
Q8. 当前研究前沿有哪些方向?#
2023–2025 年主要集中在以下几个方向:
- LLM-based 推荐:把用户历史序列化后,通过 prompt 或微调语言模型生成推荐。
- 对比学习:让物品表示的语义距离更清晰。
- 多模态序列:混合 ID、文本、图像、音频等多种模态。
- 线性/亚二次注意力:处理超长用户历史。
- 因果推断:不仅预测“下一步是什么”,还要回答“用户为什么会这样选择”。
序列推荐的核心是把用户交互的时间顺序当作信号,预测接下来会发生什么。这个领域从简单的马尔可夫链起步,逐步发展到复杂的 Transformer 和图神经网络。
关键要点:
- 顺序就是信号:过去交互的排列顺序包含 bag-of-items 模型忽略的重要信息。
- 架构演进:马尔可夫链 → RNN(GRU4Rec)→ CNN(Caser)→ Transformer(SASRec、BERT4Rec)→ GNN(SR-GNN)。每一代都在解决前一代的实际问题。
- 会话 vs 用户级:会话模型擅长捕捉匿名用户的短期意图,用户级模型则关注兴趣的长期演化。大多数生产系统会结合两者。
- 选型看需求:序列长度、辅助特征的价值、延迟要求、会话重复性,这些因素决定了架构选择。
- 评估方法:离线用 HR@K、NDCG@K、MRR,但最终还是要靠在线 A/B 测试说话。
- 工业落地的关键技巧:负采样、ANN 搜索、两阶段召回与排序、蒸馏、量化、缓存——少了哪个都过不了性能关。
本文是推荐系统系列的第 6 篇,共 16 篇。