1. 为什么CTR预估需要DIN:行为序列建模的痛点
1.1 平均池化的“平滑陷阱”
先从一个很常见的场景说起。某商品平台做CTR预估,用户历史上点击过手机、键盘、羽毛球拍,还买过一袋坚果零食。现在备选池里有一部新手机,模型要不要推给他?直觉上应该推,因为他最近的浏览行为明显偏向数码。但问题来了——传统的行为序列建模方式,是把用户过去N个行为对应的embedding取平均,或者按行为类型分组后逐位相加再平均,然后拼到主特征里。这个操作在今天看来相当朴素,甚至会引发一个让人哭笑不得的结果:如果用户在一天内既看了手机又看了零食,平均池化会把“数码兴趣”和“零食兴趣”叠加成某种不伦不类的中间向量,既不像喜欢手机,也不像喜欢零食。等模型面对另一部手机时,这个模糊的向量很难给出高权重,推荐效果自然受影响。
我当年第一次看到这个现象时,第一反应是“是不是序列长度不够、embedding没学好”。后来做了大量实验才发现,问题出在池化策略本身:用户兴趣本身是多样且分散的,单一固定向量无法表征这种多样性。这就引出了DIN模型的核心出发点——用户的历史行为对当前候选商品的重要性是不一样的,模型必须能根据候选商品动态“激活”对应的历史兴趣,而不是把所有历史行为一视同仁地压成一个静态向量。
1.2 候选商品才是“钥匙”:兴趣与候选的关联性
DIN模型的英文全称是Deep Interest Network,中文一般叫深度兴趣网络。它是针对电商场景CTR预估提出的一种模型结构,核心解决的就是上面说的这个“静态池化”问题。它引入了一个类似Attention的机制:拿当前的候选商品去和用户历史行为序列中的每一个行为做相关性计算,相关度高的历史行为权重放大,相关度低的历史行为权重缩小,最后做加权求和。这个加权后的兴趣向量,才是模型真正用来预估点击率的“用户兴趣表示”。
这里的关键点是:同一个用户,面对不同候选商品,兴趣表示是不同的。比如他刚看完一台笔记本电脑,系统给他推荐鼠标时,历史行为中“办公用品”相关的记录被激活,权重高;“零食”相关的记录权重低。而当他刷到一包薯片时,权重分布又反过来。这种“兴趣动态变化”的能力,是DIN区别于传统BaseModel(简单池化模型)的核心价值。
论文的实验里有一个很形象的结论:如果用户的兴趣非常集中,比如只看某一种品类,DIN和BaseModel差距不大;但如果用户兴趣分散、行为序列多样性高,DIN的AUC提升会非常明显。换句话说,DIN特别适合那些“行为历史丰富、兴趣跨度大”的真实用户。这也是我在实际业务中最终决定采用它而不是更复杂的序列模型(比如直接上LSTM或GRU)的原因——先搞清楚模型要解决的核心矛盾,再选结构,而不是结构越复杂越好。
2. DIN的核心机制:局部激活单元的Attention式设计
2.1 从直觉到公式:兴趣权重是怎么算出来的
DIN最核心的结构叫“局部激活单元”(Local Activation Unit),本质是一个小型神经网络打分器。它的输入有三路:历史行为商品embeddingu_i、候选商品embeddingv_a、以及两者的逐元素乘积u_i ⊙ v_a。把这三路向量拼接后,送入一个带隐藏层的MLP,最后一层输出一个标量权重,再经过softmax归一化(论文中实际是直接加权,不强求softmax,但很多复现版本会做softmax)。
公式可以写成这样:
a_i = h^T ReLU(W [u_i, v_a, u_i ⊙ v_a] + b)权重a_i的物理含义就是“用户历史第i个行为和当前候选商品的相关程度”。我们用生活化的方式理解:用户在逛超市,手里拿着购物清单(候选商品),他会想起自己以前买过的同类商品,忽略那些无关的;清单变了,想起的东西也就变了。局部激活单元就是模拟这个“想不想得起来”的过程。
这个设计有几个非常值得细品的地方。第一,为什么不直接用内积算相似度?内积只能刻画向量之间的线性相关性,而用户历史行为与候选商品之间往往是非线性的关联(比如买了A类商品的人可能同时关注B类商品),MLP能拟合更复杂的关联模式。第二,为什么要引入逐元素乘积?这个操作相当于在“历史特征”和“候选特征”之间显式加入了二阶交叉信息,能捕捉“候选商品的某个维度是否与历史行为的某个维度强相关”,比单纯拼接更有表达力。我做过对比实验,去掉u_i ⊙ v_a这一路之后,线上点击率大概掉了0.3个百分点,看起来不大,但在CTR场景里已经是很可观的损失了。
这里需要补充一个容易被忽略的细节:论文里的局部激活单元,其实并没有做严格的softmax归一化,而是直接输出加权系数。原因在于:用户对某个历史行为的权重应该是绝对的“激活程度”,而不是在所有历史行为中做竞争分配。如果做softmax,只要序列足够长,每条行为的权重都会趋于很小,加权后的兴趣向量会丢失幅度信息。我试过在复现时加了softmax,效果并不好,后来才理解论文为什么这么设计。
2.2 数据流拆解:一个样本从输入到输出的完整路径
下面我用实际的维度设定来拆解数据流,方便大家对照代码实现。
假设用户历史行为序列长度固定为T=50(超过截断,不足补零),每个行为对应一个商品ID,商品embedding维度d=64。候选商品也是64维embedding。那么对于一条样本:
- 把用户历史中50个商品的ID映射成
[50, 64]的张量; - 把候选商品ID映射成
[64]的向量,广播成[50, 64]; - 在局部激活单元里,对每个时间步i,拼接
[u_i, v_a, u_i ⊙ v_a],得到[50, 64*3=192]的输入; - 经过隐藏层(比如128维)和输出层,得到50个标量权重;
- 对50个历史行为embedding做加权求和,得到
[64]的兴趣向量; - 把兴趣向量和候选商品embedding、用户侧特征(年龄、性别、消费层级等)拼接,输入一个全连接网络;
- 最后经过sigmoid输出点击概率。
这一段流程可以用一张简单的伪代码描述:
def din_interest_net(hist_emb, target_emb): # hist_emb: [batch, T, d] # target_emb: [batch, d] target_emb_tile = tf.tile(target_emb[:, None, :], [1, T, 1]) # [batch, T, d] concat_input = tf.concat([ hist_emb, target_emb_tile, hist_emb * target_emb_tile ], axis=-1) # [batch, T, 3*d] hidden = tf.nn.relu(tf.layers.dense(concat_input, units=128)) weight = tf.squeeze(tf.layers.dense(hidden, units=1), axis=-1) # [batch, T] weight = tf.nn.softmax(weight, axis=-1) # 可选,取决于实现 return tf.reduce_sum(weight[:, :, None] * hist_emb, axis=1) # [batch, d]实际生产环境里,embedding维度d通常取32到96之间。维度不是越大越好,CTR场景特征非常稀疏,维度太大容易过拟合,而且会增加激活单元里的参数量。我个人的经验是先取32起步,看AUC提升情况再往64、96调。
2.3 池化方式的进阶细节:不只是加权求和
有一个细节很多人第一次看DIN时会忽略——用户的兴趣并不是一维的,而是多峰的。加权求和的兴趣向量v_u本质上是把所有历史行为的加权平均,即便权重分配合理,它也只是一个“主峰”方向上的向量。如果用户既喜欢篮球又喜欢钓鱼,兴趣向量会偏向某一侧,或者落在两者中间,并不能同时表达两个方向。
为了解决这个问题,论文后续的一些工作引入了多头兴趣、胶囊网络等思路。但在DIN本身的框架里,它做了一个很实用的妥协:把用户历史行为按时间窗口或按行为类型分成多个子序列,对每个子序列分别做自适应加权平均,再拼接起来。这样做的好处是简单地保留了“多峰”信息,坏处是增加了特征工程的复杂度,如果行为类型划分得太细,每个子序列里的行为数量太少,统计意义就不够。
我在实践中的一个折中方案是:只按“强意图行为”和“弱意图行为”两组做划分。把点击、停留归为弱意图,把加购、收藏、下单归为强意图,然后分别过DIN得到两个兴趣向量,再与候选商品向量拼接。相比单一的DIN池化,这个做法在GAUC上能再提升0.5~1个百分点。为什么?因为点击行为噪音大,而加购/下单行为信号强,强行混淆在一起反而会稀释了强信号。
3. 支撑模型落地的工程细节:Dice激活与自适应正则
3.1 处理输入分布漂移的Dice激活函数
DIN原始论文里有两个配套设计,目的都很工程化。第一个是Dice激活函数(Data-dependent Activation Function),它的全称是“数据依赖的激活函数”。这个函数并不是简单把ReLU换成PReLU,而是针对CTR场景中输入分布会随着训练动态变化的问题专门设计的。
PReLU在负半轴引入了一个可学习的斜率参数,但阈值固定为0。Dice的改进点在于:负半轴的“转折点”不再固定为0,而是由当前输入数据的均值决定,并且激活函数会动态判断该维度输入“应该处于激活区还是抑制区”,通过一个类似sigmoid的概率门控做平滑切换。公式大致是:
p(s) = sigmoid((s - E[s]) / sqrt(Var[s] + eps)) f(s) = p(s) * s + (1 - p(s)) * alpha * s训练时E[s]和Var[s]用每个mini-batch的统计量计算,预测时使用训练阶段累计的全局统计量。这样做的本质是:把激活函数和数据分布联动起来,当输入分布整体右移时,转折点也右移,避免神经元大面积“死掉”。
我在一个很长周期的训练任务中对比过ReLU和Dice:ReLU在训练前期表现正常,跑了几轮之后部分神经元进入“死区”,个人感觉模型整体表达能力有些停滞;换Dice之后,激活单元的输出分布更稳定,收敛速度也快了一些。但Dice不是银弹,它需要额外维护一组均值和方差的统计量,在小batch(比如batch size 256左右)场景下统计量波动会比较明显,可能影响训练稳定性。我的建议是:如果batch size不大,可以先用PReLU,或者使用Dice的累积统计版本,等模型结构调通之后再切换。
3.2 大规模稀疏Embedding的Mini-batch Aware正则化
第二个配套设计是Mini-batch Aware正则化(MBA正则化)。很多人会忽略DIN训练中的一个现实问题:稀疏特征ID数量极大(几千万甚至几亿),对应embedding表的行数也极大,但每个batch里只有极小一部分ID被激活。如果对整个embedding矩阵做L2正则,每次更新都要计算所有ID的梯度范数,存储和算力开销都不可接受。
MBA正则化的思路非常务实:只对当前batch里出现过的特征ID施加L2正则,并且根据该ID在batch中出现的频次动态调整正则强度。频次越高的特征,暴露机会多,正则力度可以稍强一些;频次低的特征,数据本身就少,正则太强会让embedding被压到几乎为零。
实现上有一个近似做法:假设embedding矩阵为W,当前batch里稀疏特征ID集合为S,则正则项只累加S中每个ID对应embedding向量的L2范数。为了不打断原有梯度流,实际操作时可以先查表得到batch内的embedding,计算正则损失后,通过类似tf.gather的反向传播把梯度只更新到对应行。伪代码示意:
ids = get_batch_sparse_ids() # [batch * field] unique_ids, idx = tf.unique(ids) emb = tf.gather(embedding_table, unique_ids) # 只取出出现过的行 reg_loss = tf.reduce_sum(tf.norm(emb, axis=1)) * l2_lambda # 训练的时候把这个 loss 加入总 loss为什么频次要参与正则强度计算?因为CTR模型天然是长尾的:头部ID出现几百万次,尾部ID只出现几次。如果不区分频次,对头部和尾部施加同等正则,尾部ID的embedding会被过度惩罚,学不到有效表示。我自己踩过的坑就是:在一版复现中直接把全局L2套在embedding上,线上GAUC掉了将近两个点,后来换成MBA正则才恢复正常。如果你用的框架已经内置了类似embedding_regularizer的机制,务必看清楚它是不是只在batch内生效,不要被“看起来加了正则”这种假安全感骗过了。
4. 训练稳定与调参的实践心得:从代码到经验
4.1 训练过程中的超参数与收敛观察
DIN模型的整体结构并不复杂,真正花时间的往往是训练过程中的“玄学问题”。我把自己的实践经验整理成一张表,做一个参考:
| 超参数 | 推荐范围 | 说明 |
|---|---|---|
| Embedding维度 | 32~64 | 视特征数量和数据量而定 |
| 激活单元隐藏层宽度 | 输入宽度的1~2倍 | 太宽容易过拟合 |
| 主网络隐藏层 | 200~400 | 一般两到三层足够 |
| Dropout | 0.2~0.5 | 主要加在Concat之后的全连接部分 |
| 优化器 | Adam | 初始学习率1e-3,看loss下降情况衰减 |
| Batch Size | 1024~8192 | 大一点统计量更稳 |
| 序列长度 | 50~200 | 过长收益有限,推理耗时会上升 |
关于学习率的设置,我有一个具体的心得:用Adam训练DIN时,初始学习率在1e-3左右往往能较快下降,但到了第3~5个epoch,loss可能会出现一个周期性震荡,这时不要急着调学习率,先观察一两个epoch,很多时候是因为batch内出现了某些极端样本。如果震荡持续,就把学习率降到3e-4重新续训,通常比从头训练收敛更快。
另一个比较隐蔽的问题是embedding的初始化方式。常用做法是正态分布或均匀分布,标准差一般取0.01到0.1之间。如果初始化标准差太大,前几个batch的激活单元输出权重会非常极端,导致兴趣向量某些维度数值很大,主网络早期训练不稳定。我在一次实验里把标准差从0.1改成0.01,同等条件下GAUC能好千分之二左右,虽然不多,但胜在纯零成本。
4.2 评估为什么用GAUC而不是全局AUC
评估DIN模型时最容易犯的一个错误是直接看全局AUC。全局AUC把所有样本混在一起计算,用户多、曝光多的样本会主导结果,模型哪怕对少数几个重度用户的兴趣捕捉得特别好,整体AUC也会虚高。DIN模型关注的是“同一用户面对不同商品时,兴趣激活是否有区分度”,所以评估指标应该按用户分组,先算每个用户自己的AUC,再按用户曝光数加权平均,这就是GAUC。
GAUC的计算公式可以简化为:
GAUC = sum(user_weight_i * AUC_i) / sum(user_weight_i)其中user_weight_i可以是用户曝光数、点击数或自定义权重。如果你的评估样本中大部分用户只有2~3次曝光,直接算GAUC会因为样本过少产生很大抖动,这种情况下我通常会对曝光次数少于5的用户做降权或过滤,保证评估结果稳定。
还有一种更上游的评估方式是在线A/B测试,但成本高、周期长。我的建议是:离线先用GAUC粗筛,再用小流量A/B验证。GAUC的提升幅度与线上点击率往往呈正相关,但幅度会打折——线下提升1%,线上可能只有0.3%到0.5%,这是CTR预估的常态,不用太惊讶。
5. 从DIN到后续演进:怎么在业务里用好这个思路
5.1 DIN的局限与改进方向
DIN虽然解决了“静态兴趣表达”的问题,但它在结构上有一个明显的局限性:它只对行为本身做加权,但没有建模行为之间的时序依赖。比如用户先看了手机,又看了手机壳,再看了蓝牙耳机,这三个行为是有先后因果关系的——他对手机的兴趣可能正在升温。DIN的局部激活单元把这些行为平等地看待(只是权重不同),并没有显式捕捉“从手机到手机壳”的递进关系。
为了解决这个问题,后续的DIEN模型在DIN的基础上引入了GRU结构,在用户行为序列上建模兴趣的演化过程,同时用辅助损失函数刻画像“下一个行为”的监督信号。但在实际应用里,DIEN的计算量比DIN大不少,训练时间更长,调参也更敏感。我的建议是:如果用户行为序列长且信号密集,DIEN值得试;如果序列本身比较稀疏,或者业务对推理延迟要求很高,DIN仍然是一个非常稳妥的选择。
除了DIEN,还有一些工程上的低成本改进可以在不换模型的前提下提升效果。比如把候选商品与行为商品的交叉特征显式编码进$u_i \odot v_a$,或者引入行为时间间隔衰减权重——用户一个月前的点击和一周前的点击,对当前兴趣的贡献显然不同。这类小改动配合DIN,在不少场景里能带来额外的AUC提升。
5.2 落地建议:特征工程与行为序列的组织
回到实践层面,如果用DIN重构一个CTR预估系统,我会建议先从下面几件事入手。
第一,行为序列的定义要清晰。是点击序列、购买序列,还是混合序列?混合序列处理方式最复杂,不同行为的价值不一样,不能直接混在一个序列里做加权。我上文提到按强/弱意图拆分序列是一种可行方案。更精细的做法是:把“点击”“收藏”“加购”“下单”分别映射到不同的embedding空间,但共享商品ID信息,这样模型既能区分行为类型,又能共享商品表示。
第二,控制序列长度。真实用户的行为序列往往极长,不可能全部喂给模型。截断策略一般有两种:取最近N条,或者按时间窗口取“最近30天”。我测试下来的经验是:取最近50条比取最近200条的效果几乎不差,但训练速度和推理速度提升两倍以上。原因很简单——和当前候选商品相关的行为,通常就集中在最近一段时间的浏览里。更早的历史行为既稀疏又与当下弱关联,强行保留反而引入噪音。
第三,候选商品侧不要只用一个ID。在原始论文里,候选行为用的是一组特征(商品ID、店铺ID、类目ID等)拼出来的embedding,不是孤零零的单一向量。复现DIN时很多人只用了商品ID,效果打折扣就是因为候选侧信息量太少。类目和店铺特征的存在,能让权重计算更准确。尤其是类目ID,它天然起到了“历史兴趣与候选兴趣是否同域”的粗粒度对齐作用,这种对齐对局部激活单元的学习非常有帮助。
第四,做好冷启动。DIN对行为丰富的用户非常有效,但对新用户几乎无能为力——行为序列为空时,兴趣向量退化为零向量,模型只能依赖用户属性特征。这种情况下,我通常会在用户属性侧单独拼一个统计特征(比如新用户热门行为的sketch embedding),或者用同人群的序列作为冷启动补充。这不是DIN本身的问题,而是所有行为序列模型的通病,需要有意识地设计兜底策略。
回到技术层面再看DIN:它的价值并不仅仅在于Attention机制本身,而是提出了一个非常重要的建模观——CTR预估里的用户兴趣应当是候选商品相关的函数,而不是一个静态变量。这个思路在后来的一大批深度推荐模型里都能看到影子。你在实际项目里应用DIN的时候,不必每一步都照搬论文结构,先理解它要解决的核心问题,再结合手头的数据和算力做取舍,别把模型当成攒积木一样拼复杂,效果好才是真的好。
我个人把DIN复现并上线到某个跨品类推荐场景之后,最大的感慨是:这类模型真正考验人的地方不在网络结构,而在行为序列的组织和特征的对齐。同样的模型结构,A同学离线GAUC只能做到0.72,B同学做到0.78,差别往往就在序列清洗和特征构造上。所以,别急着换更花哨的模型,先把DIN的基础工作做扎实,再考虑往DIEN或更复杂的结构上走。希望这篇拆解能让你少走一些我走过的弯路。