系列 · 推荐系统 · 第 6 篇

推荐系统(六)—— 序列推荐与会话建模

序列推荐方法全景:从马尔可夫链、GRU4Rec、Caser,到 SASRec、BERT4Rec、BST、SR-GNN,讲清模型动机、关键公式与实现细节,并用图示对比性能与代价。

刷 TikTok 时,每次推荐都精准得让人有点毛骨悚然——不是因为它能读懂你的想法,而是因为它捕捉到了你刚刚观看内容的时间顺序。先看一个做饭视频,再看一个旅行 vlog,和反过来的顺序,传递的信息完全不同。这种顺序,正是序列推荐系统要捕捉的核心信号。

举个例子,两个朋友给你推荐电影。第一个只知道你喜欢什么类型,但从不问你上周看了什么。第二个说:“你刚连续看了三部科幻惊悚片,试试这部吧。”传统协同过滤就像第一个朋友,而序列推荐则是第二个。

用户的交互序列:每一步都依赖前面的动作,最终指向"下一项预测"

这篇文章会按照模型发展的脉络,把马尔可夫链、GRU4Rec、Caser、SASRec、BERT4Rec、BST、SR-GNN 这条线讲清楚:每个模型的动机、公式、实现细节、优势和代价。读完后,面对具体业务问题,你应该能知道“选哪个模型、为什么选它、可能会踩哪些坑”。


你将学到什么#

  • 为什么顺序重要,序列模型如何突破基于集合的协同过滤局限
  • 马尔可夫链:最简单的序列基线模型,稀疏但解释性强,效果出人意料地稳健
  • GRU4Rec:第一个认真对待 session-based 推荐的深度学习模型
  • Caser:把序列看作“图像”,用 CNN 提取多尺度模式
  • SASRecBERT4Rec: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$ 出现的总次数(不包括末尾)。

一阶马尔可夫链只依赖当前 item。转移矩阵 M 统计每对物品之间的跳转次数,归一化后即为概率。

高阶马尔可夫链#

$$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 逐个处理物品,同时维护一个动态更新的会话摘要。

核心思想:不把会话看作物品的集合,而是按顺序逐一处理,用隐藏状态压缩所有已见信息。

GRU4Rec 架构:物品嵌入后通过 GRU,其隐藏状态用于物品词汇表上的 softmax

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{(垂直卷积,覆盖整个序列)} $$

水平和垂直卷积的输出经过池化、拼接后,送入全连接层进行预测。

Caser 把 T x D 的嵌入矩阵当作"图像"。水平卷积沿时间方向滑动,捕获联合级 n-gram 模式;垂直卷积在嵌入维度上对时间做加权聚合。

实现代码#

 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:序列推荐中的自注意力#

为什么选择 Transformer#

SASRec(Kang & McAuley, 2018)将 Transformer 编码器引入了序列推荐。自注意力一举解决了 RNN 的两个痛点:每个位置可以直接连接到所有之前的位置(没有梯度消失问题),并且所有位置可以并行处理。

核心原因:RNN 每次只能处理一个 item,速度慢,且难以捕捉时间间隔较远的关联。自注意力通过一次矩阵乘法,让每个 item 直接“看到”所有之前的 item,同时捕捉近距离和远距离的关系。

SASRec 自注意力权重热图(带因果掩码)。斜线区域被遮挡,因为模型不能偷看未来 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}). $$

下图展示了实际操作中位置编码的作用。

Item 嵌入(左)+ 位置编码(中)= Transformer 实际输入(右)。位置编码将无序的 item 集合转化为有序序列。

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] 标记,预测它的值。同样的主干,不同的预训练任务,学到的表示更丰富。

BERT4Rec 使用双向编码器重建被遮掩的位置:图中 pos 3 和 pos 6 被隐藏,编码器利用其余位置作为上下文还原它们

与 SASRec 的对比#

特性SASRecBERT4Rec
注意力方向因果(左 → 右)双向
训练任务预测下一个物品预测被遮掩的物品
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:行为序列 Transformer#

BST 的独特之处#

BST(Chen et al., 2019,阿里)在 Transformer 的基础上做了扩展,支持处理丰富的侧边特征,而不仅仅是 item ID。在真实的电商系统中,除了商品 ID,还有类目、品牌、价格、店铺 ID、用户画像等信息。BST 把这些特征都嵌入后拼接起来,再输入到 Transformer 中。

核心洞察:用户的实际行为不是简单的 ID 序列,而是事件序列。每个事件包含商品的 ID、类目、品牌、价格区间、时间间隔等信息。BST 把这一整套特征组合当作输入的基本单位。

BST 输入的每一个时间步是一个事件 bundle:item ID 加类目、品牌、价格、时间间隔等侧边特征。所有 field embedding 拼接后送入 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
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$ 出现了两次,形成了一个回路。图结构天然适合表达这种重复访问,而平面序列模型则需要额外努力才能近似。

