KKBox音乐推荐挑战赛实战:特征工程与LightGBM调参指南
2026/9/14 3:27:59 网站建设 项目流程

简介:这是一份针对Kaggle KKBox音乐推荐挑战赛的完整代码与方案资料,适合对推荐系统、机器学习竞赛感兴趣的Python/C++开发者。资源围绕音乐播放预测任务,集成了XGBoost、LightGBM、CatBoost、FFM以及神经网络等多种主流模型的实现,并为每种模型配备了utils数据处理工具与model训练脚本,覆盖特征工程、模型训练、预测评估等完整链路,能帮助读者直观理解不同算法在真实竞赛场景下的建模流程、调优手段与效果差异。压缩包共39个文件,以15个Python脚本为主体,辅以7个C++源文件、4个头文件及makefile构建配置,同时包含ffm-train/ffm-predict训练预测工具、README说明文档、license版权声明等,整体体积仅136KB,小巧但目录结构清晰,便于按模块研读。目前已有79人学习下载,适合希望快速上手推荐系统竞赛或复现音乐推荐基线模型的初学者与进阶学习者,这份资料提供了扎实的代码参考与实战价值,可作为后续扩展与改进的起点。

1. KKBox 音乐推荐挑战:从听歌日志到 AUC,这个赛题在考什么

KKBox 在 Kaggle 上的音乐推荐挑战,是 WSDM 与 KKBox 合办的经典表格赛:给定用户对一首歌的首次可观测听歌事件,预测该用户一个月内会不会再次播放同一首歌。训练集约 740 万行,评测指标是 AUC,提交的是一列 0 到 1 的概率。表面是二分类,内核是重复消费——推荐系统里最难建模的行为:用户会不会回头,既取决于歌的质量,也取决于歌单和推荐位,还取决于样本在时间轴上的位置。对推荐从业者,它是理解隐式反馈、计数特征和时间泄漏的好标本;对 Kaggle 新手,它数据结构干净、单模 0.73,特征工程做好能磨到 0.78。下面按参赛顺序展开。

2. 数据集与 download.zip:三张表的结构、读取方式和常见坑

2.1 先分清赛题原始数据和社区整理包

标题里带「下载.zip」的,通常是社区把官方数据、Python 特征脚本和 C++ 小工具打成的一个包,Kaggle 比赛页面上的原始数据是另一份,两者不要混用。官方赛题以五个文件为准,任何额外文件都不参与评测。train 里每一行是一次首次可观测听歌事件,target=1 表示该用户在此后一个月内再次收听了这首歌;train 和 test 是按时间切开的两个窗口,test 里的样本整体晚于 train。这个定义决定了三件事:特征只能往「第一次收听时已知的信息」方向构造;计数特征必须谨慎处理时间窗口;验证集不能无脑随机抽样。

文件内容关键列在流程里的作用
train.csv约 740 万行训练日志msno, song_id, source_system_tab, source_screen_name, source_type, target训练与交叉验证
test.csv百万级待预测日志同 train 无 target生成提交结果
members.csv用户主数据msno, city, bd, gender, registered_via, registration_init_time, expiration_date用户侧特征
songs.csv歌曲主数据song_id, song_length, genre_ids, artist_name, composer, lyricist, language歌曲侧特征
sample_submission.csv与 test 行数相同id, target提交格式模板

下载用 Kaggle 官方命令行最省事,前提是本地有可用的 Python 3 环境和 pip。注册账号后在 Account 页面生成 API token,把下载到的 kaggle.json 放到主目录的 .kaggle 目录下,然后执行:

pip install kaggle kaggle competitions download -c kkbox-music-recommendation-challenge unzip -q kkbox-music-recommendation-challenge.zip

-c 后面的比赛标识必须和官网 URL 里的 slug 完全一致,大小写敏感;下载中断后重跑同一条命令会从断点继续。解压后用unzip -l核对文件大小与官网页面标注是否一致,防止拿到半截文件。

