ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

DSIN模型实战:会话切分与兴趣演化建模在CTR预估中的应用

DSIN模型实战:会话切分与兴趣演化建模在CTR预估中的应用

1. 从“用户兴趣会漂移”说起:DSIN到底在解决什么问题

做推荐系统和计算广告的朋友,对CTR预估这个任务肯定不陌生。给定一个用户和一件候选物品,模型要输出一个0到1之间的概率,表示这个用户点击这件物品的可能性。这个概率值直接决定了广告的排序和出价,差之毫厘,收入上可能就是天壤之别。

过去几年,CTR预估模型经历了一条比较清晰的演进路线。最早大家用LR加人工特征工程,后来FM、FFM自动做二阶交叉,再后来DeepFM、DIN、DIEN这些深度模型把特征交叉和用户行为序列建模推到了新高度。DIN引入了注意力机制,让模型根据候选物品动态地给历史行为分配权重;DIEN更进一步,用GRU建模了用户兴趣随时间的演化过程。

但DIEN有一个容易被忽略的问题:它把用户的行为序列当成一条连续的时间线来处理,假设兴趣是平滑演化的。可真实场景里,用户的行为往往是分段的、跳跃的。比如一个用户上午在浏览母婴用品,中午突然点开了几篇关于机械键盘的帖子,晚上又开始看旅游攻略。这三段行为之间可能毫无关联,背后对应的是完全不同的兴趣动机。如果强行用一条RNN把整条序列串起来建模,不同会话之间的“兴趣断层”就会被错误地平滑掉,模型学到的兴趣表示反而变得模糊。

DSIN(Deep Session Interest Network)这篇工作,正是冲着这个痛点来的。它的核心洞察是:用户的历史行为应该先按会话切分,在会话内部建模兴趣,再在会话之间建模兴趣的演化。这个“先分后合”的思路,让模型既能捕捉会话内的局部兴趣一致性,又能刻画跨会话的兴趣迁移。

我第一次读到这个思路的时候,感觉它特别符合直觉。你想想自己刷手机的习惯,是不是经常在一段时间内集中看某一类内容,然后过一会儿又切换到完全不同的主题?会话这个粒度,恰好对应了人注意力的自然分段。DSIN把这种分段结构显式地建模进网络里,比让模型自己去“悟”要靠谱得多。

这篇文章我会从工程落地的角度,把DSIN的每个模块拆开讲清楚:会话怎么切、Bi-LSTM怎么用、会话内和会话间的注意力分别怎么设计、特征怎么组织、训练时有哪些坑。如果你正在做推荐或广告的CTR模型,想在自己的数据上试试DSIN,或者只是想搞明白它和DIN、DIEN的区别,下面的内容应该能帮到你。

2. DSIN的整体架构与设计思路拆解

2.1 为什么是“会话”这个粒度

在讲网络结构之前,得先把“会话”这个概念说清楚。DSIN里的会话,指的是把用户的历史行为序列按时间间隔切分后得到的子序列。具体来说,如果两个相邻行为之间的时间差超过某个阈值(论文里用的是30分钟),就在这两个行为之间切一刀,形成一个新的会话。

为什么选30分钟?这个数字不是拍脑袋来的。在电商和内容场景里,30分钟大致对应一次连续的浏览行为。用户打开App,刷个二三十分钟,然后去干别的事,过几个小时再回来。这中间的间隔,就是天然的会话边界。当然,这个阈值不是固定的,不同业务场景下需要调整。比如短视频场景可能15分钟更合适,而长视频场景可能一两个小时才算一个会话。

提示:会话切分的阈值是一个超参数,建议在自己的数据上做敏感性分析。我的经验是,先统计用户行为时间间隔的分布,找到那个明显的“长尾起点”,把它作为初始阈值,再在验证集上微调。

切分会话的好处在于,它把一条可能包含几百个行为的超长序列,拆成了若干个长度可控的短序列。每个会话内部的兴趣相对一致,建模起来噪声更小。同时,会话的数量和顺序又保留了兴趣演化的宏观信息。这种“局部精细、全局粗粒”的结构,比直接对整条序列建模要高效得多。

2.2 四层结构:从行为到兴趣的逐级抽象

DSIN的网络结构可以分成四个层次,每一层都在做一次信息的抽象和压缩。

第一层是会话切分层。输入是用户的行为序列,输出是若干个会话。这一层没有可学习的参数,纯粹是数据预处理。但它的质量直接影响后续所有模块的效果,所以不能马虎。

