资讯动态

DIN模型详解:用目标注意力机制动态建模用户行为序列,提升CTR预估

发布时间:2026/10/17 14:49:38 来源:尧图企业网站定制
做点击率预估CTR和推荐排序的同学应该都被“用户行为序列”这个特征坑过不少次。用户点过什么、买过什么、停留过哪些页面这些信号通常比单纯的用户画像有效得多但怎么把它揉进模型一直是让人头疼的事。最早一批EmbeddingMLP模型会先把用户历史上的行为物品逐个映射成embedding然后全部相加或取平均拼上其他特征直接过全连接。这在很多场景下能跑通但有一个天然缺陷它对所有候选item一视同仁地“平均用力”。DINDeep Interest Network就是为了解决这个问题出现的它不是把用户历史行为压成一个固定向量而是根据当前候选item动态地从历史行为中挑出相关的部分加权组合成用户兴趣表示。本文会把DIN的动机、结构、工程细节、评估方法和实现要点完整拆一遍适合已经接触过推荐系统、准备深入理解目标注意力机制的读者也适合正在复现DIN但卡在某些细节里的同学。1. 为什么需要DIN用户兴趣不是固定向量1.1 平均池化的“脸谱化”问题先看一个具体的例子。假设一个用户最近一段时间的行为序列是点击了一部手机、一条连衣裙、一双篮球鞋。现在模型要预测他会不会点击一条新款手机的商品流。直觉上他的历史行为里“手机”这条行为和候选“手机”最相关应该对预测贡献最大连衣裙和篮球鞋只能提供很弱的补充信号。但传统EmbeddingMLP模型在处理行为序列时常用的是sum pooling或者average pooling。也就是把手机、连衣裙、篮球鞋三者的embedding直接加起来或者取平均得到一个“用户兴趣向量”。这个兴趣向量里手机、连衣裙、篮球鞋的贡献完全相同。这样做的直接后果是用户兴趣表达被强行“脸谱化”了。无论模型接下来要预测的是手机、连衣裙还是篮球鞋用户表示都是同一个向量。可现实情况是用户兴趣是多样化的而且是有上下文的。同一个用户在“手机”这个候选item下表现为一个手机爱好者在“篮球鞋”这个候选item下又表现为一个运动爱好者。预测目标变了应该被激活的历史行为就应该跟着变。有人可能会说把行为序列做平均池化再用后面的MLP去学交叉理论上也可以学到相关性。但问题在于平均池化是把“所有行为信息压缩成一个固定长度向量”这个操作本身信息瓶颈太严重了。行为序列可能有一二十个item每个item的embedding假设是16维池化后用户兴趣向量还是16维。16维要装下用户所有品类的兴趣几乎是不可想象的。尤其当用户兴趣特别分散时平均池化会把真正关键的信号稀释掉。你可以把它理解成面试官问“你对什么领域最感兴趣”候选人把所有领域都背了一遍平均用力等于没说。1.2 DIN的核心思路让用户表示跟着候选item变化DIN给出的解法非常直接用户兴趣表示不应该事先算好而应该针对当前候选item“临时组装”。它会计算候选item与用户历史行为中每一个item的相关性得到一个权重然后用这个权重对所有历史行为embedding做加权求和。相关性高的行为比如候选“手机”和历史“手机”权重就会高相关性低的行为比如候选“手机”和历史“连衣裙”权重就会低。最终得到一个“针对当前候选item定制”的用户兴趣向量。这个思路现在看起来好像很自然但在当时是一个很重要的转变。它把“用户兴趣”从一个固定向量变成了一个随着预测目标动态变化的向量。我经常拿一个生活场景来类比你和朋友聊天聊到手机时你会想起最近看的手机评测、逛过的手机店铺聊到篮球时你又想起那双一直没下单的篮球鞋。同一个人的记忆会因为话题不同而被激活不同的部分。DIN做的就是这个事情。这里需要特别强调一下DIN用到的机制虽然常被叫做“目标注意力”target attention但它和后来大家熟悉的Transformer self-attention并不是一回事。在self-attention里query、key、value全部来自同一个序列内部是序列自己和自己做注意力。而在DIN里query是当前候选item的embeddingkey和value是用户历史行为序列中的item embedding。候选item去“检索”历史行为而不是历史行为自己内部做交互。这个过程在代码里实现起来也更简单现在很多工业界模型依然沿用这个习惯只是把query替换成了候选广告、候选商品或者候选视频。2. DIN模型结构拆解从特征到点击概率2.1 稀疏特征与Embedding层在推荐和广告场景里模型的输入绝大多数都是稀疏特征。用户ID、物品ID、行为序列中的item ID、品类ID、店铺ID每个特征取值空间都很大几百万甚至上千万都很正常。DIN模型的第一步就是把所有这些离散ID映射成稠密的低维向量也就是embedding。具体来说每一类稀疏特征都会维护一张embedding表形状是[特征取值个数, embedding_dim]。比如历史行为序列里有100万个不同的item ID每个item的embedding维度设为16那这张表就是[1000000, 16]。模型训练时只在当前mini-batch里出现的那些ID会被查询和更新所以即使embedding表很大单次更新的参数数量也只和batch内的特征数量有关。有一个实现细节很容易踩坑候选item和历史行为item建议共享同一张item embedding表。这样做的原因很朴素同一个item在候选侧和行为侧应该表示同一个语义空间如果分开两张表候选item的向量和历史行为item的向量之间没有天然对齐关系激活单元在计算相关性时就必须额外学一个“翻译”过程这会让训练变难而且最后效果通常也不如共享。我自己复现时共享embedding表在GAUC上比分开两张表稳定高一点点虽然幅度不大但实现更简单。行为序列本身是变长的有的人历史行为有10个有的人有50个。工程上一般会统一padding到一个固定长度L比如50、100或者200。padding部分会用一个mask标记出来后面所有计算都要带着mask走保证padding部分的权重是0。2.2 局部激活单元行为权重是怎么算出来的局部激活单元是DIN的核心模块也是DIN名称里“Local Activation”的来源。它的输入有两个一个是当前候选item的embedding记作v_i一个是用户历史行为中某个item的embedding记作v_j。激活单元要输出一个标量权重表示“候选item i”和“历史行为item j”之间的相关性。为了捕捉两个向量之间更丰富的交互关系激活单元不是简单地把两个向量拼接起来而是会额外拼上它们的element-wise乘积。所以激活单元的输入特征是[v_i, v_j, v_i ⊙ v_j]这里⊙表示逐元素相乘也就是两个embedding在每一个维度上的数值相乘。这个操作的作用是让模型直接看到两个向量在同一维度上的交叉信号。只拼接的话模型需要自己通过全连接层去构造交叉信息会有损耗显式把逐元素乘积喂进去相当于给模型一个先验两个向量是不是在相同语义维度上同时有高响应。输入拼好之后会经过若干层全连接中间层激活函数在DIN原文里推荐使用Dice后面我会专门讲它。最后一层是一个线性层输出一个标量。这个标量就是未归一化的相关性得分。得到所有历史行为的得分后一般会做一个softmax归一化让所有行为的权重之和为1。softmax的好处是能让梯度更稳定也能比较直观地解释成“注意力分配”。有的实现里也会省略softmax直接用得分做加权求和。但我在实践里会优先用softmax。原因很简单softmax让权重变成概率分布模型训练初期更容易收敛不加softmax时如果得分绝对值很大加权后的兴趣向量范数很可能被带偏需要额外调参。不过这两种方式在数学上没有本质区别因为MLP本身可以学到一个缩放只是在工程实现的稳定性上softmax更省心。最后DIN用归一化后的权重对历史行为embedding做加权求和用户兴趣向量 Σ_j weight_j * v_j这个加权求和的过程也叫DIN Pooling。它会单独把历史行为序列里所有item的信息汇集成一个和候选item相关的向量。2.3 特征拼接与MLP打分有了针对当前候选item生成的用户兴趣向量之后就进入了比较常规的“拼接全连接”阶段。需要拼接的特征一般包括几类候选item的embeddingDIN Pooling输出的用户兴趣向量用户画像特征比如性别、年龄、消费水平上下文特征比如时间、位置、场景其他统计特征比如用户历史点击率、候选item的热度等把这些特征全部拼接成一个长向量然后送入多层全连接网络。DIN原文中给出的MLP结构是[200, 80, 2]最后一层输出2维用softmax得到“点击/不点击”的概率。实际工程里中间层数和每层维度要结合数据规模调整但大体逻辑是兴趣向量负责回答“用户在这个候选item上是什么状态”MLP负责把用户兴趣和其他特征做更高阶的非线性交互。损失函数用的是标准交叉熵。需要注意的是DIN在行为序列这一侧做得再花哨最终打分的部分仍然是一个多分类全连接网络。这个设计很稳因为它把复杂的用户行为交互留给了注意力权重而MLP部分保持通用方便后续横向扩展其他特征。3. 容易被忽略的两个工程细节Dice激活函数与Mini-batch正则化3.1 Dice能让激活函数适应数据分布的Dice很多复现DIN的人会忽略一个细节DIN里的激活函数不是默认的ReLU也不是常见的PReLU而是作者专门设计的Dice。要理解Dice得先看PReLU的问题。PReLU的公式很简单输入大于0时原样输出小于0时乘以一个可学习的斜率α。这个固定斜率的问题是它假设输入的分布是相对固定的切换点永远在0。但在深度学习训练过程中每一层的输入分布是不断变化的尤其是embedding层的输出不同特征的数值范围差异很大。如果切换点一直固定在0激活函数就很难自适应。Dice的思路是把切换点从固定的0改成动态的、由输入统计量决定的点。具体来说Dice会先计算当前mini-batch输入x的均值E[x]和方差Var[x]然后构造一个概率值p(x) sigmoid((x - E[x]) / sqrt(Var[x] ε))这个p(x)在输入远大于均值时趋近于1在输入远小于均值时趋近于0在均值附近则比较平滑。最终Dice的输出是两个分支的软切换f(x) p(x) * x (1 - p(x)) * α * x当p(x)接近1输出就是x当p(x)接近0输出就是α*x。这和PReLU的“硬切换”相比多了一个平滑过渡而且切换点会根据当前数据分布自动调整。从效果上看Dice相当于给激活函数加了一层类似Batch Normalization的自适应能力但实现上更轻量也不会显式改变数据的均值和方差。用Dice时有一个需要注意的地方训练时统计量E[x]和Var[x]来自当前mini-batch但预测时mini-batch可能很小甚至只有一个样本统计量抖动会很大。所以工程上一般会像Batch Normalization一样维护全局滑动平均训练时更新全局统计量预测时直接用全局统计量保证线上和离线行为一致。如果你复现时发现离线指标正常、线上表现不稳定优先查一下这里。3.2 Mini-batch Aware Regularizer稀疏特征的L2正则怎么做CTR模型的embedding参数规模非常大动辄上亿。传统的L2正则需要对所有参数都计算梯度每一步更新都遍历一遍完整参数表这在训练大模型时是吃不消的。更麻烦的是很多长尾特征出现的次数极少如果对所有embedding参数施加同样强度的L2惩罚低频特征会被压得几乎学不出有效表示。DIN提出了一个Mini-batch Aware的L2正则策略核心思想是只对当前mini-batch中实际出现过的特征embedding做正则化并且惩罚强度结合这个特征在mini-batch中的出现次数来调整。逻辑上可以这样理解一个特征在某个mini-batch里出现了n次说明它在这一批样本里是有信息量的但它又在整体上可能是低频的不能简单粗暴地按全部参数统一惩罚。具体实现时会给每个在mini-batch里出现的特征一个缩放系数比如α * min(1, n_j / threshold)然后只更新这些特征对应行的embedding。没有在这个mini-batch里出现的特征就不用管等它出现时再说。这既大大减少了正则项的计算量也避免了对低频特征“一刀切”式地压制。这个技巧在特征维度不太大的Demo里看不出明显作用但一旦特征ID空间超过千万级别、embedding表超过几十GB普通的L2正则就完全没有可行性了。如果你在训练过程中发现loss掉不下去可以把正则去掉看一版如果加了正则后低频特征对应的GAUC明显变差大概率就是正则强度没配好或者正则方式不对。3.3 训练配置与调参建议DIN的训练配置没有特别玄学但有几个经验可以直接抄。embedding维度一般取8到32之间不是越大越好。行为序列长度如果超过50建议先做截断而不是全部保留注意力权重的计算量会随序列长度线性增长太长的序列里很多行为本来就是噪声。MLP部分从[200, 80]起步根据特征维度调整第一层最好和拼接后的特征向量维度相当。优化器建议用Adam初始学习率可以设在1e-3左右配合指数衰减或者每若干轮手动衰减。batch size在512到2048之间比较常见。和很多深度学习任务不同CTR模型的训练数据量大但模式相对固化过大的batch size反而会导致GAUC下降我猜是注意力权重在小batch上学习得更细致。另外梯度裁剪可以加上不过一般不会被触发只是兜底。4. 效果评估为什么离线指标要选GAUC4.1 AUC在CTR评估里的缺陷做排序模型大家最常用的离线指标是AUC。但AUC在用户行为数据上有一个系统性偏差AUC是把所有样本混在一起看正样本排在负样本前面的概率。这个定义本身没问题问题在于CTR场景的样本结构是“一个用户对应一组曝光”。不同用户的曝光量差异很大有的用户只有10次曝光有的用户有500次曝光全局AUC会被高曝光用户主导。而且用户与用户之间的正负样本分布也不一样。假设A用户非常活跃点了一堆东西B用户几乎不点击。全局AUC在计算时可能更多地反映了“能不能把A用户喜欢的物品排在前面”而不是“能不能对每个用户做到个性化排序”。推荐系统的目标恰恰是后者。所以模型在全局AUC上涨了0.01不代表每个用户都能得到更好的排序很可能只是活跃用户的收益掩盖了沉默用户的损失。还有一个更实际的问题广告和推荐系统的排序是分用户的。同一个用户在一次请求中会看到多个候选item模型要保证“这个用户”视角下正样本排在负样本前面。全局AUC把不同用户拉在一起比较相当于跨用户排序和线上真实场景不一致。4.2 GAUC的计算与代码实现GAUCGroup AUC就是为了解决这个问题出现的。它的做法很直接按用户分组每个用户单独算一个AUC然后再把每个用户的AUC按权重加权平均。权重一般取该用户的曝光数也可以用点击次数或者展示时长但最稳的是曝光数因为它和流量占比直接相关。计算公式可以写成GAUC Σ_u (曝光数_u * AUC_u) / Σ_u 曝光数_u这个公式理解起来不难但实现上有几个坑。第一用户组内如果只有一条样本AUC是没法算的要跳过。第二如果某个用户组的标签全为0或者全为1也就是正样本或负样本缺失这一组的AUC也没有意义需要跳过。第三权重用的是同一个用户组的曝光总数不是组内样本数如果按组内样本数加权会和曝光数加权在绝大多数场景下很接近但严格来说曝光数更合理。参考代码可以写得很简单def calculate_auc(labels, scores): # 按score从高到低排计算正样本的秩和 order sorted(range(len(scores)), keylambda i: scores[i], reverseTrue) pos [i for i, label in enumerate(labels) if label 1] pos_set set(pos) if not pos or len(pos) len(labels): return None # 简化版把所有正样本在所有样本中的平均排序比例当作AUC rank_sum 0.0 for rank_i, idx in enumerate(order): if labels[idx] 1: rank_sum rank_i 1 n1 len(pos) n2 len(labels) - n1 return (rank_sum - n1 * (n1 1) / 2) / (n1 * n2) def gauc(df, user_col, label_col, score_col): total_auc 0.0 total_w 0.0 for user_id, group in df.groupby(user_col): if len(group) 2: continue unique_labels group[label_col].unique() if len(unique_labels) 2: continue auc calculate_auc(list(group[label_col]), list(group[score_col])) if auc is None: continue weight len(group) total_auc auc * weight total_w weight return total_auc / total_w这个简化实现没有处理复杂的并列rank但日常评估够用了。实际业务里建议直接用成熟库的AUC实现自己写的版本主要用于理解逻辑和做一些定制化过滤。4.3 从GAUC到线上AB的衔接我在实际项目里观察到的规律是GAUC的涨跌和线上核心指标的方向一致性比全局AUC好得多。全局AUC偶尔会给出“假涨”信号就是模型离线涨点上线之后并没有变好换成GAUC之后这种误判明显变少。原因是GAUC更贴近线上分用户的排序场景它的变化方向基本能代表用户体感的变化方向。但GAUC也不是万能的。它没有考虑展示位置偏差同一个用户组内的曝光可能来自不同位置。GAUC也没有考虑时间衰减历史行为和当前行为的重要性差异在GAUC里体现不出来。所以一般还要配合“按天分层的GAUC”把数据按日期切分看模型在不同天上的稳定性。如果某个模型在GAUC上稳定高出0.002到0.003就值得拿去小流量AB验证如果只高0.001以内基本属于波动范围不要轻易上线。5. 基于 TensorFlow 的DIN实现要点5.1 行为序列输入与mask处理DIN模型的输入大致分三块候选item特征、用户历史行为序列、其他上下文特征。行为序列在进入模型前要做定长化。假设设定最大长度T50历史行为不足50个的用0补齐超过50个的从最近的开始截断。这里有两个细节值得注意第一padding时用0填充而真实item ID一般从1开始这样0才能作为padding的标识。第二序列不能只存item ID还必须在建模时配套一个mask。mask的长度是[B, T]有效位置为1padding位置为0。后面所有对序列的操作都要带着mask否则padding的0 item的embedding也会参与注意力权重计算把所有padding位置的权重都拉低。5.2 Activation Unit与DIN Pooling代码下面给一段简化但完整可跑的DIN核心代码思路重点看activation unit和pooling部分。import tensorflow as tf def dice(inputs, namedice): # 简化版Dice统计维度取最后一个维度 with tf.variable_scope(name): alpha tf.get_variable(alpha, shapeinputs.shape[-1], initializertf.zeros_initializer()) mean, variance tf.nn.moments(inputs, axes[0, 1, 2], keepdimsFalse) # 实际实现中通常会按最后一个维度做统计这里为了演示省略 norm (inputs - mean) / tf.sqrt(variance 1e-8) p tf.sigmoid(norm) return p * inputs (1.0 - p) * alpha * inputs def activation_unit(target_emb, hist_emb, hist_mask, hidden_units[32, 16]): # target_emb: [B, D] # hist_emb: [B, T, D] # hist_mask: [B, T] B, T, D hist_emb.shape.as_list() target_tile tf.expand_dims(target_emb, axis1) # [B, 1, D] target_tile tf.tile(target_tile, [1, T, 1]) # [B, T, D] cross target_tile * hist_emb # [B, T, D] concat tf.concat([target_tile, hist_emb, cross], axis-1) # [B, T, 3D] x concat for units in hidden_units: x tf.layers.dense(x, units, activationtf.nn.relu) score tf.layers.dense(x, 1, activationNone) # [B, T, 1] score tf.squeeze(score, axis-1) # [B, T] score tf.where(tf.equal(hist_mask, 0), tf.ones_like(score) * -1e9, score) weight tf.nn.softmax(score) # [B, T] weight weight * hist_mask # 保险再次mask user_interest tf.reduce_sum(tf.expand_dims(weight, axis-1) * hist_emb, axis1) return user_interest这段代码里的几个细节对应了前面讲的内容cross就是逐元素乘积score是未归一化相关性得分用-1e9填充padding位置后再softmax这样padding位置的softmax权重会无限接近0。weight再乘一次hist_mask是双保险防止数值误差导致padding权重略微非0。拿到user_interest之后把它和候选item embedding、其他特征concat再过几层全连接输出logits接交叉熵损失整个训练主链路就通了。实际工程里tf.layers.dense这种写法现在更多人会换成tf.keras.layers.Dense但核心结构没有变化。5.3 训练与工程注意训练时embedding层建议用独立的tf.nn.embedding_lookup去取而不是把所有稀疏特征都塞进一个大的embedding层。这样做的好处是方便处理multi-hot行为序列也方便对不同特征设置不同的embedding维度。更关键的工程点是行为序列的长度。T越大activation unit里要算的矩阵乘法算子越多训练和线上推理的时间都会变长。我见过一个比较实用的折中方案离线训练时用T100线上推理时只取最近30个行为。大部分用户的注意力分数会集中在最近的行为上更早的行为贡献很小截断带来的精度损失在线下验证过是可控的。当然如果场景是长周期兴趣类消费比如装修、家居序列长度就不能截太短。线上部署时还要注意一个问题DIN的激活单元里有Dice的全局统计量这个统计量是训练时维护的线上推理必须把它一起保存和加载。如果只保存模型参数而漏掉全局统计量预测时Dice的统计量会退化成当前batch自己算在线上小batch场景下会有抖动直接表现成预估分数不稳定。这是我踩过的一个很隐蔽的坑排查了很久才发现。6. 常见问题与踩坑记录6.1 问题速查表现象可能原因解决办法训练loss正常下降但GAUC几乎不变行为序列mask没生效padding权重被平均分配检查softmax前的padding填充是否为-1e9或-inf权重分布集中在少数行为上且太极端候选item和历史item的embedding没有共享同一空间确认共享embedding表用更平滑的归一化方式线上预估分数抖动、离线正常Dice的全局统计量没有正确保存/加载将Dice的均值、方差作为模型变量保存加了L2正则后低频特征没效果全量L2正则压低了低频embedding维度改用Mini-batch Aware的稀疏正则GAUC上涨但线上AB无效果数据穿越或特征不一致严格按时间窗口切样本检查线上特征是否离线也有6.2 行为序列太长或太短怎么办行为序列的长短对DIN效果影响很大。序列太短比如平均只有3到5个行为激活单元能利用的信息太少DIN的优势发挥不出来。这时与其纠结模型结构不如先扩充行为来源比如把点击、收藏、加购、支付多个动作都纳入序列或者把“同session”内的行为合并成聚合行为。序列太长比如平均有200个行为attention权重矩阵会非常大训练和推理成本都高而且早期行为往往是噪声。我的经验是先用行为时间衰减因子给历史行为加一个衰减权重再做DIN pooling效果通常会比直接截断好一点。原因很简单DIN的激活权重完全是数据学出来的模型需要大量样本才能学到“旧行为权重应该低”这个规律而时间衰减是一种很强的先验可以帮助模型更快收敛。6.3 激活单元权重可视化是诊断利器DIN有一个很宝贵的性质行为权重是透明的。训练完模型之后可以抽取一批候选item把用户历史行为的attention权重打印出来人工检查一下是否符合直觉。如果候选item是“手机”历史里“手机”相关行为的权重应该显著高于“连衣裙”。如果打印出来的权重呈均匀分布说明激活单元没有学到相关性问题大概率出在特征表达或训练配置上。这个方法在项目前期能节省大量时间。它比GAUC更直观能直接定位到“模型到底是没学到相关性还是学到了但评估指标没反映出来”。我每次迭代DIN模型都会先看一轮权重分布再决定下一步怎么改。6.4 长序列场景可以怎么扩展DIN之后用户行为建模的方向基本都沿着“如何更好地利用长序列”走。比如把行为序列按时间切成多个阶段先在阶段内做DIN pooling再做阶段间融合或者先用一个粗粒度的召回模型从长序列里筛出和候选item最相关的一小段再在这个小段上跑激活单元。这些做法的本质都是在控制计算量的前提下保留更多历史信息。如果追求快速上线DIN本身已经足够支撑大多数推荐和广告场景。它的计算量不算大思想表达清晰而且非常容易和现有的EmbeddingMLP框架兼容。很多时候只需要把原来的average pooling替换成DIN pooling就能看到GAUC的稳定提升。最后说一个我自己的习惯新模型上线前先把几个候选item对应的activation权重分布打印出来看一眼。如果所有历史行为权重都接近均等说明模型还没学到相关性不管GAUC涨了多少都别急着上如果权重明显集中在和候选item同类的历史上基本可以放心。这个习惯帮我挡住了好几次“看起来涨点但实际没用”的版本。DIN不是什么万能银弹但它把“用户兴趣会随着候选item变化”这件事第一次做得很优雅值得每个做排序的同学亲手复现一遍。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