ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

OTTO多目标推荐实战:单Transformer模型从0.55到0.594的源码解析

2026/9/9 1:55:06 拓冰建站 浏览量
OTTO多目标推荐实战:单Transformer模型从0.55到0.594的源码解析 简介面向数据科学竞赛选手与推荐系统学习者的Kaggle Otto多目标推荐系统完整源码包。该项目在Kaggle Otto比赛中取得单模型0.594、LB第30名左右的成绩对应代码涵盖特征工程、召回、排序等核心环节。资源共23个文件以16个Python脚本为主体按features、preprocess、candidates、predict等目录组织另有4个XML配置、1个Markdown说明及版本控制文件压缩包仅39KB结构紧凑。目前已有675人学习浏览适合想了解推荐系统竞赛实战方案的读者。通过研读代码可掌握用户/物品特征构造、相似度与共现矩阵召回、ranker排序及多目标优化思路并参考其数据处理与模型调优策略是提升推荐系统落地能力的实用参考资料。 这份OTTO多目标推荐系统的源代码我在本地验证过很多次单模型跑出来0.594LB排名稳定在30名上下。和那些动辄需要四五张卡、训练大半个月的前排方案比它最让我满意的是一个人在家也能完整复现。OTTO这场Kaggle比赛本质上是让你根据用户在电商网站的历史行为session预测用户接下来会点谁、加购谁、最终买谁。比赛的数据量不小但也没有大到没法在一张GPU上处理。最难的其实是三件事怎么理解指标权重怎么构造训练样本怎么让单模型不靠融合就追上前排的尾巴。这篇文章就从这三个问题展开把一套能跑到0.594的代码是怎么想出来、怎么组织起来的完整拆一遍。1. OTTO赛题是什么一次对多目标推荐的完整演练1.1 三种行为、候选集限制、多目标输出这三个设定决定了方案OTTO是德国的一家电商平台比赛提供了用户在平台上的匿名行为序列。每条session记录了一段连续操作浏览了哪些商品clicks把哪些加入了购物车carts最后下了订单orders。你要做的是拿到一个session已经发生过的历史行为预测接下来这个用户还可能交互哪些商品每种行为各输出20个商品id。一条session数据大致长这样用户先进了商品A的详情页点了一下退回列表页戳开B停留两分钟后把C加入了购物车最后结算时买下了D。模型拿到的就是A、B、C、D这些商品id以及对应的事件类型和时间差。我经常用一个类比来解释这个任务这有点像看一个人逛超市的监控录像画面被遮挡了只能看出他先后拿起过哪些货架上的东西——是拿起来看看又放下还是真的丢进了购物车。你要根据这些动作猜他接下来还会伸手去拿什么。电商推荐里点击-加购-购买完全对应业务漏斗的三个层级。点击代表兴趣加购代表很强的购买意向购买是最终转化目标。对业务来说转化远比点击值钱所以比赛评估时不会用同一套标准衡量三种行为。这是多目标的第一层含义模型要同时服务三个目标而不是单独优化点击。第二层含义体现在候选集上。比赛没有把全部商品放给你预测而是给每个测试session提前生成了一批候选商品列表。这其实就是在模拟召回之后的环节——你面对的不是全量item池而是一组已经被某种机制筛选过的候选。这个设定和工业界推荐链路高度接近模型本质上是精排/重排的一部分。这一点直接决定了源码里模型的设计同一个session编码器上长出三个输出头共享大部分参数但每种行为拿到不同的预测分数。1.2 为什么这种多目标推荐值得关注如果把OTTO看作一次工程演练它比单纯拿CTR预估刷榜的比赛更有意思。大多数推荐比赛只有一种交互行为你可以把全部精力放在排序模型上。OTTO强迫你同时处理稀疏度和商业价值都不同的三件事购买行为最稀少但权重最高点击最多但价值最低。这种不平衡几乎就是真实推荐系统的日常——公司不是想预测用户会点什么而是想预测用户会买什么。所以这套方案里的很多设计后期可以平移到真实业务。比如用行为类型token区分点击和购买用不同权重的loss把三个目标拉在一起训练。这些在常规教程里很难几句话讲透但在这里是躲不开的必修课。理解了这一点你才会明白为什么后面所有训练细节都在向orders行为倾斜。2. 评估指标与数据分布没看懂这两点后面全白做2.1 recall20的加权计算以及它隐含的优先级OTTO的官方评估指标是Recall20。打个比方模型给某个session预测了20个商品另一个集合是真实答案两个集合的交集占真实答案的比例就是这个session的recall。把所有session平均得到单个行为的recall20。最终分数按三种行为加权求和权重如下行为权重直观理解clicks0.10点击最容易预测但价值最低carts0.20加购行为有强意图但数据偏少orders0.70购买最稀疏、最难预测商业价值最高这个权重表看起来简单实际上决定了方案的一切优先级。如果模型训练得太在意点击clicks的recall可能很高但它在总分里只占0.1。反过来orders虽然占0.7但因为购买行为极度稀疏recall20很难拉高哪怕只提升一点点对总分的拉动都非常大。比赛前期我见过不少人还在用纯点击序列做输入等到各类行为分开评估时才反应过来购买才是重点。所以在这套源码里从数据构造那一步就开始对orders行为做针对性设计而不是等到训练完了才在postprocess里临时调权重。2.2 时间切分、session结构与冷启动OTTO数据的第一个特点是session长度差异极大。大量session只有一两次点击但也存在几百甚至上千次交互的长session。短session学不到足够的序列信息长session又需要控制截断长度避免模型被噪声淹没。第二个特点是时间顺序非常敏感。训练集覆盖前一段时间的用户行为测试集是之后某段窗口的数据。用户兴趣会随时间漂移——昨天大家都在搜圣诞礼物今天的候选分布可能就换了。验证集如果随机切分会严重高估模型效果必须从训练集尾部按时间顺序切出一段模拟未来作为验证。这一点还引出了一个冷启动问题测试session里会出现训练集没见过的新session甚至新item。模型没法依赖用户历史画像只能从当前session已经发生的动作去推理。这正是我选择session级序列模型而不是传统user-item矩阵分解的关键原因——session本身的即时上下文权重远超用户长期画像。3. 单模型方案用序列Transformer同时预测点击、加购、购买3.1 拒绝双塔选择session级序列建模先说说为什么没有用双塔。双塔模型把user和item分别编码成两个向量最后用内积或余弦相似度打分。它对用户整体兴趣表达强但对用户当前session里的即时意图表达很弱。OTTO数据里session的上下文——用户刚刚看过什么、跳过了什么、从哪个商品进页面——比历史画像重要得多。双塔结构在处理短session时还容易退化成只看热门item因为session向量本身携带的个性化信号太少。这套源码的做法是把session内的行为序列当做一个句子行为记录里的item就是一个个token送进Transformer Encoder做编码。这个思路和自然语言处理里的BERT以及推荐系统里的SASRec/BERT4Rec是同一条路线。Transformer的自注意力机制天然适合建模序列中商品之间的关系用户看了A之后又看BA和B之间的attention权重会说明很多问题。3.2 模型结构细节与三个输出头模型输入包括四路信息item序列、行为类型序列、时间间隔序列、位置索引序列。输出是三个行为各自在候选商品上的打分。结构大致如下class OttoTransformer(nn.Module): def __init__(self, num_items, hidden_size256, num_layers4, num_heads8, max_len64): super().__init__() self.item_emb nn.Embedding(num_items, hidden_size) self.behavior_emb nn.Embedding(3, hidden_size) # click/cart/order self.position_emb nn.Embedding(max_len, hidden_size) self.time_proj nn.Linear(1, hidden_size) self.encoder nn.TransformerEncoder( nn.TransformerEncoderLayer( d_modelhidden_size, nheadnum_heads, dim_feedforwardhidden_size * 4, batch_firstTrue, dropout0.1, ), num_layersnum_layers, ) self.head_clicks nn.Linear(hidden_size, hidden_size) self.head_carts nn.Linear(hidden_size, hidden_size) self.head_orders nn.Linear(hidden_size, hidden_size) def forward(self, item_seq, behavior_seq, time_gap, pos_seq, candidates): # item_seq: [B, L] emb self.item_emb(item_seq) emb emb self.behavior_emb(behavior_seq) emb emb self.position_emb(pos_seq) emb emb self.time_proj(time_gap.unsqueeze(-1)) hidden self.encoder(emb) # [B, L, H] session_rep hidden[:, -1] # 取最后位置也可用attention pooling cand_emb self.item_emb(candidates) # [B, N, H] click_logits (self.head_clicks(session_rep).unsqueeze(1) * cand_emb).sum(-1) cart_logits (self.head_carts(session_rep).unsqueeze(1) * cand_emb).sum(-1) order_logits (self.head_orders(session_rep).unsqueeze(1) * cand_emb).sum(-1) return click_logits, cart_logits, order_logits代码是简化的示意但体现了这个方案的核心共享item embedding、共享序列编码器三个行为通过各自线性投影得到不同语义空间的session表示再分别和候选item做点积。几个细节值得展开。行为类型embedding是必须加的。把这是一次点击还是一次加购编码进序列模型才能学到不同行为的语义差异。不加这个模型容易把点击和购买混为一谈orders预测能力明显下降。时间间隔embedding也是一个稳定涨分点——用户两分钟前和两天前看过的商品重要性完全不同模型需要感知到这个差别。候选商品复用了item embedding参数少、训练稳定。但坏处是三个行为共享同一个item语义空间有些用户会点但不买的商品可能干扰orders预测。所以我让三个输出头在点积之前各加了一个线性投影把共享session表示映射到各自行为专属的语义空间这基本解决了共享空间带来的干扰。所谓单模型指的就是一个Transformer编码器加三个输出头的整体没有叠加任何树模型、图模型或预训练向量。4. 从baseline到0.594数据构造和训练里做对的几件事先交代一下进程。我的baseline模型跑出来在0.55附近当时LB排名离前排还很远。后面所有改动都是围绕这一节说的几个点逐步推进最后稳定在0.594左右。这一章讲清楚每件事为什么这么做。4.1 数据构造序列截断、候选集与负采样原始行为数据要先转成模型输入。处理流程大致是按session聚合行为按时间排序。每个样本取该session最近N个行为作为输入N通常取20到50。更长的序列不是不能用但训练时间线性增加收益会快速饱和。OTTO这种大量短session的数据集N取30左右是一个比较舒服的平衡点。预测目标该session在未来窗口通常是当天剩余时间或下一段固定窗口内实际发生过点击、加购、购买的商品作为正样本。负样本从候选集中按一定比例采样常见1:20到1:100。负采样这里有个很关键的细节。如果从全量商品均匀采样模型会认为采样到的每个负样本都不可能被交互但实际上热门商品被交互的概率远高于长尾商品。我当时的做法是负样本按商品出现频率做幂次采样让模型见过更多常见但不是正样本的商品。这比均匀采样更接近线上分布也能抑制模型无脑给热门商品加分的倾向。候选集在这一步的作用也体现出来了预测时只对测试集给定的候选商品打分排序而不是对全量item打分。这大大缩短了inference时间也让模型聚焦在真正需要区分的范围内。如果你换成自己的场景这一步通常可以替换成你自己的召回结果。4.2 训练细节loss加权、优化器和验证策略训练时三个输出头各算各的交叉熵损失然后加权求和loss 0.10 * ce(click_logits, target_clicks) \ 0.20 * ce(cart_logits, target_carts) \ 0.70 * ce(order_logits, target_orders)这个权重和最终指标一致。也有人觉得训练时用均衡权重、最后提交时再按指标权重融合会更好我实测下来差别不大。直接用指标权重可以让模型把能力优先花在orders上收敛方向更明确。优化器用AdamW学习率在1e-3到3e-3之间warmup 10%的步数后cosine decay。batch size在单卡可承受范围内尽量开大序列模型对batch size比较敏感太小的话in-batch负样本不足模型收敛很慢。验证集必须按时间切。我用训练集尾部最后一个完整日作为验证前面全部做训练。这样验证集和测试集的分布最接近。这里我踩过一个坑一开始随手随机切了个9:1验证分数比LB整整高出一截一度以为自己的方案无敌了。后来才发现是数据泄露——同一个session的后续行为被切进了训练集模型等于提前看到了答案。换成时间切分之后验证分数和LB基本能对上。还有一个工程上的小问题负采样随机种子不固定的话验证曲线毛刺会很大。这不是模型学得不好而是每个step计算的负样本都不同loss数值自然在跳。固定随机种子以后曲线平滑多了也能更准确地判断超参效果。4.3 最后阶段的小改进分数卡住之后真正起作用的不再是大改动而是一批细小的决策把序列中同一行为的连续重复做了合并或截断降低模型被无关点击干扰的程度。有些用户会机械式地反复点同一个商品这种噪声对短session尤其有害。给orders行为增加重复正样本如果一个session在未来窗口里多次购买了同一商品除了保留第一次再复制几条同样商品但不同位置的样本让模型对重复购买更敏感。后处理时不是三个行为各自独立排序就完事而是先分别预测三种分数再按权重加权得到一个综合分最后做去重和top20截断。这样提交文件里商品的顺序天然融合了多目标不会出现某个行为霸榜的情况。对极短的session模型输出可能不稳定所以后处理里保留了一个小规模热门商品回填逻辑。这算是在线推荐里很常见的兜底召回思路虽然提升不大但能稳定踩住分数下限。5. 这套源码怎么用代码组织、复现步骤和扩展点5.1 代码包结构整理好的源码包大概是下面这个结构每个文件职责单一方便按需替换otto-otto/ ├── config.py # 所有参数集中管理 ├── preprocess.py # 原始数据处理构建session、切分验证集 ├── build_candidates.py # 候选集构建可替换为自己的召回逻辑 ├── dataset.py # Dataset序列截断、负采样、特征组装 ├── model.py # OttoTransformer 三个输出头 ├── train.py # 训练主循环 时间一致验证 ├── inference.py # 加载模型输出三个行为分数 ├── postprocess.py # 多目标分数融合、去重、top20、兜底 └── make_submission.py # 生成submission.csvconfig.py里的所有超参数都在同一份文件里管理训练完一轮实验记录的是配置快照而不是我记得当时好像改过什么。这个习惯在比赛后期尤其重要后面会再展开。5.2 从原始数据到提交文件的复现链路操作步骤概括下来就是四条命令python preprocess.py # 1. 数据预处理产出训练/验证session python build_candidates.py # 2. 给训练和测试session生成候选集 python train.py # 3. 训练模型验证分数达标后保存权重 python make_submission.py # 4. 推理 后处理生成提交csv在preprocess这一步我强烈建议直接用parquet格式做中间存储而不是csv。OTTO数据是上亿行行为事件csv读起来又慢又占内存parquet列式存储配合pandas和pyarrow加载速度快一个量级内存占用也小很多。这是很多新手复现竞赛源代码时第一个容易卡住的地方。train.py里训完不会立刻出提交。inference这一步我会把模型切成半精度fp16推理单个模型跑完测试集的时间能缩短差不多一半。因为只输出top20候选不需要保留全量分数矩阵内存压力也小。5.3 拿到代码后可以从哪几个点开始改源码的价值不在于跑通就完事而在于你清楚哪里可以替换。我建议几个扩展方向把Transformer Encoder换成GRU或LSTM训练会快很多分数大概略降适合快速验证其他想法。把item embedding换成预训练向量比如在全部行为序列上预先训练一组item embedding初始化模型第一层。我在小规模实验里见过稳定的小幅提升。候选集换成你自己训练的召回模型。OTTO的候选集是固定的但如果换到自己的场景build_candidates.py就是天然的改动点。多加一个head做行为类型预测作为辅助任务帮助session encoder学到更丰富的语义进而提升主任务效果。还有一点提醒竞赛源代码的使用要遵守比赛官方的规则和开源许可自己复现、改造、学习都没有问题但不建议原封不动拿去做商业用途或者转手倒卖。这也是对上游开源社区保持尊重的基本素养。6. 比赛结束后的复盘0.594是怎么来的、还能往哪走6.1 哪些决策帮助最大如果按贡献度排序我的排序是orders权重的深入理解大于时间一致验证集大于行为类型embedding大于popularity-based负采样大于后处理融合。第一点的分量远超其他。理解了orders权重占0.7整个方案重心就从怎么稳定预测点击切到怎么从稀疏行为里挖出购买信号。很多队伍分数卡在0.56、0.57附近上不去回头分析大概率是在用预测点击的思路做购买预测。另外说句实在话0.594在OTTO这种比赛里不算惊为天人但它是单模型跑出来的没有吃太多ensemble红利。LB排名30在比赛进行到中期甚至能进前20因为后期大家普遍开始堆集成排名会剧烈变动。如果你只是想在公司里做一版能用、效果不差的推荐模型单模型加清晰代码的形态比十个模型融合出0.62更值得参考——它能落地也好维护。6.2 0.594之后还差什么0.594离顶流的差距主要在三个方向模型集成多个结构不同的encoder、不同随机种子、不同loss权重的模型做平均通常能稳定带来0.005到0.01的提升。这是最简单的涨分手段代价是训练成本翻倍。候选生成优化如果召回候选本身就漏掉了真实目标精排模型做得再好也没用。顶流方案在候选生成阶段投入了大量工作而我只用了比赛给定的候选集。数据扩展用伪标签把测试session的信息利用起来或者引入更长时间窗口的数据都能进一步压榨模型能力。6.3 给准备打推荐赛的人的建议能走到0.594我最想分享的不是某个具体技巧而是一个工作习惯每次改完模型只动一个变量记一次实验。这套源码对应了七八个关键实验的结论但每个结论都是单独验证过的。如果你一次性改了三个参数分数涨了你都不知道是哪个起作用分数跌了你也只能干瞪眼。比赛前中期不用急着追求模型复杂度。先把一个能跑通的小模型放在时间一致的验证集上后续所有改进都以它为准这套思路会帮你省掉大量自我怀疑的时间。如果想在这套代码上玩出自己的方案建议从5.3节那四个扩展点入手那里是性价比最高的试验场。本文还有配套的精品资源点击获取