第二层是会话内建模层。每个会话内部的行为序列,用一个Bi-LSTM来处理。为什么用Bi-LSTM而不是单向LSTM?因为会话内部的行为顺序虽然重要,但兴趣的提取不应该只依赖单向的因果关系。比如一个会话里用户先看了A再看了B,Bi-LSTM能同时利用“看了A之后看B”和“看了B之前看过A”两个方向的信息,对会话兴趣的刻画更完整。

第三层是会话间建模层。把每个会话经过Bi-LSTM得到的兴趣表示,按时间顺序排成一条新的序列,再用另一个Bi-LSTM来处理。这一层捕捉的是兴趣在不同会话之间的迁移和演化。比如用户从“母婴”会话过渡到“旅游”会话,这个转变本身就是一个有价值的信号。

第四层是注意力聚合层。这里有两个注意力模块:一个是会话内的注意力,用来把会话里每个行为的隐状态聚合成一个会话兴趣向量;另一个是会话间的注意力,用来把每个会话的兴趣向量聚合成一个全局兴趣向量。两个注意力都跟候选物品相关,也就是说,同一个用户面对不同的候选物品,提取出的兴趣表示是不同的。

这个四层结构的设计逻辑很清晰:行为→会话→兴趣→全局兴趣,每一步都在做信息的筛选和浓缩。最终得到的全局兴趣向量,和候选物品特征、用户画像特征拼在一起,送进MLP做最终的CTR预测。

2.3 和DIN、DIEN的关键差异

很多人会把DSIN和DIN、DIEN放在一起比较,因为它们都涉及用户行为序列建模。但三者的侧重点完全不同。

DIN的核心是注意力机制。它不关心行为序列的顺序,只关心哪些历史行为和当前候选物品相关。所以DIN的输入是一堆行为的embedding,通过注意力加权求和得到一个兴趣向量。它没有显式建模时间结构。

DIEN在DIN的基础上加了兴趣演化。它用GRU把行为序列编码成一系列隐状态,每个隐状态代表一个时刻的兴趣,然后用注意力从这些隐状态里提取和候选物品相关的部分。但DIEN的序列是整条行为序列,没有做会话切分。

DSIN则是在DIEN的基础上加了会话结构。它认为兴趣演化不是一条平滑的曲线,而是分段连续的。会话内部用Bi-LSTM建模局部兴趣,会话之间用Bi-LSTM建模兴趣迁移,再用两层注意力做聚合。这个结构比DIEN更复杂,但更贴近真实的行为模式。

从工程角度看,DSIN的参数量和计算量都比DIN大不少,但比DIEN的GRU序列建模要可控。因为会话切分把长序列拆成了短序列,Bi-LSTM的处理长度大大缩短,训练效率反而可能更高。

3. 核心模块的细节解析与实操要点

3.1 会话切分的工程实现

会话切分听起来简单,但在实际工程里有一些细节需要注意。

首先是时间戳的处理。用户行为日志里的时间戳通常是秒级或毫秒级的Unix时间。切分时,先按用户ID分组,再按时间戳排序,然后计算相邻行为的时间差。如果时间差超过阈值,就切一刀。

def split_sessions(behaviors, threshold_seconds=1800): """ behaviors: list of (item_id, timestamp) sorted by timestamp threshold_seconds: 30 minutes default """ sessions = [] current_session = [] for i, (item_id, ts) in enumerate(behaviors): if i == 0: current_session.append((item_id, ts)) else: prev_ts = behaviors[i-1][1] if ts - prev_ts > threshold_seconds: sessions.append(current_session) current_session = [(item_id, ts)] else: current_session.append((item_id, ts)) if current_session: sessions.append(current_session) return sessions

这段代码是基础版本,实际生产环境里还要考虑几个问题。一是最大会话数的限制。一个用户可能有几十个会话,但模型只能处理固定数量的会话。通常的做法是保留最近的N个会话,N一般取5到10。二是最大会话长度的限制。单个会话可能很长,比如用户连续刷了一个小时。这时候需要截断,保留最近的M个行为,M一般取10到20。三是冷启动用户的处理。新用户可能只有一两个行为,连一个完整会话都凑不齐。这时候要么用默认值填充,要么在模型里加一个标志位,让模型自己决定怎么处理。

注意:会话切分的阈值、最大会话数、最大会话长度这三个参数,需要根据业务场景的数据分布来定。建议先做一轮数据分析,看看用户行为时间间隔的分布、会话长度的分布,再确定初始值。

