推荐系统里做CTR预估的同学,大概率都经历过这样的场景:离线AUC涨了千分之几,信心满满上线,结果线上CTR纹丝不动,甚至偶尔还跌。排查一圈发现,问题出在用户行为序列的建模方式上——大多数模型把用户历史行为当成一个扁平的序列来处理,完全忽略了行为之间天然存在的"会话"边界。DSIN(Deep Session Interest Network)这篇工作就是冲着这个痛点去的,它把用户行为按时间间隔切分成一个个会话,再在会话内部和会话之间分别建模,最后用一个自适应的方式把会话兴趣聚合起来。我第一次读到这个结构的时候,感觉它把"用户兴趣是有节奏的"这件事讲清楚了,而不是简单堆一个更深的网络。下面我就按自己复现和调参的过程,把这个模型的来龙去脉、结构细节、实操坑点完整拆一遍,适合已经做过基础CTR模型、想进一步优化序列建模的算法同学参考。
1. 为什么扁平序列建模会丢掉用户兴趣的节奏感
1.1 用户行为天然是分段的,不是一条直线
先想一个很日常的例子。你上午刷电商App,看了几款跑鞋,比了比价格;中午吃饭的时候顺手点开了几个外卖店铺;晚上下班又去看了几件衬衫。这三个时间段的行为,动机完全不同,跑鞋、外卖、衬衫之间几乎没有关联。但如果你把这些行为按时间顺序排成一条序列喂给模型,模型看到的是一条"跑鞋→跑鞋→外卖→外卖→衬衫"的混合序列,它很难判断用户当前到底对什么感兴趣。
这就是扁平序列建模的核心问题:它假设用户的所有历史行为都在同一个兴趣空间里连续演化,而现实中用户的兴趣是分段的、跳跃的。传统做法里,DIN用注意力机制去捕捉"当前候选商品和历史行为的相关性",思路是对的,但它依然是在一整条序列上做注意力,没有区分哪些行为属于同一段兴趣。DIEN引入了GRU来建模兴趣的演化过程,比DIN进了一步,但同样没有显式地处理会话边界。
DSIN的关键洞察就是:用户行为序列应该先按时间间隔切分成会话(session),会话内部的兴趣是相对一致的,会话之间的兴趣则会发生明显漂移。这个假设非常符合直觉,也是它区别于前作的根本点。
1.2 会话切分的判定标准与工程实现
那怎么切会话?DSIN用的判据很朴素:相邻两次行为的时间间隔超过某个阈值(论文里用的是30分钟),就认为进入了新的会话。这个30分钟不是拍脑袋来的,它和业界常用的会话定义一致——很多日志分析系统默认30分钟无操作就算会话结束。
工程上实现切分的时候,有几个细节要注意。第一,切分是在样本构造阶段做的,不是在模型里做的,也就是说每个样本要预先算好它的会话列表。第二,会话长度是不固定的,有的会话只有一两个行为,有的可能有几十个,这就涉及到后续的padding和mask处理。第三,要保留会话内部的行为顺序,因为会话内的兴趣演化是有方向性的。
我自己的实现里,切分逻辑大概是这样:先按用户ID和时间戳排序,然后遍历,一旦发现当前行为时间减去上一条行为时间超过阈值,就新开一个会话。这里有个容易踩的坑——时间戳的精度。如果你的日志时间戳只精确到秒,而用户行为又很密集,可能会出现大量间隔刚好卡在阈值附近的情况,建议统一转成分钟级再做比较,避免边界抖动。
def split_sessions(behaviors, threshold_minutes=30): sessions = [] current = [] for i, b in enumerate(behaviors): if i == 0: current.append(b) else: gap = (b['ts'] - behaviors[i-1]['ts']) / 60.0 if gap > threshold_minutes: sessions.append(current) current = [b] else: current.append(b) if current: sessions.append(current) return sessions这段代码看着简单,但它是整个模型的地基。切分质量直接决定了后面会话兴趣抽取的效果,如果切得太碎,每个会话信息量不足;切得太粗,又会把不同兴趣混在一起。后面我会讲怎么调这个阈值。
2. 会话兴趣抽取:Bi-LSTM加注意力到底在做什么
2.1 为什么用Bi-LSTM而不是单向RNN
DSIN在会话内部用的是Bi-LSTM来抽取会话兴趣。这里有个问题值得说清楚:为什么是双向的?单向RNN只能看到当前行为之前的信息,而一个会话内部的兴趣演化,往往需要结合前后文才能判断准确。比如会话里是"跑鞋→袜子→跑鞋",如果只看前向,第二个跑鞋的表示里已经混入了袜子的信息;但如果结合后向信息,模型能更好地判断这个会话的核心兴趣其实是跑鞋。
Bi-LSTM把前向和后向的隐状态拼接起来,得到每个行为的表示,然后对这个会话内的所有行为表示做一次注意力聚合,得到一个会话级别的兴趣向量。注意力的query是可学习的,相当于问"这个会话里哪些行为最能代表这段兴趣"。这一步的输出维度是固定的,不管会话多长,最后都压成一个向量,方便后续处理。
实操中我建议把Bi-LSTM的隐藏层维度设得不要太大,64到128之间就够了。原因有两个:一是会话本身不长,维度太大容易过拟合;二是后面还有会话间的建模,参数量会叠加。我试过256的隐藏层,离线指标没涨,训练时间倒是翻倍了。
2.2 会话内注意力的偏置问题
这里有个论文里没细说、但实操中很关键的点:会话内注意力容易偏向长会话。因为注意力权重是在会话内做softmax的,会话越长,单个行为分到的权重越容易被稀释,聚合出来的兴趣向量就越"平均",反而丢失了焦点。
我的处理办法是在注意力打分上加一个长度归一化项,或者干脆对超长会话做截断。截断的策略是保留最近的N个行为,因为越近的行为越能反映当前兴趣。N取多少?我一般取20,超过20的会话只保留最后20个。这个数字不是绝对的,你可以根据自己业务的平均会话长度来定,原则是覆盖大部分会话的完整长度。
另外,padding的处理也要小心。不同会话长度不同,batch里要对齐,padding位置必须在注意力计算时mask掉,否则softmax会把权重分给这些无意义的占位符。这个坑我在第一次复现的时候就踩了,离线AUC莫名其妙低了一大截,查了半天才发现是mask没做对。
3. 会话间建模:兴趣漂移怎么被捕捉
3.1 Bi-LSTM在会话序列上的作用
抽取出每个会话的兴趣向量之后,这些向量按时间顺序排成一个会话序列。DSIN在这个序列上又跑了一层Bi-LSTM,目的是捕捉会话之间的兴趣演化。这一步和会话内的Bi-LSTM是两回事:会话内建模的是"一段兴趣内部的行为关联",会话间建模的是"兴趣本身如何随时间迁移"。
举个例子,用户第一个会话在看跑鞋,第二个会话在看运动服,第三个会话在看健身器材。这三个会话的兴趣向量之间是有演化逻辑的——从单一品类扩展到运动装备生态。会话间的Bi-LSTM就是要把这种演化趋势编码进去。它的输出是每个会话位置上的隐状态,这些隐状态既包含了当前会话的兴趣,也包含了它和前后会话的关系。
这里要注意的是,会话序列的长度通常比行为序列短很多,一般也就几个到十几个会话。所以这一层的计算量不大,但它的信息密度很高。我在调参时发现,会话间Bi-LSTM的隐藏层维度和会话兴趣向量的维度保持一致比较好,这样拼接和后续处理都方便,不用额外做投影。
3.2 局部激活单元:让当前候选决定看哪些会话
会话间Bi-LSTM的输出是一串隐状态,但最终要得到一个用户表示,就需要把这些隐状态聚合起来。DSIN在这里用了一个局部激活单元(Local Activation Unit),本质上是注意力机制,但它的query是当前候选商品。
这个设计很巧妙。传统的做法是把所有会话兴趣简单求和或平均,但用户的兴趣是多样的,当前要预测的这个候选商品,可能只和其中某几个会话相关。比如候选是跑鞋,那用户看外卖和衬衫的会话就不应该贡献太多。局部激活单元就是让候选商品去"挑选"相关的会话,给相关会话更高的权重。
实现上,它计算候选商品embedding和每个会话隐状态的相关性分数,softmax归一化后加权求和。这里有个细节:DSIN用的是候选商品和会话隐状态的外积或者拼接后过MLP来算分数,而不是简单的点积。拼接后过MLP的表达能力更强,但参数更多。我实测下来,如果候选商品和会话兴趣的维度都不大(比如64),拼接过MLP的效果确实比点积好一些,但差距没有想象中那么大,追求轻量化的场景用点积也够。
4. 完整前向流程与关键张量形状梳理
4.1 从原始行为到最终预测的链路
把前面的模块串起来,DSIN的完整前向流程是这样的:
- 输入层:用户画像特征、候选商品特征、上下文特征,以及用户行为序列(已切分成会话)。
- 会话兴趣抽取层:对每个会话,用Bi-LSTM加注意力得到一个会话兴趣向量。
- 会话间建模层:把所有会话兴趣向量按时间排成序列,过Bi-LSTM得到会话隐状态序列。
- 局部激活层:用候选商品作为query,对会话隐状态序列做注意力聚合,得到用户会话兴趣表示。
- 特征拼接层:把用户会话兴趣表示、用户画像、候选商品、上下文特征拼接。
- 全连接层:过几层MLP,最后sigmoid输出CTR预估值。
这个链路里,张量形状的变化是最容易出错的地方。我整理了一个对照表,方便你在实现时核对:
| 阶段 | 张量含义 | 典型形状 |
|---|---|---|
| 行为embedding | 每个行为的向量 | [B, S, L, D] |
| 会话兴趣向量 | 每个会话压成一个向量 | [B, S, D] |
| 会话隐状态 | 会话间Bi-LSTM输出 | [B, S, D] |
| 用户表示 | 局部激活聚合后 | [B, D] |
| 拼接特征 | 所有特征合并 | [B, D_total] |
其中B是batch size,S是最大会话数,L是最大会话长度,D是embedding维度。注意S和L都是需要padding对齐的,实际实现里要维护两个mask矩阵,一个标记有效会话,一个标记会话内的有效行为。
4.2 两个mask矩阵的维护
mask的处理是DSIN实现里最琐碎但也最不能出错的部分。第一个mask是会话级别的,标记哪些会话是真实存在的(因为不同用户的会话数不同,batch里要对齐到最大会话数)。第二个mask是行为级别的,标记每个会话内哪些行为是真实的(因为会话长度不同)。
这两个mask会分别用在两个注意力模块里。会话内注意力要用行为mask,把padding行为的注意力分数置为负无穷,softmax后权重为0。局部激活单元要用会话mask,把padding会话的分数置为负无穷。如果漏了任何一个,模型都会学到padding位置的虚假模式,离线指标会明显下降。
我踩过的一个坑是:在会话间Bi-LSTM里,padding会话也会参与计算,虽然它们的输出最终会被mask掉,但Bi-LSTM是序列模型,padding会话的隐状态会影响后续真实会话的隐状态。解决办法是用sequence_length参数让框架自动处理,或者在计算时把padding位置的输入置零并配合mask。TensorFlow的dynamic_rnn和PyTorch的pack_padded_sequence都能处理这个问题,但要注意会话序列本身也要按真实长度排序后再pack。
5. 复现过程中最容易翻车的几个地方
5.1 会话切分阈值对效果的影响
前面提到30分钟这个阈值,但它是论文在特定数据集上的选择,不一定适合你的业务。我在自己的数据上做过一组对比实验,阈值从10分钟到60分钟,离线AUC的变化大概在千分之三到千分之五之间。太小的阈值会把一个完整兴趣切碎,太大的阈值又会把不同兴趣混在一起。
怎么定这个阈值?我的经验是看业务的行为密度。如果用户平均每天只有几次行为,阈值可以设大一点,比如60分钟;如果是高频场景(比如短视频、资讯),用户一天几十次行为,阈值就要小一点,15到30分钟比较合适。更严谨的做法是画一下行为时间间隔的分布,找一个明显的"谷底"作为切分点。
还有一个细节:不同用户的活跃度差异很大,用统一的绝对阈值可能对低频用户不友好。可以考虑用相对阈值,比如取该用户行为间隔的中位数作为参考。不过这样会增加实现复杂度,是否值得要看你的业务场景。
5.2 序列长度截断与padding策略
行为序列和会话序列都需要截断到固定长度。截断策略直接影响模型能看到多少历史信息。我的建议是:
- 会话数S:取最近S个会话,S一般取5到10。太早的会话对当前预测的贡献很小,反而引入噪声。
- 会话长度L:每个会话取最近的L个行为,L一般取10到20。
- 总行为数:如果S乘以L太大,显存扛不住,可以进一步限制总行为数,比如最多保留100个行为。
截断的时候一定要保留最近的,因为CTR预测本质上是预测"当前时刻"的点击概率,越近的行为越相关。我见过有人为了省事直接取前N个,效果差很多。
padding的时候,embedding层要用mask,不能让padding的embedding参与梯度更新。有些框架里padding的index是0,如果0对应的embedding被更新了,会污染整个embedding表。稳妥的做法是给padding单独留一个index,并且在优化时把它排除,或者直接用mask把它的梯度置零。
5.3 学习率与正则化的调参经验
DSIN的参数量比DIN大不少,主要是多了两层Bi-LSTM。训练的时候过拟合的风险更高。我自己的调参经验是:
- 学习率:从1e-3开始,配合Adam,如果loss震荡就降到5e-4。Bi-LSTM对学习率比较敏感,太大容易梯度爆炸。
- Dropout:在Bi-LSTM的输出和全连接层都加dropout,比例0.2到0.5之间。会话兴趣向量那一层dropout可以设大一点,因为它是中间表示,过拟合风险高。
- L2正则:对embedding层加L2,系数1e-5到1e-4。embedding是最容易过拟合的部分。
- 梯度裁剪:Bi-LSTM一定要做梯度裁剪,阈值设5左右,防止梯度爆炸。
还有一个容易被忽略的点:batch size。DSIN的序列结构比较深,batch size太小会导致BN统计不稳定(如果用了BN),太大又会让每个epoch的更新次数变少。我一般用256到1024之间,根据显存调整。
6. 和其他序列建模方案的横向对比
6.1 DSIN与DIN、DIEN的核心差异
把DSIN和它的前作放在一起看,能更清楚地理解它的定位:
| 模型 | 序列建模方式 | 是否处理会话 | 兴趣演化建模 | 主要局限 |
|---|---|---|---|---|
| DIN | 注意力 | 否 | 无 | 忽略行为顺序和分段 |
| DIEN | GRU+注意力 | 否 | 有 | 未显式切分会话 |
| DSIN | 双层Bi-LSTM+注意力 | 是 | 有 | 结构复杂,参数量大 |
DIN的贡献是引入了注意力来捕捉候选商品和历史行为的相关性,但它把序列当成无序集合。DIEN用GRU建模了兴趣随时间的演化,比DIN进了一步,但它依然是在整条序列上做演化,没有区分会话。DSIN则明确地把"会话"作为建模单元,先会话内、再会话间,层次更清晰。
从效果上看,论文里的实验显示DSIN在多个数据集上都优于DIN和DIEN,提升幅度在千分之几到百分之一之间。这个提升幅度在CTR领域已经算可观了,但代价是模型复杂度和训练成本上升。
6.2 什么场景适合上DSIN
不是所有场景都值得上DSIN。我的判断标准是:
- 用户行为序列比较长,且存在明显的时间分段特征。如果用户行为很稀疏,一天就一两次,切会话没意义。
- 候选商品和用户历史行为的相关性对预测很重要。如果CTR主要靠用户画像和商品特征就能预测准,序列建模的边际收益有限。
- 有足够的算力和工程能力支撑更复杂的模型。DSIN的训练和推理成本都比DIN高,线上serving的延迟要提前评估。
如果以上条件不满足,我建议先用DIN打好基础,确认序列建模确实有收益,再考虑升级到DSIN。盲目上复杂模型,很可能投入产出比不划算。
7. 线上部署时的一些实际考量
7.1 推理延迟与模型压缩
DSIN的推理延迟主要来自两层Bi-LSTM。Bi-LSTM是序列模型,没法像全连接层那样完全并行,会话数多的时候延迟会比较明显。线上部署时,我做过几个优化:
第一,限制会话数和会话长度。线上serving时不需要保留太长的历史,S取5、L取10,延迟能降不少,效果损失很小。
第二,把Bi-LSTM换成单向LSTM或者用卷积替代。如果延迟实在压不下来,可以牺牲一点效果换速度。我试过用一维卷积加注意力替代会话内的Bi-LSTM,离线AUC掉了千分之一左右,但延迟降了将近一半。
第三,模型蒸馏。用DSIN做teacher,蒸馏到一个更简单的student模型上。这个做法在工业界比较常见,能把复杂模型的能力迁移到轻量模型上。
7.2 特征更新与在线学习
CTR模型的特征分布变化很快,DSIN这种依赖行为序列的模型对特征新鲜度更敏感。线上部署时要注意:
- 用户行为序列要实时更新,不能用过期的缓存。一般用近线的方式,行为发生后几秒内更新到特征库。
- 会话切分要在线上做,或者近线预计算。如果每次请求都重新切分整个历史序列,延迟会很高。我的做法是近线维护每个用户的会话列表,线上只做读取和拼接。
- 模型要定期更新,最好是每天全量加增量。DSIN参数量大,全量更新的成本不低,可以用增量学习的方式,只更新变化大的部分。
还有一个实际问题是冷启动。新用户没有历史行为,会话序列是空的,DSIN的序列部分就失效了。这时候要退化到只用画像和上下文特征,或者用一些默认的会话填充。这个降级逻辑要提前设计好,不能等线上出问题了才补。
8. 我复现时踩过的几个具体坑
8.1 会话内注意力的mask写反了
第一次复现的时候,我把会话内注意力的mask逻辑写反了——本该mask掉padding位置,结果mask掉了真实位置。训练loss一直降不下去,我还以为是学习率的问题,调了半天。后来打印了注意力权重才发现,权重全集中在padding上。这个错误的隐蔽性在于,模型依然能训练,只是学到的全是噪声。教训是:mask相关的代码一定要写单元测试,用一个小例子验证mask后的softmax权重是否符合预期。
8.2 会话间Bi-LSTM没有处理变长序列
第二个坑是会话间Bi-LSTM。我一开始直接把padding后的会话序列喂进去,没有传真实长度。结果padding会话的隐状态影响了后续真实会话,离线指标比预期低了不少。后来改用pack_padded_sequence,把真实长度传进去,指标才恢复正常。这个坑的根源是:RNN类模型对padding很敏感,不像CNN那样可以通过mask完全屏蔽。用RNN就一定要处理变长序列。
8.3 embedding维度设得太大导致过拟合
第三个坑是embedding维度。我一开始把行为embedding设成了256,想着维度大表达能力强。结果训练集loss降得很快,验证集AUC早早见顶然后下滑,典型的过拟合。后来降到64,加了dropout和L2,验证集指标反而更好。CTR场景里,embedding维度不是越大越好,64到128通常就够了,关键看特征基数和数据量。特征基数大、数据量足,可以适当加大;否则宁可小一点。
8.4 忽略了负样本的构造方式
最后一个坑和模型无关,但影响很大:负样本构造。CTR训练样本里,负样本的选取方式直接影响模型学到的偏好。我一开始用的是随机负采样,后来发现曝光未点击的样本和随机样本分布差异很大,改用曝光未点击作为负样本后,离线AUC看着降了一点,但线上CTR反而涨了。原因是随机负样本太容易区分,模型学到的判别边界和线上真实分布不匹配。这个经验说明,离线指标和线上效果之间隔着样本分布这道坎,不能只看离线。
9. 一些可以继续深挖的方向
DSIN本身是个比较完整的框架,但还有不少可以改进的地方。我自己想过几个方向:一是会话切分的自适应化,不用固定阈值,而是根据用户行为模式动态调整;二是把会话兴趣和长期兴趣结合起来,DSIN主要建模的是会话级别的中短期兴趣,长期稳定兴趣可以用单独的模块建模;三是引入多任务学习,把CTR和CVR一起优化,共享底层的会话兴趣表示。
另外,DSIN的局部激活单元用的是候选商品作为query,这个设计可以扩展到多query的场景,比如同时考虑候选商品和上下文,让聚合更精准。这些方向有的已经有后续工作在做了,有的还比较开放,感兴趣的同学可以自己试试。
最后分享一个我在调参时的小技巧:DSIN的模块比较多,出问题的时候很难定位是哪个模块的锅。我的做法是逐模块验证——先只保留会话内建模,看指标是否合理;再加上会话间建模;最后加局部激活。每加一个模块都对比指标,这样一旦某一步指标异常,就能快速定位。这个"增量式调试"的思路,在复现任何复杂模型时都适用,比一上来就端到端调参高效得多。