推荐系统(十)—— 深度兴趣网络与注意力机制

从 DIN 的目标注意力到 DIEN 的 AUGRU、再到 BST 的 Transformer——阿里巴巴是怎样让 CTR 模型像主厨读懂客人那样读懂用户行为的。

好厨师不会给每位客人做同一道菜。她会留意你进门的状态、点的酒,瞄一眼你盯着黑板看的方向,然后才决定今晚的招牌该是牛排还是烩饭。你过去来过几次很重要,但只有契合 此刻 心情的那部分才作数。

过去的推荐模型像个糟糕的厨师:把用户点过的所有东西平均成一个向量,然后给店里所有人端上同一道菜。你上周看过的那件复古皮衣和半年前随手点过的手机充电器分量相同,无论你现在正在浏览什么。

深度兴趣网络(Deep Interest Network, DIN) 让模型学会了"看人下菜"。它的思想朴素得不像话:在给候选商品打分时,按每个历史行为对 这个 候选商品的相关程度来加权。同一个用户,对每个候选商品都得到一份不同的表征——就像主厨对每种心情都有一道不同的菜。

本文带你走完这条以注意力为核心的 CTR 模型谱系:DIN(目标注意力)、DIEN(GRU + AUGRU 建模兴趣演化)、DSIN(会话感知)、BST(在行为序列上跑 Transformer)。我们会让数学保持诚实、让代码能跑起来、让直觉一针见血。

你将学到

  • 为什么"取平均"会丢掉关键信息,注意力机制如何修复它
  • DIN——用 Local Activation Unit 实现目标注意力
  • DIEN——用 GRU + AUGRU + 辅助损失刻画兴趣演化
  • DSIN——把会话级别的浏览模式纳入建模
  • BST——让 Transformer 同时看行为序列和候选商品
  • 工业化技巧:Dice 激活、小批次感知正则、序列截断与服务优化