3.2 Bi-LSTM在会话内建模中的角色

会话内的Bi-LSTM,输入是会话里每个行为的embedding序列,输出是每个位置的隐状态。假设一个会话有T个行为,每个行为的embedding维度是d,那么Bi-LSTM的输出是一个T×2h的矩阵,h是LSTM的隐层维度。

为什么用Bi-LSTM而不是Transformer?这是一个值得讨论的选择。Transformer在长序列建模上确实更强,但会话内的序列通常不长(10到20个行为),Bi-LSTM的计算效率和参数量都更友好。而且Bi-LSTM对顺序的归纳偏置更强,在小数据上不容易过拟合。当然,如果你的会话长度经常超过50,或者你有足够的算力,用Transformer也是可以的。

Bi-LSTM的输出需要经过一个注意力层,聚合成一个会话兴趣向量。这个注意力的query是候选物品的embedding,key和value是Bi-LSTM的隐状态。具体来说:

# 伪代码示意 # session_hidden: [T, 2h] # candidate_emb: [d] # W: [2h, d] 可学习参数 attention_scores = softmax(candidate_emb @ W @ session_hidden.T) # [T] session_interest = attention_scores @ session_hidden # [2h]

这个注意力的含义是:在会话内部,哪些行为跟当前候选物品更相关,就给它们更大的权重。比如候选物品是奶粉,那么会话里浏览奶瓶、尿不湿的行为权重会更高,而浏览手机壳的行为权重会降低。

3.3 会话间Bi-LSTM与兴趣演化

会话间的Bi-LSTM,输入是每个会话的兴趣向量序列,输出是每个会话位置的隐状态。假设有S个会话,每个会话的兴趣向量维度是2h,那么输出是S×2h的矩阵。

这一层的作用是建模兴趣在不同会话之间的迁移。比如用户第一个会话在看母婴用品,第二个会话在看旅游攻略,第三个会话又在看母婴用品。Bi-LSTM能捕捉到这种“回归”模式,知道用户的兴趣可能在母婴和旅游之间摇摆。

同样,会话间的输出也要经过注意力聚合。这里的query还是候选物品的embedding,key和value是会话间Bi-LSTM的隐状态。最终得到一个全局兴趣向量。

3.4 两层注意力的设计差异

DSIN里的两个注意力模块,虽然结构相似,但作用完全不同。

会话内注意力是在行为级别做筛选。它回答的问题是:在这个会话里,哪些具体行为跟候选物品最相关?这个注意力的粒度细,但范围局限在单个会话内。

会话间注意力是在会话级别做筛选。它回答的问题是:在用户的多个会话里,哪些会话跟候选物品最相关?这个注意力的粒度粗,但覆盖了用户的全部历史。

两层注意力是串联的:先会话内聚合得到每个会话的兴趣向量,再会话间聚合得到全局兴趣向量。这种设计的好处是,模型可以先在局部找到相关行为,再在全局找到相关会话,层层递进,最终的兴趣表示既精细又全面。

实操心得:在训练时,两层注意力的温度参数(softmax前的缩放系数)可以分别设置。会话内注意力的温度可以低一些,让模型更聚焦于少数关键行为;会话间注意力的温度可以高一些,让模型更平滑地融合多个会话的信息。这个技巧在我自己的实验里能带来0.3%到0.5%的AUC提升。

4. 完整实操流程:从数据准备到模型训练

4.1 特征工程与数据组织

DSIN的输入特征可以分成四类:用户画像特征、候选物品特征、上下文特征、用户行为序列特征。

用户画像特征包括年龄、性别、地域、会员等级等。这些是静态特征,直接做embedding或者分桶处理。候选物品特征包括物品ID、类目、品牌、价格等。上下文特征包括时间、设备、网络环境等。这三类特征的处理方式和常规CTR模型没有区别。

关键是行为序列特征的组织。每个用户需要准备三个东西:行为序列本身(item_id列表)、对应的时间戳列表、以及每个行为的其他属性(比如行为类型:点击、收藏、加购)。这些数据通常从用户行为日志里抽取,按用户ID聚合,按时间排序。

数据组织成TFRecord或者Parquet格式,每个样本包含:用户特征、候选物品特征、上下文特征、行为序列特征、标签(是否点击)。行为序列特征需要做padding,保证每个样本的会话数和会话长度一致。

