☰
DIN深度兴趣网络:候选商品引导的注意力机制与推荐系统CTR模型实战解析
2026/10/17 23:36:59 网站建设 项目流程

做推荐系统的同行应该都有这种感觉:用户的历史行为越长,数据越丰富,模型反而越难做。明明一个用户点了不少裙子、看了不少口红,给他推下一件商品的时候,这些历史行为到底哪些有用、哪些是噪声,就成了一个很难回答的问题。早年大家习惯把用户的历史行为向量全部池化成一个固定向量,然后拼上候选商品特征一起过MLP。这个方法很稳定,但它有一个先天缺陷——不管候选商品是什么,池化出的用户兴趣向量都是同一份。仔细想想这是不合理的:用户在看一个篮球的时候,他历史里的口红行为真的应该和看一支口红时贡献相同的权重吗?

DIN模型,全称Deep Interest Network,就是冲着这个问题来的。它的核心思路特别直白:用户的历史行为不应该被一成不变地压缩成固定向量,而应该根据当前候选商品,去“唤醒”历史行为中和这个候选商品最相关的部分。这个唤醒过程就是注意力机制,只不过它在推荐系统里换了名字,叫“局部激活单元”。这篇文章我会把这个模型的动机、网络结构、训练细节、复现要点和工程落地中的坑全部讲透,尤其会解释很多论文里一笔带过、但实践中非常关键的地方。

1. 为什么需要DIN:从固定池化到局部激活

1.1 固定向量池化的根本问题

在DIN出来之前,业内主流的深度学习CTR模型基本长这样:把用户的历史点击商品ID序列做Embedding,得到一个向量序列,然后做sum pooling或者mean pooling,把序列压成一个向量;再把这个向量和候选商品Embedding、用户画像特征拼在一起,送进MLP,最后过一个sigmoid输出点击概率。

这个结构本身没有问题,问题出在pooling这个环节。sum pooling的本质是把所有行为向量的信息全部“揉”进一个等维度的向量里。假设用户的兴趣维度是128,他点了100个商品,这100个商品的Embedding加起来除以100,得到的平均向量已经把每个商品自身的细节抹掉了一大半。更关键的是,这个池化后的向量无论如何都不会变——候选商品是篮球还是口红,用户历史行为的权重分配都是那同一套。

你可以把它理解成一位导购员,不管顾客进来是想买运动鞋还是想买护肤品,她都用同一套说辞推荐。这套说辞可能在某种场景下有效,但面对兴趣跨度极大的真实用户,它必然顾此失彼。用户的兴趣天生就是多样的,一个年轻用户可能同时喜欢数码产品和潮牌服饰,这两个兴趣在池化时会互相干扰。结果就是,模型为了平衡,学出来的用户表示变得“平平无奇”,哪个兴趣方向都表达得不够充分。

1.2 为什么要用候选商品来“指挥”注意力

DIN的论文给了我一个非常关键的思想转变:用户兴趣表示不应该是独立计算出来的,它应该和候选商品有关。

所谓局部激活,就是当候选商品是篮球鞋的时候,模型应该去用户的历史行为序列里,重点提取和篮球鞋相关的行为;当候选商品是美白精华的时候,模型应该把注意力放到美妆类的历史行为上。这个“重点提取”的过程,就是让每个历史行为Embedding和候选商品Embedding做一个交互计算,得出一个权重,然后对所有历史行为向量做加权和。

这样做的好处立竿见影。第一,模型的有效表达能力变强了,因为用户兴趣表示不是固定的,而是针对每个候选商品“定制”的。第二,噪声干扰减少了,历史行为中与当前候选商品无关的部分会被自动压权,不会拖累预测。第三,这个机制是端到端学出来的,不需要人工去定义“篮球鞋和哪些历史行为有关”这样的规则,完全靠数据自己拟合。

我当时读完论文的第一反应是:这个思路太自然了,为什么之前没人这么做?其实是有人这么想的,但把注意力机制稳定地用在亿级稀疏特征的CTR场景里,并且把训练技巧、激活函数、正则方法都补齐到工程可用,DIN是做得最完整的一个。

2. DIN模型结构逐层拆解

2.1 候选商品、用户行为与上下文特征的输入设计

