ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

DSIN深度会话兴趣网络:用户行为序列如何切分会话提升CTR预测

DSIN深度会话兴趣网络:用户行为序列如何切分会话提升CTR预测

做点击率预测的人,几乎都会和用户行为序列打交道。我前几年在一个电商场景里做CTR模型时,最头疼的其实不是特征工程,而是怎么处理用户的历史行为:同一个用户凌晨在被窝里刷了一堆游戏皮肤,白天上班又去搜打印机和A4纸。这两种兴趣几乎是断开的,可传统序列模型会把它们塞进同一条序列里,让网络去学一个“四不像”的隐藏兴趣。直到我认真读懂了DSIN(深度会话兴趣网络)的设计思路,才意识到问题不一定出在模型容量,而在于没有把用户行为按“会话”切开。这篇文章就围绕DSIN来聊聊它是怎么处理这个问题的,以及我在复现它时踩过的那些坑。

1. 为什么行为序列要“切段”:连续序列建模掩盖了用户的场景切换

1.1 用户的兴趣不是一条平滑曲线

在DIN出现之后,CTR模型已经很习惯用attention机制去“挑选”行为历史中和当前候选item相关的部分。DIN的做法是把用户所有历史行为的embedding看成一个集合,用候选item作为query,给每个历史行为算一个权重,最后加权求和当作兴趣表示。这个思路很巧妙,但它有一个隐含假设:所有历史行为在“与候选item的相关性”之外,彼此之间没有结构关系。

但这个假设在真实业务里站不住脚。用户行为受场景驱动:晚上躺在床上的娱乐场景,白天工作会议间隙的通勤场景,周末陪娃的亲子场景。这些场景之间的切换往往是断崖式的,一个手机用户可能上一分钟还在看母婴内容,下一分钟就切到电竞直播,中间没有任何过渡。如果模型只能看到一条平铺的序列,它能学到的就只有“平均兴趣”。而平均兴趣往往不等于用户在当下场景里的真实兴趣,AUC差口气,线上效果也会忽上忽下,原因常常就在这。

1.2 从DIN到DIEN:补上了时间演化,却仍然没有场景边界

DIEN其实意识到了这个问题的一部分。它在DIN的基础上引入了GRU,把行为历史的“演变过程”显式建模出来,再用注意力机制在演变过程的某个兴趣状态上做激活。好处是模型开始“关心”兴趣随时间怎么变化,坏处是它依然把变化当作一条连续的时间线。可用户行为的真实时间线是被睡眠、工作、通勤这些现实事件切开的,很多时间间隙长达几个小时甚至一天。在这样的跨度里做GRU递推,前面的行为信息早就被稀释掉了,模型很难区分“一个连贯的短时兴趣”和“两个不同场景的并置”。

DSIN的核心出发点就在这。它先把用户的行为历史切成若干“会话”,假设每个会话内部是连贯的、处于同一场景下的行为,再让“会话”成为兴趣表达的最小单元。这样做的好处是:模型不需要从噪声很大的行为级序列里硬学场景切换信号,而是先完成一层的结构化压缩,再到更高层去建模会话之间的关系。从工程视角看,这也相当于把原始行为日志做了一次有业务含义的预处理,后续模型学起来会轻松很多。

1.3 会话在业务上意味着什么

会话这个概念并不是DSIN发明的,它来自搜索和电商的用户行为分析。一次会话可以理解成:用户在一个连续时间段内,围绕同一意图做的一系列操作。比如打开App,翻了几件外套,对比了一下尺码,下单,熄屏。这通常是一段5到20分钟的行为簇。论文里常用的切分规则是:相邻两次行为间隔超过30分钟,就视作不同会话。

理解会话的另一个角度是:它是兴趣的“短时快照”。用户一整天的浏览历史可能有8个会话,分别对应睡前刷的短视频、早上搜的酒店、午休看的数码评测、下班后查的餐厅。传统模型只能把这些行为混成一个大袋子,DSIN则明确告诉网络:这些行为属于不同的快照,先分别理解每个快照,再理解快照之间的关系。这个“先局部后全局”的思路,其实和人回忆事情的方式很像——你不会把去年所有记忆逐帧回放,而是先想起几个片段,再在这些片段之间建立联系。

2. 会话切分的工程口径:没有session id时最容易被忽略的三件事

2.1 会话切分靠什么:时间间隔是底线

