ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

KKBox音乐推荐实战:特征工程与LightGBM排序全流程

KKBox音乐推荐实战:特征工程与LightGBM排序全流程 简介面向Kaggle音乐推荐挑战的完整代码包聚焦KKBox歌曲推荐场景适合对推荐系统、机器学习竞赛感兴趣的开发者、学生及数据科学学习者。zip压缩包内共39个文件以Python脚本15个py和C源码7个cpp与4个h头文件为主辅以README、Makefile、Markdown文档整体约136KB体积小巧、易于通读。方案覆盖XGBoost、CatBoost、LightGBM、FFM及神经网络等多类模型包含训练、预测与工具脚本代码目录按模型模块划分便于对照理解特征工程、模型调参与融合思路。目前已吸引79人学习浏览尤其适合初次接触推荐类竞赛的读者对理解音乐推荐场景下的数据预处理、特征筛选、模型调参与融合优化思路具有直接参考价值。整体内容精炼适合快速研读模型核心实现并迁移到自身项目中。1. KKBox 挑战的本质把音乐推荐拆成“会否重复收听”二分类2017 年 KKBox 在 Kaggle 上放出的音乐推荐挑战赛题目标非常集中给定用户和歌曲预测用户在未来 30 天内是否会再次收听这首歌曲。表面看这是“推荐系统”竞赛实际提交的指标是 AUC而不是 Top-K 命中率所以更准确地说这是一个点击率预估类问题和广告、信息流里的 CTR 任务高度同构。Data 由三张业务表组成用户收听日志transactions、用户注册信息members、歌曲元数据songs合起来不到 1GB单机 Python 完全能跑。适合人群是有 Python 基础、想完整走一遍推荐特征工程到排序模型全流程的工程师而 C 在这里的合理角色是离线训练后做低延迟工业落地。先把这个任务定义清后面每一步的样本构造和模型选型才有依据。2. 从三张业务表到训练样本Python 与 pandas 的时序特征构造2.1 三张原始表怎么关联成一张宽表KKBox 比赛数据里最容易犯的错误是把三张表直接 join。transactions 表记录的是每次收听行为user_id, song_id, source_system_tab, source_screen_name, source_type, timestamp同一对user_id, song_id在历史里会出现很多次members 表是性别、城市、注册时间、注册渠道songs 表是歌曲发布时间、语言、艺人、歌曲时长。直接 join 会膨胀成海量重复行正确做法是以“用户-歌曲”为粒度先做聚合再补两侧的属性。我一般先读 transactions把时间列解析成 datetime然后按 user_id 和 song_id 做 groupby得到每个用户-歌曲对的累计收听次数、最新收听时间、历史序号等原始统计量。import pandas as pd df pd.read_csv(transactions.csv, parse_dates[timestamp]) df df.sort_values([user_id, song_id, timestamp]) pair df.groupby([user_id, song_id]).agg( play_count(timestamp, count), last_played(timestamp, max), first_played(timestamp, min), ).reset_index() pair[total_days] (pair[last_played] - pair[first_played]).dt.days pair[play_freq] pair[play_count] / (pair[total_days] 1)这段代码里有三个关键点先排序是为了后面取“最后一次播放日期”稳定agg 一次性把次数、首末时间全拿到total_days 加 1 是为了防止除零。对 KKBox 这种收听日志用户对一首歌的播放次数分布极度偏斜很多用户只播过 1 次所以这个 pair 表的行数会比原始日志小一个量级。2.2 时间窗口切分训练集和验证集要按时间划KKBox 官方把训练期和预测期按 30 天分开训练数据的正样本是“训练期内有收听、未来 30 天又听了”负样本则是“训练期内有收听、未来 30 天没再听”。用户复购是强时间依赖行为因此不能把所有历史混在一起做随机切分否则会用未来信息“教会”模型。划分区块使用数据正样本定义特征窗口timestamp 某截止日计算累计收听次数、活跃天数等全部特征标签窗口截止日后 30 天该 (user_id, song_id) 在此窗口内是否再次出现预测窗口官方测试集不再给标签只给特征实际操作里我会把截止日设成官方训练期最后一天往前再推 30 天让特征窗口至少覆盖 60 天历史这样“用户最近是否活跃”“歌曲近期热度”才有意义。负样本构造不要随便全局随机抽样因为样本分布决定了模型输出分数的含义。按 pair 表中出现过的user_id, song_id作为候选池取约 1:1 的正负比对 AUC 是稳定的如果正负比调到 1:100训练出的概率会偏向低分排序能力不受影响但校准就变了。2.3 内存优化一板斧category 类型和稀疏存储1GB 原始数据在 pandas 里处理不至于爆内存但如果你把用户 ID 和歌曲 ID 都当 int64join 之后再生成几十列统计特征内存就会翻到 3~5GB。常见做法是把 user_id 和 song_id 转成 category然后让聚合产生的计数列用 int32。for col in [user_id, song_id]: df[col] df[col].astype(category) df[count] df[count].astype(int32) df[days_since_last] df[days_since_last].astype(int32)category 类型在 pandas 底层用整数编码、字典映射存储重复值越多越省内存。KKBox 数据里有几万个 user、几十万首歌这条优化能省 40% 以上内存。特征列如果存在大量 0建议直接从 DataFrame 转成 scipy 的 csr_matrix 再喂给模型避免 pandas 和 sklearn 之间来回拷贝。3. 协同过滤与隐语义模型KKBox 推荐的召回基线与隐语义3.1 为什么在这类场景先做矩阵分解基线KKBox 的海量收听日志天然形成用户-歌曲交互矩阵虽然矩阵稀疏度通常超过 99%但协同过滤的思路仍然有效用户群体喜欢相似歌曲歌曲被相同群体收听这个先验在音乐场景下比在电商场景更强。矩阵分解把 user 和 item 各映射到一个低维向量空间比如 50~100 维内积预测收听概率。在 Kaggle 这个比赛里很多公开方案的纯矩阵分解基线 AUC 能到 0.68~0.72直接碾压非时间特征版的 LR。实现上推荐用 implicit 库而非 sklearn 的 NMF。虽然 sklearn 的 NMF 上手快但它不支持缺失值填充需要手动把未观测项补 0在百万级候选集上非常慢implicit 用的是交替最小二乘ALS不管是显式反馈还是隐式反馈都能把未交互项当成 0 参与计算并且对稀疏矩阵的存储做了优化。from implicit.als import AlternatingLeastSquares from scipy.sparse import csr_matrix sparse_mat csr_matrix((df[play_count].values, (df[user_id].cat.codes, df[song_id].cat.codes))) model AlternatingLeastSquares(factors64, iterations15, regularization0.1, alpha40) model.fit(sparse_mat) user_vecs model.user_factors item_vecs model.item_factors这段代码里 alpha40 表示把每次收听行为的置信度放大 40 倍这是 implicit 里调参最敏感的参数。alpha 太小模型会把低频用户行为也当强信号alpha 太大头部热门歌曲对全局的干扰会增强。iterations 15 次足够收敛再大容易过拟合。3.2 从稀疏矩阵到嵌入特征把因子喂给下游模型矩阵分解产出的 user_factors 和 item_factors 本身就可以作为后续 LightGBM 的特征列——每个用户和歌曲各拿 64 维向量拼成 128 维稠密特征这对模型上限的拉升非常明显。我不建议直接只靠内积打分去提交因为矩阵分解对时间衰减、歌曲新鲜度、用户新老程度这些上下文信息无感知。实际项目中我喜欢把因子矩阵转成两列user_embedding 和 song_embedding 存下来做成 user_factor.parquet、song_factor.parquet等训练 LightGBM 时再按 ID 去合并。这里有个重要的坑如果 ALS 是拿全集训练出的因子那么在验证集上切分时必须保证验证集的 ID 在因子表里出现过否则该行特征全是 NaN。最优做法是先只对训练段做 ALS用验证段之前的历史训练因子。3.3 负采样的随机性与稳定点协同过滤的负例是“用户没听过的歌”但你没听过不代表你不喜欢只是没被推荐。所有隐式反馈模型都面临这个偏置。常见做法有随机全局负采样和热门歌曲负采样前者简单但太容易后者更接近真实曝光分布但可能把热门歌曲压得太低。方法优点问题全局随机负采样实现快分布均匀对热门歌曲不公平按热度加权采样贴近真实曝光调参复杂热门偏差明显对每个用户只从其他用户听过的歌里采保持协同过滤语义候选池要预先算好MMatch 中我常用热度加权采样概率与歌曲收听量成正比然后靠负样本权重调节。先跑通随机采样再切换到热度加权对比验证集 AUC 增益是否值得引入额外复杂度。4. 特征融合与模型调参LightGBM 二分类在 KKBox 数据上的现值4.1 为什么要用 GBDT 而不是深度学习KKBox 这类表格型数据特征是“用户行为统计 歌曲属性 嵌入向量”混合体彼此量纲不同、很多特征与特征之间是高度非线性关系。LightGBM 按特征分裂天然处理混合类型并且对缺失值不敏感几十维到几百维特征都能吃得动。在 Kaggle 的公开历史里前排方案基本都是 LightGBM XGBoost 矩阵分解融合深度学习模型反而难以直接碾压 GBDT。你不需要一次上深度模型先让 LightGBM 跑出 baseline再回头补特征这个循环在竞赛里远比上来堆模型高效。4.2 特征向量拼接的完整代码模板我整理一个可复用的训练流程。特征列大致分成四组pair 统计特征播放次数、播放频率、距离最后收听天数、用户侧特征用户累计收听数、活跃天数、注册天数、歌曲侧特征歌曲总收听数、发布距今天数、歌曲时长、嵌入特征user_emb、song_emb 各 8~16 维。import lightgbm as lgb feature_cols [play_count, play_freq, days_since_last, user_total_plays, user_active_days, user_age_days, song_total_plays, song_release_days, song_duration, user_emb_1, user_emb_2, song_emb_1, song_emb_2] train pd.merge(pair, user_feat, onuser_id, howleft) train pd.merge(train, song_feat, onsong_id, howleft) train pd.merge(train, user_emb, onuser_id, howleft) train pd.merge(train, song_emb, onsong_id, howleft) train train[train[cutoff_date] 2017-04-01].copy() d_train lgb.Dataset(train[feature_cols], labeltrain[label]) d_valid lgb.Dataset(valid[feature_cols], labelvalid[label]) params { boosting_type: gbdt, objective: binary, metric: auc, learning_rate: 0.05, num_leaves: 64, max_depth: -1, feature_fraction: 0.8, bagging_fraction: 0.8, bagging_freq: 1, min_data_in_leaf: 50, verbose: -1, } model lgb.train(params, d_train, num_boost_round1000, valid_sets[d_valid], callbacks[lgb.early_stopping(50), lgb.log_evaluation(100)])这套参数里比较关键的是 min_data_in_leaf50防止叶子节点直接落到几个样本上让预测分数抖动剧烈bagging_freq1 要求每次迭代都重新采样比每 N 次采样一次收敛更平滑。判定好模型不是看训练集 AUC而是看 valid AUC 是否在迭代 400~600 轮左右开始走平如果训练 AUC 持续上涨、valid AUC 掉头就说明树的规模过大需要调大 min_data_in_leaf 或减小 num_leaves。4.3 KKBox 场景下最容易漏掉的三个特征组歌曲新鲜度一首歌刚发布几天内的收听概率和发布一年后完全不同。可以在 songs 表里算出 release_days并统计 30 天内的新歌比例作为另一个特征。渠道与来源的交叉source_system_tab 和 source_screen_name 很多取值和播放场景相关比如“搜索页”“歌单页”推荐的歌曲比“电台”场景的收听概率更明确。把这两个变量做交叉后目标编码能提升 0.005~0.01 的 AUC。用户-歌曲互动的近期强度最近 7 天的播放次数权重按指数衰减加权比累计播放次数更贴近用户此刻的口味漂移。目标编码在这个场景要小心直接对整个训练集做 target encoding标签信息会通过编码特征回流到模型造成验证集 AUC 虚高但线上掉点。正确做法是 K 折内做 target encoding每一折只用训练部分计算编码值验证部分不参与计算。4.4 验证策略别用随机 K 折用滑窗时间折KKBox 考察的是对未来 30 天的预测数据分布随时间漂移。随机 K 折会把未来数据泄露到训练段导致模型对时间漂移的鲁棒性被高估。常见做法是把训练期拆成 3 段每段末尾 30 天作为验证第 1 段训练、第 1 段末尾验证第 1~2 段训练、第 2 段末尾验证以此类推。用滑窗平均 AUC 来调参最后再用全部历史训练提交模型。5. C 线上打分从 LightGBM 模型到低延迟推理服务5.1 Python 训练、C 推理的分工边界Kaggle 竞赛只需要提交预测结果模型效率往往被忽略。但在现实的音乐推荐系统里用户听歌的每次请求都要求毫秒级返回而 Python 加载 pandas 数据、组特征、调模型推理会引入不可控延迟。训练阶段用 Python 做特征工程和模型搜索因为它们迭代快推理阶段把训练好的模型转换成 C 可读的格式加载到服务进程里执行打分。LightGBM 的 C API 已经提供了 model 文件转出和加载能力你不需要用 C 重新训练模型只需加载模型文件推理。#include LightGBM/c_api.h #include vector #include string BoosterHandle booster nullptr; const char* model_path model.txt; const char* out_str nullptr; int result LGBM_BoosterCreateFromModelfile(model_path, result, booster); std::vectordouble row(13, 0.0); // 13 维特征与 Python 特征顺序一致 std::vectordouble pred(1); int64_t out_len; LGBM_BoosterPredictForMat(booster, row.data(), C_API_DTYPE_FLOAT64, 1, row.size(), C_API_PREDICT_NORMAL, 0, -1, , out_len, pred.data());这段代码里 LGBM_BoosterPredictForMat 的关键参数有几个row.size() 必须与训练时的 feature_cols 数量严格一致顺序也不能变。C_API_PREDICT_NORMAL 表示输出原始得分或概率取决于训练时 objective 是 binary 还是 multiclass。工程上建议在 Python 测一组样本的预测值然后在 C 侧对同一组样本打分比较误差是否在 1e-6 内。5.2 特征拼接发散怎么排查C 推理最常见的问题是特征拼接错位。Python 侧特征顺序是 play_count 在前song_duration 在后C 侧如果按字典序或成员变量顺序填模型的每个树的 split 都对应错特征预测分数完全乱套。我的做法是让 Python 侧导出特征列名顺序到一个 JSON 文件C 启动时按 JSON 解析列名再按列名填充数值从根上避免顺序问题。超出训练分布边界的 feature 值也可能导致分裂路径异常比如训练时 play_count 最大是 500线上一个热门歌曲的 count 到了 5000C 推理不会报错但叶子节点归属会很怪。这部分要靠线上日志回捞特征离线重训时加入 min/max 截断而不是在推理端动手脚。5.3 把 AUC 验证搬上线batch 打分对拍表验证对象Python LightGBM 预测C 预测差异要求5000 条随机样本0.723456710.72345671完全一致含 NaN 特征的样本缺失值处理默认走 left缺失值必须补训练时的默认值1e-7 内边界值样本count0按叶子分裂按叶子分裂1e-7 内对拍脚本不需要复杂设计把 Python 训练集里的 dense 特征矩阵抽 5000 行导出成 CSVC 启动后读同一份 CSV 打分两端算相关性和最大绝对误差。若最大误差超过 1e-6优先检查浮点精度Python 侧用 float64而 C 传参用 C_API_DTYPE_FLOAT64只要两端一致就不会出现指数级差异。线上打分要做到真正的低延迟还有一个容易忽略的点LGBM 的 predict 调用本身很快但每次请求都重新把特征 vector 从业务字段转成模型输入这部分往往占掉 80% 耗时。把用户 ID 对应的嵌入向量和歌曲 ID 对应的嵌入向量预加载到内存 map请求进来直接查表拼特征比每次查数据库再组特征要快一个数量级。这样一个单机 C 服务在普通配置下能扛住每秒几千次的打分请求KKBox 这类音乐推荐场景的线上推理压力就完全可控了。本文还有配套的精品资源点击获取
返回列表