简介面向电信客户流失预测的Kaggle综合实践项目基于交互式编程环境开发完整覆盖数据获取、清洗预处理、特征可视化、特征工程、模型训练与评估的全流程。资源共5个文件包含可执行的Python代码、原始数据文件、分析笔记、说明文档以及答辩演示文稿压缩包整体约10.88MB结构清晰便于按步骤复现。目前已吸引805人学习适合具备一定编程基础、希望入门数据挖掘与分类预测的初中级学习者。项目将数据划分为三大特征群进行可视化对比利用饼图直观展示客户流失分布随后结合皮尔逊相关系数剔除冗余特征并通过类别平衡处理缓解样本不均衡问题。建模阶段采用逻辑回归、随机森林、AdaBoost和XGBoost四种模型配合K折交叉验证评估准确率最终输出特征重要性排序同时合并预测结果与真实流失率形成决策分组为运营商制定精准召回策略提供依据。通过这套资料可完整掌握电信客户流失分析的方法论与实操技能。1. 电信用户流失预测Kaggle 数据集之外真正会卡住你的三个问题如果只看标题流失预测就是个标准二分类任务给一堆用户历史数据让 Python 模型输出“会流失/不会流失”。但真正把 Kaggle 电信用户流失数据集跑起来之后你会发现卡住你的往往不是算法而是三个藏在数据里的问题第一流失标签的时间口径对不对谁在什么时间点被标记为流失第二样本里流失用户只占两成左右准确率很容易虚高第三特征之间互相打架比如套餐金额和账号时长的相关性高到让模型学偏。这套资源是一份完整的 Python 工程从数据清洗、特征工程、类别不平衡处理到模型解释和运营名单输出每一步都有可运行的代码。适合手里有用户运营数据、想自己动手跑通留存分析的数据分析师也适合做算法落地但不想在数据预处理上反复返工的工程师。2. 数据清洗与特征画像先让原始表变成能喂给模型的样子2.1 先看流失标签怎么定Churn 的取值没你想的那么简单Kaggle 电信客户流失数据集的经典结构是七千多行、二十一列每行代表一个用户最后一列 Churn 标记用户是否流失。大多数人拿到后第一件事就是跑df.head()然后立刻开始搭模型这是最容易翻车的地方。我一般会先做一件事把 Churn 这一列单独拿出来做分布检查同时把 tenure在网时长、MonthlyCharges月费、TotalCharges累计消费这三列放到一起看。import pandas as pd import numpy as np import matplotlib.pyplot as plt df pd.read_csv(telecom_churn.csv) print(df.shape) print(df.dtypes) # 流失标签分布 print(df[Churn].value_counts(normalizeTrue)) # 看三个核心数值列的统计量 print(df[[tenure, MonthlyCharges, TotalCharges]].describe())这段代码的逻辑是先把数据集读入确认行列规模再单独看标签分布是否失衡最后用 describe 查看核心数值列的四分位数和极值。这里有两个容易被忽略的参数点value_counts(normalizeTrue)比直接value_counts()多输出一个比例列能直观看到正负样本是否平衡describe()默认只统计数值列如果数据里有 object 类型的列它不会自动包含需要先对列做类型转换。建议在跑这段代码之前先确认TotalCharges的 dtype因为这份数据集里它偶尔会被读成 object 类型导致后面运算直接报错。在标签口径上还有一个常见分歧有些团队把“当前未续费”就算作流失有些团队要求“连续多个月未产生消费”才算流失。数据集的 Churn 是二值标签但你在自己的业务数据里可能要自己构造这个标签。我的习惯是先画出 tenure 对 Churn 的分组箱线图看看流失用户在网时长的分布是否与直觉一致这一步能帮你提前发现标签构造逻辑里的系统性偏差。2.2 缺失值与异常值先分类型再决定补法这套数据集的缺失情况不算严重但TotalCharges列存在少量空值而且这些空值通常集中在 tenure 为 0 的用户身上。tenure 等于 0 意味着用户刚开通服务就被记录为流失这部分样本的 TotalCharges 为空是业务逻辑导致的不是随机缺失。如果你直接用dropna()删掉会把一批重要的“极短期流失”样本全丢掉模型会失去对这类用户的判断能力。# 缺失率统计 missing_rate df.isnull().mean().sort_values(ascendingFalse) print(missing_rate[missing_rate 0]) # 检查 tenure0 时的空值情况 zero_tenure df[df[tenure] 0] print(zero_tenure[[tenure, MonthlyCharges, TotalCharges]].head(10)) # 处理方案用月费估算累计消费 df[TotalCharges] df[TotalCharges].fillna( df[MonthlyCharges] * df[tenure] )处理逻辑分三步先统计每列缺失率确认缺失集中在哪再把 tenure 为 0 的记录单独打印出来核对空值是否与该条件强相关最后用MonthlyCharges * tenure估算 TotalCharges这比全局填充中位数更符合业务含义。值得说明的是fillna的写法它接收一个 Series 作为填充值是按行逐条计算的不是填充一个固定常数。如果你发现某列缺失率超过三成我建议直接剔除该列而不是强行填充因为高缺失率的列往往来源于埋点不全或业务中断填充出来的特征只会让模型学到噪声。2.3 数值列与分类列的编码策略one-hot 不是唯一解电信数据里有大量分类特征比如 InternetService光纤/DSL/无、Contract月付/年付/两年付、PaymentMethod四种支付方式。最常见的做法是对所有分类列做 one-hot但这份数据的分类特征有两个坑第一MultipleLines、OnlineSecurity这类列里包含 “No internet service” 这种值它和 “No” 是不同语义前者表示用户没开通该服务后者表示开通了但没购买附加项直接 one-hot 会生成冗余维度第二tenure是数值特征但它和Contract高度相关长期合约用户的 tenure 普遍高把它们同时丢进模型会出现多重共线性。from sklearn.preprocessing import OneHotEncoder, StandardScaler from sklearn.compose import ColumnTransformer # 分列 num_cols [tenure, MonthlyCharges, TotalCharges, SeniorCitizen] cat_cols [c for c in df.columns if c not in num_cols [Churn, customerID]] # 把 No internet service 统一归为 No减少维度 for col in [OnlineSecurity, OnlineBackup, DeviceProtection, TechSupport, StreamingTV, StreamingMovies]: df[col] df[col].replace(No internet service, No) preprocessor ColumnTransformer([ (num, StandardScaler(), num_cols), (cat, OneHotEncoder(handle_unknownignore, dropfirst), cat_cols) ])这里的一步关键处理是把 “No internet service” 映射为 “No”目的是减少分类基数避免 one-hot 之后特征矩阵过于稀疏。OneHotEncoder的两个参数值得单独说明dropfirst会删除每个分类的第一列用于处理共线性handle_unknownignore保证训练集和测试集类别不一致时不会直接抛异常。至于SeniorCitizen它原本是 0/1 整数放进数值列做标准化即可不需要额外编码。这样处理后特征维度从原始 21 列变成 30 列左右模型复杂度控制在合理范围。3. 基线模型与特征工程从逻辑回归起步再用树模型看边界3.1 为什么先用逻辑回归打基线可解释且能快速排出数据问题很多人拿到数据直接上 XGBoost这不算错但会让你没法判断问题是出在数据质量还是模型能力上。我习惯先跑一个逻辑回归当基线因为它对特征尺度敏感任何数据泄漏、异常值、编码错误都会在系数和 AUC 上暴露出来。逻辑回归训练快一分钟内能出结果而且系数可以直接解释方便和业务方对齐。from sklearn.model_selection import train_test_split from sklearn.pipeline import Pipeline from sklearn.linear_model import LogisticRegression from sklearn.metrics import classification_report, roc_auc_score X df.drop([Churn, customerID], axis1) y df[Churn].map({No: 0, Yes: 1}) X_train, X_test, y_train, y_test train_test_split( X, y, test_size0.2, random_state42, stratifyy ) lr_pipeline Pipeline([ (pre, preprocessor), (clf, LogisticRegression(max_iter1000, C1.0, class_weightbalanced)) ]) lr_pipeline.fit(X_train, y_train) y_prob lr_pipeline.predict_proba(X_test)[:, 1] print(Logistic AUC:, roc_auc_score(y_test, y_prob))这段代码把预处理和模型塞进同一个 Pipeline训练时preprocessor在训练集上 fit再变换测试集不会发生信息泄漏。stratifyy保证切分后训练集和测试集的流失比例一致这个参数非常重要如果不加随机切分可能导致测试集里流失样本占比偏离整体分布。class_weightbalanced会让逻辑回归自动调整正负样本权重缓解类别不平衡。这里我特意用predict_proba输出概率而不是直接predict输出类别因为后续做阈值调整时只有概率才够用而且 AUC 的计算也依赖概率排序。第一次跑完如果 AUC 低于 0.75不要急着调模型先回去检查两步数值列是否包含 0 方差特征分类列里是否有基数特别高的脏值。AUC 低往往是预处理出了问题不是算法问题。3.2 特征工程的第一批方向时间感知与消费变化原始数据里最有价值但最容易被忽略的是tenure和TotalCharges之间的隐含关系。TotalCharges / tenure能得到月均实际消费而MonthlyCharges是当前套餐月费两者之间的差就是用户消费变化趋势。如果当前月费比历史月均高说明用户最近升级了套餐或者被加了增值服务如果月均消费远高于当前月费说明用户可能在降级边缘。这两类信号对流失预测都很关键。# 月均消费与消费变化趋势 df[AvgMonthlyCharge] df[TotalCharges] / df[tenure].replace(0, np.nan) df[ChargeTrend] df[MonthlyCharges] - df[AvgMonthlyCharge] # 交互特征高月费 多次客服工单 df[HighBill_ServiceCalls] ( (df[MonthlyCharges] df[MonthlyCharges].median()).astype(int) * df[numServiceCalls] )第一个特征的逻辑是TotalCharges除以tenure得到每个用户的历史月均消费减掉MonthlyCharges后正数表示用户在降级负数表示在升级。这里replace(0, np.nan)必须先做否则 tenure 为 0 会产生除零警告和无穷值。第二个交互特征的逻辑是筛选出“月费高且客服工单多”的用户群这类用户往往已经在流失边缘组合特征的预测能力通常比单独两个特征强。numServiceCalls在原始数据里可能叫其他名字如果你是拿自己的业务数据做对应替换成客服投诉次数即可。特征工程做完后建议重新训练一次逻辑回归对比 AUC 提升幅度。如果加了ChargeTrend后 AUC 没有明显变化检查一下是不是缺失值太多导致特征被大量填充必要时用“有无缺失”单独做一列二值特征。3.3 用树模型验证特征边界随机森林还是 LightGBM逻辑回归的线性假设会漏掉特征间的非线性关系。比如Contract和tenure对流失的影响不是简单叠加短期合约且 tenure 短的用户流失率极高长期合约且 tenure 长的用户几乎不会流失这种交互效应需要树模型才能捕获。随机森林的优势是稳定、不需要过多调参适合做第二道验证LightGBM 更快但参数敏感新手容易在小数据集上过拟合。这份数据只有七千多行我用随机森林就够能跑出比逻辑回归更高的上限。from sklearn.ensemble import RandomForestClassifier rf_pipeline Pipeline([ (pre, preprocessor), (clf, RandomForestClassifier( n_estimators300, max_depth8, min_samples_leaf5, class_weightbalanced, random_state42, n_jobs-1 )) ]) rf_pipeline.fit(X_train, y_train) y_prob_rf rf_pipeline.predict_proba(X_test)[:, 1] print(Random Forest AUC:, roc_auc_score(y_test, y_prob_rf))随机森林的几个参数是配合这套数据量定的n_estimators300保证稳定性再往上加对结果影响不大max_depth8限制单棵树深度防止在小数据集上过拟合min_samples_leaf5保证每个叶子节点至少有 5 个样本让预测结果平滑。class_weightbalanced和逻辑回归里的一样都是为了处理样本不平衡。一个容易踩的细节是 Pipeline 里接随机森林时preprocessor输出的稀疏矩阵会占用较大内存。如果数据量再大一个量级建议在随机森林之前加一步 PCA 降维或者改用稀疏感知的模型。跑完随机森林后用feature_importances_输出特征重要性排名重点看ChargeTrend和Contract排在第几位这一步能帮你确认特征工程的方向是否正确。4. 类别不平衡与评估指标别让准确率骗了你4.1 准确率陷阱当流失率只有 26% 时模型在“偷懒”这套数据的流失用户占比大约 26%如果模型把所有用户都预测为“不流失”准确率能到接近 74%看起来像是一个还不错的模型但对业务毫无价值。准确率在类别不平衡场景下是失效指标你需要换一套评估方式。业界常用的替代方案有两个ROC-AUC 和 PR-AUC。ROC-AUC 对类别不平衡相对钝感适合看整体排序能力PR-AUC 对正样本的查准率更敏感流失预测这种正样本占比低的场景应该以 PR-AUC 为主要参考。这里还要分清一个概念类别不平衡影响的是训练过程不是评估过程。你要解决的是模型“学不到流失用户的特征”而不是单纯调整阈值。阈值调整只能改变预测结果的截断点不能提升模型的排序能力。4.2 SMOTE、下采样与类权重三种不平衡处理实操对比处理类别不平衡有三条常见路线第一种是数据层面做重采样用 SMOTE 合成少数类样本第二种是下采样从多数类里随机抽一部分和少数类对齐第三种是算法层面调class_weight给少数类更高的惩罚权重。三条路线没有绝对优劣我一般会同时跑一遍对比 PR-AUC 再选。方法实现方式优点风险SMOTE在特征空间插值合成少数类不丢失多数类信息可能生成不合理的样本点随机下采样随机丢弃多数类样本训练快信息损失大易过拟合class_weight损失函数加权不改数据稳定对复杂模型效果有限from imblearn.over_sampling import SMOTE from imblearn.under_sampling import RandomUnderSampler from imblearn.pipeline import make_pipeline from sklearn.metrics import average_precision_score # SMOTE 方案 smote_pipe make_pipeline( preprocessor, SMOTE(random_state42, k_neighbors5), LogisticRegression(max_iter1000) ) smote_pipe.fit(X_train, y_train) y_prob_smote smote_pipe.predict_proba(X_test)[:, 1] print(SMOTE average precision:, average_precision_score(y_test, y_prob_smote)) # 下采样方案 under_pipe make_pipeline( preprocessor, RandomUnderSampler(random_state42), LogisticRegression(max_iter1000) ) under_pipe.fit(X_train, y_train) y_prob_under under_pipe.predict_proba(X_test)[:, 1] print(Under sample average precision:, average_precision_score(y_test, y_prob_under))这段代码的关键点是使用imblearn.pipeline.make_pipeline而不是 sklearn 原生 Pipeline。原因在于SMOTE和RandomUnderSampler需要在训练集上拟合、在测试集上只做变换它们不是标准 sklearn transformer必须用 imblearn 提供的 pipeline 才能正确处理重采样流程。k_neighbors5是 SMOTE 的默认参数表示每个少数类样本用 5 个近邻来插值合成新样本如果少数类样本本身很少这个值可以调小到 3。跑完对比后发现差异不大时我倾向于选择class_weightbalanced方案因为它更稳定不会引入合成样本带来的噪声而且和后续模型上线后的每周增量训练兼容性更好。4.3 评估矩阵与阈值搜索重点看 PR-AUC评估模型时我先打印出一份分类报告然后画 PR 曲线和 ROC 曲线最后做阈值搜索。阈值搜索的目标不是找 F1 最大点而是找业务收益最大点。流失预测落地到运营场景里模型输出一个概率运营团队根据概率高低决定给谁发优惠券、给谁打电话这个决策需要的是“在高召回率的前提下保持可接受的精确率”。from sklearn.metrics import precision_recall_curve, f1_score precision, recall, thresholds precision_recall_curve(y_test, y_prob_rf) # 计算每个阈值下的 F1选最优阈值 f1_scores 2 * precision[:-1] * recall[:-1] / (precision[:-1] recall[:-1] 1e-9) best_idx np.argmax(f1_scores) best_thr thresholds[best_idx] # 用最优阈值重新预测 y_pred_tuned (y_prob_rf best_thr).astype(int) print(best threshold:, best_thr) print(F1 at best threshold:, f1_scores[best_idx])这里有一个容易写错的地方precision_recall_curve返回的precision和recall数组长度比thresholds多 1精度的最后一个元素是 1、召回是 0对应“把所有样本都预测为正类”的极端情况。计算 F1 时要先对 precision 和 recall 做切片截掉最后一个元素否则维度对不上会直接报错。1e-9是防止分母为零的平滑项。阈值选完之后别忘了回到业务视角校准一次如果运营团队能承受的触达成本有限可能要把阈值调高只捞最可能流失的那批用户如果用户流失成本极高那就把阈值调低宁可误报也不能漏掉。这个业务校准在代码里没有标准答案但我建议把不同阈值下的精确率和召回率列一张表让运营直接拍板选哪一行。5. 避坑排查从数据泄漏到 SHAP 解释的五个实战陷阱5.1 数据泄漏训练集里混进了“未来”信息现象模型在验证集上 AUC 高达 0.95上线后表现断崖式下跌AUC 掉到 0.7 左右。原因特征里包含了标签发生之后才能产生的信息。典型情况是你用用户“当前是否还在使用服务”作为流失标签同时又用“最后一次登录时间”作为特征。这个特征本身就泄露了答案。在我处理的一份模拟项目 X 里同事把“客服最近一次通话时间”放进了特征这个时间距离流失日期越近流失概率越高模型学到的是“快流失的人最近有客服记录”而不是真正的原因。解决检查每个特征的时间戳是否都早于标签时间点。最稳妥的做法是建一个特征时间窗比如只用流失日期前 30 天的数据构造特征之后的信息一律丢弃。把这套检查写成一个清单每次跑数据前逐项核对。5.2 随机切分还是时间切分预测未来流失要靠时间窗现象随机切分训练集和测试集模型表现很好但按时间切分后 AUC 下降明显。原因随机切分打乱了时间顺序训练集里可能包含测试集之后的样本模型实际上“偷看”了未来。流失预测本质是一个时间序列预测问题用户在某个月流失应该用这个月之前的信息来预测而不是用之后的信息。解决不要直接用train_test_split改成按时间点切分。比如以 2024 年 6 月为界之前的数据训练之后的数据验证。时间序列切分后还要检查训练集和测试集的数据分布是否发生偏移如果套餐结构或费率调整过需要做分段建模。5.3 one-hot 膨胀高基数分类特征怎么处理现象分类特征编码后特征维度从 30 增长到 3000模型训练明显变慢逻辑回归系数稀疏随机森林特征重要性被高基数特征霸占。原因把CustomerID这类唯一标识符或城市、区域等高基数列直接 one-hot是初学者最常犯的错误。每个用户都有唯一 ID模型会学到“特定 ID 的用户会流失”这种严重过拟合的映射。解决先剔除唯一标识列再把高基数分类列用目标编码target encoding或者频次编码替代 one-hot。目标编码在浅层模型里效果不错但要注意防止泄漏必须用交叉验证方式在训练集内估计编码值。对于这份电信数据支付方式、合约类型这些特征基数低one-hot 没问题。5.4 Pipeline 里丢特征名SHAP 解释对不上号现象用 sklearn Pipeline 做完预处理后把模型接进 SHAP 解释器输出显示排名前十的特征全是编码后的列名比如x0或onehot__0根本看不出是原始哪个字段。原因Pipeline 里的ColumnTransformer会对特征重命名输出结果丢失原始列名。SHAP 拿到的 feature_names 是变换后的索引不是原始字段名。解决我一般会分两步走先把预处理和训练分开跑不用 Pipeline。训练时保存一个特征名列表用get_feature_names_out()拿到变换后的名称再手动映射回原始列名。如果一定要用 Pipeline就单独保存一个feature_names数组传给 SHAP 的feature_names参数。这一步虽然麻烦但解释性分析做不好模型结果在业务评审会上很难说服人。5.5 交叉验证与重采样顺序SMOTE 放错位置导致数据泄漏现象对全量数据做 SMOTE 后再切分模型评估结果看起来很好但线下复现时效果总差一截。原因SMOTE 如果先作用于全量数据会基于测试集附近的信息合成样本导致合成样本和测试集的分布高度重合验证集被污染。这是数据层面重采样最容易犯的泄漏错误。解决正确顺序是先切分训练集和测试集再在训练集内做 SMOTE测试集完全不参与合成。代码上用好imblearn.pipeline.make_pipeline能自动保证这个顺序但如果你手动调用 SMOTE务必确认切片发生在合成之前。同样的逻辑也适用于目标编码、标准化等所有需要在训练集上拟合的预处理步骤。注意排查时先往数据源头看不要一上来就调模型参数。上面五个坑里前三个都和数据口径相关后两个和代码顺序相关。先解决数据问题再去调模型效率最高。6. 落地技巧把预测概率转成一份能执行的运营名单模型训练完AUC 再高也只是一串数字真正对业务有价值的是那批“该优先触达的用户名单”。我习惯做最后一步转化用模型输出的流失概率结合用户价值评估生成一份分层次运营名单直接给运营团队使用。# 组装预测结果和用户信息 result X_test[[customerID]].copy() result[churn_prob] y_prob_rf result[pred_label] (result[churn_prob] best_thr).astype(int) # 估算用户生命周期价值按 CLV 排序 result[monthly_charge] X_test[MonthlyCharges] result[estimated_clv] result[monthly_charge] * 12 * 0.6 result_sorted result.sort_values( [pred_label, churn_prob, estimated_clv], ascending[False, False, False] ) # 导出待挽回名单 result_sorted[result_sorted[pred_label] 1].to_csv( churn_action_list.csv, indexFalse )这份名单的逻辑是先把流失概率和预测标签组装回一个表再估算每个用户的年度生命周期价值最后按“预测会流失 流失概率高 高价值”综合排序。最终落到 CSV 里的就是运营费用有限时最先接触的那批人。实际操作里运营团队对召回率的容忍度和对误报的接受度不一样我会额外要求按 CLV 分档输出三份名单高价值用户不管概率高低都列入优先名单中价值用户看概率阈值低价值用户只在预算富余时触达。最后说一个我自己踩过多次的教训模型的概率分数只有和运营成本结构绑定才有意义。以前我只给运营一张概率分数表运营不知道多少分该行动干脆把整个名单全打电话成本翻了四倍主管直接叫停。从那以后我每次做用户流失预测都强制把“阈值对应的触达成本”和“名单里预估挽回金额”一起算出数字直接写在邮件里给业务方看。评定一条名单逻辑是否成功就看它能不能让运营按着名单执行而不追问“这个分数到底要不要管”。希望帮到你。本文还有配套的精品资源点击获取