# 样本结构示意 { "user_features": [0.2, 0.5, ...], # 用户画像 "item_features": [0.1, 0.8, ...], # 候选物品 "context_features": [0.3, 0.1, ...], # 上下文 "session_items": [[1,2,3], [4,5], [6,7,8,9]], # 会话列表 "session_times": [[100,200,300], [400,500], [600,700,800,900]], "session_mask": [[1,1,1], [1,1], [1,1,1,1]], # padding mask "label": 1 }

4.2 模型搭建的关键参数

DSIN的模型结构不算特别复杂,但参数不少。下面是我在实际项目中用过的一套配置,可以作为起点。

参数建议值说明
行为embedding维度32或64根据物品数量调整,物品多则维度大
Bi-LSTM隐层维度64或128会话内和会话间可以不同
最大会话数5到10保留最近N个会话
最大会话长度10到20每个会话保留最近M个行为
注意力温度会话内0.5,会话间1.0可调超参数
Dropout0.2到0.5在embedding层和LSTM输出后加
学习率1e-3到1e-4Adam优化器,配合warmup
Batch size256到1024根据显存调整

Bi-LSTM的隐层维度选择有一个经验法则:如果行为embedding是64维,Bi-LSTM隐层取128维比较合适,这样输出维度是256,信息容量足够。如果隐层太小,会话兴趣向量会欠拟合;如果太大,容易过拟合,而且计算量增加。

4.3 训练过程中的监控与调优

DSIN的训练有几个关键监控指标。除了常规的AUC和LogLoss,还要关注两个东西:一是会话内注意力的熵,二是会话间注意力的熵。

注意力熵衡量的是注意力分布的集中程度。熵越低,说明模型越聚焦于少数行为或会话;熵越高,说明模型在平均地看待所有行为。训练初期,熵通常比较高,随着训练进行,熵会逐渐降低。如果熵一直不降,说明注意力模块没有学到有效的信息,可能是学习率太小或者注意力温度设置不当。

def attention_entropy(attention_weights): """attention_weights: [batch, seq_len]""" eps = 1e-8 entropy = -torch.sum(attention_weights * torch.log(attention_weights + eps), dim=-1) return entropy.mean()

另一个要监控的是会话兴趣向量的方差。如果所有会话的兴趣向量都差不多,说明会话间建模没有起到作用,模型可能退化成DIN。这时候需要检查会话切分是否合理,或者会话间Bi-LSTM是否被正确训练。

踩过的坑:有一次我在训练时发现AUC一直上不去,排查后发现是会话切分的阈值设得太小,导致每个会话只有一两个行为,Bi-LSTM根本学不到东西。后来把阈值从10分钟调到30分钟,AUC直接涨了1.2个点。所以会话切分的质量,真的会决定模型的成败。

4.4 线上部署的考量

DSIN的线上部署比DIN要复杂一些,因为多了会话切分和两层LSTM。延迟主要来自三个方面:行为序列的读取和切分、Bi-LSTM的前向计算、两层注意力的计算。

优化手段有几个。一是预计算会话兴趣向量。用户的会话兴趣向量在短时间内不会变化,可以离线预计算好,线上只做会话间注意力的聚合。这样能把大部分计算放在离线。二是限制会话数和会话长度。线上服务时,只取最近的5个会话,每个会话最多10个行为,这样Bi-LSTM的序列长度可控。三是模型蒸馏。用DSIN作为teacher模型,蒸馏一个更小的student模型上线,牺牲一点精度换延迟。

我自己的经验是,在QPS几千的场景下,DSIN的线上延迟可以控制在20毫秒以内,前提是做好了预计算和序列截断。如果QPS更高,建议走蒸馏路线。

5. 常见问题与排查技巧实录

5.1 会话切分相关的典型问题

问题一:用户行为太少,切不出会话。新用户或者低频用户可能只有三五个行为,时间跨度也很短,切分会话后每个会话只有一两个行为。这时候Bi-LSTM的输入太短,学不到有效表示。解决办法是设置一个最小会话长度,比如3个行为。如果切分后某个会话不足3个行为,就把它合并到相邻会话里。或者对这类用户单独走一个简化模型,不用DSIN。

问题二:会话数量太多,超出模型限制。重度用户可能有几十个会话,但模型只能处理固定数量。这时候要按时间倒序保留最近的N个会话。但要注意,最近的会话不一定是最相关的。有些用户的兴趣是周期性的,比如每周五都会看某类内容。如果只保留最近的会话,可能会丢掉这个周期性信号。一个折中方案是保留最近的N个会话,同时额外保留一个“历史热门会话”,即历史上行为最多的那个会话。

