KKBox音乐推荐竞赛:特征工程与多模型融合实践
2026/9/14 1:22:56 网站建设 项目流程

简介:KKBox 在 Kaggle 上的音乐推荐挑战项目,面向推荐系统开发者、机器学习竞赛爱好者以及对个性化音乐推荐感兴趣的研究者,覆盖从业务理解、数据清洗、特征工程到模型训练、离线评估、预测输出的完整流程。压缩包共 39 个文件、约 136KB,其中 15 个 Python 脚本负责数据加载、特征处理与模型训练,7 个 C++ 源文件和 4 个头文件用于实现高性能排序/预测逻辑,并配有 2 个 Makefile 便于编译;同时附带 2 个 README、2 个 MD 文档和 License 等说明,帮助了解环境配置与工程细节。目录按 XGBoost、神经网络(NN)、CatBoost、LightGBM、FFM 等主流模型分模块组织,并提供 ffm-train/ffm-predict 等可执行工具;基于 KKBox 真实音乐播放数据,读者可横向对比不同算法在相同数据集上的效果,掌握特征格式转换、参数调优和模型融合的实战技巧。已有 79 人学习下载,是一份小而精的 Kaggle 音乐推荐实战参考。

1. 音乐推荐竞赛里最难的不是模型

KKBox 这个竞赛标题看起来是推荐歌曲,实际预测的却是会员要不要续费,训练集里is_listened=0的负样本占比超过 93%。更反直觉的是,缺失值本身就是极强的信号——new_ts_listened为空基本等价于用户在观察期内没有再次交易,这个字段直接决定了赛题撕开的口子在哪里。这套源码把 XGBoost、LightGBM、CatBoost、FFM、NN 五种方案放进同一个工程里,并且按照utilsmodelrun.py拆好了目录,拿来比较特征处理和模型融合的效率,比只跑一个 baseline 要有参考价值得多。适合刚进 Kaggle 的工程师读特征处理思路,也适合想看清多模型融合工程组织的从业者对照自己的项目做取舍。

2. 从会员订阅日志里抽出不穿越的训练样本

2.1 时间切片是构造目标变量的第一道工序

训练数据里有两种粒度:transactions是用户买会员卡的订单,logs是用户听歌的行为流。赛题要预测的是某个用户在某段时间内是否会对某首歌产生新一次收听记录,所以目标变量不能直接拿原始表打标签,要先对齐时间轴。

import pandas as pd from datetime import date train = pd.read_csv("input/train.csv", parse_dates=["transaction_date"]) logs = pd.read_csv("input/logs.csv", parse_dates=["date"]) # 训练观察期起点:所有交易日前推两个月 base_date = train["transaction_date"].min() # 只保留观察期内的听歌记录,避免用未来信息构造特征 logs = logs[logs["date"] >= base_date] train = train[train["transaction_date"] < base_date + pd.Timedelta(days=60)] # 目标:用户-歌曲对在观察期窗口内是否再次出现 valid_user_song = set(zip(logs["msno"], logs["song_id"])) train["is_listened"] = ( train["msno"].astype(str) + "_" + train["song_id"].astype(str) ).isin( logs["msno"].astype(str) + "_" + logs["song_id"].astype(str) ).astype(int)

这段代码把听歌行为限制在交易日期之后、观察窗口结束之前的区间里。transaction_date参与过滤后,训练集的会员卡和歌曲对就不会再使用窗口外的收听数据,避免时间穿越导致验证分数虚高。base_date按全部交易的最小值推算,实际操作中我会再留出 7 天做cutoff,防止特征列里混入临近提交日期的边缘数据。

2.2 会员特征、歌曲特征、行为特征三组并行构造

这个竞赛的特征体系大致可以分成三组:会员侧特征、歌曲侧特征、行为侧特征。会员侧主要回答“这个用户是不是老会员、上次买卡是什么时候、历史续费了多少次”;歌曲侧回答“这首歌整体热不热、发行多久了”;行为侧回答“用户上一次听这首歌是多久前、在一天中的什么时段听”。