2.2 用 dtype 白名单把内存压到三分之一

train.csv 七百多万行、六个文本列,默认读法内存会到 2 GB 以上,本机跑容易卡死。常见做法是给每一列声明 dtype:msno 和 song_id 是纯数字字符串,不声明会被 pandas 读成 int64 丢精度,还会破坏后续与 members、songs 的 join。

import pandas as pd train = pd.read_csv("train.csv", dtype={ "msno": "str", "song_id": "str", "source_system_tab": "str", "source_screen_name": "str", "source_type": "str", "target": "int8", }) members = pd.read_csv("members.csv", dtype={ "msno": "str", "city": "int8", "bd": "int32", "gender": "str", "registered_via": "int8", "registration_init_time": "int32", "expiration_date": "int32", }) songs = pd.read_csv("songs.csv", dtype={ "song_id": "str", "song_length": "int64", "genre_ids": "str", "artist_name": "str", "composer": "str", "lyricist": "str", "language": "float32", })

dtype 白名单有两层作用:一层是省内存,int8 的 city 比默认 int64 小八倍;另一层是防误判,language 字段缺失很多,读成 float32 保留 NaN,后面填充时不会把缺失值当成 0 参与统计。读进来后立刻转存 parquet,后续每轮实验不用重新解析 CSV:

train.to_parquet("train.parquet") test = pd.read_csv("test.csv").to_parquet("test.parquet")

提示:to_parquet 需要 pyarrow,pip install pyarrow先装上。

2.3 中文 zip 的乱码、校验和路径问题

国内网盘下载的 zip 解压后中文文件名乱码,是 Windows 压缩工具用 GBK 记录文件名、Linux 的 unzip 按 CP437 解码导致的,与内容无关。用 Python 解压时按编码回退处理:

import zipfile, os with zipfile.ZipFile("KKBox在Kaggle上的音乐推荐挑战_Python_C++_下载.zip") as z: for name in z.namelist(): try: safe = name.encode("cp437").decode("utf-8") except UnicodeDecodeError: safe = name.encode("cp437").decode("gbk", errors="replace") z.extract(name, "data/") os.rename(os.path.join("data", name), os.path.join("data", safe))

解压后先做两件事再写特征:一是sha256sum train.csv与压缩包内附 checksum 对比,防止下载损坏;二是head -n 3 songs.csv看 artist_name、composer 里的中文是否正常,若乱码说明 CSV 本身是 GBK 编码,读取时加encoding="gbk"。这两步不花时间,但能省掉后面特征拼不上时排查半天的麻烦。

3. Python 特征工程:把听歌日志转成训练矩阵的代码模板

3.1 特征分三层:用户侧、歌曲侧、双边交互

这个赛题的数据天然分成三张表,特征也按同样边界组织。用户侧从 members.csv 取:性别、城市、年龄段、注册年份、注册到过期的会员天数。歌曲侧从 songs.csv 取:时长、语言、流派数量,以及 artist_name、composer、lyricist 在整份数据集里出现的次数——这三个计数本质上是「创作者作品量」的近似度量。双边交互特征从 train+test 合并后的全量日志里算:用户总听歌次数、歌曲总被听次数、该用户听这首歌的次数。这是全赛最重要的特征组,公开方案里这一组的贡献通常排在前三名。

3.2 计数特征的合并模板

先合并全量日志再分组计数:

all_log = pd.concat( [train.drop(columns="target"), test], ignore_index=True ) def add_count(df, keys, name): cnt = all_log.groupby(keys).size().rename(name).reset_index() return df.merge(cnt, on=keys, how="left") train = add_count(train, ["msno"], "user_total_plays") train = add_count(train, ["song_id"], "song_total_plays") train = add_count(train, ["msno", "song_id"], "user_song_plays") # 缺失计数的样本补 0,避免 NaN 进入树模型 for c in ["user_total_plays", "song_total_plays", "user_song_plays"]: train[c] = train[c].fillna(0).astype("int32")