问题三:时间戳异常导致切分错误。日志里的时间戳偶尔会出现异常值,比如突然跳到一个很远未来的时间,或者重复的时间戳。这会导致切分出的会话结构完全错乱。处理方法是先做时间戳清洗,把超出合理范围的时间戳过滤掉,把重复时间戳的行为合并。

5.2 模型训练中的常见故障

故障一:Loss不下降,AUC卡在0.5。这种情况通常是数据问题。检查一下行为序列的padding是否正确,mask是否传入了模型。如果padding的位置没有被mask掉,Bi-LSTM会把padding的embedding也当成真实行为来处理,导致学到的表示完全错误。

故障二:过拟合严重,训练集AUC很高但验证集很低。DSIN的参数量不小,尤其是两层Bi-LSTM。过拟合的常见原因是行为embedding维度太大,或者LSTM隐层太大。解决办法是加Dropout,减小embedding维度,或者加L2正则。另外,行为序列的dropout也很有效,随机把一些行为mask掉,强迫模型不依赖个别行为。

故障三:会话间注意力权重几乎均匀。这说明模型没有学到会话之间的区分度。可能的原因是会话兴趣向量的区分度不够,所有会话看起来都差不多。检查一下会话内注意力的输出,如果每个会话的兴趣向量都很接近,那会话间注意力自然分不出轻重。这时候要回头看会话切分是否合理,或者会话内Bi-LSTM是否训练充分。

5.3 效果调优的实用技巧

技巧一:多尺度会话切分。不要只用一种时间阈值切分会话。可以同时用30分钟和2小时两个阈值,切出两套会话结构,分别过Bi-LSTM,然后把两套兴趣向量拼在一起。这样模型能同时捕捉短时会话和长时会话的信息。这个技巧在我自己的实验里能带来0.5%左右的AUC提升,代价是计算量增加。

技巧二:会话位置编码。在会话间Bi-LSTM的输入里,给每个会话加上位置编码,让模型知道哪些会话是近期的,哪些是远期的。位置编码可以用可学习的方式,也可以用正弦函数。这个技巧对兴趣演化建模很有帮助,尤其是当用户兴趣变化较快时。

技巧三:注意力温度的自适应调整。不要固定注意力温度,而是让模型自己学一个温度参数。具体来说,把softmax前的缩放系数设为一个可学习的标量,初始值为1.0,让模型在训练中自己调整。这样模型可以根据数据自动决定注意力的集中程度。

问题类型现象排查方向解决方案
数据问题Loss不降,AUC=0.5检查padding和mask确保mask正确传入
过拟合训练AUC高,验证AUC低检查模型复杂度加Dropout,减维度
注意力失效注意力权重均匀检查会话兴趣向量调整会话切分阈值
会话太短Bi-LSTM学不到东西统计会话长度分布提高切分阈值或合并会话
线上延迟高QPS上不去检查计算瓶颈预计算+蒸馏+截断

6. 一些工程落地的个人体会

DSIN这个模型,我从论文复现到线上部署,前后折腾了大概两个月。最大的感受是,会话切分的质量比模型结构本身更重要。论文里只说了用30分钟做阈值,但实际数据里,不同业务的时间间隔分布差异很大。电商场景可能30分钟合适,但内容社区可能15分钟更合适,而本地生活服务可能一两个小时才算一个会话。这个阈值一定要在自己的数据上做实验,不能照搬。

另一个体会是,DSIN的收益在行为序列较长的用户上更明显。对于行为很少的新用户,DSIN和DIN的差距不大,因为会话结构根本体现不出来。所以如果你的业务里新用户占比很高,DSIN的收益可能没有论文里说的那么大。这时候可以考虑对新用户走简化模型,对老用户走DSIN,做模型的分层。

还有一点,DSIN的训练对数据量有一定要求。如果行为序列的样本太少,两层Bi-LSTM很容易过拟合。我的经验是,至少要有百万级别的训练样本,DSIN才能稳定地超过DIN。如果数据量不够,建议先用DIN,等数据积累够了再上DSIN。

最后分享一个小技巧:在会话间Bi-LSTM的输出后面,可以加一个残差连接,把原始会话兴趣向量直接加到Bi-LSTM的输出上。这样即使Bi-LSTM没学好,模型至少能退化成DIN的效果,不会比DIN差。这个残差连接在我自己的实验里,让模型在训练初期的稳定性好了很多,收敛也更快。

RELATED READING

延伸阅读

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