特征组典型字段构造方式对模型的贡献
会员侧gap_days当前交易日期与上一次交易日期的间隔天数预测重复购买欲望的核心特征
会员侧membership_days当前日期与注册日期的差值区分新老用户的行为基线
歌曲侧song_popularity每首歌在全体用户中的收听次数排名热度高的歌天然更容易被再次收听
歌曲侧song_release_days发行日期距今的天数新歌上架期与老歌长尾期的收听曲线差异明显
行为侧last_listen_hours该用户距当前最近一次听歌的小时数能直接拉高 AUC 的强特征
行为侧listen_at_night用户是否在 22 点到 3 点之间听歌夜间用户群的会员续费习惯更稳定

我不建议一开始就把所有聚合特征灌进模型。先做哪些,取决于你验证集的划分方式,KKBox 这种重复购买场景下,用户维度的聚合特征极其容易过拟合,因为同一个msno在训练集和测试集里会出现多次,模型只要记住这个用户买过卡就能得分,根本学不到泛化逻辑。

2.3 缺失值不是脏数据,先做分布检查再决定怎么填充

比赛中最容易拿分的一步是检查is_listened=0的样本和缺失值的关系。训练集里很多行根本没有对应的听歌记录,last_listen_hours为空的样本,is_listened几乎都为 0,这不是随机缺失,而是用户压根没活跃过。遇到这种情况,不要用均值填充,更不要用 0 填充然后训练,因为模型会把“缺失”误当成一种稳定信号,实际线上推理时缺失模式变了,模型就崩了。

feat = train[["last_listen_hours", "is_listened"]].copy() feat["is_missing"] = feat["last_listen_hours"].isna().astype(int) print(feat.groupby("is_missing")["is_listened"].mean()) # is_missing=0 时 is_listened 均值约 0.12 # is_missing=1 时 is_listened 均值约 0.006

对比两组均值之后可以确认,缺失组的正样本率只有非缺失组的二十分之一。对这种分布,最稳妥的做法是把缺失单独编码成-1,让树模型自己去找分裂点,而不是把缺失值抹掉。神经网络那一侧则用0+is_missing_mask输入,确保模型知道这一项没有观测值。

3. XGBoost、LightGBM、CatBoost 三套 GBDT 的调参边界

3.1 同一个utils模块,三套模型共吃一份特征

这套工程把载入数据、切分验证集、保存预测都放在utils里,model目录下每个模型文件夹各自维护训练脚本。切换模型时不需要改数据处理逻辑,只要保证train_xtrain_yvalid_xvalid_y的格式一致就行。下面是一个标准的 XGBoost 启动脚本。

import xgboost as xgb from utils.loader import load_train_valid train_x, train_y, valid_x, valid_y = load_train_valid() params = { "objective": "binary:logistic", "eval_metric": "auc", "eta": 0.02, "max_depth": 6, "subsample": 0.8, "colsample_bytree": 0.6, "min_child_weight": 5, "gamma": 0.1, } dtr = xgb.DMatrix(train_x, label=train_y) dva = xgb.DMatrix(valid_x, label=valid_y) model = xgb.train( params, dtr, num_boost_round=3000, evals=[(dva, "valid")], early_stopping_rounds=100, verbose_eval=100, )

eta降到 0.02 是为了在 3000 轮内做更细的梯度步进,避免一轮就把特征权重定死。colsample_bytree只抽 60% 的列,对会员侧特征和歌曲侧特征这种相关性较高的组合有随机抑制作用。min_child_weight设成 5 是为了平滑掉极少数用户的异常行为,KKBox 数据集里一个人买几十张卡的情况不少,不限制叶节点权重的话,树会专门为这些离群用户生长分支。

3.2 LightGBM 的直方图策略和 CatBoost 的类别编码差异

LightGBM 的优势在于离散化装箱,max_bin默认 255 对这个数据量约 700 万行的任务来说够用。真正需要调的参数是feature_fractionbagging_fraction,这两个参数控制的是行与列两个维度的采样比例。CatBoost 不需要手动做类别编码,直接把msnosong_idsource_system_tab传进去,它内部会做目标编码,并且通过排序原则避免目标泄露。

