简介:面向KKbox音乐网站用户流失预测场景的Python设计源码,适合数据分析初学者、机器学习爱好者及推荐系统从业者参考。项目以用户历史行为数据为基础,覆盖数据清洗、特征工程、模型训练、评估与预测的完整流程,并融合逻辑回归、LightGBM、XGBoost等常用算法,便于迁移到其他音乐或内容平台的留存分析场景。压缩包共47个文件,整体约5.18MB,以33个ipynb交互式分析笔记为主,辅以Python训练脚本、Markdown方案说明、readme使用文档及gitignore文件,目录按EDA、LR、LGBM等模块划分,结构清晰,方便按阶段查阅。目前已有276人学习,适合希望快速上手用户流失建模、复现比赛思路并查看分步骤实验过程的读者。
1. 当你以为“用户流失预测”只是调包调参时,KKBox 这个项目会让你改观
“用户流失预测”听起来是个老话题,但 KKBox 这个基于真实音乐网站业务的 Python 项目,落地时会逼你面对一堆教科书里不讲的脏东西:时间窗口怎么切、未来信息怎么防泄漏、类别不平衡怎么处理、三套模型怎么对齐评估。这个项目总计包含 44 个文件,核心是 33 个 IPython Notebook,几乎覆盖了从 EDA、特征工程到模型训练评估的完整链路,并且团队同时踩了逻辑回归、XGBoost、LightGBM 三条路线,代码和思路都很适合拿来复现。它不止是给你一套能跑的脚本,更像是有人替你把数据挖掘竞赛的完整攻防过程整理成了可翻阅的工程记录,适合正在做用户留存、订阅付费预测、或者想用结构化数据练手的 Python 数据分析师和机器学习初学者。
2. 理解 KKBox 业务与流失定义:时间窗口一错,后面全部白做
2.1 流失预测的核心不是模型,而是“谁在什么时候算流失”
KKBox 是音乐流媒体订阅平台,用户按周期付费。这个项目的关键业务假设是:如果用户在某个时间点之后不再有付费行为,且在观察窗口内没有续费动作,就判定为流失。常见的照搬做法是直接拿“是否付费”当标签,但这里有个隐蔽的坑——免费试用用户和活跃听歌用户的行为模式完全不同。
我拿到项目里的 EDA+FE 文件时,最先看的就是1_EDA_FE_KKBox.ipynb,因为标签构造全在这里。该项目没有把用户简单地分成“流失/未流失”,而是按时间切片来定义标签:对于训练集中的每个用户,取其在某个基准日期之前的行为数据作为特征,然后看基准日期之后一段固定时间内是否有续费动作,有就是 1,没有就是 0。这个切法直接决定了后续所有模型的上限。
再看project_summary.md和kkbox-music-recommendation-challenge.md这两个 Markdown 文件,能理清整个项目的评估口径。实际做的时候要注意:不要把训练集和测试集混在一个时间窗口里切,否则模型会学到“时间”这个虚假强特征。
2.2 数据源梳理:会员表、交易表、日志表、歌曲表怎么对齐
项目里.keep文件标注了几个数据源目录,结合张峰诚那份0_Pre_KKBox_Music.ipynb的代码,可以看到标准处理流程是先把四张核心表加载进来:
- members 会员信息:性别、年龄、注册渠道、注册日期
- transactions 交易记录:付费金额、付费周期、交易类型
- logs 用户听歌日志:每天听歌次数、歌曲ID、来源类型
- songs 歌曲信息:歌曲元数据、歌手、发布时间
实际落地时,四张表不都是按用户 ID 一一对齐的,听歌日志是长表,一条用户一天可能产生多行记录。这里最常见的做法是写一个聚合函数,把长表压成宽表。我在 2_Create_Index_KKBox_Music.ipynb 里看到他们建了索引,这一步很有用——先用用户 ID + 日期做排序键,再去聚合,避免多表关联时产生笛卡尔积。
# 以听歌日志为例做用户维度的特征聚合 import pandas as pd logs = pd.read_csv('logs.csv', parse_dates=['date']) logs['date'] = pd.to_datetime(logs['date']) # 按用户ID分组,统计时间窗口内的行为 user_log_feat = logs.groupby('msno').agg( total_listen_cnt=('listen_type', 'count'), # 总听歌次数 unique_song_cnt=('song_id', 'nunique'), # 听过的不同歌曲数 avg_listen_per_day=('listen_type', 'count') / logs['date'].nunique() # 日均听歌量 ).reset_index()这段代码里groupby('msno')是按用户维度聚合,nunique是去重计数,avg_listen_per_day这里我除以的是全量天数,但实际要注意:如果该用户不是每天都活跃,分母应该用该用户活跃的天数而不是全表天数。项目里金策的music_member_category_1.ipynb就处理过这个问题,他按用户活跃天数重新计算均值,这个细节直接影响训练集和测试集分布。
2.3 特征工程的隐藏重点:会员年龄和注册日期的坑
项目中1_FE_KKBox_Songs_Members.ipynb处理了会员特征。很多人的第一反应是直接把年龄、性别塞进模型,但这里有个经典陷阱——会员表里的年龄字段存在严重的数据缺失和异常值。
我具体操作时的做法是:先用describe()查看四分位数,通常会发现年龄在 0 到 100 之间都有分布,但 0 岁和超过 90 岁的极值需要特殊处理,要么截断、要么单独做一个“年龄异常”二值特征。项目里张峰诚在3_EDA_FE_KKBox_Music_Train.ipynb中做了分层统计,把年龄切成 5 个分箱后作为有序 categorical 特征,比直接喂数值效果好。注册日期则是重点注意“注册到基准日期之间的天数”,这个时间跨度特征对流失预测的贡献非常大。
# 会员表特征工程示例 import numpy as np members = pd.read_csv('members.csv', parse_dates=['registration_init_time']) members['registration_days'] = (pd.Timestamp('2017-03-01') - members['registration_init_time']).dt.days # 年龄异常截断 members['age'] = members['age'].clip(10, 70) # 注册天数分箱 members['reg_days_bucket'] = pd.cut( members['registration_days'], bins=[0, 30, 90, 180, 365, 730, 9999], labels=['0-30', '31-90', '91-180', '181-365', '366-730', '730+'] )这里clip(10, 70)是把年龄截断到合理范围,pd.cut按天注册天数做分箱。注意我这里的基准日期是写死的,实际项目里应该从训练集的标签构造逻辑里取,不能拍脑袋定。
3. 三大模型路线对比与选型:XGBoost、LightGBM、逻辑回归谁更适合流失预测
3.1 为什么项目里同时出现三套模型,而不是一个模型打到底
这个项目的组织方式很接近真实团队协作:李伟负责 XGBoost 路线的xgb.py和data_helpers.py,金策推进 LightGBM 并做了model_compare_lgb_rate.ipynb这样的对比实验,陈霞飞则走了逻辑回归路线,从train_Logist.ipynb到test_Logist_predict.ipynb搭了一套完整流程。这不是重复劳动,而是在验证“简单模型能不能做底线,复杂模型能提升多少”。
对于用户流失预测这类表格型数据,逻辑回归的优势在于可解释性强,系数直观地告诉你“注册天数每增加一天,流失概率怎么变化”。而 XGBoost 和 LightGBM 的优势在于能自动捕获特征交互。比如“用户听歌数少但付费金额高”和“用户听歌数多但很久没续费”这两种人群,逻辑回归需要手动构造交互项,树模型可以直接学到分裂路径。
3.2 LightGBM 的核心参数与过拟合控制
金策的lightGBM_music_train.ipynb展示了 LightGBM 训练的标准姿势。流失预测的数据集往往特征维度不高(几十到几百个特征),但样本量可能达到百万级,LightGBM 的直方图算法在速度和内存占用上明显优于 XGBoost 的预排序方案。
import lightgbm as lgb # 构建 LightGBM 数据集 lgb_train = lgb.Dataset(X_train, y_train) lgb_valid = lgb.Dataset(X_val, y_val, reference=lgb_train) params = { 'objective': 'binary', 'metric': 'auc', 'learning_rate': 0.05, 'num_leaves': 63, 'max_depth': 7, 'min_child_samples': 50, 'subsample': 0.8, 'colsample_bytree': 0.7, 'reg_alpha': 0.1, 'reg_lambda': 1.0, 'n_estimators': 1000, 'early_stopping_rounds': 50, 'verbose': -1 } model = lgb.train( params, lgb_train, valid_sets=[lgb_valid], num_boost_round=1000, callbacks=[lgb.early_stopping(50), lgb.log_evaluation(100)] )这个配置里num_leaves=63是控制模型复杂度的核心,min_child_samples=50防止叶子节点样本太少导致过拟合。early_stopping是项目里验证集 AUC 不再提升就自动停,防止训练轮次过多。我一般会用 80% 做训练、20% 做验证,并且保证验证集的用户不在训练集中出现,否则会有身份泄漏。
3.3 XGBoost 脚本的工程化封装
李伟那份xgb.py把训练流程做成了可复用脚本,这不是 notebook 里一段代码的事,而是把数据加载、参数网格、训练、预测、输出全部封装成函数。这种做法在项目落地时特别实用,因为 Notebook 适合探索,不适合定时重跑或接入上线流程。
# xgb.py 的关键结构(简化版) import xgboost as xgb from sklearn.model_selection import GridSearchCV def train_xgb_with_grid(X_train, y_train, param_grid): """ 用网格搜索确定 XGBoost 关键参数 param_grid: {'max_depth': [5, 7], 'learning_rate': [0.01, 0.05]} """ model = xgb.XGBClassifier( n_estimators=500, objective='binary:logistic', eval_metric='auc', use_label_encoder=False ) grid = GridSearchCV( model, param_grid, cv=5, scoring='roc_auc', n_jobs=-1, verbose=1 ) grid.fit(X_train, y_train) return grid.best_estimator_, grid.best_params_这段代码里GridSearchCV的cv=5是五折交叉验证,scoring='roc_auc'指定用 AUC 作为打分指标。需要注意use_label_encoder=False,新版 XGBoost 不传这个参数会有 DeprecationWarning。参数网格不要一开始就铺太大,先用粗网格确定范围再细调,能省下大量时间。
3.4 模型对比的统一口径
金策的model_compare_lgb_rate.ipynb和model_compare_lgb_category.ipynb分别从“概率”和“分类”两个角度对比模型差异。这里有个很多新手会犯的错:不同模型之间直接比准确率,而不看预测概率的分布对齐。
我的经验是,先统一用 AUC 比较排序能力,再单独看最优阈值下的精确率和召回率。对于流失预测业务,通常更关注召回率——宁可多召回一些可能流失的用户,也不希望漏掉真正的高风险人群。陈霞飞的train_Logist.ipynb里做逻辑回归时就没有盲目使用默认 0.5 阈值,而是用验证集上的 ROC 曲线找最优切分点。
4. 从 Notebook 到工程链路:训练、验证、预测的完整数据流
4.1 训练集和测试集的时间对齐
KKBox 流失预测这个业务里,训练集和测试集不是同期的。张峰诚的4_EDA_FE_KKBox_Music_Test.ipynb和训练集的处理逻辑保持了严格一致——包括聚合窗口、缺失值填充方式、特征命名。
# 训练集与测试集特征对齐示例 def build_member_features(members_df, base_date): """ base_date 是标签切分时间点,训练集和测试集必须传不同值 但特征构造逻辑必须完全一致 """ df = members_df.copy() df['reg_days'] = (base_date - df['registration_init_time']).dt.days df['is_male'] = (df['gender'] == 'M').astype(int) # 性别转换成0/1 df['age'] = df['age'].clip(10, 70) return df[['msno', 'reg_days', 'is_male', 'age']] train_feat = build_member_features(members_train, base_date='2017-02-28') test_feat = build_member_features(members_test, base_date='2017-03-31')这里我传入了不同的base_date,训练集和测试集的特征基准日不同,但聚合逻辑相同。很多失败的复现都是因为在测试集上多加了一个“未来特征”——比如把用户流失后的行为也聚合进特征里。
4.2 合并多个特征源的顺序
金策在2_FE_Train_Test_KKBox_Music.ipynb里给出了一个合理流程:先分别构建成员特征、交易特征、日志特征,然后在msno用户 ID 上做外连接。
我一般的做法是:先set_index('msno'),然后用join而不是merge,因为后者在大样本量下会多一层匹配检查,影响性能;并且必须检查左表和右表的用户数量,确认连接后没有出现大量 NaN。
# 多表特征合并 final_train = train_member_feat.set_index('msno') \ .join(train_trans_feat.set_index('msno'), how='left') \ .join(train_log_feat.set_index('msno'), how='left') # 检查缺失率 missing_rate = final_train.isnull().mean().sort_values(ascending=False) print(missing_rate[missing_rate > 0.3])缺失率超过 30% 的特征通常直接丢弃,或者做一列“是否有该行为”的二值特征。交易特征缺失代表该用户没有交易行为,而日志特征缺失代表没有听歌行为——这两者在业务上的含义完全不同,不能统一填 0 了事,否则模型分不清“没记录”和“真的没有”。
4.3 预测结果的输出和格式化
预测环节在test_predict.ipynb中体现了标准化写法:加载模型、读取测试特征、输出概率,然后按照 Kaggle 或业务要求的格式写 CSV。实际工程里,我一般会在这一步额外输出一个“人群分布”统计,方便后续看预测结果有没有明显的取值漂移。
import pandas as pd import lightgbm as lgb # 加载训练好的模型 best_model = lgb.Booster(model_file='lgb_model.txt') # 读取测试特征,特征列表必须与训练时完全一致 test_feat = pd.read_csv('test_features.csv') # 预测概率 test_pred = best_model.predict(test_feat[feature_columns]) # 输出结果 submission = pd.DataFrame({ 'msno': test_feat['msno'], 'probability': test_pred, 'prediction': (test_pred >= 0.3).astype(int) # 阈值按业务需求调整 }) submission.to_csv('submission.csv', index=False)这里阈值选 0.3 而不是 0.5,是因为流失预测通常是“宁可错杀不可放过”的业务,召回率优先级高于精确率。具体阈值应该用验证集上 precision-recall 曲线的平衡点来定,而不是凭感觉写。
5. 避坑与常见问题排查:数据泄漏、内存爆炸和特征穿越
5.1 现象:训练集 AUC 接近 1,但测试集 AUC 只有 0.6 左右
原因:这是典型的“特征穿越”或者叫时间泄漏。最常见的是把用户未来付费行为的统计值放进了训练特征。比如在构造特征时,把整个时间范围内的交易记录都聚合了一遍,其中包含了标签所在时间点之后的交易。模型学到了“这个用户后来付过费”这个未来信息。
解决:检查每一个聚合特征是否只使用了基准日期之前的数据。正确做法是先按基准日期过滤交易表和日志表,再做聚合。可以写一个自动检查函数,对每个特征计算它在训练集和测试集上的均值,如果差异超过 0.5 个标准差就重点排查。
5.2 现象:内存爆炸,Notebook 跑到一半 Kernel 直接死掉
原因:合并多张长表时没有先稀疏化数据,logs 表动辄几千万行,直接和 members 表做 merge 会产生巨大的中间结果。项目里张峰诚在0_Pre_KKBox_Music.ipynb中预先对日志表做了日期过滤和采样,这是处理大表才有的意识。
解决:先过滤、后聚合、再合并。把“行数多”的表先压缩到用户维度,再和会员表连接。另外可以把数据切成多块分批处理,做完特征之后再用pd.concat拼起来。
5.3 现象:LightGBM 训练完成后所有特征重要性近乎平均,无法解释
原因:可能是参数feature_fraction设得太低,或者特征数量太少导致每个特征都被轮流选中。另一个常见原因是 categorical 特征没有声明,模型把它按连续值处理。
解决:在lgb.Dataset中显式传categorical_feature参数,并且提高min_gain_to_split,让分裂更谨慎。也可以用lgb.plot_importance查看排序,如果结果仍然没有区分度,回到特征工程补一列有业务含义的强特征。
5.4 现象:同一个特征在训练时和预测时构造的分布不一致
原因:最常见的是缺失值填充分散在两处,一处填 0 一处填 -1;或者是分箱边界写死了,但预测集分布变化导致类别分布漂移。
解决:把特征构造函数抽成单独模块,训练和预测必须调用同一个build_features()函数。不要在两份 Notebook 各写一份特征工程代码。项目里陈霞飞的test_proprecessing.ipynb就是在确认这一点。
5.5 现象:复现别人的代码时 AUC 怎么都差一截
原因:除了特征泄漏外,最大的坑是随机种子没固定。LightGBM 和 XGBoost 虽然确定性较强,但特征抽样和样本抽样仍是随机的。如果团队里复现项目时不固定seed,结果天然会有波动。
解决:在lgb.train的参数中固定seed=42,并且在数据划分时用train_test_split(..., random_state=42)。更严谨的做法是跑三次随机种子取平均,报告均值而非单次结果。
6. 模型上线前的验证技巧:用 SHAP 解释流失模型,把预测转成留存动作
到这里,模型训练完了,AUC 也不错,但业务方更关心一个问题:你告诉我这个用户要流失,然后呢?这个项目中 33 个 Notebook 的沉淀不只是模型,还有对特征的理解,而把这个理解转化成运营动作,需要从模型黑匣子里提出业务洞察。我一般会用 SHAP 做这件事,这也是我拿到这份资料后自己动手验证模型价值的第一步。
import shap import lightgbm as lgb # 加载模型和验证集特征 model = lgb.Booster(model_file='lgb_model.txt') X_val = val_feat[feature_columns] # 用 SHAP 解释预测 explainer = shap.TreeExplainer(model) shap_values = explainer.shap_values(X_val) # 输出每个特征对流失预测的贡献 shap.summary_plot(shap_values, X_val, max_display=15)shap.TreeExplainer专门针对树模型做解释,shap_values是一个数组,行对应样本、列对应特征,正负号代表该特征把预测往哪个方向推。这个 summary plot 能快速看出哪些特征对流失影响最大。
从 SHAP 输出中,一个很常见的结论是:“用户最近 30 天听歌次数下降幅度”和“距离上次付费天数”这两个特征贡献最高。这意味着运营策略不应该是无差别发券,而是针对这两类人群做不同动作:对听歌活跃度下降的用户推送个性化歌单,对超过付费周期未续费的用户推续费优惠。
我还会做一步分层验证:把预测概率最高的 10% 用户拎出来,统计他们的特征画像,确认和业务直觉一致。比如高概率流失用户是不是集中在注册 180 到 365 天的区间、是否大部分是免费用户。如果不一致,说明模型学到了奇怪的东西,需要回溯数据。
从这个项目里我学到最大的一个习惯是:训练完模型之后,不要急着看 AUC,而是先抽样查看几个预测结果,人工判断“这个用户为什么被判流失”。从那以后我每次做用户流失预测,都会强制走一遍 SHAP 解释加分层画像的流程,宁可慢半天也要确保模型学到的逻辑业务上说得通。希望这个项目的复现思路和坑位经验能帮到你,少走几步弯路。
本文还有配套的精品资源,点击获取