第一个问题:数据里到底有没有session id。很多推荐场景的产品端会记录session id,比如一次App启动、一次网页会话。有session id时直接按id分组就好。但更多时候我们拿到的只是一串带时间戳的行为日志,session id在采集链路里丢了。此时最常用的做法就是时间间隔切分:把行为按用户分组、按时间排序,相邻行为间隔大于阈值就切一刀。

这个阈值非常关键。论文里写的是30分钟,但那是“论文里的30分钟”。实际业务中应该先画出“同一用户相邻行为时间间隔”的累计分布曲线,再结合业务场景选阈值:电商购物通常30分钟到1小时比较合理,而短视频场景可能15分钟以内就该切,因为用户刷内容、切场景的节拍更快。阈值切小了,会把一个连贯会话拆碎;切大了,又会让多个不同场景混进同一个会话。这两种情况对最终模型的影响是相反的,所以需要数据来回答,而不是拍脑袋。

2.2 会话内行为长度分布:截断策略要先看数据

把行为序列切成二维结构(会话数 × 会话内行为数)后,会出现严重的不均匀:有的会话只有1个行为,有的会话可能超过100个。工程上一般要设置两个截断参数:会话数上限和会话内行为数上限。我的经验是先统计分布,取80到90分位作为上限值,而不是随手定一个看似合理的固定数字。

这里有个容易被忽略的地方:会话内行为数很少(比如只有1、2个)的会话,在过BiLSTM时几乎学不到“序列内交互”的信息。所以实践中会把短会话直接做padding,让模型把它当成“短会话”处理,靠bias encoding里的位置信息来兜底,而不是强行指望长度为1的序列能表达出多丰富的兴趣。换句话讲,数据形态本身就是先验知识,模型结构只是去把这些先验知识物化出来。

2.3 bias encoding:会话顺序和时间差是必须给的信息

会话切分之后,模型拿到的是一个[K, L, D]的张量,K是会话数,L是会话内最大行为数。如果直接把行为embedding喂给Transformer,模型完全不知道这是第几个会话、会话内第几个行为。DSIN在论文里提出的做法是,给每个行为embedding叠加一个bias encoding,它包含两部分:会话序号对应的位置偏置,以及会话内行为序号对应的位置偏置。

这个设计的内在逻辑是:第2个会话里的行为,天然比第5个会话里的行为离当前决策点更近,应当获得不同的初始编码;同一个会话内,第1个行为在做attention时,位置权重也和第10个行为不一样。我在复现时做过对比:丢掉bias encoding,直接套用Transformer自带的正弦位置编码,离线AUC会有小幅下降,而且模型对会话顺序的敏感性明显变弱。位置信息不是锦上添花,而是DSIN结构里不可或缺的组成部分。

3. 核心结构三步拆解:会话内提取、会话间交互、目标激活

3.1 会话兴趣提取层:在单次会话内部做注意力交互与时序压缩

第一层处理的是每个会话内部。输入是第k个会话里所有行为embedding组成的序列,先做multi-head self-attention,让会话内的行为互相“看见”。这里解决的问题是:一个会话里用户看了手机、看了手机壳、又看了耳机,这几个行为之间有很强的相关性——用户在比较同一个大品类下不同的东西。Self-attention能把这种相关性编码进每个行为表示,比单纯按位置做RNN更直接,因为它一步就能把会话内任意两个行为的关联拉出来,而不是靠一步一步递推慢慢传递。

然后是双向LSTM,把交互后的行为序列压缩成一个固定维度的向量,作为这个会话的兴趣表示。为什么用“self-attention + BiLSTM”组合,而不是只留其中一个?我的理解是:attention负责捕捉行为之间的语义关联,它没有递归次序感(位置编码只是弱先验);BiLSTM天然适合把序列按时间顺序聚合成一个整体语义向量。两者能力不重复,拼在一起才完整。两段操作完成后,K个会话就得到K个兴趣向量,每个向量代表一个短时场景下的用户兴趣快照。

3.2 会话兴趣交互层:把K个快照当成一个“会话级序列”来演化

有了K个会话向量之后,下一个问题是这些会话之间是什么关系。DSIN把K个会话向量当成一个新的序列,再送入一个Transformer层做交互。这一层的目标是建模“兴趣的演化路径”。举例来说,用户前两个会话一直在调研数码相机,第三、第四个会话在看镜头和存储卡,这就是一条很明显的兴趣迁移链。通过跨会话的self-attention,后一个会话的兴趣表示可以吸收前几个会话里的相关信息,从而让“当前兴趣”带上演化语义。