DIN的输入分为三大块。第一块是候选商品相关的特征,这一块直接决定预测目标,通常包括商品ID、类目ID、店铺ID等,全部经过Embedding后拼接成一个候选向量。第二块是用户历史行为序列,重点就是用户在过去一段时间内点击过的商品ID序列,每个商品ID同样要做Embedding,形成一个向量序列。第三块是用户画像特征和上下文特征,比如用户的年龄、性别、城市等级、当前时间等。

在特征工程层面有一个值得注意的设计:候选商品和用户历史行为里的商品,在Embedding层是共享参数的。候选商品ID是ID,行为序列里的商品ID也是ID,如果分开建两套Embedding,模型就学不到“候选商品和历史点击商品的关系”,注意力权重也就失去了基础。这一点在复现时特别容易漏掉,一旦漏掉,AUC会明显下降。

输入长度方面,历史行为序列不能无限长。常见做法是固定截断长度,比如取最近点击的50个或者100个商品。如果不足长度就做padding,如果超过就按时间倒序截断。实际场景中,很多用户的历史行为可能只有十几个,固定长度会带来大量padding,需要在Embedding层把padding对应的向量置为零,防止它参与有效性计算。

2.2 局部激活单元的计算逻辑

局部激活单元是整个DIN的核心,它的输入是历史行为序列里某一个商品对应的Embedding向量,以及候选商品对应的Embedding向量。

DIN的局部激活单元结构与传统的直接拼接做点乘不同,它设计了一个小型的MLP来学习这两个向量之间的交互关系。具体的计算方式是:把两个向量本身拼接在一起,再让它们做逐元素的外积,得到一组交叉项,然后把这三部分拼接起来,送进一个两层的全连接网络,最后输出一个标量权重。这个权重再经过指数映射,就是那个历史行为对候选商品的重要程度。

很多初学者会问一个问题:为什么不直接用softmax归一化?DIN有意没有对全部行为序列做softmax,因为用户兴趣的绝对强度本身是有意义的。如果一个用户对某个历史行为极其感兴趣,他对候选商品的外显权重就应该整体偏高;如果做全序列的softmax,就相当于把所有行为权重强制拉成相对比例,这会丢失兴趣强度的绝对值信息,反而影响预测精度。

计算出每个行为向量的权重之后,对所有行为向量做加权求和,得到最终的向量,再与候选商品向量、用户画像向量拼接,送入MLP。MLP部分就是标准的全连接网络,最后接sigmoid输出点击率。

2.3 为什么不是先建模行为顺序

很多人会觉得,历史行为是序列数据,应该先用GRU、LSTM这些建模时序,然后再接注意力。这个想法在逻辑上没错,但在DIN提出的那个阶段,用序列模型把所有用户历史行为建模完整,计算量非常大,效果增益却未必匹配。

DIN的定位是尽量简洁地解决“兴趣表达与候选商品无关”的问题,它没有刻意去建模行为之间的先后依赖,也没有做长短期兴趣的拆分,就是一个注意力加权求和。论文后续确实有人在这条路上继续延伸,比如把行为序列换成Transformer结构做建模,但作为基石,DIN把最有价值的一步先走通了——让每个候选商品都能看到用户历史行为的“不同切面”。这一步走通之后,后面的一切优化都有了更牢固的地基。

3. 训练中的两个关键细节:Dice激活与Mini-batch Aware正则

3.1 Dice激活函数做了什么

DIN论文里第一次看的时候,大部分人都会被Dice激活函数这个小节卡住。它看起来只比PReLU多了一个自适应开关,但理解它的动机很重要。

PReLU是带可学习参数的ReLU变体,公式很好理解:当输入大于零时保持原值,当输入小于等于零时乘以一个可学习的系数。这个系数是全局固定的一组标量,在所有输入样本上共享。问题来了:推荐系统里的特征输入分布极其不稳定,有的特征均值可能在零附近,有的特征整体是负数,有的特征方差爆炸。用一个固定负斜率去处理所有分布特征,很容易压不住数据分布的变化。

Dice的做法是对输入做标准化,用当前batch数据的均值和方差把输入调整到零均值单位方差附近,再用sigmoid函数动态控制这个激活门。当输入分布不稳定时,这个sigmoid门会自适应地调节激活点的位置,相当于一个可以随着数据分布流动的“软开关”。复现的时候可以直接用PyTorch的batch normalization思路类比,但要清楚Dice的标准化是不做仿射变换的重建的,它只做标准化和门控。

