news 2026/9/13 15:44:04

多目标推荐系统实战:Otto竞赛LightGBM单模型0.594技术拆解

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
多目标推荐系统实战:Otto竞赛LightGBM单模型0.594技术拆解

简介:对标Kaggle Otto多目标推荐系统赛题,这是一份单模型LB分数0.594、排名约30的完整源代码方案,适合想冲击推荐类竞赛榜单的选手及希望深入多目标推荐工程的数据科学学习者。代码覆盖数据处理、用户/物品特征与相似度特征构建、协同过滤与图嵌入召回、BPR/ALS矩阵分解、排序模型训练与预测等关键模块,并体现多目标优化、交叉验证、特征选择与模型融合的实用思路。压缩包共28个文件,大小仅40KB,以16个Python脚本为主,按preprocess、features、candidates等目录清晰组织,同时包含XML工程配置、README说明与附赠内容,方便快速定位与复现。目前已有124人学习该资源。研读源码可完整理解从候选召回、特征工程到排序预测的竞赛pipeline,为迁移到电商推荐业务提供扎实参考。

1. Otto多目标推荐与单模型0.594的含金量

Otto是2022年末到2023年初的Kaggle多目标推荐赛题。它给出的不是用户画像,而是匿名会话(session)级的点击、加购、购买事件流,要求对每个会话输出三份商品列表,分别预测下一次点击、加购和购买。0.594这个分数放在当时的公开榜上大约是前30的水平,如果只用单个LightGBM而不是深度模型集成就达到这个值,说明特征和候选召回已经把信息压榨到位了。下面把单模型到0.594的完整路径拆开讲:从数据表、加权指标、候选召回、特征设计到三个目标的合并和提交验证。适合两类人读:一是想复现一个高排名基线来起步的竞赛选手,二是要在真实电商场景里快速搭建推荐排序服务、又不想上重模型的中后台工程师。

2. 读懂Otto数据与指标:三种行为、两套时间戳、一个加权召回率

2.1 train/test表的字段和规模

拿到压缩包后先别急着写模型。Otto的parquet只有三张表:train.parquet、test.parquet和candidate.parquet(终端提交时需要候选集做格式校验)。train和test的schema完全一致,共四列:session、aid、ts、type,type的取值只有0、1、2,分别对应clicks、carts、orders。train大约包含1200万个会话和超过一亿条行为,test大约80万个会话,其中公榜可见的是第一批约20万。

test表本身也带行为事件,这一点和其他推荐竞赛差别很大:它给的是每个测试会话在预测时刻之前的一部分历史交互,而不是只给一个空会话。也就是说,提交时要预测的是这个会话“接下来”会发生什么,而不是从零开始猜用户兴趣。session维度上测试集与训练集是互斥的,不会出现同一个session横跨两表的情况,但商品aid在两表间完全共享,因此全局热度、商品共现这类统计可以放心用全量数据计算。

候选集candidate表则是官方提前算好的“每个会话在每个目标下允许提交的商品池”,每行包含session、type、aid,也就是测试会话在某个行为类型下能用的合法候选。评分时只在这个池子里计算recall@20,池子之外的预测直接不计入。许多复现项目忽略这张表的过滤作用,导致本地分数虚高,提交后被判非法,这一步在数据读取阶段就要处理掉。

2.2 加权召回率@20:为什么订单权重远大于点击

评估指标是三个目标各自的recall@20加权求和,权重分别是0.1、0.3、0.6,对应clicks、carts、orders。每个测试会话只保留预测得分最高的20个aid,先看这20个里有没有真实发生对应行为,再除以该会话这个行为的真实商品总数。recall对“命中的比重”敏感,对命中顺序却不敏感,所以三份候选列表的排序只要在20名内就等价。

这个权重设置直接决定了建模策略:购买行为的占比只有大约5%-8%,但权重高达0.6,意味着把订单找对比把点击找对值六倍。因此大部分方案会专门为orders单独建模,并在训练时提高订单样本的权重,而不是让模型按自然分布去学习。反过来,点击目标权重只有0.1,但点击样本量巨大,可以当“基础召回”用,先保证点击候选不差,再靠加购和购买模型把分拉上去。