这个环节的输入还要叠加会话级别的位置信息和时间差信息,也就是前面说的bias encoding。和第一层的bias相比,它们的作用对象不同:第一层编码的是“会话内部的行为坐标”,这一层编码的是“会话在整个轨迹中的坐标”。一个负责局部位置,一个负责全局顺序,两者共同告诉模型时间结构和距离关系。

3.3 会话兴趣激活层:用候选item决定哪些快照值得被放大

最后要输出一个对当前候选item有效的用户兴趣表示。DSIN让候选item对K个会话兴趣做attention,得到加权组合。这一步和DIN里的target attention很像,但作用对象从“行为”变成了“会话”。业务语义很清晰:候选商品相关的会话兴趣被突出,无关会话被压低。比如候选商品是奶粉,模型会把和母婴相关的会话权重拉高,把游戏、数码类会话压下去,这样得到的用户兴趣表示会更有针对性。

还需要注意一个细节:DSIN并不是只保留加权和。它把多个会话兴趣的加权结果、以及整体池化后的全局信息一起拼接,再进入后面的MLP。这样做的考虑是:用户本身可能同时拥有多种兴趣,只压缩成一个向量容易丢失信息;保留多条语义分支,让MLP自己去判断当前场景下该听谁的。这在广告场景里尤其重要,因为同一个用户很可能被不同广告命中完全不同的兴趣分支。

为了把结构讲得更直观,这里给一个简化版的核心层示意,重点看接线关系:

# 简化版DSIN核心层示意,只描述结构,不做性能优化 class SimplifiedDSIN(nn.Module): def __init__(self, d_model, n_heads, n_layers): super().__init__() self.session_attn = nn.MultiheadAttention(d_model, n_heads, batch_first=True) self.bilstm = nn.LSTM(d_model, d_model // 2, bidirectional=True, batch_first=True) self.cross_session_transformer = nn.TransformerEncoder( nn.TransformerEncoderLayer(d_model, n_heads, batch_first=True), num_layers=n_layers ) self.target_attention = nn.MultiheadAttention(d_model, n_heads, batch_first=True) def forward(self, behavior_emb, target_emb): # behavior_emb: [batch, K, L, D] batch, K, L, D = behavior_emb.shape # 展平成 [batch*K, L, D],对每个会话内部做self-attention behavior_2d = behavior_emb.reshape(batch * K, L, D) attn_out, _ = self.session_attn(behavior_2d, behavior_2d, behavior_2d) # 过BiLSTM后取最后时刻/均值,得到每个会话的兴趣向量 [batch, K, D] lstm_out, _ = self.bilstm(attn_out) session_interest = lstm_out.mean(dim=1).reshape(batch, K, D) # 跨会话Transformer:对K个会话兴趣做交互,得到演化后的会话兴趣 evolved_interest = self.cross_session_transformer(session_interest) # 目标激活:候选item作为query,对会话兴趣做attention target = target_emb.unsqueeze(1) # [batch, 1, D] activated, _ = self.target_attention(target, evolved_interest, evolved_interest) return activated, evolved_interest

这段代码看着简单,但每一个模块的输入输出维度都对应前面的结构描述。实际工程里还要把bias encoding加上,并处理好各种mask,代码只是帮我们分清数据流。

4. 与DIN、DIEN的横向对比:DSIN到底补上了哪块短板

4.1 三张模型卡片:从建模粒度到复杂度

把DIN、DIEN、DSIN放在一起看,能更清楚地看到DSIN的位置。下面是我在权衡模型选型时习惯画的对比维度:

对比维度DINDIENDSIN
建模粒度行为行为 + 时间演化会话
会话切分无无有
行为间交互无,行为互相独立由GRU隐式建模self-attention显式建模
兴趣演化无显式建模GRU递推Transformer跨会话交互
目标激活对象行为embedding兴趣状态会话兴趣向量
实现复杂度低中高

这个表格里最值得关注的是“建模粒度”。DIN和DIEN不管怎么加attention或GRU,始终是在行为级序列上操作;DSIN则把最小单位从“行为”提升到了“会话”。别小看这个变化,它让后续所有计算都发生在一个更稳定、噪声更小的中间表示上。

4.2 关键差异到底差在哪

DIN对行为做的attention是并行的,它完全不管行为之间的依赖顺序,每个行为只和候选item算相关性。这种假设太强了:用户先看了A再看了B,A对B的“铺垫作用”在真实场景里是存在的,但DIN只能把A和B当作两个独立样本点。