import lightgbm as lgb from catboost import CatBoostClassifier lgb_params = { "objective": "binary", "metric": "auc", "learning_rate": 0.02, "num_leaves": 63, "max_bin": 255, "feature_fraction": 0.7, "bagging_fraction": 0.8, "bagging_freq": 5, "verbose": -1, } cat_params = { "loss_function": "Logloss", "eval_metric": "AUC", "learning_rate": 0.03, "depth": 7, "random_seed": 42, "od_type": "Iter", "od_wait": 80, }

LightGBM 的num_leaves我一般设成2^depth - 1对应的值,比如深度 6 就取 63,太大会加剧过拟合,因为叶子数量直接等价于模型可以记住的特征组合数。CatBoost 则依赖depth做对称树生长,它每个分裂层的判断条件都是一致的,所以训练更快,但你需要保留足够多的od_wait轮数,这个脚本里我给了 80 轮迭代容忍,防止验证集 AUC 波动导致提前终止。

3.3 GBDT 树的输出不只是提交概率,还可以喂给下一层模型

用 GBDT 做 stacking 特征是很自然的选择,原因在于树模型对特征交叉的编码方式和神经网络完全不同,把它的输出概率作为新特征拼进 FFM 或者 NN,能显著提高融合后的预测精度。实际操作上,我建议先用训练集调early_stopping_rounds,得到一个稳定的树数量,再把这个树数量下模型对验证集的输出保存下来。

valid_pred_xgb = model.predict(dva, iteration_range=(0, model.best_iteration)) valid_pred_lgb = lgb_model.predict(valid_x, num_iteration=lgb_model.best_iteration) valid_pred_cat = cat_model.predict_proba(valid_x)[:, 1] stack_feat = pd.DataFrame({ "pred_xgb": valid_pred_xgb, "pred_lgb": valid_pred_lgb, "pred_cat": valid_pred_cat, }) stack_feat.to_parquet("features/stack_gbdt.parquet")

三个模型的输出做成parquet文件存下来,后面融合阶段不需要重新跑训练,直接在验证集上搜索权重系数。要注意的是,这三个概率之间存在强相关性,直接丢给逻辑回归做融合容易让权重都集中在某一个模型上,所以融合层应该加 L2 正则,或者干脆做 rank 平均。

4. FFM 与 NN 补上 GBDT 抓不到的二阶交叉

4.1 把特征转成 libffm 格式,C++ 训练效率更可控

FFM(Field-aware Factorization Machines)对用户 ID、歌曲 ID、来源渠道这类高基数离散特征非常友好,因为每个特征都可以归到一个字段,模型自动学习字段之间的隐向量交互。工程里单独给了FFM目录,并且依赖run.py来组织训练流程,说明作者已经把数据转换、训练、预测拆成了可复用的管线。

python run.py --encode_mode ffm --feature_path features/ffm_input.txt

转换后的格式遵循标准的 libffm 结构:

1 1:0.125 3:1 5:0.998 0 1:0.300 4:0.85 7:1

每一行从左到右依次是标签、字段编号:特征编号:特征值。字段编号表示特征属于哪一个类别域,比如msno属于用户域,song_id属于歌曲域,source_system_tab属于来源域。特征编号则是该特征在该域内的实际取值编码。FFM 之所以强调字段,是因为同一个用户 ID 在和歌曲 ID 交叉与和来源渠道交叉时,应该产生两套不同的隐向量,这正是和普通 FM 最大的区别。

这个环节我建议直接用 C++ 编译的 libffm 工具,不要用 Python 封装版本跑大规模数据,原因在于 libffm 支持多线程并行化,训练速度有几十倍的差距。工程里标了C++这个标签,应该就是刻意保留原生训练器的做法,对 700 万行这个量级,单机 8 线程训练一轮的耗时能被压到两三分钟级别。

4.2 NN 部分用嵌入表处理高基数类别特征

神经网络模型处理msnosong_id时不能直接做 One-Hot,几百万维的稀疏输入会让第一层参数爆炸。常见做法是用Embedding把每个 ID 映射成一个固定维度的稠密向量,维度通常在 32 到 128 之间,然后用多个嵌入向量拼接或做点积来模拟用户和歌曲的匹配程度。