3.2 Mini-batch Aware正则的动机与简化

这个部分论文里给了比较复杂的形式,很多复现教程会直接略过,但工程上它非常重要。

推荐系统的Embedding层参数动辄上亿,模型的权重数量远超样本数,过拟合风险极大。常用的L2正则理论上能压制复杂度,但它要求对全量参数更新时计算梯度,这在工业级的Embedding规模下是不可行的——每个batch只碰到了极少一部分稀疏ID,对全部Embedding做惩罚是巨大的浪费。

Mini-batch Aware正则的思路是:在计算L2正则时,只对当前batch中出现过的稀疏特征对应的Embedding做惩罚,并且惩罚力度要依据该特征在当前batch中的出现频率进行调整。更直白的解释是:如果一个参数在当前batch中出现了两次,它获得的惩罚是出现一次样本的两倍;如果一个参数在当前batch中一次都没出现,那就不惩罚它。论文里还做了一个频次的p次方近似处理,避免高频特征被惩罚过重,在实际复现时如果数据量不大,不做p次方近似也不会差太多。

我自己的复现经验是:Mini-batch Aware正则这个细节最容易被省略,如果不加,结果往往是离线AUC有小幅上升但波动大,线上反馈差距更明显。做工程落地时,这个正则一定要保留。

4. 动手复现DIN:PyTorch代码核心要点

4.1 稀疏特征与行为序列的预处理

在开始写模型之前,先把数据流想清楚。输入样本通常长这样:user_id、一个候选商品id、一个候选商品的类目id、一个历史点击商品id序列、一个历史点击商品类目序列、还有若干上下文特征。

这里我建议把用户历史行为里的商品id和候选商品id放进同一个词表,序列长度固定为N。预处理阶段,把历史行为序列倒序排列,按时间最近优先取前N个,不足补零。

class DINBase(nn.Module): def __init__(self, item_count, embedding_dim, sequence_length, hidden_units): super().__init__() self.item_embedding = nn.Embedding(item_count, embedding_dim, padding_idx=0) self.sequence_length = sequence_length self.activation_unit = ActivationUnit(embedding_dim, hidden_units) self.mlp = nn.Sequential( nn.Linear(embedding_dim * 3 + 48, 200), Dice(200), nn.Linear(200, 80), Dice(80), nn.Linear(80, 1) )

注意这里我拼接了用户向量、候选商品向量、用户画像特征,总计维度要算清楚。用户向量就是注意力加权求和后的维度,候选商品向量就是单个Embedding的维度。画像特征通常是稠密数值特征加离散特征的Embedding拼接。

4.2 局部激活单元的代码实现

局域激活单元的重点是计算交互特征。我给的实现是把候选商品向量复制成多个副本,与每个历史行为向量做外积,再把三部分拼接送进MLP。

class ActivationUnit(nn.Module): def __init__(self, embedding_dim, hidden_units): super().__init__() self.linear = nn.Linear(embedding_dim * 3, hidden_units) self.dice = Dice(hidden_units) self.linear_out = nn.Linear(hidden_units, 1) def forward(self, candidate_item, behavior_sequence): # candidate_item: [batch, dim] # behavior_sequence: [batch, seq_len, dim] batch_size, seq_len, dim = behavior_sequence.shape c = candidate_item.unsqueeze(1).expand(batch_size, seq_len, dim) interaction = c * behavior_sequence concat = torch.cat([behavior_sequence, c, interaction], dim=-1) hidden = self.dice(self.linear(concat)) weight = self.linear_out(hidden).squeeze(-1) weight = torch.exp(weight) mask = (behavior_sequence.sum(dim=-1) != 0).float() weight = weight * mask weight = weight / (weight.sum(dim=-1, keepdim=True) + 1e-8) return weight

大多数人第一次实现都会纠结要不要对权重做归一化。刚才分析过,论文思想是不强制做softmax,但实际工程里如果序列长度差异很大,不做归一化,长序列用户注意力分数天然会大,跨样本比较就混乱了。我习惯在加权求和前对有效权重做一次求和归一化,保留兴趣强度的相对差异,但避免样本间的序列长度偏差问题。当然,如果有更贴近论文原始含义的复现需求,也可以不做这里的归一化,而是直接加权求和。

4.3 训练主流程与超参数配置