提示:本地验证切分也要按同样的0.1/0.3/0.6加权计算,否则调参方向可能和公开榜相反。

2.3 时间切分与候选池生成

Otto赛题不做严格的时序切分时,分数容易虚高。常见做法是把train按ts排序后,取最后比如3天的数据当作验证集,前面的全部作为训练集。注意验证会话和训练会话在session维度天然互斥,但同一aid可以在两边出现,这正好模拟了test的分布。

验证阶段还需要一个“伪候选池”。直接把官方的candidate表用在训练集上是不行的,因为它只覆盖test。一个实用做法是:把验证会话的前80%行为当作可见部分,后20%当作待预测真实值,然后用“最后一小时出现过的商品+热门商品+共现商品”来生成候选。这个候选池要略大于20,通常取50到100,把下一步排序模型要打分的范围圈出来。

对象候选来源保留数量
验证集可见部分最后若干次行为中出现的aid50
验证集训练尾部全局热门aid50
测试集官方candidate表 + 测试历史末尾aid官方值/50

把这三部分合并去重,得到的候选数量一般会超过20,但不会超过300。用这个池子做排序,最后截断到20,才能真实反映提交时recall@20的得分。

import polars as pl cand = pl.read_parquet("candidate.parquet") # 官方候选: session, type, aid train = pl.read_parquet("train.parquet") # 训练尾部12小时作为"伪test前缀" train_ts_max = train["ts"].max() prefix = train.filter(pl.col("ts") >= train_ts_max - 12 * 3600) # 从prefix按会话取最后10个aid,作为行为复制候选来源 recent_cand = ( prefix.sort("ts") .group_by("session") .agg(pl.col("aid").tail(10).alias("recent_aids")) .explode("recent_aids") )

注意这里的recent_cand不能直接拿来训练,它只是候选池构造逻辑的证据。真正训练时,要给候选池里的(session, aid, type)三元组标注真实标签,再交给排序模型。type仍然按0/1/2处理,三份列表分别生成。

补充说明:为什么是12小时而不是更长?因为test只给了每个会话极短前缀,太长的窗口会把训练分布带偏。这个窗口是一个可以搜索的超参数,后面第4章会再提到。

3. 单模型的特征体系:把Otto会话行为翻译成排序特征

3.1 会话内统计与商品全局统计

在GitHub上搜otto-competition相关的源代码,结构高度统一:候选召回、特征构建、三份模型、提交校验四段逻辑。0.594这个分数对应的单模型版本,把重心几乎全压在前两段。单模型要兼顾三个目标,特征必须能同时区分“用户是不是想要这件商品”和“用户这件商品是买还是放购物车”。会话内统计是第一个层次。按session分组算出的特征包括:会话内已发生的动作总数、每个type各自的次数、会话中最后三个aid是什么、最后动作距离当前ts的时间差、当前aid是不是最后交互的对象。

第二个层次是商品全局统计。同一个订单模型在候选池里遇到冷门商品时,全局热度、最近一周内该商品被加购/购买的次数、它被购买时同会话里还出现过哪些商品,这些要素决定了排序结果。Otto这类会话型场景里,商品共现关系比画像特征更可靠,因为推荐对象不是“用户喜欢什么”,而是“下一步要动哪个商品”。

把两个层次拼进一张特征表时,注意所有统计都必须只用“可见部分”计算,也就是对验证会话要时刻记住:后20%行为对特征不可见。一个简单防御是写一个按ts过滤的函数,在构造特征时就按ts过滤。这也是很多0.58分方案和刚入门方案之间的第一个分水岭。

3.2 候选召回:热门、共现、行为复制

目标0.594的单模型不是靠一个大排序模型从全量商品中选出来的。它必须先把候选池压缩到几十个,再排序。候选召回有三个来源值得保留:热门商品、会话内最近交互商品的关联商品、以及行为复制。第三点尤其反直觉但极其有效:把训练数据最后几个小时里、测试会话历史里已经出现过的aid直接塞进候选池,甚至在候选池里重复出现相关的交互商品。