DIEN补上了依赖关系,引入了GRU,相当于给行为序列加了一条时间轴。但它仍然是行为级演化:用户一天产生几百次行为,真正对下一单有影响的可能只是其中几个会话。DIEN得在这几百次行为里靠递推去记住关键片段,难度很大。DSIN的做法是把序列先压缩到K个会话级向量,行为噪声在第一层就被self-attention清洗过一轮,后续演化建模是在一个干净得多的空间里做的,效率自然不一样。

4.3 不是越复杂就越好:DSIN的代价与适用边界

DSIN并不永远比DIEN好。当用户行为历史很短,会话数很少时,跨会话Transformer能学到的交互信号非常有限,反而会因为参数更多而有过拟合风险。如果会话内行为长度集中在1到2,BiLSTM和self-attention的能力也发挥不出来。所以我在实际使用中会先看数据形态:用户的平均会话数、平均会话长度、时间间隔分布,这三个指标决定DSIN适不适合上线。

另一个代价是工程侧的复杂性。DSIN把序列变成二维,padding和mask逻辑比一维序列麻烦不少。线上推理时,行为序列长度动态变化,会话切分逻辑必须和训练时完全一致。这些都是模型AUC提升之外的隐性成本,做技术选型时不能只盯离线指标。

5. 复现DSIN的五个实操坑:从mask到session阈值

5.1 坑一:mask处理不干净,padding会被当成真实行为

把行为序列padding成[K, L]之后,最经典的问题就是在多头attention里忘了传mask。padding位置如果被当成真实行为参与加权,模型等于在学一批“假行为”,轻则引入噪声,重则让离线指标虚高。我建议在self-attention实现里显式构造key padding mask,BiLSTM阶段则通过pack_padded_sequence等方式跳过padding位置,别让它们在最终聚合时贡献信息。

5.2 坑二:session切分阈值不验证,离线AUC和线上效果对不上

我自己在复现时先用30分钟一刀切,离线AUC涨得挺明显,但线上却没什么变化。后来把“相邻行为时间间隔”的分布画出来才发现,这个业务里大量用户在15分钟左右就会换一次场景。把阈值改成20分钟之后,离线AUC又涨了一截,线上也终于有了正向收益。所以切分阈值必须当作一个正经超参来调,和embedding维度、attention heads一起进参数搜索,不要迷信论文数值。

5.3 坑三:会话数和会话内行为上限要一起考虑batch效率

K和L的上限直接决定batch内padding比例。举个例子,L上限取20,但大多数会话实际只有5到6个行为,那batch里大量空间都在padding,训练又慢又费显存。我的做法是先用分布卡80分位,如果会话内行为数超限,就均匀采样或保留最近的N个行为;如果会话数超限,优先保留离目标item时间更近的会话,而不是硬截最前面的行为。上线后会发现,这一招对显存消耗和训练速度都很友好。

5.4 坑四:Transformer和BiLSTM都很吃dropout

DSIN引入的Transformer层和BiLSTM层都容易过拟合,dropout要好好调。我在一个项目里试过,attention模块的dropout从0.1加到0.2,离线AUC稳步上涨;继续调到0.3就开始掉了。优化器可以先用Adam,学习率从1e-3起步,再配指数衰减。embedding维度建议和序列表示维度保持一致,避免在升维降维过程中引入额外噪声。这些参数不一定普适,但作为起点很合适。

5.5 坑五:线上serving改掉了session切分逻辑

训练数据里用的是20分钟阈值切分,线上如果直接复用session id,或者换了另一套切分策略,训练和推理看到的输入分布就漂移了。模型离线见过的是“会话结构A”,线上给到的是“会话结构B”,再好的网络也白搭。解决方式是把切分逻辑抽成公共函数,训练和serving共用同一份代码;会话数、行为数超限时的兜底策略也要保持一致。这个点特别容易被忽略,因为离线训练脚本和线上推理服务往往分开维护。

5.6 我的扩展建议:把DSIN当兴趣抽取层,而不是非要端到端

最后说一个我在实践里的体会:DSIN不一定非得作为完整CTR模型端到端使用。它的会话兴趣提取和交互模块可以单独拿出来,当作长序列建模中间的一个“压缩器”。比如先通过检索机制挑出和相关片段,再把片段按会话组织好,交给DSIN的兴趣抽取层去压缩。这样既继承了“先分段再交互”的优点,又避免了在整个长序列上直接做Transformer带来的算力问题。

我后来再做兴趣建模时,不管最终选不选DSIN,都会先问一句:这批行为到底该按什么逻辑组织?是连续时间线、会话片段,还是更细的场景标签?把这个问题想清楚,后面加多少层网络都不会跑偏。毕竟数据处理的第一步,往往就决定了模型效果的天花板。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进