左:同一会话作为平面序列,B 出现两次。右:SR-GNN 把重复 item 收缩为同一个节点,转移变成有向加权边,B 与 C 之间形成回路。

图的构建#

对于会话 $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 的亮点#

  • 图结构捕捉平面序列无法表达的复杂转移模式。
  • 重复物品优先处理——多次出现的物品汇聚到同一节点,转移权重叠加。
  • 局部消息传递捕获“某个物品是会话内兴趣团簇中心”这类局部信号。

序列长度该怎么选?#

不同模型对序列长度的敏感度差别很大,付出的代价也完全不同。下图展示了这个问题的关键点。

左图:HR@10 随最大序列长度的变化。Transformer 类模型一直随序列变长而提升。右图:相对训练开销——RNN 串行,开销线性增长;Transformer 并行,几乎平稳

有三点值得注意:

  • 马尔可夫链很早就到顶了。固定窗口假设让它无法利用更长的上下文。
  • 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#

按照以下顺序逐步优化:

  1. 负采样:训练时不要对全量词表计算 softmax。
  2. 近似最近邻(FAISS、HNSW):在线召回阶段必不可少。
  3. 两阶段检索:先用 embedding 召回候选集,再用复杂模型对几百条候选进行精排。
  4. 蒸馏:训练一个小模型模仿大模型,推理时用小模型加速。
  5. 量化:FP16 或 INT8 推理,满足严格的延迟要求。
  6. 缓存:item embedding、热门序列甚至稳定用户的预测结果都可以缓存下来重复利用。

典型在线服务流水线:请求 → 序列构建 → 序列编码(SASRec/BST)→ ANN 召回 → 精排 → 返回结果,整体预算约 50-100 ms。编码器、item embedding 与 ANN 索引由离线流程定期刷新。

模型对比与选型建议#

模型架构可并行长程能力Side feature最适合
马尔可夫链统计n/a基线 / 极冷启动
GRU4RecRNN流式更新 / 简单会话
CaserCNN短会话 / 局部模式强
SASRecTransformer通用首选
BERT4RecTransformer双向上下文显著提升效果时
BSTTransformer特征丰富的电商场景
SR-GNNGNN部分含重复 item 的复杂会话

选型建议

  • 默认选择 SASRec。它在大多数数据集上表现最强,适合作为起点。
  • 如果需要流式会话的在线更新,用 GRU4Rec
  • 如果会话较短且局部模式明显,用 Caser
  • 如果 item 侧特征丰富且有意义,用 BST
  • 如果会话中包含大量重复 item 且转移关系复杂,用 SR-GNN
  • 如果有大规模语料支持预训练,再考虑 BERT4Rec

问答环节#

Q1. 什么时候该选会话推荐,而不是用户级序列推荐?#

会话推荐适合匿名用户、短期意图明确的场景(比如电商浏览、新闻阅读),以及需要实时低延迟的情况。用户级序列推荐则更适合有稳定用户 ID、历史数据较长、关注兴趣演化的场景(比如音乐流媒体、视频平台)。大多数生产系统会同时用两种方法:长期嵌入表示“你是谁”,当前会话表示“你现在想要什么”。

Q2. max_len 怎么定?#

看会话长度的实际分布,取 90% 分位数作为参考。常见范围是:会话 20–50,用户历史 50–200。不是越长越好——每多一个位置就多一份计算开销,而且老的交互数据往往噪声更多,价值更低。

Q3. 为什么 Transformer 通常比 RNN 更好?#

实际中有三个关键原因:训练可以并行化;任意两个位置之间直接建立联系,没有梯度消失问题;注意力权重天然提供可解释性。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 篇。

本系列

推荐系统 16 篇

  1. 01 推荐系统(一)—— 入门与基础概念
  2. 02 推荐系统(二)—— 协同过滤与矩阵分解
  3. 03 推荐系统(三)—— 深度学习基础模型
  4. 04 推荐系统(四)—— CTR 预估与点击率建模
  5. 05 推荐系统(五)—— Embedding 表示学习
  6. 06 推荐系统(六)—— 序列推荐与会话建模 当前
  7. 07 推荐系统(七)—— 图神经网络与社交推荐
  8. 08 推荐系统(八)—— 知识图谱增强推荐系统
  9. 09 推荐系统(九)—— 多任务学习与多目标优化
  10. 10 推荐系统(十)—— 深度兴趣网络与注意力机制
  11. 11 推荐系统(十一)—— 对比学习与自监督学习
  12. 12 推荐系统(十二)—— 大语言模型与推荐系统
  13. 13 推荐系统(十三)—— 公平性、去偏与可解释性
  14. 14 推荐系统(十四)—— 跨域推荐与冷启动解决方案
  15. 15 推荐系统(十五)—— 实时推荐与在线学习
  16. 16 推荐系统(十六)—— 工业级架构与最佳实践

读有所得?

GitHub 关注我 → 新文周更

GitHub