DSIN深度解析:会话兴趣网络在CTR预估中的原理与实战
1. 从"用户兴趣会漂移"说起:DSIN要解决的核心痛点
做CTR预估这行的,绕不开一个老生常谈的问题:同一个用户,昨天在逛母婴用品,今天可能在挑机械键盘,明天又去看露营装备。你如果拿一个静态的用户向量去概括他所有的行为,那这个向量大概率什么都像、又什么都不像。早期我们用Pooling、用Attention把用户历史行为压成一个定长向量,效果在行为序列短的时候还凑合,一旦序列拉长到几百上千,信息就被平均掉了,模型学到的其实是"这个用户平均下来喜欢什么",而不是"这个用户此刻想要什么"。
DSIN(Deep 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的选择要在效果和显存之间权衡。我试过N=10、20、50,20是个比较甜的点,再往上收益递减明显。
第二个是会话内长度上限。单个会话内物品数也要截断,比如最多取50个。截断策略一般是保留最近的,因为越靠近当前时刻的行为越相关。这里有个细节:截断之后要记录真实的长度,用于后续的mask,不然padding的0会被当成真实物品参与计算,引入噪声。
# 会话切分的核心逻辑示意 def split_sessions(behavior_list, time_gap_threshold=1800, max_sessions=20, max_len_per_session=50): """ 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而不是Transformer
DSIN在会话内用的是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), dim=1) return session_repr我见过不少复现代码忘了这一步,离线AUC可能只掉0.001,看起来不严重,但线上因为padding比例更高,掉得会更明显。mask这件事,宁可多检查三遍。
3.3 会话内建模的另一个选择:平均池化真的不行吗
说句实话,我在一个行为序列比较短的数据集上试过,把Bi-LSTM+Attention换成简单的平均池化,AUC只掉了0.002。这说明什么?如果你的会话内序列很短(平均3-5个物品),复杂建模的收益有限。这时候与其花精力调LSTM,不如把时间花在特征工程和会话切分上。
但反过来,如果会话内序列较长(平均20+),Bi-LSTM+Attention的优势就体现出来了。所以我的经验是:先看你的数据分布,再决定建模复杂度。不要因为论文用了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-4,5e-4配合batch size 256比较稳。学习率太大容易震荡,太小收敛慢。另外,Bi-LSTM对学习率比较敏感,如果你发现loss不降,先检查学习率是不是太大了。
6.4 坑四:负采样策略影响巨大
CTR预估的负样本通常是从未点击的曝光里采样。DSIN论文没有详细说负采样,但实践中负采样比例对效果影响很大。我试过1:1、1:3、1:5,1:3左右比较平衡。负样本太多,模型学到的区分度不够;负样本太少,正样本主导,模型偏向预测正类。另外,负样本要随机采样,不要用固定的负样本集,否则模型会记住负样本的分布。
6.5 坑五:线上推理的性能瓶颈
DSIN的线上推理比DIN慢,因为多了会话切分和两层序列建模。我实测下来,单次推理耗时大约是DIN的2-3倍。如果你的QPS要求高,需要做优化:会话切分可以离线预计算,会话内建模可以缓存(因为用户行为不变时会话表示不变),只有候选物品交互需要实时算。这些优化能把耗时压到可接受范围。
7. 效果验证:DSIN到底比DIN强多少
7.1 离线AUC对比
我在一个电商数据集上做了对比实验,结果如下:
| 模型 | AUC | 相对DIN提升 |
|---|---|---|
| LR | 0.721 | - |
| Wide&Deep | 0.748 | - |
| DIN | 0.762 | baseline |
| DIEN | 0.768 | +0.006 |
| DSIN | 0.774 | +0.012 |
DSIN比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的稀疏问题。
我自己最近在试的是把会话表示和用户画像特征做交叉,效果还在验证中。如果你也在做类似的工作,欢迎交流。这个领域没有银弹,每个数据集都有自己的脾气,多试、多调、多验证,比盲目追新模型有用得多。