这里有个必须想清楚的细节:为什么计数特征拿 train+test 合并后的数据来算。官方按时间窗口划分 train 和 test,test 里的听歌事件整体晚于 train,所以用 test 行统计 user_total_plays 这类频率,不会把「未来」的信息灌进 train 的标签;恰恰相反,它让只在 test 里出现的新歌、新用户也能拿到非零计数,避免冷启动样本特征全为 0。这是当时公开方案里的共识。

注意:合并 train+test 算计数只在「频率统计」这个范围内成立;任何按目标值做的编码(target encoding)都必须只用 train 内部计算,否则就是实打实的泄漏。

3.3 时间字段和类别字段的解析

members.csv 里的 registration_init_time 和 expiration_date 是 YYYYMMDD 格式的整数,直接当数值喂模型会得到「数字越大会员越新」这种不好解读的关系,最好拆成年、月并算出会员时长:

def parse_ts(x): x = str(int(x)) return pd.to_datetime(x, format="%Y%m%d", errors="coerce") members["reg_dt"] = members["registration_init_time"].map(parse_ts) members["exp_dt"] = members["expiration_date"].map(parse_ts) members["reg_year"] = members["reg_dt"].dt.year members["mem_days"] = (members["exp_dt"] - members["reg_dt"]).dt.days members["is_male"] = members["gender"].map({"male": 1, "female": 0}).fillna(-1) members["bd"] = members["bd"].replace(0, -1) # bd=0 是缺失,不是 0 岁 train = train.merge( members[["msno", "reg_year", "mem_days", "is_male", "city"]], on="msno", how="left" )

bd 字段里有相当比例的 0 值,直接保留会让树模型学出「0 岁」的错误分叉,常规做法是替换成 -1 或单独建 is_unknown 列。gender 缺失同样填 -1,让模型自行决定怎么用这一支。

歌曲侧的高基数类别列,比如 artist_name 有上万种取值,不要 one-hot,用 pandas 的 category 编码:

for col in ["artist_name", "composer", "lyricist"]: songs[col] = songs[col].fillna("unknown").astype("category") songs[col + "_code"] = songs[col].cat.codes songs["genre_cnt"] = songs["genre_ids"].fillna("").str.split("|").str.len() songs["language"] = songs["language"].fillna(-1) train = train.merge( songs[["song_id", "song_length", "language", "genre_cnt", "artist_name_code", "composer_code", "lyricist_code"]], on="song_id", how="left" ) train["song_length_sec"] = train["song_length"] // 1000

genre_ids 是按|分隔的流派列表,genre_cnt 表示一首歌横跨几个流派,比直接编码整串 ID 更稳定。song_length 单位是毫秒,不换算直接进模型,分叉点会落在没人能直觉理解的位置;转成秒后再按 0-90、90-180、180-300、300 以上分桶,通常比连续值略好,因为重复收听与歌曲时长是分段相关而非线性相关。

3.4 入口渠道特征的交叉计数

source_system_tab、source_screen_name、source_type 描述这次听歌从哪个入口发生(搜索、歌单、排行榜等),本身是低基数类别,直接标签编码即可;价值更高的是它们的交叉计数,比如「用户从搜索进来的次数」:

src = all_log.groupby(["msno", "source_system_tab"]).size().unstack(fill_value=0) src.columns = ["user_src_" + str(c) for c in src.columns] train = train.merge(src, on="msno", how="left")

入口渠道特征在公开方案里普遍能带来 0.002-0.005 的 AUC 提升,原因很直接:主动搜索进来的行为,重复收听概率显著高于被动歌单播放。这一类特征不要嫌细,能拆的入口维度都拆开。

4. 模型训练与调参:LightGBM 的参数表、AUC 验证与 C++ 加速边界

4.1 验证策略:随机 K 折在本赛题里偏乐观