DIN在训练时的超参数选择直接影响最终效果。论文给我的经验是使用Adam优化器,初始学习率从0.001开始,配合指数衰减,大概每两轮迭代衰减10%。Batch size在稠密ID规模不夸张时设为256到512比较合适。

负样本采样也是一个重要环节。曝光未点击样本与点击样本的比例,通常在5:1到20:1之间调整。比例高了,模型会倾向预测低点击率,需要配合阈值校准;比例低了,正样本噪声会污染注意力权重的学习。

训练过程中的Loss就是标准的二值交叉熵。我建议在训练日志里同时记录训练集和验证集AUC,并且按用户分组计算GAUC,这一点和DIN论文的实验设置保持一致,因为全局AUC会被少数高频用户主导,掩盖模型在长尾用户上的真实表现。

5. 实验评估、工程落地与常见问题排查

5.1 评估指标:为什么全局AUC不够用

DIN论文在实验部分强调了评估指标的选择问题。直接算全量样本的AUC,结果会被行为数量极大的头部用户“带偏”,这些用户的兴趣通常比较集中,历史行为很长,模型对它们的预测本来就比较容易;真正难预测的是那些历史行为稀疏的长尾用户,他们的点击行为跨领域跨度大,候选商品的注意力计算会遇到更多挑战。

所以推荐的做法是:先把测试样本按用户分组,在每个用户组内计算AUC,再按曝光数加权平均,得到GAUC。我自己的复现对比中,DIN的相对AUC提升大约在0.5到1个百分点,但GAUC提升会更明显,说明模型的增益确实集中在了原有模型搞不定的样本上。评估时千万要两个指标一起看。

5.2 线上推理时的性能瓶颈与优化

工业落地中,局部激活单元带来的计算开销不容忽视。在线推理时,候选商品数量可能是几千上万个,用户历史行为序列长度在50到100,如果每个候选都和全部历史行为做一次外积和MLP计算,压力不小。

一种有效优化是预先缓存用户行为序列的Embedding结果,因为用户的动作频率远低于候选商品的变化频率。行为向量的个数不变,只是权重计算时需要和每个候选商品重新交互,这部分无法完全预计算,但可以把行为Embedding向量的查询开销省掉。如果延迟敏感,还可以对行为序列做截断或者只保留最高注意力权重的前K个行为,实测下来精度损失很小。

模型参数存储方面,Embedding矩阵是最大的内存消耗点。如果商品量级是千万级,每个商品32维Embedding,那么单是商品Embedding就有数亿个参数,必须利用参数服务器或者按分片方式存储,不要让单机内存成为瓶颈。

5.3 复现与落地常见问题速查

问题现象可能原因解决思路
局部激活单元训练不收敛学习率过大、行为序列padding没有遮罩调小初始学习率,对padding位置强制置零权重
AUC提升不明显候选商品与历史行为商品用了两套Embedding检查是否共享词表与Embedding矩阵
训练过程loss震荡正负样本比例失衡或batch size过小提高负样本比例或增大batch size
线上延迟超标注意力计算量过大截断行为序列、只保留TopK高权重行为
长尾用户预测偏平行为序列太短、候选信息不足引入跨会话行为或补充用户长期画像特征

还有一个隐蔽的坑值得单独提:注意力权重的饱和现象。当候选商品非常好猜时,比如用户历史行为里有大量同款商品,注意力网络可能会把几乎全部权重集中到一个行为上,导致加权向量退化成单纯的某个行为Embedding。这种情况虽然不报错,但会让模型丧失泛化能力。我的经验是,可以在激活单元输出层加少量dropout,或者对注意力权重logits做轻微的正则,防止过度集中。

迭代到后面,你会发现DIN的真正价值不在模型结构有多复杂,而在于它把“候选商品引导历史行为表示”这个思路做成了推荐系统深度模型的标配。后续很多模型,比如用多头注意力做多兴趣抽取的MIND,用胶囊网络区分兴趣的ComiRec,本质上都能看到DIN的影子。如果你想在推荐领域深入,吃透DIN的每一处细节是最值得投入的一件事。

最后分享一个我在实际复现中收获最大的习惯:不要只对着论文公式写代码,把注意力网络输出的权重可视化出来。随机挑几个样本,看模型到底把注意力给了哪些历史行为,你会发现一个训练充分的DIN,它的注意力分布和人的直觉高度吻合——这个观察过程能帮你快速定位特征问题和模型问题,比单纯盯AUC数字有效得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询