简介:面向电信客户流失预测的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(normalize=True)) # 看三个核心数值列的统计量 print(df[['tenure', 'MonthlyCharges', 'TotalCharges']].describe())这段代码的逻辑是先把数据集读入,确认行列规模,再单独看标签分布是否失衡,最后用 describe 查看核心数值列的四分位数和极值。这里有两个容易被忽略的参数点:value_counts(normalize=True)比直接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(ascending=False) print(missing_rate[missing_rate > 0]) # 检查 tenure=0 时的空值情况 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_unknown='ignore', drop='first'), cat_cols) ])这里的一步关键处理是把 “No internet service” 映射为 “No”,目的是减少分类基数,避免 one-hot 之后特征矩阵过于稀疏。OneHotEncoder的两个参数值得单独说明:drop='first'会删除每个分类的第一列,用于处理共线性;handle_unknown='ignore'保证训练集和测试集类别不一致时不会直接抛异常。至于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'], axis=1) y = df['Churn'].map({'No': 0, 'Yes': 1}) X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.2, random_state=42, stratify=y ) lr_pipeline = Pipeline([ ('pre', preprocessor), ('clf', LogisticRegression(max_iter=1000, C=1.0, class_weight='balanced')) ]) 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,再变换测试集,不会发生信息泄漏。stratify=y保证切分后训练集和测试集的流失比例一致,这个参数非常重要,如果不加,随机切分可能导致测试集里流失样本占比偏离整体分布。class_weight='balanced'会让逻辑回归自动调整正负样本权重,缓解类别不平衡。这里我特意用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_estimators=300, max_depth=8, min_samples_leaf=5, class_weight='balanced', random_state=42, 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_estimators=300保证稳定性,再往上加对结果影响不大;max_depth=8限制单棵树深度,防止在小数据集上过拟合;min_samples_leaf=5保证每个叶子节点至少有 5 个样本,让预测结果平滑。class_weight='balanced'和逻辑回归里的一样,都是为了处理样本不平衡。
一个容易踩的细节是 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_state=42, k_neighbors=5), LogisticRegression(max_iter=1000) ) 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_state=42), LogisticRegression(max_iter=1000) ) 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_neighbors=5是 SMOTE 的默认参数,表示每个少数类样本用 5 个近邻来插值合成新样本,如果少数类样本本身很少,这个值可以调小到 3。
跑完对比后发现差异不大时,我倾向于选择class_weight='balanced'方案,因为它更稳定,不会引入合成样本带来的噪声,而且和后续模型上线后的每周增量训练兼容性更好。
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', index=False )这份名单的逻辑是先把流失概率和预测标签组装回一个表,再估算每个用户的年度生命周期价值,最后按“预测会流失 + 流失概率高 + 高价值”综合排序。最终落到 CSV 里的就是运营费用有限时最先接触的那批人。实际操作里,运营团队对召回率的容忍度和对误报的接受度不一样,我会额外要求按 CLV 分档输出三份名单:高价值用户不管概率高低都列入优先名单,中价值用户看概率阈值,低价值用户只在预算富余时触达。
最后说一个我自己踩过多次的教训:模型的概率分数只有和运营成本结构绑定才有意义。以前我只给运营一张概率分数表,运营不知道多少分该行动,干脆把整个名单全打电话,成本翻了四倍,主管直接叫停。从那以后我每次做用户流失预测,都强制把“阈值对应的触达成本”和“名单里预估挽回金额”一起算出数字,直接写在邮件里给业务方看。评定一条名单逻辑是否成功,就看它能不能让运营按着名单执行而不追问“这个分数到底要不要管”。希望帮到你。
本文还有配套的精品资源,点击获取