行为复制的原理是,电商会话里大量购买是重复行为,比如用户刚才点过的商品,下一时间步很可能加购。官方评估只认20个位置的命中,模型完全可以把“用户最近交互过的商品再推一次”当作高优先级候选。实际在0.59左右的公开方案里,“last seen”特征普遍出现在特征重要性前10名中。

共现召回用一张i2i表实现:对一个aid,统计它与另一个aid在同一session、同一目标下出现的次数,取top K作为关联。冷启动商品用全局热门兜底。下表是不同候选来源在验证集上的覆盖率:

候选来源验证集覆盖真实订单比例
热门top100约21%
会话内行为复制约46%
i2i共现top20约38%
官方候选池约100%(指标口径)

行为复制覆盖率比热门商品高一倍以上,这是Otto最值得利用的规律。

3.3 特征表与负样本构造

特征表以召回阶段产生的(session, type, aid)元组为行,每个元组拼上会话统计、商品统计、交互统计和候选来源标记。正样本是验证或训练阶段真实发生过对应该type行为的aid,负样本是没有真实行为的候选aid。由于每个会话每类真实行为通常只有个位数,候选池却有几十个,负样本天然占比90%以上。

对单模型来说,负样本不是越多越好。常见的做法是控制正负比在1:5到1:10,超出部分直接丢弃。丢弃时要按session随机抽,不能让同一session的候选全部消失。处理完之后,存储为parquet,作为LightGBM的输入。特征列全部是数值或离散编码,避免直接喂字符串。

def build_features(df, stats): # df: candidate-pool 三元组 df = df.join(stats["session"], on="session", how="left") df = df.join(stats["item"], on="aid", how="left") df = df.with_columns( (pl.col("ts_max") - pl.col("session_ts_max")).alias("ts_diff"), (pl.col("aid_cart_cnt") / pl.col("session_len").clip(1)).alias("cart_ratio"), ) return df

这里的ts_diff衡量当前候选与最近一次会话行为的时间间隔;cart_ratio是商品被加购次数与会话时长的比值,用来捕捉“点击很多但没买”的过渡状态。所有特征都要在样本拼好后再一次性算齐,不要循环逐行算,否则3500万行特征表会跑一天都跑不完。

4. LightGBM排序模型与Otto赛题参数配置

4.1 三类行为分开建模还是合并建模

单模型路线下推荐多输出写法:特征表只有一份,三个LightGBM分别对应三个目标,结构上仍是同一套模型骨架。共享特征表能减少重复计算,三份模型各自输出分数,最后按权重合并。我一般把三个模型分开调,每轮的验证分数单独看。也可以把type做成一列特征训练一个模型,但Otto的经验是分开建模更可控:orders的真实正样本极少,合并训练时其梯度会被点击样本淹没。

分开建模时,orders模型的训练样本要额外做一次上采样或权重放大。通常orders模型里正负比调到1:20甚至更低,再叠加样本权重,效果更稳定。clicks模型则相反,正样本太多,需要把历史点击中的立即重复点击降权。三个模型的特征重要性排序会明显不同,这本身就是很好的特征筛选反馈。

4.2 核心参数与训练配置

深度方案在Otto上需要花大力气处理序列建模,但单模型路线用LightGBM就够。理由很简单:候选池不大,特征以统计型为主,GBDT对这个设定足够敏感,且训练一个模型只需要十几分钟。常用参数可以按下面的表去初始化:

参数数值说明
objectivebinary候选排序按二分类处理
metricauc验证看auc,提交看recall
learning_rate0.05小一点配合深度叶子
num_leaves127叶子数,别太小
min_child_samples150防过拟合
feature_fraction0.7列采样
bagging_fraction0.8行采样
lambda_l25.0正则

训练时用early stopping轮数100,n_estimators上限1200。orders模型的learning_rate可以放到0.03,因为它正样本比例低,收敛更慢,早停轮数也要加大。如果数据量大,可以开categorical_feature把type、aid的高频桶作为类别特征喂进去,但aid基数极大,一般只把aid的rank分桶做数值用。

