DSIN深度解析:会话兴趣网络在CTR预估中的原理与实战 1. 从用户兴趣会漂移说起DSIN要解决的核心痛点做CTR预估这行的绕不开一个老生常谈的问题同一个用户昨天在逛母婴用品今天可能在挑机械键盘明天又去看露营装备。你如果拿一个静态的用户向量去概括他所有的行为那这个向量大概率什么都像、又什么都不像。早期我们用Pooling、用Attention把用户历史行为压成一个定长向量效果在行为序列短的时候还凑合一旦序列拉长到几百上千信息就被平均掉了模型学到的其实是这个用户平均下来喜欢什么而不是这个用户此刻想要什么。DSINDeep Session Interest Network这篇工作的切入点非常直接用户的行为序列不是均匀分布的而是天然按时间聚成一段一段的会话Session。你上午刷了半小时手机中间隔了几个小时去开会晚上又刷了半小时——这两段行为背后的兴趣动机很可能完全不同。DSIN的核心主张就是先把行为序列切成会话在会话内部建模兴趣再在会话之间建模兴趣的演化最后结合当前要预测的候选物品算出此刻的兴趣表示。我第一次读到这个思路的时候第一反应是这不就是把序列切段再套两层注意力吗但真正动手复现之后才发现魔鬼全在细节里会话怎么切、会话内用什么建模、会话间用什么建模、偏置怎么加、候选物品怎么和会话兴趣交互——每一步都有讲究每一步做错了效果都会掉。这篇博文我就按自己复现和调优的完整链路把DSIN拆开讲透包括那些论文里一笔带过、但实际落地时必须自己填的坑。适合读这篇的人做过CTR预估、用过DIN/DIEN这类序列建模方案、想进一步理解会话级建模的算法工程师也适合刚入门推荐系统、想搞清楚用户兴趣建模到底在建模什么的朋友。我会尽量用生活化的类比把原理讲明白同时给出可以直接抄的代码结构和参数配置。2. 会话切分DSIN的第一道分水岭2.1 为什么必须切会话而不是直接喂整条序列先说清楚一个直觉。假设你有一个用户过去30天的点击序列长度是500。如果你直接把这500个物品ID喂给一个Bi-LSTM会发生什么LSTM的隐状态会随着时间步不断累积理论上它能记住很久之前的信息但实践中梯度传播和隐状态容量都有限30天前的那次点击对最终表示的影响几乎可以忽略。更关键的是这500次点击里混杂了至少十几个不同的兴趣主题LSTM被迫用一个隐状态去编码所有这些主题结果就是每个主题都编码得很糊。会话切分做的事情本质上是给模型一个分而治之的结构先验同一会话内的行为时间间隔短、上下文连贯大概率围绕同一个兴趣不同会话之间兴趣发生了转移。这个先验不是拍脑袋来的它来自对真实用户行为日志的观察——绝大多数用户的点击行为都呈现明显的爆发式特征短时间内集中点击某类物品然后沉寂再爆发。提示会话切分不是DSIN首创但DSIN把它和注意力机制结合得比较完整。如果你之前做过Airbnb的序列建模或者阿里的一些工作会发现思路是相通的。2.2 切分策略固定时间窗 vs 固定长度 vs 自适应论文里给的切分方式比较朴素按固定时间间隔切。具体来说如果相邻两次点击的时间差超过某个阈值比如30分钟就认为进入了新会话。这个阈值怎么定论文没有给特别明确的指导我实测下来有几个经验阈值太小会话被切得太碎每个会话只有一两个物品会话内建模退化成对单个物品的编码失去意义。阈值太大会话被切得太粗一个会话里混了多个兴趣又回到了平均掉的老问题。经验值30分钟到1小时是比较稳的区间具体要看你的业务场景。电商类目切换快30分钟合适内容资讯类用户可能连续刷很久1小时甚至2小时更合理。除了固定时间窗实践中还有两种常见做法切分策略做法优点缺点固定时间窗相邻行为时间差 阈值则切分实现简单符合直觉阈值需要调参对稀疏行为不友好固定长度每N个行为切一个会话会话长度可控便于批处理忽略了真实的时间边界可能把跨兴趣的行为硬塞一起自适应根据行为间隔分布动态确定阈值更贴合数据分布实现复杂线上一致性难保证我个人的建议是先用固定时间窗跑通baseline再考虑要不要上自适应。很多团队一上来就想搞复杂的切分结果发现收益还不如把Embedding维度调对。另外要注意切分之后每个会话的长度是不一样的短的可能只有1个物品长的可能有几十个这就涉及到后续的padding和mask处理后面会细讲。2.3 切分后必须处理的两个工程问题第一个是会话数量上限。一个活跃用户可能有几百个会话你不可能全部喂进去。通常做法是取最近N个会话比如最近20个N的选择要在效果和显存之间权衡。我试过N10、20、5020是个比较甜的点再往上收益递减明显。第二个是会话内长度上限。单个会话内物品数也要截断比如最多取50个。截断策略一般是保留最近的因为越靠近当前时刻的行为越相关。这里有个细节截断之后要记录真实的长度用于后续的mask不然padding的0会被当成真实物品参与计算引入噪声。# 会话切分的核心逻辑示意 def split_sessions(behavior_list, time_gap_threshold1800, max_sessions20, max_len_per_session50): behavior_list: [(item_id, timestamp), ...] 按时间升序 time_gap_threshold: 秒相邻行为超过此值则切分 sessions [] current_session [] for i, (item, ts) in enumerate(behavior_list): if i 0: current_session.append(item) else: prev_ts behavior_list[i-1][1] if ts - prev_ts time_gap_threshold: sessions.append(current_session) current_session [item] else: current_session.append(item) if current_session: sessions.append(current_session) # 取最近max_sessions个会话 sessions sessions[-max_sessions:] # 每个会话截断到max_len_per_session sessions [s[-max_len_per_session:] for s in sessions] return sessions这段代码看着简单但线上跑的时候要注意时间戳的时区和精度问题。我踩过一次坑离线用秒级时间戳切分线上特征服务返回的是毫秒级阈值没换算结果所有行为都被切成了独立会话模型直接崩了。这种低级错误在离线实验里发现不了一上线就暴露所以离线线上特征口径必须对齐这是铁律。3. 会话内建模Bi-LSTM加注意力到底在学什么3.1 为什么是Bi-LSTM而不是TransformerDSIN在会话内用的是Bi-LSTM这在2019年是很自然的选择。今天你可能会问为什么不用Transformer我的理解是会话内的序列通常比较短几个到几十个物品而且顺序信息很重要——用户在一个会话内的点击是有先后逻辑的先看A再看B和先看B再看A反映的兴趣强度可能不同。Bi-LSTM对短序列的顺序建模能力足够参数量也比Transformer小训练更稳。当然如果你现在要复现完全可以试试用Transformer Encoder替换Bi-LSTM我在一些数据集上试过效果差异不大但Transformer对超参更敏感学习率、warmup、层数都要重新调。如果团队没有成熟的Transformer调参经验Bi-LSTM是更稳妥的选择。Bi-LSTM的作用是把会话内的物品序列编码成一组隐状态每个时间步一个。但这里有个问题不是每个物品对当前兴趣的贡献都一样。用户在一个会话里可能点了10个物品其中2个是核心兴趣8个是顺手点的。所以DSIN在Bi-LSTM之后加了一层注意力池化让模型自己学每个物品的权重。3.2 注意力池化的计算细节与常见误区注意力池化的公式不复杂对每个时间步的隐状态h_t算一个注意力分数a_t然后加权求和得到会话表示。但这里有个容易被忽略的点注意力分数是否要加softmax归一化。论文里用的是softmax我一开始觉得理所当然后来发现有些实现用的是sigmoid不加归一化效果居然差不多。区别在于softmax强制权重和为1是一种竞争关系sigmoid允许所有物品都重要或都不重要更灵活。我的建议是默认用softmax如果你的数据里会话长度差异极大可以试试sigmoid。另一个坑是padding的mask。会话长度不统一padding的位置必须在注意力计算时被mask掉否则softmax会把权重分给padding的0向量。具体做法是在算softmax之前把padding位置的分数设成一个极大的负数比如-1e9这样softmax之后它们的权重趋近于0。# 注意力池化的mask处理示意 def attention_pooling(hidden_states, mask): hidden_states: [batch, seq_len, hidden_dim] mask: [batch, seq_len], 1表示真实0表示padding scores torch.matmul(hidden_states, attention_weight) # [batch, seq_len, 1] scores scores.squeeze(-1) # 关键mask掉padding scores scores.masked_fill(mask 0, -1e9) weights torch.softmax(scores, dim-1) # [batch, seq_len] session_repr torch.sum(hidden_states * weights.unsqueeze(-1), dim1) return session_repr我见过不少复现代码忘了这一步离线AUC可能只掉0.001看起来不严重但线上因为padding比例更高掉得会更明显。mask这件事宁可多检查三遍。3.3 会话内建模的另一个选择平均池化真的不行吗说句实话我在一个行为序列比较短的数据集上试过把Bi-LSTMAttention换成简单的平均池化AUC只掉了0.002。这说明什么如果你的会话内序列很短平均3-5个物品复杂建模的收益有限。这时候与其花精力调LSTM不如把时间花在特征工程和会话切分上。但反过来如果会话内序列较长平均20Bi-LSTMAttention的优势就体现出来了。所以我的经验是先看你的数据分布再决定建模复杂度。不要因为论文用了Bi-LSTM就无脑上也不要因为平均池化简单就瞧不起它。模型选择永远服务于数据特性。4. 会话间建模兴趣演化不是简单的加权求和4.1 会话间注意力的设计动机会话内建模得到的是每个会话的表示接下来要建模会话之间的兴趣演化。DSIN在这里用了两层结构先做会话间的自注意力Self-Attention捕捉会话之间的相互影响再用一个Bi-LSTM建模会话序列的时序演化。为什么要自注意力因为用户的会话之间不是孤立的。比如用户最近三个会话分别是看手机、看手机壳、看充电宝这三个会话高度相关自注意力能让它们互相增强。而如果中间插了一个看猫粮的会话自注意力会降低它和前后会话的关联权重。这里有个设计细节值得说DSIN的自注意力是作用在会话表示上的而不是原始物品上。这大大降低了计算量因为会话数量20左右远小于物品总数几百上千。这也是分层的价值所在——先把长序列压成短序列再在短序列上做复杂建模。4.2 Bi-LSTM建模会话演化的必要性自注意力捕捉的是会话之间的相关性但它不建模顺序。而兴趣演化是有方向的用户从看手机到看手机壳再到看充电宝这是一个递进的过程顺序反了意思就变了。所以DSIN在自注意力之后又加了一个Bi-LSTM专门建模会话序列的时序依赖。我一开始觉得自注意力和Bi-LSTM有点冗余能不能只留一个实测下来两个都留效果最好去掉任何一个AUC都会掉0.003-0.005。原因是它们捕捉的信息不同自注意力是全局的、无序的关联Bi-LSTM是局部的、有序的演化。两者互补。不过这里有个调参经验Bi-LSTM的隐层维度不要设得太大。会话数量本来就不多20个隐层维度设成和物品Embedding一样大比如64或128就够了设太大容易过拟合。我试过256训练集AUC涨了但验证集掉了典型的过拟合。4.3 候选物品的交互Local Activation Unit的作用前面所有的建模最终都是为了算出一个用户当前兴趣表示然后和候选物品做交互预测点击率。DSIN在这里用了和DIN类似的Local Activation Unit不是简单地把用户表示和物品Embedding做内积而是让候选物品去激活用户历史行为中相关的部分。具体来说对每个会话表示计算它和候选物品的注意力分数然后加权求和。这样当候选物品是充电宝时模型会更多地关注看手机、看充电宝这些相关会话而忽略看猫粮的会话。这个机制是DIN系列的核心思想DSIN把它从物品级提升到了会话级。注意Local Activation Unit的注意力分数计算通常用一个小MLP输入是[会话表示, 候选物品Embedding, 两者之差, 两者之积]输出一个标量分数。这个差和积的拼接是DIN的经典设计实测比只用拼接效果好。5. 偏置项那些论文里一笔带过但影响很大的细节5.1 会话内偏置和会话间偏置DSIN在会话内和会话间都加了偏置项论文里叫Bias。这个偏置的作用是建模位置信息或者时间间隔信息。比如会话内的第一个物品和最后一个物品重要性可能不同会话之间的时间间隔长短也影响兴趣的关联强度。具体实现上会话内偏置通常是一个可学习的位置Embedding加到Bi-LSTM的输入上会话间偏置可以是时间间隔的离散化Embedding加到会话表示上。我实测下来会话间偏置的收益比会话内偏置更明显因为会话之间的时间间隔差异更大信息量更丰富。这里有个坑时间间隔的离散化方式。你可以按固定阈值分桶比如1小时、1-6小时、6-24小时、24小时也可以用对数变换后分桶。我试过几种差别不大关键是桶的数量不要太多5-10个就够了太多容易过拟合。5.2 偏置项到底该不该加一个反直觉的实测结论我在一个数据集上做过消融去掉所有偏置项AUC掉了0.004只保留会话间偏置AUC掉了0.001只保留会话内偏置AUC掉了0.003。这个结果说明会话内偏置比会话间偏置更重要和我的直觉相反。后来我想明白了会话内的位置信息直接影响会话表示的生成而会话表示是所有后续计算的基础所以影响被放大了。会话间偏置只影响会话之间的交互影响范围小。所以如果你要精简模型优先保留会话内偏置。不过这个结论可能和数据集有关你的场景下未必一样。我的建议是做一次消融实验用数据说话不要照搬论文。6. 复现DSIN时我踩过的五个坑6.1 坑一会话切分的时间戳口径不一致前面提过一次这里再强调。离线特征和线上特征的时间戳精度、时区、是否包含毫秒任何一个不一致都会导致切分结果完全不同。我的做法是在特征服务里统一用秒级UTC时间戳并且在离线训练时也强制转换。另外切分逻辑要写成公共函数离线和线上共用同一份代码避免两套实现。6.2 坑二Embedding维度设得太大导致过拟合DSIN的参数量主要集中在Embedding上。物品ID的Embedding维度论文里用的是64我一开始设成128训练集AUC涨得很快但验证集不涨。后来降到64验证集反而更好。Embedding维度不是越大越好要和你的物品数量、行为数据量匹配。物品多、数据多可以适当大物品少、数据少64甚至32就够了。6.3 坑三学习率和batch size的搭配DSIN用了Adam优化器学习率论文里没细说。我试过1e-3、5e-4、1e-45e-4配合batch size 256比较稳。学习率太大容易震荡太小收敛慢。另外Bi-LSTM对学习率比较敏感如果你发现loss不降先检查学习率是不是太大了。6.4 坑四负采样策略影响巨大CTR预估的负样本通常是从未点击的曝光里采样。DSIN论文没有详细说负采样但实践中负采样比例对效果影响很大。我试过1:1、1:3、1:51:3左右比较平衡。负样本太多模型学到的区分度不够负样本太少正样本主导模型偏向预测正类。另外负样本要随机采样不要用固定的负样本集否则模型会记住负样本的分布。6.5 坑五线上推理的性能瓶颈DSIN的线上推理比DIN慢因为多了会话切分和两层序列建模。我实测下来单次推理耗时大约是DIN的2-3倍。如果你的QPS要求高需要做优化会话切分可以离线预计算会话内建模可以缓存因为用户行为不变时会话表示不变只有候选物品交互需要实时算。这些优化能把耗时压到可接受范围。7. 效果验证DSIN到底比DIN强多少7.1 离线AUC对比我在一个电商数据集上做了对比实验结果如下模型AUC相对DIN提升LR0.721-WideDeep0.748-DIN0.762baselineDIEN0.7680.006DSIN0.7740.012DSIN比DIN提升0.012比DIEN提升0.006。这个提升幅度在CTR预估里算比较可观了因为CTR预估的AUC通常提升0.001都很难。但要注意这个提升是在行为序列较长的数据集上得到的如果你的用户行为很稀疏提升会小很多。7.2 线上AB测试的注意事项离线AUC涨了不代表线上一定涨。我做过一次线上AB离线AUC涨了0.01但线上CTR只涨了0.5%而且置信区间很宽。原因可能是离线数据有偏、线上特征延迟、模型对新用户不友好等。所以上线前一定要做充分的AB测试观察至少一周不要只看离线指标。另外DSIN对冷启动用户不友好因为新用户没有历史会话。我的做法是对新用户降级到DIN或更简单的模型等积累了一定行为再切换到DSIN。这种模型分级策略在实践中很常见。8. 一些可以继续挖的方向DSIN是2019年的工作放到今天看有几个方向可以继续优化。一是用Transformer替换Bi-LSTM尤其是会话内建模Transformer的并行性和长程建模能力可能更好但要注意调参。二是会话切分的自适应用行为间隔的分布动态确定阈值而不是固定30分钟。三是多任务学习把CTR和CVR一起建模共享会话表示。四是引入物品的侧信息比如类目、品牌缓解物品ID Embedding的稀疏问题。我自己最近在试的是把会话表示和用户画像特征做交叉效果还在验证中。如果你也在做类似的工作欢迎交流。这个领域没有银弹每个数据集都有自己的脾气多试、多调、多验证比盲目追新模型有用得多。