前置知识

  • PyTorch 基础(模块写法、前向传播、损失计算)
  • Embedding(第 5 篇
  • 对 RNN/GRU 有点印象(不强制)

1. 从平均池化到注意力

平均池化丢失了什么

设想一个用户最近点了 5 部动作片、3 部爱情片、2 部纪录片和 1 部恐怖片。当你为他打分一部新的动作片时,那 5 次动作片点击应当占主导。简单平均把 11 次点击一视同仁,恐怖片这个离群点反而会把用户表征拽离你正想推荐的方向。

形式化地写出来,传统做法把用户压成一个固定向量:

$$\mathbf{v}_u = \frac{1}{T} \sum_{j=1}^{T} \mathbf{e}_{b_j}$$

其中 $\mathbf{e}_{b_j}$ 是行为 $b_j$ 的 embedding。这个向量完全无视候选商品。无论你打分的是动作片还是纪录片,用户长得都一样。

注意力如何修复

DIN 注意力权重——同一个用户,候选不同时模型读出的"重点"截然不同

注意力为每个历史行为 $b_j$ 计算一个相对于候选商品 $i$ 的相关度分数 $\alpha_j$:

$$\alpha_j = \text{score}(\mathbf{e}_{b_j}, \mathbf{e}_i)$$

用户表征变成 加权 求和:

$$\mathbf{v}_u(i) = \sum_{j=1}^{T} \alpha_j \, \mathbf{e}_{b_j}$$

现在 $\mathbf{v}_u(i)$ 与 $i$ 有关。打分动作片,动作类点击亮起来;打分爱情片,爱情类点击占上风。同一段历史,读出不同重点。上图正展示了这一点:同一位用户的 10 次点击,两个不同的候选商品,注意力权重的分布完全不同。模型没变,问题变了。

打分函数怎么选

按表达力从弱到强有三种常见选择:

  • 点积——$\text{score}(\mathbf{q}, \mathbf{k}) = \mathbf{q}^\top \mathbf{k}$。便宜,能力有限。
  • 缩放点积——除以 $\sqrt{d}$ 稳住数值幅度,Transformer 用的就是它。
  • 加性 MLP——$\mathbf{v}^\top \tanh(\mathbf{W}_q \mathbf{q} + \mathbf{W}_k \mathbf{k} + \mathbf{b})$。表达力最强,DIN 选了这个

DIN 还更进一步:不止把 $\mathbf{q}$ 和 $\mathbf{k}$ 拼起来,而是把四样东西一起喂给 MLP——query、key、query−key、query⊙key。相减刻画 差异,按位相乘刻画 交互,MLP 在四者之上学一个非线性的兼容函数。


2. Deep Interest Network (DIN)

DIN 由阿里巴巴在 2018 年提出(Zhou 等,KDD'18),是基于注意力的 CTR 模型的奠基之作,至今仍是工业界的常备工具。它的工作核心是 Local Activation Unit——一个小巧的 MLP,专门给每个历史行为相对候选商品打分。

DIN 的工作流程

给定用户行为序列 $[b_1, b_2, \ldots, b_T]$ 和候选商品 $i$:

  1. 嵌入:把行为、候选、用户特征、上下文都映射成稠密向量。
  2. 打分:用 activation unit 给每个历史行为评分。
  3. 加权求和:得到"被激活过的"用户表征。
  4. 拼接 + MLP:与其他特征拼在一起,过 MLP 输出 CTR。

Activation unit 的打分公式是:

$$\text{score}(\mathbf{e}_{b_j}, \mathbf{e}_i) = \text{MLP}\big([\,\mathbf{e}_{b_j};\ \mathbf{e}_i;\ \mathbf{e}_{b_j} - \mathbf{e}_i;\ \mathbf{e}_{b_j} \odot \mathbf{e}_i\,]\big)$$

一个不起眼但关键的细节:原版 DIN 不做 softmax。作者发现,让权重和不必为 1,能保留 强度 信息——一个历史里有大量强相关行为的用户,本来就应该产生比只有几次弱相关行为的用户更"重"的表征向量。下面的代码两种写法都给出。

实现

 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
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
import torch
import torch.nn as nn
import torch.nn.functional as F


class LocalActivationUnit(nn.Module):
    """DIN 的 Local Activation Unit。

    用一个小 MLP 同时消化四种交互视角:行为本身、候选本身、二者之差、
    二者按位乘——以此给每个历史行为相对候选打一个相关度分数。
    """

    def __init__(self, embedding_dim, hidden_dims=(80, 40), use_softmax=False):
        super().__init__()
        # 输入 = [行为; 候选; 行为-候选; 行为*候选]
        in_dim = embedding_dim * 4
        layers = []
        for h in hidden_dims:
            layers += [nn.Linear(in_dim, h), nn.PReLU()]
            in_dim = h
        layers.append(nn.Linear(in_dim, 1))
        self.mlp = nn.Sequential(*layers)
        self.use_softmax = use_softmax

    def forward(self, behaviors, candidate, mask=None):
        """
        behaviors: (B, T, D)   历史行为 embedding
        candidate: (B, D)      候选商品 embedding
        mask:      (B, T)      真实位置为 1,padding 为 0
        Returns:
            user_repr: (B, D)
            weights:   (B, T)
        """
        B, T, D = behaviors.shape
        cand = candidate.unsqueeze(1).expand(B, T, D)

        # 四种交互视角
        feats = torch.cat([behaviors, cand, behaviors - cand, behaviors * cand], dim=-1)
        scores = self.mlp(feats).squeeze(-1)              # (B, T)

        if mask is not None:
            scores = scores.masked_fill(mask == 0, -1e9)

        if self.use_softmax:
            weights = F.softmax(scores, dim=1)
        else:
            # DIN 默认:保留原始权重,不做归一化
            weights = scores
            if mask is not None:
                weights = weights * mask

        user_repr = torch.bmm(weights.unsqueeze(1), behaviors).squeeze(1)
        return user_repr, weights


class DIN(nn.Module):
    """用于 CTR 预估的 Deep Interest Network。"""

    def __init__(self, item_dim=64, user_dim=32, ctx_dim=16,
                 mlp_hidden=(200, 80), dropout=0.5):
        super().__init__()
        self.activation = LocalActivationUnit(item_dim)

        in_dim = item_dim + item_dim + user_dim + ctx_dim
        layers = []
        for h in mlp_hidden:
            layers += [nn.Linear(in_dim, h), nn.PReLU(), nn.Dropout(dropout)]
            in_dim = h
        layers += [nn.Linear(in_dim, 1)]
        self.mlp = nn.Sequential(*layers)

    def forward(self, user_feats, behaviors, candidate, ctx_feats, mask=None):
        user_repr, attn = self.activation(behaviors, candidate, mask)
        x = torch.cat([user_repr, candidate, user_feats, ctx_feats], dim=1)
        logit = self.mlp(x).squeeze(-1)
        return logit, attn


# 跑通性测试
model = DIN(item_dim=64, user_dim=32, ctx_dim=16)
B, T = 32, 20
logits, attn = model(
    user_feats=torch.randn(B, 32),
    behaviors=torch.randn(B, T, 64),
    candidate=torch.randn(B, 64),
    ctx_feats=torch.randn(B, 16),
    mask=torch.ones(B, T),
)
print(logits.shape, attn.shape)   # torch.Size([32]) torch.Size([32, 20])

训练目标与阿里的工业化技巧

DIN 用 logits 上的二分类交叉熵训练:

$$\mathcal{L} = -\frac{1}{N} \sum_{i=1}^{N} \big[ y_i \log \sigma(\hat{y}_i) + (1 - y_i) \log(1 - \sigma(\hat{y}_i)) \big]$$

论文把大部分提升归功于三个技巧:

  • Dice 激活函数——一种数据自适应的 PReLU,拐点会随当前 batch 的分布漂移(见第 7 节)。
  • 小批次感知正则化(Mini-batch Aware Regularization)——与其对全部 embedding 都做 L2(千万级 item,本 batch 99.99% 没出现),不如只对当前 batch 中出现过的 embedding 正则、并按其频次加权。效果几乎一致,代价小几个数量级。
  • 梯度裁剪——长行为序列在训练早期最容易爆梯度。

3. Deep Interest Evolution Network (DIEN)

DIN 把历史看作一袋行为,忽略时间。但兴趣会 移动——上个月你在研究笔记本,本周变成笔记本配件,下周可能转向人体工学椅。

用户兴趣随周次的迁移——DIEN 建模这条轨迹,DIN 看不到

这张图说出了 DIN 说不出的故事:用户的"兴趣"不是一个向量,而是一条 时序——不同峰值按可预测的顺序起落。DIN 把所有峰值的并集一锅端了。DIEN(Zhou 等,AAAI'19)在行为 embedding 上加了两层,专门刻画这条轨迹。

一张图看懂结构

DIEN:GRU 抽取每个时间步的兴趣,AUGRU 让兴趣朝候选演化

第 1 层 —— 兴趣抽取(GRU):在行为序列上跑标准 GRU:

$$\mathbf{h}_t = \text{GRU}(\mathbf{e}_{b_t}, \mathbf{h}_{t-1})$$

每一个隐藏状态 $\mathbf{h}_t$ 就是用户在 $t$ 时刻的兴趣。

第 2 层 —— 兴趣演化(AUGRU):一个改造版 GRU,它的更新门被注意力权重 $a_t$(即 $\mathbf{h}_t$ 与候选的相关度)相乘:

$$\tilde{u}_t = a_t \cdot u_t \qquad \mathbf{h}'_t = (1 - \tilde{u}_t) \odot \mathbf{h}'_{t-1} + \tilde{u}_t \odot \tilde{\mathbf{h}}_t$$

口语化地说:当某个时刻的兴趣与候选高度相关,就让它驱动状态演化;与候选无关时就冻住状态——别让噪声把信号冲淡。图中演化层的箭头粗细与 $a_t$ 成正比:粗箭头把信息推进去,细箭头基本保留前一状态。

辅助损失这个小窍门

光靠 CTR 损失,GRU 可能学到"懒"的隐藏状态——能压低 loss,却没真正表达兴趣。DIEN 用一个 辅助损失 强迫 $\mathbf{h}_t$ 去预测 下一个 行为 $b_{t+1}$:

$$\mathcal{L}_{\text{aux}} = -\frac{1}{T-1}\sum_{t=1}^{T-1} \Big[ \log \sigma(\mathbf{h}_t^\top \mathbf{e}_{b_{t+1}}^+) + \log\big(1 - \sigma(\mathbf{h}_t^\top \mathbf{e}_{b_{t+1}}^-)\big)\Big]$$

翻成大白话:如果 $t$ 时刻的隐藏状态既能预测 $t+1$ 真实点击的正样本、又预测不准随机采的负样本,那它就学到了真东西。

总目标是 $\mathcal{L} = \mathcal{L}_{\text{ctr}} + \lambda \cdot \mathcal{L}_{\text{aux}}$,$\lambda$ 一般取 $[0.1, 1.0]$。

AUGRU 的实现

 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
class AUGRUCell(nn.Module):
    """更新门被注意力加权过的 GRU cell。"""

    def __init__(self, input_dim, hidden_dim):
        super().__init__()
        self.W_ir = nn.Linear(input_dim, hidden_dim)
        self.W_hr = nn.Linear(hidden_dim, hidden_dim)
        self.W_iz = nn.Linear(input_dim, hidden_dim)
        self.W_hz = nn.Linear(hidden_dim, hidden_dim)
        self.W_in = nn.Linear(input_dim, hidden_dim)
        self.W_hn = nn.Linear(hidden_dim, hidden_dim)

    def forward(self, x_t, h_prev, a_t):
        """
        x_t:    (B, input_dim)   t 时刻的兴趣(来自 GRU 层)
        h_prev: (B, hidden_dim)  上一步演化后的状态
        a_t:    (B, 1)           相对候选的注意力权重
        """
        r = torch.sigmoid(self.W_ir(x_t) + self.W_hr(h_prev))
        z = torch.sigmoid(self.W_iz(x_t) + self.W_hz(h_prev))
        n = torch.tanh(self.W_in(x_t) + r * self.W_hn(h_prev))
        z_tilde = a_t * z                       # ← AUGRU 的关键
        h_t = (1 - z_tilde) * h_prev + z_tilde * n
        return h_t


class DIEN(nn.Module):
    def __init__(self, item_dim=64, user_dim=32, ctx_dim=16,
                 hidden_dim=64, mlp_hidden=(200, 80), dropout=0.5):
        super().__init__()
        self.gru = nn.GRU(item_dim, hidden_dim, batch_first=True)
        self.attn = LocalActivationUnit(hidden_dim, use_softmax=True)
        self.augru = AUGRUCell(hidden_dim, hidden_dim)

        in_dim = hidden_dim + item_dim + user_dim + ctx_dim
        layers = []
        for h in mlp_hidden:
            layers += [nn.Linear(in_dim, h), nn.PReLU(), nn.Dropout(dropout)]
            in_dim = h
        layers += [nn.Linear(in_dim, 1)]
        self.mlp = nn.Sequential(*layers)

    def forward(self, user_feats, behaviors, candidate, ctx_feats):
        # 第 1 层:兴趣抽取
        interest, _ = self.gru(behaviors)               # (B, T, H)

        # 与候选的注意力权重
        _, attn = self.attn(interest, candidate)        # (B, T)

        # 第 2 层:用 AUGRU 演化兴趣
        B, T, H = interest.shape
        h = torch.zeros(B, H, device=behaviors.device)
        for t in range(T):
            h = self.augru(interest[:, t, :], h, attn[:, t:t+1])
        final_interest = h                              # (B, H)

        x = torch.cat([final_interest, candidate, user_feats, ctx_feats], dim=1)
        return self.mlp(x).squeeze(-1), interest        # interest 用于辅助损失

线上部署时这段按时间步循环的 Python 代码会被换成自定义 CUDA kernel,但概念上就是这么回事。


4. Deep Session Interest Network (DSIN)

用户行为常常 扎堆:午饭时间花十五分钟翻笔记本,晚上回来扫一遍耳机,第二天早上看跑鞋。每一阵儿内部主题一致,阵儿与阵儿之间往往伴随心境切换。

DSIN:行为按 30 分钟空隙切成会话,会话内自注意力,会话间 Bi-LSTM

DSIN(Feng 等,IJCAI'19)把这种结构显式建出来。上图把 9 次行为分进 3 个会话,完整流水线一目了然:

  1. 会话切分——只要前后两次行为间隔超过 30 分钟(原论文的阈值),就切。
  2. 会话内自注意力——同一会话内用多头自注意力捕捉局部关系(同阵儿的物品之间彼此呼应)。
  3. 会话间 Bi-LSTM——跨会话用双向 LSTM 建模兴趣从一个会话漂移到下一个会话的过程。
  4. 目标注意力——最后用候选作为 query,对各会话向量加权,得到与候选最相关的会话级用户表征。

直觉是这样的:会话是模型的"思考单位"。把三十次点击当作没差别的一袋扔进去,就丢失了它们其实分成三个连贯的小堆这件事;把每次点击当独立时间步,又忽略了同一会话内点击话题往往很近。会话恰好处在两个极端之间合适的粒度上。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class DSINSessionLayer(nn.Module):
    """会话内自注意力 → 平均池化 → 会话向量。"""

    def __init__(self, embed_dim, num_heads=4):
        super().__init__()
        self.attn = nn.MultiheadAttention(embed_dim, num_heads, batch_first=True)

    def forward(self, session_behaviors, mask=None):
        # session_behaviors: (B, S, D)
        attended, _ = self.attn(session_behaviors, session_behaviors, session_behaviors,
                                key_padding_mask=mask)
        return attended.mean(dim=1)                      # (B, D)


def split_sessions(timestamps, gap_seconds=1800):
    """按时间间隔把行为序列切成若干会话区间。"""
    sessions, start = [], 0
    for i in range(1, len(timestamps)):
        if timestamps[i] - timestamps[i - 1] > gap_seconds:
            sessions.append((start, i))
            start = i
    sessions.append((start, len(timestamps)))
    return sessions

什么时候选哪个

模型关键创新适合的场景
DIN在扁平行为列表上做目标注意力历史短、时序结构不明显
DIENGRU + AUGRU + 辅助损失历史长、兴趣演化平滑可循
DSIN会话内自注意力 + 会话间 Bi-LSTM浏览模式带明显会话边界
BSTTransformer 同时吃行为序列与候选历史长、希望并行化部署

5. Behavior Sequence Transformer (BST)

到 2019 年,Transformer 已经吞下整个 NLP。淘宝团队问:能不能干脆把它怼到行为序列上、当天结束?

BST:在行为序列+候选上跑 Transformer,再过 MLP 出 CTR

BST(Chen 等,DLP-KDD'19)就是这么做的:把行为序列与候选商品一起当成一段 token 序列,整体喂给 Transformer encoder。多头自注意力让每个行为都能关注其他每个行为 以及 候选;位置 embedding 编码时间顺序。

整体结构基本就是:

$$\mathbf{Z} = \text{TransformerBlock}\big(\,[\mathbf{e}_{b_1} + \mathbf{p}_1,\, \ldots,\, \mathbf{e}_{b_T} + \mathbf{p}_T,\, \mathbf{e}_i + \mathbf{p}_{T+1}]\,\big)$$

随后把 $\mathbf{Z}$ 与其他侧特征拼接、过 MLP。当时 BST 在淘宝日志上相对 WDL 基线的 AUC 提升约 7.5%。注意 BST 没有显式的"目标注意力"步骤,因为它不需要——[历史, 候选] 上的自注意力本身就让候选 token 直达每个历史, 让每个历史直达彼此(这是 DIN 一直没建模的部分)。

 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 BST(nn.Module):
    def __init__(self, item_dim=64, max_len=50, num_heads=8, num_layers=2,
                 user_dim=32, ctx_dim=16, mlp_hidden=(1024, 512, 256), dropout=0.2):
        super().__init__()
        self.pos_embed = nn.Embedding(max_len + 1, item_dim)
        encoder_layer = nn.TransformerEncoderLayer(
            d_model=item_dim, nhead=num_heads,
            dim_feedforward=item_dim * 4, dropout=dropout,
            batch_first=True, activation='relu',
        )
        self.transformer = nn.TransformerEncoder(encoder_layer, num_layers=num_layers)

        in_dim = item_dim * (max_len + 1) + user_dim + ctx_dim
        layers = []
        for h in mlp_hidden:
            layers += [nn.Linear(in_dim, h), nn.PReLU(), nn.Dropout(dropout)]
            in_dim = h
        layers += [nn.Linear(in_dim, 1)]
        self.mlp = nn.Sequential(*layers)

    def forward(self, user_feats, behaviors, candidate, ctx_feats, mask=None):
        # 把候选拼到行为序列尾部
        seq = torch.cat([behaviors, candidate.unsqueeze(1)], dim=1)   # (B, T+1, D)
        pos = torch.arange(seq.size(1), device=seq.device)
        seq = seq + self.pos_embed(pos).unsqueeze(0)

        z = self.transformer(seq, src_key_padding_mask=mask)          # (B, T+1, D)
        flat = z.reshape(z.size(0), -1)
        x = torch.cat([flat, user_feats, ctx_feats], dim=1)
        return self.mlp(x).squeeze(-1)

6. 这些技巧到底带来了多少收益?

Amazon Books CTR 基准上的 AUC 进展

数据来自 DIN/DIEN/DSIN/BST 各原论文在 Amazon Books CTR 基准上的报告(统一到可比设置)。两点值得注意:

  • 从 sum/avg pooling 到 DIN 的跳跃最大。引入注意力是单一影响最大的变化,后续都是增量优化——DIEN 在 DIN 上多出几分点,DSIN 再多一点点,BST 视数据集大致和 DSIN 持平或略胜。
  • AUC 看似涨得不多,规模一上就值钱。淘宝量级上 0.005 的 AUC 提升就能换来若干百分点的 CTR、几亿的 GMV 增量。这就是为什么团队会在外人觉得"看起来像噪声"的指标上反复打磨。

成本侧也有差别:DIN 上线最便宜,注意力就是每个行为多过一个 MLP;DIEN 的 AUGRU 串行最慢;BST 在 GPU 上跑得快但显存吃得猛;DSIN 的会话切分这种"账本工作"最容易在工程上反复出错。

经验上的取舍:先上 DIN,它用 20% 的工程投入拿到 80% 的收益。当行为序列长、且话题 顺序 重要时(订阅类产品、有明确升级路径的爱好),上 DIEN。会话特别清晰频繁时(短视频、电商浏览)上 DSIN。如果想要"一种心智模型管所有"且服务栈本来就喜欢 Transformer,上 BST。


7. 真正能挪动指针的工业化技巧

Dice——数据自适应的激活函数

PReLU 与 Dice 对比:Dice 的拐点跟着 batch 分布走

PReLU 的拐点固定在 $x = 0$——如果激活值刚好以 0 为中心还行,分布稍有偏移就尴尬。Dice(Data-adaptive Activation)把硬切换换成以 batch 滑动均值为中心的平滑 sigmoid:

$$\text{Dice}(x) = p(x) \cdot x + (1 - p(x)) \cdot \alpha x, \qquad p(x) = \sigma\!\left(\frac{x - \mathbb{E}[x]}{\sqrt{\text{Var}[x] + \epsilon}}\right)$$

转折点跟着数据走。上图右半部分给出了三个不同均值的 batch——Dice 的拐点跟着漂移,PReLU 死死钉在零点。不同层、不同分布、不同的有效激活函数——白送的。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
class Dice(nn.Module):
    def __init__(self, dim, eps=1e-8):
        super().__init__()
        self.alpha = nn.Parameter(torch.zeros(dim))
        self.bn = nn.BatchNorm1d(dim, eps=eps, affine=False)

    def forward(self, x):
        x_norm = self.bn(x)
        p = torch.sigmoid(x_norm)
        return p * x + (1 - p) * self.alpha * x

小批次感知正则化

每步对 1 亿条 item embedding 做 L2 正则太浪费——梯度有 99.99% 是零。把正则限定在当前 batch 中出现过的 embedding 上,并按它们在 batch 中的频次缩放:

$$\mathcal{L}_{\text{reg}} = \frac{\lambda}{2} \sum_{j \in \mathcal{B}} \frac{n_{j,\mathcal{B}}}{n_j} \|\mathbf{e}_j\|^2$$

其中 $n_{j,\mathcal{B}}$ 是 item $j$ 在 batch $\mathcal{B}$ 中出现的次数,$n_j$ 是它的全局次数。效果几乎相同,开销小几个数量级。

变长序列怎么处理

真实用户的历史长度差异极大。统一 pad 到固定上限,然后 mask:

1
2
3
4
5
6
7
8
def pad_and_mask(sequences, max_len, pad_value=0):
    padded, masks = [], []
    for seq in sequences:
        seq = seq[-max_len:]                                    # 保留最近的
        pad_len = max_len - len(seq)
        padded.append([pad_value] * pad_len + list(seq))
        masks.append([0] * pad_len + [1] * len(seq))
    return torch.LongTensor(padded), torch.FloatTensor(masks)

注意力中先把被 mask 的位置打到 $-10^9$ 再 softmax——这样它们的权重就被压到零。

大流量下的服务优化

百万 QPS 时常用的几招:

  • 离线预算并缓存 item embedding:item 表相对静态,每晚重算一次即可。
  • 截断到最近 N 条行为:N = 50–100 通常已经覆盖绝大部分信号。
  • 量化:FP16 或 INT8 能把模型瘦 2–4 倍,AUC 几乎无损。
  • 批量推理:GPU 在 64 以上 batch 才舒服。
  • 把 AUGRU 的 Python 循环换成自定义 CUDA 算子,如果 DIEN 真的得上线。

8. 常见问题

为什么 DIN 用目标注意力而不是自注意力? 目标注意力回答的是"哪些过去行为对 这个 候选相关"。自注意力只在历史内部看(“笔记本和手机都属于电子产品”)——有用,但没条件在候选上,而条件在候选上恰恰是核心目的。BST 后来证明,用 Transformer 可以两者兼得。

为什么 DIN 不做 softmax? 作者发现 softmax 会把 强度 信息抹平。一个有大量强匹配的用户和一个只有一次弱匹配的用户会被归一成同样的总权重。不做 softmax,用户向量自身的大小就能反映兴趣强度。

辅助损失真的有用吗? 长序列场景下显著有用。没有它,GRU 可能塌成一些能压低 CTR loss 但毫无兴趣含义的平凡状态。DIEN 论文报告,仅辅助损失在 Amazon 数据集上就值 ~0.3% 的 AUC。

计算成本怎么样? 注意力对序列长度是 $O(T^2 \cdot d)$——$T \le 100$ 时无所谓,再长就吃不消。长历史的常见对策:截断(最常用)、稀疏/线性注意力、两阶段检索(如 SIM 硬搜 → DIN)。

冷启动用户怎么处理? 回退到用户画像特征(人口属性、地域、设备)和品类先验。基于内容的 item embedding(标题、图像)在任意一侧行为稀疏时都帮得上忙。

注意力权重真的可解释吗? 基本上可以,但要小心。它告诉你模型对某次推荐 依赖了 哪些过去行为,对调试和可信度很有价值。但 softmax 归一化后的权重是 相对 的——权重高不代表绝对相关度高,只代表在这条序列里相对于其他位置较高。


总结

深度兴趣网络给推荐留下了一个能传承下去的想法:不是所有过去行为都同等重要,模型应该每一次都自己想清楚到底哪些重要

剩下的都是这个主题的变奏:

  1. DIN —— 按相对候选的相关度给行为加权。
  2. DIEN —— 对兴趣随时间的演化建模。
  3. DSIN —— 把行为分进会话,尊重那种结构。
  4. BST —— 把所有事情交给 Transformer 自己去摆弄。

好厨师不会给每位客人做同一道菜。DIN 之后,好的推荐系统也不会。


系列导航

本文是推荐系统 16 篇系列的 第 10 篇

上一篇下一篇
第 9 篇:多任务学习全部章节第 11 篇:对比学习

Liked this piece?

Follow on GitHub for the next one — usually one a week.

GitHub