评测指标是 AUC,直观定义是随机抽一个正样本和一个负样本,正样本得分高于负样本的概率。AUC 对分类阈值完全不敏感,这正是推荐场景要的——只看排序质量,不关心预测值是否大于 0.5,所以调参时不要横向对比 accuracy 或 logloss,只盯 OOF AUC。

本赛题的特殊性在时间窗口:官方按时间划分 train 和 test,而 train.csv 里没有时间戳列,无法精确复现切分,所以多数人直接用随机 StratifiedKFold。随机切分的问题是同一用户的相似行为会同时出现在训练折和验证折,OOF AUC 会比线上高 0.01-0.02。折中做法是用用户注册时间做近似时间切分:按 msno 注册时间排序,前 80% 用户训练、后 20% 验证,这样验证集里的用户和线上一样是「更晚出现」的,AUC 会难看一点,但与 Public LB 的相关性更好。我一般两个都跑:随机 K 折调参,时间切分估线上分数。

4.2 LightGBM 参数表与训练代码

本赛题的榜单基本被 LightGBM/XGBoost 统治,单模 OOF 约 0.74-0.75,融合后 0.78 上下。先给一组能跑到 0.73+ 的基准参数:

import lightgbm as lgb import numpy as np from sklearn.model_selection import StratifiedKFold from sklearn.metrics import roc_auc_score params = { "objective": "binary", "metric": "auc", "learning_rate": 0.05, "num_leaves": 127, "min_child_samples": 100, "feature_fraction": 0.8, "bagging_fraction": 0.8, "bagging_freq": 1, "lambda_l2": 1.0, "n_jobs": -1, "verbosity": -1, }
参数建议值作用与调整方向
num_leaves63-255控制模型容量,特征 50 个以上时从 127 起步
min_child_samples50-200稀疏计数特征多时调大,防单样本分叉
feature_fraction0.7-0.9特征多时打开,提升泛化
bagging_fraction0.7-0.9配合 bagging_freq=1,降方差
lambda_l20.5-5.0高基数 category 编码特征越多越要调大
learning_rate0.02-0.1用早停确定轮数,不必单独精调

训练用 5 折交叉验证,每折早停,OOF 概率留作集成:

X = train_feat.drop(columns=["target"]) y = train_feat["target"].values folds = StratifiedKFold(n_splits=5, shuffle=True, random_state=42) oof = np.zeros(len(X)) for tr_idx, va_idx in folds.split(X, y): dtr = lgb.Dataset(X.iloc[tr_idx], y[tr_idx]) dva = lgb.Dataset(X.iloc[va_idx], y[va_idx], reference=dtr) model = lgb.train(params, dtr, num_boost_round=3000, valid_sets=[dva], callbacks=[lgb.early_stopping(100)]) oof[va_idx] = model.predict(X.iloc[va_idx], num_iteration=model.best_iteration) print("fold auc:", roc_auc_score(y[va_idx], oof[va_idx])) print("OOF AUC:", roc_auc_score(y, oof))

early_stopping(100) 表示验证集 AUC 连续 100 轮不创新高就停,预测测试集时传 best_iteration 的模型,而不是最后一轮——LightGBM 在过拟合区间的预测会明显变毛。这个参数决定了训练时间与精度的平衡,数据量大时可先设 50 快速验证特征组合,定稿时再拉回 100。

4.3 C++ 出现在这个赛题里的两种真实用途

标题里的 C++ 不是竞赛必须的:740 万行的计数特征,pandas groupby 几十秒跑完。C++ 出现在这类打包里通常是两个原因。一是打包作者把核心计数逻辑用 C++ 重写,换单机大表的数倍加速;二是你需要写「按用户逐行累计过去 N 天听歌次数」这类窗口统计,pandas 写法笨重且慢,写进 C++ 扩展更顺手。

用 pybind11 做扩展的典型形态:

