Python机器学习电信用户流失预测实战:从特征工程到模型上线 简介这份资源面向希望入门机器学习实战的Python学习者与数据挖掘爱好者以Kaggle电信用户流失数据集Telco Customer Churn为案例完整演示从业务背景解读到模型训练的全流程。项目按算法竞赛与建模的一般思路分为三阶段先解读业务背景并做数据探索包括分布检验、正确性校验、质量检验与训练集/测试集规律一致性检验再进行数据重新编码与模型训练最后深入特征衍生与筛选涵盖批量特征衍生、Filter方法及海量特征筛选等技巧。包内共128个文件以98个png与10个jpg图示、10个md笔记、7个ipynb笔记本为主另含1个py脚本与1个csv数据集压缩包约10.21MB结构清晰便于按阶段检索。已有431人学习下载适合想系统掌握流失预测建模流程、积累特征工程与数据探索经验的读者参考。1. 电信用户流失预测为什么 80% 的准确率反而说明模型废了电信行业拉新成本是留存成本的 5 到 10 倍这个数字做运营的都知道。但真正让数据团队头疼的不是「要不要做流失预测」而是「做出来的模型到底能不能用」。我见过太多团队拿着 80% 准确率的模型去汇报结果上线后一个高流失风险用户都没捞出来——因为流失用户本身只占 5% 到 10%模型把所有用户都预测成「不流失」准确率自然有 90%但召回率是零。Python 基于机器学习的电信用户流失预测核心要解决的就是这个问题从用户的话单、套餐、缴费、投诉等行为数据里提前识别出「快要走的人」让运营团队有时间做挽留动作。它适合两类人一类是刚接触机器学习、想找一个特征工程和类别不平衡都齐全的实战项目练手另一类是在运营商或订阅制业务里做数据运营需要一套能跑通、能解释、能上线的方案。这篇笔记按「数据长什么样 → 特征怎么造 → 模型怎么选 → 阈值怎么定 → 坑在哪」的顺序讲代码可以直接抄。2. 数据到手先别急着建模电信流失数据的字段拆解与清洗2.1 一份典型的电信流失数据集长什么样常见的电信流失数据集一般包含三类字段。第一类是用户画像性别、是否老年人、是否有伴侣、是否有子女。第二类是服务信息电话服务、多条线路、互联网服务类型、在线安全、在线备份、设备保护、技术支持、流媒体电视、流媒体电影。第三类是账户信息合约期限、是否无纸化账单、付款方式、月费用、总费用、入网时长。标签字段通常是二值的Churn 为 Yes 表示流失No 表示未流失。这个标签的分布往往很不均衡流失比例在 20% 到 30% 之间算正常低于 10% 就要格外小心。拿到数据后第一件事不是df.describe()而是先看TotalCharges这类字段有没有被读成字符串。很多数据集里这个字段混了空格pandas 会把它当 object 类型处理后面做数值计算直接报错。import pandas as pd import numpy as np df pd.read_csv(telco_churn.csv) # 先看字段类型和缺失情况 print(df.dtypes) print(df.isnull().sum()) # TotalCharges 常见问题混入空格导致被读成 object df[TotalCharges] pd.to_numeric(df[TotalCharges], errorscoerce) # 转换后会产生 NaN看看有多少 print(TotalCharges 转换后缺失数, df[TotalCharges].isnull().sum()) # 这些缺失通常对应入网时长为 0 的新用户用月费用填充更合理 df[TotalCharges] df[TotalCharges].fillna(df[MonthlyCharges]) # 标签转成 0/1 df[Churn] df[Churn].map({Yes: 1, No: 0})这段代码的关键在errorscoerce它把无法转换的值变成 NaN 而不是直接抛异常让你能先看到问题规模再决定怎么处理。填充策略上用MonthlyCharges填充比用均值填充更合理因为缺失的往往是刚入网、总费用还没来得及累积的用户。2.2 类别字段的编码方式选择电信数据里大量字段是类别型的比如合约期限有「按月」「一年」「两年」三种付款方式有四种。编码方式直接影响到后面模型能不能学到东西。常见做法有三种独热编码、标签编码、有序编码。合约期限这种有明确顺序的用有序编码最合适付款方式这种没有顺序的用独热编码性别这种二值的直接映射成 0/1 就行。from sklearn.preprocessing import LabelEncoder # 二值字段直接映射 binary_cols [gender, Partner, Dependents, PhoneService, PaperlessBilling] for col in binary_cols: df[col] df[col].map({Yes: 1, No: 0, Male: 1, Female: 0}) # 有序字段手动指定顺序 contract_map {Month-to-month: 0, One year: 1, Two year: 2} df[Contract] df[Contract].map(contract_map) # 无序多类别字段用独热编码 multi_cols [InternetService, PaymentMethod, MultipleLines, OnlineSecurity, OnlineBackup, DeviceProtection, TechSupport, StreamingTV, StreamingMovies] df pd.get_dummies(df, columnsmulti_cols, drop_firstTrue)drop_firstTrue是为了避免独热编码带来的多重共线性对线性模型尤其重要。树模型虽然不太受共线性影响但少一列能省一点内存和训练时间。提示独热编码后字段数会膨胀如果类别取值特别多比如超过 20 个考虑用目标编码或者频率编码替代否则维度爆炸会让训练变慢且容易过拟合。3. 特征工程才是分水岭从原始字段到流失信号3.1 三个必须造出来的衍生特征原始字段直接喂给模型也能跑但效果往往差一截。电信流失场景里有三个衍生特征几乎每次都能带来提升。第一个是「月均费用」用TotalCharges / (Tenure 1)计算。它反映的是用户实际感知的价格水平比单纯的月费用更能区分「觉得贵」和「刚涨价」两类流失动机。第二个是「费用差值」用MonthlyCharges - 月均费用。如果这个值是正的且比较大说明用户最近被涨价了流失风险会明显上升。第三个是「服务数量」统计用户订阅了多少项增值服务。服务越多的用户迁移成本越高流失概率越低。# 月均费用加 1 防止入网时长为 0 时除零 df[AvgCharges] df[TotalCharges] / (df[Tenure] 1) # 费用差值正数表示最近费用高于历史平均 df[ChargeDiff] df[MonthlyCharges] - df[AvgCharges] # 服务数量统计增值服务列中值为 1 的个数 service_cols [c for c in df.columns if any(k in c for k in [OnlineSecurity, OnlineBackup, DeviceProtection, TechSupport, StreamingTV, StreamingMovies])] df[ServiceCount] df[service_cols].sum(axis1)这三个特征背后的逻辑是一致的把「静态快照」变成「动态信号」。原始字段告诉你用户现在是什么状态衍生特征告诉你用户正在往哪个方向变化。3.2 用 IV 值筛特征别凭感觉删列特征不是越多越好。电信数据独热编码后动辄四五十列里面有不少是噪声。信息价值IV是流失预测里常用的筛选指标它衡量一个特征对标签的区分能力。IV 小于 0.02 基本没区分度0.02 到 0.1 是弱区分0.1 到 0.3 是中等0.3 到 0.5 是强区分超过 0.5 要警惕是不是数据泄漏。def calc_iv(df, feature, target): 计算单个特征的 IV 值 lst [] for val in df[feature].unique(): # 该取值下的正负样本数 pos ((df[feature] val) (df[target] 1)).sum() neg ((df[feature] val) (df[target] 0)).sum() lst.append([val, pos, neg]) iv_df pd.DataFrame(lst, columns[val, pos, neg]) # 加 0.5 平滑防止某组样本为 0 时 log 报错 iv_df[pos_pct] (iv_df[pos] 0.5) / (iv_df[pos].sum() 0.5) iv_df[neg_pct] (iv_df[neg] 0.5) / (iv_df[neg].sum() 0.5) iv_df[woe] np.log(iv_df[pos_pct] / iv_df[neg_pct]) iv_df[iv] (iv_df[pos_pct] - iv_df[neg_pct]) * iv_df[woe] return iv_df[iv].sum() # 对连续特征先分箱再算 IV df[TenureBin] pd.cut(df[Tenure], bins10, labelsFalse) iv_scores {col: calc_iv(df, col, Churn) for col in df.select_dtypes(include[np.number]).columns if col ! Churn} iv_series pd.Series(iv_scores).sort_values(ascendingFalse) print(iv_series.head(20))连续特征必须先分箱再算 IV否则每个取值单独成组IV 会虚高。分箱数量一般 5 到 10 箱电信场景里入网时长按 10 箱切比较合适。注意IV 筛选要在训练集上做不能在全集上做。否则测试集的信息会通过特征筛选泄漏到训练过程里评估结果会偏乐观。4. 模型选型与类别不平衡为什么 AUC 高不代表能上线4.1 逻辑回归、随机森林、XGBoost 怎么选电信流失预测里最常用的三个模型是逻辑回归、随机森林和 XGBoost。它们各有适用场景。逻辑回归的优势是可解释性强系数直接告诉你每个特征对流失概率的影响方向。如果业务方需要「为什么这个用户被判定为高风险」的明确理由逻辑回归最省事。缺点是它假设特征和 log-odds 是线性关系对复杂交互的捕捉能力有限。随机森林能自动处理非线性和特征交互对缺失值和异常值也不敏感。缺点是模型体积大解释性差而且在不平衡数据上容易偏向多数类。XGBoost 在结构化数据上通常是效果最好的它内置了scale_pos_weight参数来处理类别不平衡训练速度也快。缺点是调参空间大新手容易调出过拟合的模型。我的建议是三个都跑一遍用交叉验证的 AUC 和召回率做对比别一上来就上 XGBoost。from sklearn.model_selection import train_test_split, StratifiedKFold, cross_val_score from sklearn.linear_model import LogisticRegression from sklearn.ensemble import RandomForestClassifier from xgboost import XGBClassifier from sklearn.preprocessing import StandardScaler X df.drop(columns[Churn]) y df[Churn] # 分层切分保证训练集和测试集流失比例一致 X_train, X_test, y_train, y_test train_test_split( X, y, test_size0.2, random_state42, stratifyy) # 逻辑回归对尺度敏感需要标准化 scaler StandardScaler() X_train_scaled scaler.fit_transform(X_train) X_test_scaled scaler.transform(X_test) # 计算正负样本比例用于 XGBoost 的 scale_pos_weight scale (y_train 0).sum() / (y_train 1).sum() models { LR: LogisticRegression(class_weightbalanced, max_iter1000), RF: RandomForestClassifier(n_estimators300, class_weightbalanced, random_state42), XGB: XGBClassifier(n_estimators300, scale_pos_weightscale, learning_rate0.05, max_depth5, eval_metriclogloss, random_state42) } cv StratifiedKFold(n_splits5, shuffleTrue, random_state42) for name, model in models.items(): data X_train_scaled if name LR else X_train scores cross_val_score(model, data, y_train, cvcv, scoringroc_auc) print(f{name} AUC: {scores.mean():.4f} (/- {scores.std():.4f}))class_weightbalanced和scale_pos_weight的作用是一样的让模型在训练时更关注少数类。区别在于逻辑回归和随机森林用前者XGBoost 用后者。StratifiedKFold保证每折里流失比例一致避免某一折里流失样本太少导致评估波动大。4.2 阈值调优0.5 不是圣旨模型输出的是概率默认阈值 0.5 意味着「流失概率超过一半就判定为会流失」。但在流失预测场景里漏掉一个真正要走的用户代价远大于误判一个不会走的用户。所以阈值应该往低调。具体调到多少取决于业务能承受的挽留成本。如果运营团队每天只能打 100 个挽留电话那就按概率排序取前 100 个阈值自然就定下来了。from sklearn.metrics import precision_recall_curve, f1_score model XGBClassifier(n_estimators300, scale_pos_weightscale, learning_rate0.05, max_depth5, eval_metriclogloss, random_state42) model.fit(X_train, y_train) # 获取测试集上的预测概率 y_prob model.predict_proba(X_test)[:, 1] # 遍历阈值找 F1 最高的点 precisions, recalls, thresholds precision_recall_curve(y_test, y_prob) f1_scores 2 * precisions * recalls / (precisions recalls 1e-8) best_idx np.argmax(f1_scores) best_threshold thresholds[best_idx] print(f最佳阈值{best_threshold:.3f}, F1{f1_scores[best_idx]:.4f}) # 按业务容量定阈值取概率最高的前 15% 用户 top_k int(len(y_prob) * 0.15) threshold_by_capacity np.sort(y_prob)[-top_k] print(f按容量定的阈值{threshold_by_capacity:.3f})precision_recall_curve返回的 thresholds 长度比 precisions 少一个索引时要注意。按业务容量定阈值更实用因为它直接对应「运营团队能处理多少量」这个现实约束。提示阈值确定后要固化到配置文件里不要每次预测都重新算。线上环境没有标签算不了 F1只能沿用离线调好的阈值。5. 避坑指南五个让模型翻车的血泪教训5.1 数据泄漏Tenure 和 TotalCharges 的陷阱现象模型 AUC 跑到 0.95 以上上线后效果断崖式下跌。原因TotalCharges是入网至今的总费用如果用户已经流失这个值就停在了流失那一刻。它隐含了「用户是否还在网」的信息等于把答案泄漏给了模型。Tenure同理流失用户的入网时长天然比在网用户短。解决训练时把TotalCharges和Tenure都去掉或者只用它们构造衍生特征比如月均费用不要直接喂原始值。验证方法是看特征重要性排名如果这两个字段排在最前面基本可以确认泄漏。5.2 独热编码后训练集和测试集列数不一致现象训练时 45 列预测时 43 列模型直接报错。原因pd.get_dummies是在全量数据上做的如果先切分再编码测试集里某个类别可能没出现导致列数对不上。解决先合并再做独热编码或者用sklearn的OneHotEncoder(handle_unknownignore)它会在遇到未知类别时输出全零向量而不是报错。from sklearn.preprocessing import OneHotEncoder from sklearn.compose import ColumnTransformer # 用 ColumnTransformer 把编码和模型串成 pipeline preprocessor ColumnTransformer( transformers[ (cat, OneHotEncoder(handle_unknownignore), multi_cols), (num, StandardScaler(), num_cols) ])5.3 用准确率评估不平衡数据现象模型准确率 85%但一个流失用户都没预测出来。原因流失样本只占 20%模型全预测「不流失」就有 80% 准确率。准确率在不平衡数据上几乎没有参考价值。解决看 AUC、召回率、F1 和 KS 值。电信流失场景里召回率比精确率更重要因为漏掉一个流失用户的代价远大于多打一个挽留电话。5.4 特征重要性当因果结论用现象模型显示「月费用越高越容易流失」运营团队直接给所有高费用用户打折结果流失率没降。原因特征重要性只反映相关性不反映因果。高费用用户流失率高可能是因为他们本身对价格敏感而不是费用高导致流失。打折可能吸引来一批薅完就走的人。解决特征重要性用来筛特征和做解释不要直接当决策依据。要验证因果得做 A/B 实验。5.5 模型上线后不监控特征漂移现象模型上线三个月后效果逐渐变差但没人发现。原因用户行为会变套餐会调整竞品会推新活动。训练时的数据分布和线上数据分布逐渐偏离模型学到的规律失效了。解决定期计算线上特征的 PSI群体稳定性指标PSI 超过 0.2 就说明分布偏移明显需要重新训练。同时监控预测概率的分布如果高概率用户占比突然变化也要排查原因。6. 从离线模型到可解释的挽留名单一个实用技巧模型跑通只是第一步真正让业务方愿意用靠的是「可解释的挽留名单」。我一般会在输出预测结果时给每个高风险用户附上三条信息流失概率、Top 3 影响特征、建议动作。实现方式是用 SHAP 值做单样本解释。SHAP 能告诉你「这个用户的流失概率是 0.82其中月费用高贡献了 0.15合约按月贡献了 0.12入网时长短贡献了 0.08」。运营看到这个就知道该推什么挽留方案。import shap # 用树模型训练好后构建 SHAP 解释器 explainer shap.TreeExplainer(model) shap_values explainer.shap_values(X_test) # 取一个高风险样本看解释 high_risk_idx np.argmax(y_prob) sample_shap shap_values[high_risk_idx] sample_features X_test.iloc[high_risk_idx] # 按 SHAP 绝对值排序取前三个影响最大的特征 top_idx np.argsort(np.abs(sample_shap))[::-1][:3] for i in top_idx: print(f特征{sample_features.index[i]}, f取值{sample_features.iloc[i]}, fSHAP{sample_shap[i]:.4f})TreeExplainer对 XGBoost 和随机森林都适用计算速度快。SHAP 值为正表示该特征推高了流失概率为负表示拉低了流失概率。给运营的名单里每个用户附上「推高概率最大的两个特征」他们就能针对性地说「您这个月费用比平均高了 30 元我们有个更划算的套餐」。验证这套方案是否靠谱我习惯做两件事。一是留出最近一个月的真实流失数据做时间外验证看模型在「未来数据」上的召回率而不是只看随机切分的测试集。二是把预测名单按概率分十档看每档的实际流失率是否单调递增。如果第一档实际流失率 60%、第二档 55%、第三档 58%说明模型排序能力不稳定得回去检查特征或样本权重。这套流程跑下来从数据清洗到输出挽留名单一个熟手大概两三天能搭完第一版。后面迭代的重点不在换模型而在补特征——比如加入近三个月的投诉记录、客服通话时长、套餐变更次数。这些行为数据比静态画像更能提前捕捉流失信号。我踩过最大的坑是早期太迷信模型调参花了大量时间在网格搜索上后来发现把「近三个月是否投诉过」这个特征加进去AUC 直接涨了 0.04比调参管用得多。特征工程的天花板远高于模型调参这个顺序别搞反。希望帮到你。本文还有配套的精品资源点击获取