简介:这份资源是面向高校学生与数据挖掘初学者的电信客户流失预测与分析完整项目源码,可作为数据挖掘课程期末大作业或课程设计使用。项目基于Python实现,围绕电信用户流失这一典型分类场景,涵盖数据预处理、特征降维、模型构建与训练等环节,代码附有注释,新手也能理解整体流程。压缩包共36个文件,以20个pt模型权重文件、10个py源码文件为主,另含4个xml配置、1个gitignore与1个iml工程文件,整体约1.79MB,目录中可见数据处理、模型定义、训练脚本与工具函数等模块,结构清晰便于按功能查阅。目前已有1471人学习下载,说明该案例在同类作业中具有较高参考价值。下载后简单部署即可运行,读者可据此掌握从数据清洗到模型训练评估的完整思路,并在此基础上调整参数或替换数据集,快速完成一份功能完善、逻辑完整的分析项目。
1. 电信客户流失预测:一份数据挖掘大作业为什么值得你花两周做透
电信客户流失预测这个题目,几乎每年都会出现在数据挖掘、机器学习课程的期末大作业清单里。表面上看它只是一个二分类任务:给你一份客户表,预测谁会走。但真正动手做过的人都知道,这份数据集的价值远不止交作业——它同时覆盖了缺失值处理、类别不平衡、特征工程、多模型对比、业务解释这几条主线,是少数能在一份数据里把数据挖掘全流程跑通的场景。对新手来说,它能让你第一次完整经历「读数据到出结论」的闭环;对熟手来说,它是一块测试特征构造思路和评估策略的试验田。这篇笔记就围绕基于 Python 的电信客户流失预测与分析源码该怎么写、参数怎么调、坑在哪,把一份能拿得出手的大作业拆开讲清楚。
2. 先搞清楚预测目标与数据底盘:字段含义决定特征工程上限
2.1 流失标签怎么定义,为什么它决定了整个项目的走向
拿到数据的第一步不是急着read_csv,而是先确认标签列。电信流失数据集里通常有一列叫Churn,取值是Yes/No,它表示客户在观察窗口内是否主动终止了服务。这个定义看起来简单,但它隐含了两个关键约束:第一,标签是历史快照,不是实时状态,所以你做的模型本质上是在拟合「过去某段时间内流失的人长什么样」;第二,流失是低频事件,正样本占比往往只有两成左右,这直接决定了后面不能用准确率当主指标。
我一般会先做三件事:看标签分布、看字段类型、看缺失情况。这三件事做完,整个项目的难度和方向基本就定了。如果正样本低于 15%,你就要认真考虑类别权重或者重采样;如果字段里既有数值又有类别,那编码方式的选择会直接影响树模型和线性模型的差距。
import pandas as pd df = pd.read_csv("telco_churn.csv") # 标签分布:判断类别是否失衡 print(df["Churn"].value_counts(normalize=True)) # 字段类型概览:数值 vs 类别 print(df.dtypes.value_counts()) # 缺失情况:哪些列需要单独处理 missing = df.isnull().sum() print(missing[missing > 0].sort_values(ascending=False))这段代码的作用是快速建立数据画像。value_counts(normalize=True)给出的是比例而不是绝对数量,方便你判断失衡程度;dtypes.value_counts()让你一眼看出类别字段有多少,因为类别字段多意味着编码和特征交叉的空间大;缺失值排序则是为了决定哪些列直接删、哪些列要填。参数上没有什么需要调的,但要注意:如果Churn列是Yes/No字符串,后面建模前必须映射成 0/1,否则 sklearn 会直接报错。
2.2 三类字段的处理策略:数值、类别、以及容易被忽略的 ID 列
电信流失数据的字段大致可以分成三组。第一组是人口属性,比如性别、是否老年人、是否有伴侣;第二组是账户信息,比如合同类型、付款方式、月费;第三组是服务订阅情况,比如是否开通了网络安全、在线备份、流媒体电视。这三组字段的处理方式完全不同。
数值字段里,tenure(在网时长)和MonthlyCharges(月费)是最重要的两个。tenure的分布往往双峰,新客户和老客户各占一堆,直接标准化效果一般,我通常会额外构造一个「是否新客户」的布尔特征。类别字段里,Contract(合同类型)是流失预测里最强的单特征之一,按月付费的客户流失率远高于两年合同客户,这个字段一定要保留并且做有序编码或者独热编码。
最容易被忽略的是customerID这类列。它看起来是唯一标识,实际上没有任何预测价值,留着只会让模型学到噪声。我的习惯是在特征工程开始前就把它单独存一份映射表然后从特征矩阵里删掉,这样后面排查问题时还能靠 ID 回溯到原始记录。
# 删除无预测价值的 ID 列,但保留一份备份 id_backup = df["customerID"].copy() df = df.drop(columns=["customerID"]) # 标签映射 df["Churn"] = df["Churn"].map({"Yes": 1, "No": 0}) # 构造新客户特征:在网时长小于 12 个月视为新客户 df["is_new_customer"] = (df["tenure"] < 12).astype(int) # 合同类型有序编码:按月 < 一年 < 两年 contract_order = {"Month-to-month": 0, "One year": 1, "Two year": 2} df["Contract_encoded"] = df["Contract"].map(contract_order)这里的关键逻辑是:is_new_customer这个特征不是拍脑袋加的,它来自tenure分布的双峰观察;Contract_encoded用有序编码而不是独热,是因为合同类型本身有强弱顺序,有序编码能让树模型更高效地找到切分点。参数上,12 个月这个阈值可以根据你的数据分布调整,如果新客户定义是 6 个月,就改成 6。注意map之后如果出现NaN,说明原始字段里有你没预料到的取值,这时候要回去检查数据而不是直接fillna。
3. 从原始表到模型输入:特征工程与不平衡处理的实操路径
3.1 数值标准化与类别编码:什么时候用 StandardScaler,什么时候用 OneHotEncoder
特征工程这一步,最常见的翻车点是把所有字段一股脑塞进ColumnTransformer然后发现模型效果还不如不处理。问题往往出在编码方式选错了。数值字段里,MonthlyCharges和TotalCharges量纲接近,标准化后对逻辑回归友好;但tenure这种双峰分布,标准化并不会让它更接近正态,反而可能让树模型多切几刀。我的做法是:树模型(随机森林、XGBoost)不做标准化,直接喂原始数值;线性模型(逻辑回归)才做标准化。
类别字段的编码更讲究。低基数类别(比如性别,只有两个值)用独热编码没问题;高基数类别(比如付款方式,可能有四种以上)如果独热,维度会膨胀,这时候要么用目标编码,要么直接交给树模型处理。我一般会先跑一版独热编码的逻辑回归作为基线,再跑一版不做编码的随机森林,对比两者的 AUC,差距大的话就说明编码方式对当前数据影响很大。
from sklearn.compose import ColumnTransformer from sklearn.preprocessing import StandardScaler, OneHotEncoder from sklearn.pipeline import Pipeline numeric_features = ["tenure", "MonthlyCharges", "TotalCharges"] categorical_features = ["gender", "Partner", "Dependents", "PaymentMethod"] # 数值标准化 + 类别独热,注意 handle_unknown 防止线上新类别报错 preprocessor = ColumnTransformer( transformers=[ ("num", StandardScaler(), numeric_features), ("cat", OneHotEncoder(handle_unknown="ignore"), categorical_features), ] ) pipeline = Pipeline([ ("prep", preprocessor), ("clf", None), # 模型占位,后面替换 ])这段代码的核心是ColumnTransformer把不同处理逻辑并行化,避免手动拼接特征矩阵时列顺序错乱。handle_unknown="ignore"是一个容易被忽略但非常重要的参数:如果训练集里没有某个类别值,测试集出现了,不加这个参数会直接抛异常。参数上,StandardScaler默认按列减均值除标准差,如果你的数据有极端离群值,可以考虑换成RobustScaler。
3.2 类别不平衡的三种处理方式:权重、采样与阈值移动
电信流失数据的正样本比例通常在 20% 到 27% 之间,这个失衡程度不算极端,但足以让准确率变成一个误导性指标。我见过太多大作业用准确率 80% 来证明模型好,实际上把所有样本都预测成「不流失」也能拿到 73% 以上的准确率。正确的做法是看 AUC 和召回率。
处理不平衡有三种常见方式。第一种是设置class_weight="balanced",让模型在训练时自动给少数类更高权重,这是最省事的方式,逻辑回归和随机森林都支持。第二种是重采样,比如 SMOTE 合成少数类样本,但要注意 SMOTE 只能在训练集上做,测试集绝对不能碰,否则评估结果会虚高。第三种是阈值移动,模型输出概率后不按 0.5 切,而是根据业务需求选一个更低的阈值来提高召回。
from sklearn.linear_model import LogisticRegression from sklearn.ensemble import RandomForestClassifier from sklearn.model_selection import train_test_split from sklearn.metrics import roc_auc_score, recall_score X = df.drop(columns=["Churn"]) y = df["Churn"] X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.2, stratify=y, random_state=42 ) # 方式一:类别权重 lr = LogisticRegression(class_weight="balanced", max_iter=1000) rf = RandomForestClassifier(class_weight="balanced", n_estimators=200, random_state=42) for name, model in [("LR", lr), ("RF", rf)]: pipe = Pipeline([("prep", preprocessor), ("clf", model)]) pipe.fit(X_train, y_train) proba = pipe.predict_proba(X_test)[:, 1] print(name, "AUC:", roc_auc_score(y_test, proba))stratify=y保证训练集和测试集的标签比例一致,这是做不平衡数据时必须加的参数。max_iter=1000是因为逻辑回归在标准化后的数据上有时需要更多迭代才能收敛,默认 100 会报收敛警告。n_estimators=200是随机森林的树数量,太少方差大,太多训练慢,200 是一个比较稳的起点。跑完这段代码后,如果两个模型的 AUC 都在 0.82 以上,说明特征工程基本到位;如果低于 0.75,要回去检查是不是漏了关键字段或者编码方式有问题。
4. 模型训练与评估:AUC、召回率和业务阈值的三角权衡
4.1 为什么准确率在这个任务里几乎没用
前面提到过,流失预测的正样本比例低,准确率会被多数类主导。举个具体数字:如果测试集有 1000 个客户,其中 250 个流失,你把所有客户都预测成不流失,准确率是 75%,但召回率是 0。这个模型在业务上毫无价值,因为一个流失客户都没抓到。所以从大作业的角度,评估指标至少要报 AUC、召回率和 F1,准确率可以提但不要当主指标。
AUC 衡量的是模型把正样本排在负样本前面的能力,它对阈值不敏感,适合用来比较不同模型的排序质量。召回率衡量的是「真正流失的人里,你抓到了多少」,这个指标直接对应业务价值。F1 是精确率和召回的调和平均,适合在两者之间找平衡。我一般会先看 AUC 选模型,再看召回率定阈值。
from sklearn.metrics import classification_report, confusion_matrix # 用随机森林做最终模型,输出多指标报告 final_pipe = Pipeline([("prep", preprocessor), ("clf", rf)]) final_pipe.fit(X_train, y_train) proba = final_pipe.predict_proba(X_test)[:, 1] pred_default = (proba >= 0.5).astype(int) pred_low = (proba >= 0.3).astype(int) print("默认阈值 0.5:") print(classification_report(y_test, pred_default)) print("降低阈值到 0.3:") print(classification_report(y_test, pred_low))这段代码对比了两个阈值下的分类报告。你会看到,阈值从 0.5 降到 0.3 后,召回率明显上升,精确率下降。这不是模型变好了,而是你主动选择了「宁可错杀不可放过」的策略。参数上,0.3 不是固定值,你可以画一条 precision-recall 曲线,找到召回率满足业务要求时精确率最高的那个点。注意predict_proba返回的是两列,第一列是负类概率,第二列才是正类概率,取[:, 1]不要取错。
4.2 交叉验证与超参数搜索:GridSearchCV 的正确打开方式
单次 train_test_split 的结果有随机性,换一个random_state可能 AUC 波动两三个点。要让大作业的结果更可信,至少要做 5 折交叉验证。如果时间允许,用GridSearchCV在训练集上搜一轮超参数,再把最优参数放到测试集上评估。注意搜索空间不要设太大,随机森林的n_estimators和max_depth是两个最值得调的参数,逻辑回归的C是正则化强度的倒数,越小正则越强。
from sklearn.model_selection import GridSearchCV param_grid = { "clf__n_estimators": [100, 200, 300], "clf__max_depth": [None, 10, 20], "clf__min_samples_leaf": [1, 5, 10], } grid = GridSearchCV( Pipeline([("prep", preprocessor), ("clf", RandomForestClassifier( class_weight="balanced", random_state=42))]), param_grid, cv=5, scoring="roc_auc", n_jobs=-1, ) grid.fit(X_train, y_train) print("最优参数:", grid.best_params_) print("最优 AUC:", grid.best_score_)cv=5表示 5 折交叉验证,scoring="roc_auc"指定用 AUC 作为选择标准,n_jobs=-1表示用满所有 CPU 核心加速。参数网格里max_depth=None表示不限制深度,让树自由生长,这在小数据集上通常效果不差,但容易过拟合,所以同时试 10 和 20 做对比。min_samples_leaf控制叶子节点最少样本数,增大它可以防止模型学到噪声。跑完 GridSearchCV 后,best_score_是交叉验证的平均 AUC,通常比单次测试集 AUC 略低,这是正常的。
5. 避坑与排查:电信流失预测里最容易翻车的五个地方
5.1 坑一:TotalCharges 列混进了空字符串导致类型转换失败
现象:读数据时TotalCharges显示为 object 类型,做数值计算时报TypeError。原因:这一列里有些新客户的TotalCharges是空字符串而不是NaN,pandas 读进来默认当字符串处理。解决:用pd.to_numeric(df["TotalCharges"], errors="coerce")强制转换,空字符串会变成NaN,然后再决定是填 0 还是填中位数。我一般填 0,因为新客户还没产生总费用,填 0 比填中位数更符合业务逻辑。
5.2 坑二:SMOTE 用在了测试集上导致 AUC 虚高
现象:做了 SMOTE 之后 AUC 从 0.84 跳到 0.95,开心得太早。原因:SMOTE 在测试集上合成了少数类样本,测试集分布被改变了,评估结果不再反映真实泛化能力。解决:SMOTE 只能在训练集上做,而且要在交叉验证的每一折内部做,不能在全量数据上做完再切分。更稳妥的做法是先用class_weight跑一版基线,如果 AUC 已经够用,就不折腾采样。
5.3 坑三:把 customerID 当特征喂进模型
现象:模型训练不报错,但特征重要性里 customerID 排得很高。原因:customerID 是唯一标识,模型可能学到「ID 小的客户更容易流失」这种毫无意义的规律。解决:在特征工程第一步就删掉 ID 列,如果后面需要回溯,单独存一份映射表。这个坑在新手里非常常见,因为 sklearn 不会阻止你喂字符串 ID,只会默默做编码然后给你一个看似合理的结果。
5.4 坑四:用准确率选模型,选出一个「全预测不流失」的废物
现象:某个模型准确率 78%,但混淆矩阵里正样本一个都没抓到。原因:类别不平衡下,多数类主导了准确率。解决:把评估指标换成 AUC 和召回率,并且在classification_report里确认正类的 recall 不是 0。如果正类 recall 低于 0.5,说明模型基本没学到东西,要回去检查特征和标签映射。
5.5 坑五:特征重要性解释和业务逻辑对不上
现象:随机森林给出的特征重要性里,某个无关字段排在前三。原因:可能是编码方式让这个字段产生了大量稀疏列,树模型在稀疏特征上容易过拟合。解决:对高基数类别字段改用目标编码或者直接删除,然后重新训练看重要性排序是否合理。另外,特征重要性有impurity和permutation两种,后者更可靠但计算慢,大作业里用前者就够了,但要心里有数。
6. 让大作业脱颖而出的进阶技巧:从模型输出到业务建议
6.1 用 SHAP 做单客户解释,把预测概率翻译成挽留动作
一份大作业如果只停在 AUC 和混淆矩阵,很难和别人的拉开差距。真正让老师或面试官眼前一亮的,是你能把模型输出翻译成业务语言。SHAP 是目前最实用的解释工具,它能告诉你每个特征对单个客户预测结果的贡献方向。比如某个客户流失概率 0.82,SHAP 会显示Contract_Month-to-month推高了 0.15,tenure低推高了 0.12,那挽留动作就很明确:优先推长期合同优惠。
import shap # 用训练好的随机森林管道做解释 explainer = shap.TreeExplainer(final_pipe.named_steps["clf"]) X_test_transformed = final_pipe.named_steps["prep"].transform(X_test) shap_values = explainer.shap_values(X_test_transformed) # 输出第一个客户的特征贡献 shap.initjs() shap.force_plot( explainer.expected_value[1], shap_values[1][0], X_test_transformed[0], )这段代码里TreeExplainer专门用于树模型,比通用的KernelExplainer快很多。shap_values[1]取的是正类(流失)的贡献值,[0]是第一个测试样本。force_plot会生成一个可视化图,红色表示推高流失概率,蓝色表示拉低。参数上没有什么需要调的,但要注意X_test_transformed必须是经过preprocessor转换后的矩阵,不能直接喂原始 DataFrame,否则列对不上会报错。
6.2 把流失概率分档,输出可执行的客户分层名单
模型给出的是连续概率,业务需要的是可执行的分层。我一般会把测试集客户按预测概率分成三档:高风险(大于 0.7)、中风险(0.4 到 0.7)、低风险(小于 0.4)。高风险客户直接进挽留名单,中风险客户做定向优惠测试,低风险客户不打扰。这样一份分层名单比一个 AUC 数字更有说服力,因为它直接对应运营动作。
| 风险档位 | 概率区间 | 建议动作 | 预期召回覆盖 |
|---|---|---|---|
| 高风险 | > 0.7 | 人工外呼 + 合同优惠 | 覆盖约 60% 流失客户 |
| 中风险 | 0.4 - 0.7 | 短信推送折扣 | 覆盖约 25% 流失客户 |
| 低风险 | < 0.4 | 不主动触达 | 覆盖约 15% 流失客户 |
这张表的数字不是固定的,要根据你的测试集实际分布来填。做法是:先按概率排序,取累计召回达到 60% 时的概率截断点作为高风险阈值,再取累计召回达到 85% 时的截断点作为中风险阈值。这样分出来的档位是有数据支撑的,不是拍脑袋定的。
6.3 一个我踩过的坑:不要为了刷 AUC 而过度调参
最后说一个血泪经验。我刚开始做这个题目的时候,花了整整三天调随机森林的超参数,把 AUC 从 0.84 刷到了 0.86,然后发现换一个random_state重新切分数据,AUC 又掉回 0.83。后来才明白,在小数据集上,两个点的 AUC 差异很可能只是噪声,不值得花那么多时间。真正让这份大作业有价值的,是特征工程有没有业务逻辑、评估指标有没有选对、结论有没有可执行性。把调参的时间省下来做 SHAP 解释和分层名单,收益大得多。希望帮到你。
本文还有配套的精品资源,点击获取