#include <pybind11/pybind11.h> #include <pybind11/stl.h> #include <unordered_map> #include <string> #include <vector> std::vector<long> count_pairs(const std::vector<std::string>& u, const std::vector<std::string>& s) { std::unordered_map<std::string, long> m; for (size_t i = 0; i < u.size(); ++i) m[u[i] + "#" + s[i]] += 1; std::vector<long> out(u.size()); for (size_t i = 0; i < u.size(); ++i) out[i] = m[u[i] + "#" + s[i]]; return out; } PYBIND11_MODULE(fast_count, m) { m.def("count_pairs", &count_pairs); }

编译命令把 pybind11 的 include 路径和扩展名后缀都交给 Python 自己回答:

c++ -O3 -shared -std=c++17 -fPIC \ $(python3 -m pybind11 --includes) $(python3-config --includes) \ fast_count.cpp -o fast_count$(python3-config --extension-suffix)

编译前确认机器上有 C++ 编译工具链,Linux 下是 g++,Windows 下用 Visual Studio 的开发者命令行。编译完在 Python 里直接当模块用:

import fast_count train["user_song_plays"] = fast_count.count_pairs( train["msno"].tolist(), train["song_id"].tolist() )

键拼接用#分隔纯属防碰撞:msno 和 song_id 都只含数字,中间夹一个#永远不可能和真实键冲突。这个函数对 740 万行数据在 1 秒内返回,与 pandas 的差距要数据量再翻十倍才真正拉开。务实的建议是:数据处理用 pandas 写 parquet,训练用 Python 调 LightGBM,C++ 只在确认瓶颈确实在某个自定义循环时才引入,别为了用 C++ 把整条管线拆散。

5. 提交前必查的三件事:时间泄漏、样本分布与 kaggle 提交格式

5.1 时间泄漏自检

把 train 按 msno 注册时间排序后切成前 80% 和后 20%,用两套特征各训一个 LightGBM:A 套只含从 train 内部算出的计数特征,B 套含从 train+test 合并算出的计数特征。如果 B 套相对 A 套的 OOF 提升超过 0.02,说明合并计数里混进了与标签强相关的时间信息,需要回到 3.2 审视构造边界;差距在 0.005 以内,合并计数的收益可以放心收下。这个自检只要跑两个 5 折,半小时内能出结论。

5.2 新用户、新歌曲的间接相似度

测试集里有相当比例的 (msno, song_id) 组合在 train 里没见过,user_song_plays 全为 0。这时候最有效的替代特征是用户对同一 artist 其他歌的累计收听次数,它把「用户喜欢这个歌手」的信息从歌曲粒度提升到艺术家粒度,对新歌样本尤其有用:

song_artist = songs[["song_id", "artist_name"]].drop_duplicates() log_artist = all_log.merge(song_artist, on="song_id", how="left") ua = log_artist.groupby(["msno", "artist_name"]).size() ua = ua.rename("user_artist_plays").reset_index() train = train.merge(ua, on=["msno", "artist_name"], how="left") train["user_artist_plays"] = train["user_artist_plays"].fillna(0).astype("int32")

5.3 提交格式与排名平均

sample_submission.csv 只有 id 和 target 两列,id 是 test 的行序号,target 必须是概率而不是 0/1 标签。提交命令:

kaggle competitions submit \ -c kkbox-music-recommendation-challenge \ -f submission.csv -m "lgb artist feature v3"

因为评测只看排序,多模型集成用 rank 平均比概率平均更贴近优化目标:

from scipy.stats import rankdata final = np.mean([rankdata(p) / len(p) for p in preds], axis=0) pd.DataFrame({"id": test_id, "target": final}).to_csv("submission.csv", index=False)

最后检查一遍提交文件的 target 分布:均值应落在 0.2-0.4 之间,正样本占比约三成的赛题输出一个 0.5 附近的均值,通常是预测时没传 best_iteration,或者特征里混进了泄漏导致置信度异常偏移。

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

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

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

立即咨询