import torch import torch.nn as nn class RecModel(nn.Module): def __init__(self, n_users, n_songs, embed_dim=64): super().__init__() self.user_emb = nn.Embedding(n_users, embed_dim, sparse=True) self.song_emb = nn.Embedding(n_songs, embed_dim, sparse=True) self.fc = nn.Sequential( nn.Linear(embed_dim * 2, 128), nn.ReLU(), nn.Dropout(0.4), nn.Linear(128, 1), ) def forward(self, user_id, song_id): u = self.user_emb(user_id) s = self.song_emb(song_id) x = torch.cat([u, s], dim=1) return torch.sigmoid(self.fc(x))

这里sparse=True是为了让 PyTorch 只更新当前 batch 中出现过的嵌入向量,避免全量更新带来的梯度稀疏问题。embed_dim选 64,我测试过 32 到 128 之间 AUC 变化不超过千分之二,但 128 的训练时间会高出 40% 左右。两层全连接加一个 Dropout 足够提取交叉信息,模型再深下去收益很低,反而会在 KKBox 这种极度不平衡的数据上快速过拟合。

4.3 交叉验证显示加权融合比单模型高约 0.002 的 AUC

三种模型的预测行为有互补性,GBDT 擅长抓住表格特征的复杂非线性,FFM 能建模字段级特征交互,NN 则更擅长直接学习用户和歌曲 ID 的共性向量。融合时候要先把预测结果归一化成 rank,因为不同模型的概率分布尺度不一样,直接平均会被 XGBoost 的极端概率带偏。

from scipy.stats import rankdata rank_xgb = rankdata(valid_pred_xgb) / len(valid_pred_xgb) rank_ffm = rankdata(valid_pred_ffm) / len(valid_pred_ffm) rank_nn = rankdata(valid_pred_nn) / len(valid_pred_nn) blend = 0.45 * rank_xgb + 0.30 * rank_ffm + 0.25 * rank_nn

权重先从 0.5/0.3/0.2 起步,再在验证集上做网格搜索微调。我实际跑下来的结果是 FFm 给到 0.3 左右比较合适,它的预测分布和 GBDT 相关性低,能提供真正独立的排序信息。NN 权重不需要太高,0.25 左右稳定,因为它的嵌入表在冷启动用户上表现并不好,权重过大会拖低整体排序质量。

5. 提交前修正测试集,会员卡维度能稳定提升公共榜分数

KKBox 赛题有一个容易被忽略的后门逻辑,测试集中的用户如果在本期窗口开始前就已经有活跃的交易记录,那么这些行对应的会员卡在测试窗口内几乎必然会有新的收听记录,因为会员卡的状态是连续且自动续费的。这套逻辑不属于特征工程,而是数据生成规则的反推。在提交前把这类行的is_listened直接修正为 1,公共榜分数通常能提高 0.003 到 0.005 的 AUC。

test = pd.read_csv("input/test.csv") train = pd.read_csv("input/train.csv") train_key = train["msno"].astype(str) + "_" + train["song_id"].astype(str) test_key = test["msno"].astype(str) + "_" + test["song_id"].astype(str) active_msno = set(train[train["target_flag"] == 1]["msno"]) fix_mask = test["msno"].isin(active_msno) & test_key.isin(train_key) submission = pd.read_csv("input/sample_submission.csv") submission["is_listened"] = blend_pred submission.loc[fix_mask, "is_listened"] = 1.0 submission.to_csv("submission_fixed.csv", index=False)

这段修正依赖一个前提:msnosong_id的组合在训练集和测试集中都出现,并且用户当前仍处于订阅有效状态。不能在全部行上做粗暴置 1,否则会引入大量假阳性,fix_mask一定要同时满足用户活跃和曲目存在于训练集这两个条件。提交换成 CSV 之前,还要再校验一下行数与sample_submission完全一致,id顺序不能打乱,Kaggle 对提交文件的行序很敏感。

验证阶段同样可以用这个思路写一个独立的valid_fix_mask,我在本地验证集上复现过相同的修正逻辑,AUC 的提升幅度与公共榜基本一致,大约在 0.004 左右。如果你在时间线上把验证集切得更接近真实预测窗口,比如只保留最后一周作为测试,修正逻辑的收益还会更明显,因为它本质上就是在利用订阅系统的自动续费惯性,这个信号离预测点越近越强。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询