4.3 训练代码与验证脚本

下面是一段可以直接落地的轻量训练骨架,直接用lightgbm的sklearn接口。

import lightgbm as lgb from sklearn.model_selection import train_test_split feats = [c for c in df.columns if c.startswith("f_")] X = df[feats] y = df["label"] X_train, X_val, y_train, y_val = train_test_split( X, y, test_size=0.15, random_state=42 ) model = lgb.LGBMClassifier( objective="binary", learning_rate=0.05, num_leaves=127, n_estimators=1200, min_child_samples=150, feature_fraction=0.7, bagging_fraction=0.8, lambda_l2=5.0, ) model.fit( X_train, y_train, eval_set=[(X_val, y_val)], eval_metric="auc", callbacks=[lgb.early_stopping(100)], )

注意这里label的构造有几个坑。一是验证集里不要把同一会话的样本散落到train和val,要按session分组切分;二是负样本需要在切分后再生成,不能在切分前做随机负采样,否则val里会出现训练时见过的正样本。实际工程里用group-based split工具或者自己按session哈希取模。

验证分数不能只看整体AUC,还要复算提交口径的加权recall@20。写一个compute_recall函数,对每个session取出得分前20的aid,与真实行为列表求交集,再按0.1/0.3/0.6合并三类。本地加权recall到0.57-0.58附近,公开榜大约对应0.58左右,后面才是细节优化的事。

5. 从0.58到0.594:Otto单模型的细节优化

从0.58往上走到0.594,单模型拼的不再是更多特征,而是样本权重的分配、几个交互型特征和三个目标合并时的选择。下面三个点按对最终分数的贡献排序,第一个最值得先做。

5.1 样本权重与时序衰减

到0.58这个档位,大多数方案的瓶颈不再是特征数量,而是“模型认为每个样本都同等重要”。Otto的行为不是这样:ts离预测时间越近的样本,对未来的指示作用越强。把样本权重设计成ts的衰减函数,是单模型往上走的第一个开关。

weight = 0.2 + 0.8 * (1 - (train_ts_max - ts) / (train_ts_max - train_ts_min))

这个权重对三个目标分开用:orders的权重上限更高,clicks的衰减更平缓。实现时把weight列传给LGBMClassifier的sample_weight即可。调的时候只有一个原则:验证集加权recall要提升,而不是训练集loss下降。本地能稳定看到0.001-0.003的提升,这对排行榜上30名左右的位置是决定性的。

5.2 用共现特征弥补单模型缺少序列模型的问题

很多参赛者默认序列信息要靠GRU、Transformer去提,但单模型路线里,会话内最近一次行为、当前候选与最近行为商品的共现次数,一样能抓住“加购前的点击过渡”。建议构造三类交互类特征:候选商品与最近N个交互商品在训练集中的共现次数、候选商品与session内同type其他商品的jaccard相似度、候选在最近一小时全局行为中的出现频次。

其中“候选商品与最近交互商品共现次数”的区分度最明显,因为购买行为往往出现在一对商品反复共现的上下文里。这个特征的计算可以先建一张aid pair计数表,然后用join批量完成,避免逐session算相似度。

提示:如果共现表太大,只保留计数大于5的pair,可以在不损失分数的情况下显著减少内存。

5.3 三个模型分数合并:排序比分数重要

三个目标分别输出了分数,最后要把它们统一成20位的提交列表。这里有个容易忽略的细节:提交格式里每个session_type对应一份新的候选列表,而不是把三份分数做加权平均后取一个共同排名。也就是说,clicks列表、carts列表、orders列表分别是独立的Top 20,三份互不相同是允许的。

合并代码写起来很直接,但有几个策略问题。一是orders分数普遍偏低,要不要用两端分数把orders列表顶上去:一般做法是对orders模型输出的分数不做任何变换,直接取Top20;如果orders正样本太少,可以尝试给orders分数乘一个小的放大系数,让它在排序阶段更激进。二是候选数量不足20的会话,用全局热门商品补足,不能用重复aid填充,重复会导致格式校验失败。

三是三个模型使用不同种子训练多次之后取平均,会带来微小提升,但这已经不再是严格意义的单模型。要在0.594这个水平保持纯净单模型,建议用同一份特征、三个种子做bagging平均,效果和“三个模型”的说法不冲突,因为仍是同一模型结构。

6. 提交前的最后验证:Otto多目标候选格式复核

6.1 时间一致性与候选覆盖检查

提交之前做三件事,能避免大部分“本地0.60提交0.55”的翻车。第一件是检查公共候选池覆盖:把提交列表中每个session的aid与官方candidate表做交集,覆盖率必须接近100%,凡是覆盖不到的候选在评分时是无效的。第二件是检查测试会话的数量与官方第一批发布的数量是否一致,多行少行都会导致提交失败。

第三件是检查ts的分布。test里会话的首条行为时间整体晚于train里最后一条行为时间,如果你的特征表里用了全局“最近一小时频次”,这个窗口的计算和时间对齐必须正确。一个最常见的错误是把train和test拼接后统一算滑动窗口,导致测试会话的统计特征看到了未来数据。

6.2 提交文件与本地分数复核

把三份Top20拼成标准提交文件后,先跑一段快速校验脚本,确认格式再提交。

sub = pd.read_csv("submission.csv") assert sub["session_type"].str.endswith("clicks").mean() > 0.3 assert sub["aid"].str.split().apply(len).is_between(1, 20).all() merged = sub.merge(cand[["session", "type", "aid"]], how="left", indicator=True) assert (merged["_merge"] == "both").mean() > 0.99

脚本里的两个断言分别检查行数和每个列表的长度范围,cand合并检查则验证候选合法性。如果最后一个断言不通过,说明补位逻辑有问题,不要急着改分数,先检查补位是否用了训练集中的aid但没查候选池。

本地复算加权recall时,要确保验证集的真实值、候选池、特征可见性都按第2章的口径同步更新。把这条校验流水线固定下来之后,每次特征迭代都可以在20分钟内得到可信的分数,0.594之后的每次提交都按这个流程复核。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/13 15:43:36

Python+OpenCV实现相机标定:从棋盘格到内参矩阵的完整方案

简介:面向计算机视觉初学者与机器人、三维重建等领域的开发者,这份相机标定Python程序提供了完整可用的内参求解方案。资源自带1110规格的正友棋盘格图片,既可打印后拍摄,也可直接放在显示器上配合附带程序使用,大幅降…

作者头像 李华
网站建设 2026/9/13 15:42:39

猫抓 cat-catch 使用教程:网页视频下载与 M3U8 解析一次讲清

猫抓 cat-catch 使用教程:网页视频下载与 M3U8 解析一次讲清 【免费下载链接】cat-catch 猫抓 浏览器资源嗅探扩展 / cat-catch Browser Resource Sniffing Extension 项目地址: https://gitcode.com/GitHub_Trending/ca/cat-catch 你想把一节网课视频存到电…

作者头像 李华
网站建设 2026/9/13 15:41:36

Java课程设计网上书店系统源代码拆解:环境配置与核心改造实战

简介:一份基于Java的网上书店系统课程设计与毕业设计源码包,面向正在完成期末大作业、课程设计或毕业设计的计算机专业学生。项目覆盖用户注册登录、商品分页浏览、加入购物车等核心业务流程,前端提供Vue、HTML、CSS与JavaScript页面组件&…

作者头像 李华
网站建设 2026/9/13 15:41:17

Pixelle-Video 如何启动 HTTP API 服务并通过健康接口检查可用性?

Pixelle-Video 如何启动 HTTP API 服务并通过健康接口检查可用性? 【免费下载链接】Pixelle-Video 🚀 AI 全自动短视频引擎 | AI Fully Automated Short Video Engine 项目地址: https://gitcode.com/GitHub_Trending/pi/Pixelle-Video Pixelle-V…

作者头像 李华