☰
电信客户流失预测实战:朴素贝叶斯建模与避坑指南
2026/9/26 11:27:34 网站建设 项目流程

简介:面向数据挖掘初学者及相关课程设计场景,基于朴素贝叶斯算法完成电信客户流失预测建模,资源包含Kaggle公开客户数据集、完整Python代码与8000字实验报告。数据共7043条、21个属性,按客户个人信息、账户信息、订阅服务和流失评价指标四类划分,可直接用于特征分析与模型训练。压缩包共9个文件、约3.1MB,其中csv/tsv为原始数据与结果,ipynb和html对应可运行代码与可视化输出,docx为详细实验报告,json与tfevents记录模型训练过程,整体结构清晰。目前已有297人学习下载,适合需要从数据预处理、特征工程到算法评估完整实践朴素贝叶斯流程的读者;由此可快速搭建电信客户流失预测基线模型,并能根据报告中的思路扩展改进。

1. 电信客户流失预测:为什么用朴素贝叶斯而不是先上深度学习

一个做数据挖掘的初学者拿到“电信客户流失分析预测”这个题目,最容易踩的坑是上来就套 XGBoost 或者神经网络,等到写实验报告时才发现解释不了每一层在干什么,参数也调不明白。标题里点名的朴素贝叶斯算法,恰好是这类场景里性价比最高的起点:数据量不大、特征有明确的业务含义、需要交代清楚每个特征的贡献。它不会给你刷出一个惊人的准确率,但能让你的实验报告从“调包”变成“真懂”。这篇文章就围绕数据集、代码、实验报告这三个交付物,把从原始用户表到一份能交差的预测模型完整拆开,包括数据预处理、模型选型、代码实现、参数调整和最常见的翻车点。适合正在做课程设计、毕业设计或想快速验证一个数据挖掘流程的从业者。

2. 数据集与预处理:从原始用户表到能喂进朴素贝叶斯的样子

2.1 电信客户流失数据集长什么样:常见字段与目标变量

电信客户流失预测这个题目在公开数据集里非常常见,比如某电信公司用户档案,通常包含几十个字段,目标变量是Churn(是否流失)。典型字段包括:

  • 用户ID、性别、是否老年人、是否配偶或家属同住
  • 是否开通电话服务、是否开通多条线路
  • 网络服务类型:光纤、DSL、无网络
  • 在线安全、在线备份、设备保护、技术支持、流媒体电视、流媒体电影等是否开通
  • 合同类型:按月、一年、两年
  • 付款方式:电子支票、邮寄支票、银行自动转账、信用卡自动转账
  • 月费用、总费用
  • 流失标签:是/否

这些字段里,有大量是“是否”型(Yes/No),少量是类别型(如合同类型),还有两个连续数值(月费用、总费用)。给朴素贝叶斯用,必须先把它们都转成数值。很多人直接拿原始字符串跑LabelEncoder,把“Yes”和“No”变成 1 和 0,看起来没问题,但遇到InternetService这种有三种取值的字段,LabelEncoder会编成 0、1、2,朴素贝叶斯会认为 2 和 0 之间是“两倍远”,这本质上是强加了一个不存在的顺序关系。正确做法是一律做 One-Hot 编码,或者直接用pd.get_dummies。

2.2 清洗与编码:缺失值、字符串列、数值分布

先说缺失值。电信数据集里最常见的坑是TotalCharges字段,看起来是数值,实际上读进来是字符串,里面可能有空字符串或字母。我一般用pd.to_numeric转一次,errors='coerce'会把非法值变成NaN,然后看缺失比例。如果缺失比例小于 1%,直接删除这些行;如果超过 5%,就要考虑用中位数填充,因为TotalCharges往往右偏,均值会被高额用户拉高。

import pandas as pd df = pd.read_csv('telecom_customer_churn.csv') df['TotalCharges'] = pd.to_numeric(df['TotalCharges'], errors='coerce') print(df.isnull().sum()) # 流失用户里 TotalCharges 缺失的情况 print(df[df['Churn'] == 'Yes'][['TotalCharges']].isnull().sum()) # 如果缺失很少,直接删除;如果较多,用中位数填充 df = df.dropna(subset=['TotalCharges']) df['TotalCharges'] = df['TotalCharges'].fillna(df['TotalCharges'].median())

这段代码的逻辑是:先用errors='coerce'把非数值变成 NaN,再看缺失数量。很多公开数据集里TotalCharges对于刚入网一个月的用户本来就是 0,不能删掉,否则会系统性丢掉新用户样本。填充时用中位数而不是均值,是因为这个字段长尾严重,少数老用户贡献了极高费用,均值会偏移。

接下来处理字符串类别特征。我的做法是把目标变量和特征分开,特征做 One-Hot:

# 目标变量转 0/1 df['Churn'] = df['Churn'].map({'Yes': 1, 'No': 0}) # 找出所有 object 类型的列 obj_cols = df.select_dtypes(include=['object']).columns.tolist() obj_cols = [c for c in obj_cols if c != 'Churn'] # 对特征做独热编码 df_encoded = pd.get_dummies(df, columns=obj_cols, drop_first=False)

这里drop_first=False是故意的。朴素贝叶斯不要求特征之间线性无关,保留全部哑变量反而能让每个类别的概率估计更直接。如果drop_first=True,第一个类别被丢掉,模型输出概率时少了一个维度,解释起来麻烦。

2.3 特征工程:哪些特征对流失预测真的有区分度

特征工程不是越花哨越好,电信流失预测里真正有区分度的往往就那几个:合同类型、月费用、总费用、开通服务数量。我的经验是先做一个简单的单变量分析:把每个特征按不同取值统计流失率,看差异。这里给出一个可复制的交叉表方法:

# 按合同类型看流失率 churn_by_contract = df.groupby('Contract')['Churn'].mean() print(churn_by_contract) # 按月费用分箱看流失率 df['MonthlyChargesBin'] = pd.cut(df['MonthlyCharges'], bins=5) print(df.groupby('MonthlyChargesBin')['Churn'].mean())

如果Contract里“按月”合同的流失率明显高于“两年”合同,说明合同期限是强特征。连续变量分箱后看趋势,能帮你决定是直接用原始数值进高斯朴素贝叶斯,还是先离散化再用多项式朴素贝叶斯。实际上,高斯朴素贝叶斯对连续特征直接建模正态分布,但如果特征明显双峰或长尾,效果会差。这时候可以对MonthlyCharges做分箱,转成有序类别,再用多项式变体。分箱的边界不要用等宽,而是用分位数,否则极端值会把多数样本挤到一个箱里。

还有一个容易忽略的点:TotalCharges和MonthlyCharges高度相关,因为总费用约等于月费乘以在网月数。两个强相关特征放进朴素贝叶斯,会放大条件独立的假设误差。我的做法是构造一个衍生特征TenureMonths = TotalCharges / MonthlyCharges,在网月数,然后舍掉原始总费用。这个衍生特征业务含义清晰,而且比原始两个相关特征更稳。

3. 朴素贝叶斯原理与建模:先搞懂条件独立假设,再谈调参

3.1 朴素贝叶斯的三种变体:高斯、伯努利、多项式怎么选

朴素贝叶斯的核心是通过贝叶斯定理计算后验概率,然后默认特征之间条件独立。这个“朴素”假设在现实中几乎不成立,但流失预测这种业务场景里,特征之间的相关性相对弱,模型依然能给出不错的排序能力。实现上,sklearn提供了三个常用变体:

  • GaussianNB:特征服从高斯分布,适合连续数值
  • BernoulliNB:特征都是二值的,适合“是否开通”这种特征
  • MultinomialNB:特征取值是计数或频次,适合文本、离散计数

电信流失数据里既有连续值又有二值特征,怎么选?我的常见做法是:如果只做快速基线,直接用GaussianNB,把所有特征都当成连续值。缺点是二值特征会被强行拟合成高斯分布,但结果往往还能接受。如果追求严谨,把二值特征单独拆出来,用BernoulliNB,连续特征单独处理后拼概率。但sklearn没有现成的混合朴素贝叶斯,所以大部分课程设计都用GaussianNB一把梭。我的建议是:先跑通GaussianNB做基线,再看验证集表现决定要不要拆特征。多数情况你会发现,GaussianNB的准确率在 75%~80%,已经够写实验报告了。

3.2 用 sklearn 构建基线模型:训练集/测试集划分与评估指标

搭建一个最小可用模型只需要几行代码。先切分数据,再做标准化,然后训练。标准化对朴素贝叶斯很重要,虽然算法本身基于概率分布,不依赖距离,但高斯朴素贝叶斯估计均值方差时,如果量纲差异过大(比如月费用几十、总费用几千),数值稳定性会受影响。

from sklearn.model_selection import train_test_split from sklearn.naive_bayes import GaussianNB from sklearn.preprocessing import StandardScaler from sklearn.metrics import classification_report, confusion_matrix X = df_encoded.drop('Churn', axis=1) y = df_encoded['Churn'] # 划分训练集和测试集,保持类别分布一致 X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.2, random_state=42, stratify=y ) # 标准化连续特征(这里假设所有特征都标准化) scaler = StandardScaler() X_train_scaled = scaler.fit_transform(X_train) X_test_scaled = scaler.transform(X_test) model = GaussianNB() model.fit(X_train_scaled, y_train) y_pred = model.predict(X_test_scaled) print(classification_report(y_test, y_pred)) print(confusion_matrix(y_test, y_pred))

代码里的stratify=y是必须的。电信流失数据里“流失”通常只占 20%~30%,如果不分层抽样,随机划分可能让测试集里流失样本占比变成 10%,模型评估指标会失真。random_state=42固定随机种子,保证实验报告结果可复现。标准化时要先fit_transform训练集,再transform测试集,不能用测试集去fit,否则就是数据泄露。

3.3 参数与先验:平滑系数、类先验、样本权重的影响

朴素贝叶斯的超参数不多,但每个都有实际意义。GaussianNB里有priors参数,可以手动指定类别先验概率。默认情况下,模型会从训练数据里估计先验,也就是训练集中流失样本的占比。如果你的训练集经过采样导致流失比例被改变,就要手动传入真实先验。

# 假设业务方告诉你真实流失率是 25% model_prior = GaussianNB(priors=[0.75, 0.25]) model_prior.fit(X_train_scaled, y_train)

MultinomialNB里有alpha平滑系数,默认是 1.0,也就是拉普拉斯平滑。当某个特征在某个类别下从未出现过时,概率为 0 会把整个乘积打成 0,平滑就是给所有计数加一个小数,避免零概率。alpha太小会过拟合,太大则所有概率趋向均匀。我通常用网格搜索在[0.01, 0.1, 1.0, 10]里选。

BernoulliNB的参数是binarize,决定把特征大于多少变成 1,小于等于变成 0。如果你的数据是连续值但想用伯努利版本,这个阈值需要调。默认 0.0,但标准化后均值为 0,实际会变成约一半 1 一半 0,你需要根据业务含义设置。比如月费用大于 50 才算“高费用”特征,就手动传binarize=50,但前提是没做标准化。

4. 代码实现与落地:完整流程从训练到预测

4.1 最小可运行代码:数据加载、模型训练、结果输出

如果你拿到的是标题里那种“数据集+代码+实验报告”的压缩包,里面大概率已经有一个.py或.ipynb文件。但自己做一遍才是正经事。这里我给出一套能直接跑通的最小流程,假设你已经把数据清理成df_encoded。

import pandas as pd from sklearn.model_selection import train_test_split from sklearn.naive_bayes import GaussianNB, BernoulliNB from sklearn.preprocessing import StandardScaler from sklearn.metrics import roc_auc_score, accuracy_score def load_and_prepare(csv_path): df = pd.read_csv(csv_path) df['TotalCharges'] = pd.to_numeric(df['TotalCharges'], errors='coerce') df = df.dropna(subset=['TotalCharges']) df['Churn'] = df['Churn'].map({'Yes': 1, 'No': 0}) obj_cols = df.select_dtypes(include=['object']).columns.drop('Churn') df = pd.get_dummies(df, columns=obj_cols, drop_first=False) return df df = load_and_prepare('telecom_customer_churn.csv') X = df.drop('Churn', axis=1) y = df['Churn'] X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.2, random_state=42, stratify=y ) scaler = StandardScaler() X_train = scaler.fit_transform(X_train) X_test = scaler.transform(X_test) model = GaussianNB() model.fit(X_train, y_train) y_pred = model.predict(X_test) y_prob = model.predict_proba(X_test)[:, 1] print('准确率:', accuracy_score(y_test, y_pred)) print('AUC:', roc_auc_score(y_test, y_prob))

这个函数把数据准备和建模封装在一起,方便你在实验报告里贴。关键点是predict_proba拿到的第二列是“流失”的概率,做 ROC 曲线时用概率而不是预测标签。AUC 比准确率更能反映排序能力,因为流失预测的核心是找出高风险用户,而不是把所有人都猜成“不流失”。

4.2 交叉验证与网格搜索:把准确率压榨到合理区间

单次划分训练测试集容易受随机波动影响,实验报告里最好加 5 折交叉验证。朴素贝叶斯训练速度快,交叉验证几乎没有成本。我一般用cross_val_score输出平均分和标准差。

from sklearn.model_selection import cross_val_score, GridSearchCV from sklearn.naive_bayes import MultinomialNB # 如果特征是离散的,用 MultinomialNB 做交叉验证 model_mnb = MultinomialNB() scores = cross_val_score(model_mnb, X_train, y_train, cv=5, scoring='roc_auc') print('5折 AUC 均值:', scores.mean(), '标准差:', scores.std()) # 网格搜索平滑系数 param_grid = {'alpha': [0.01, 0.1, 1.0, 10]} grid = GridSearchCV(MultinomialNB(), param_grid, cv=5, scoring='roc_auc') grid.fit(X_train, y_train) print('最佳 alpha:', grid.best_params_) print('最佳 AUC:', grid.best_score_)

注意这里我用的是X_train,但网格搜索内部会再做交叉验证。如果你把X_train和X_test都传进去,测试集就被污染了。网格里的alpha从 0.01 到 10 指数增长,是因为平滑系数的效果是数量级变化的,线性搜索没有意义。scoring='roc_auc'比默认的准确率更适合不平衡数据。

还有一个容易被忽略的参数是class_prior。如果数据集里流失用户很少,模型会把所有样本都预测成“不流失”,准确率可能有 80%,但 AUC 只有 0.5。这时候可以给模型传入类先验,或者用predict_proba输出概率后自己调整判定阈值。朴素贝叶斯并没有内置的阈值参数,但你可以这样用:

y_prob = model.predict_proba(X_test)[:, 1] # 用自定义阈值 0.3 判定流失 y_custom = (y_prob >= 0.3).astype(int)

把阈值从默认的 0.5 降到 0.3,会抓出更多潜在流失用户,虽然误报增加,但业务上挽回一个用户的收益远大于打扰一个不流失用户的成本。实验报告里如果能写出“按业务风险调整阈值”,会显得你考虑过落地而不是只跑了一个模型。

4.3 输出可解释结果:预测概率与特征贡献

朴素贝叶斯相对树模型的最大优势是概率输出可以直接查表。GaussianNB训练完后,每个特征在“流失”和“不流失”两个类别下都有一组均值和方差,这就是模型的“参数说明”。你可以把这些参数打印出来,分析哪些特征对判断流失影响最大。

import numpy as np # 获取每个特征在每个类别下的均值和方差 # 注意 X_train 是标准化后的,所以要映射回原始特征名 sigma = np.sqrt(model.var_) mean_diff = model.theta_[1] - model.theta_[0] # 流失类均值 - 不流失类均值 # 按均值差异排序,找到最有区分度的特征 feature_names = X.columns.tolist() ranking = sorted(zip(feature_names, mean_diff), key=lambda x: abs(x[1]), reverse=True) for name, diff in ranking[:10]: print(f'{name}: {diff:.4f}')

这里有一个坑:model.theta_是按标准化后的尺度算的均值,不能直接解释成“月费用增加一美元流失概率增加多少”。如果你要写进实验报告,需要对每个特征做业务解释,而不是套用模型内部的标准化数值。我的习惯是重新训练一个不加标准化的GaussianNB,打印原始尺度的均值和方差,再配合上面的排序,结果更直观。标准化只为了数值稳定,解释模型时用原始输入重训一遍完全不冲突。

5. 避坑清单:做电信客户流失分析最常见的 5 个翻车现场

5.1 数据泄露:把未来的信息放进训练集

现象:训练出来的 AUC 高达 0.95,怎么看都像假数据。原因:数据里有“总费用”字段,它是在用户整个生命周期结束后统计的。如果一个用户已经流失,总费用就是流失前的全部费用;如果还没流失,总费用就是截至今天。模型直接学到了“总费用高的人还没走”,而不是“什么样的人会走”。解决:要么把TotalCharges换成MonthlyCharges * TenureMonths,要么只用MonthlyCharges和合同类型,不要动总费用。更隐蔽的还有tenure字段,它在预测时是已知的,但如果业务上是要预测未来三个月内是否流失,tenure是当前的合约时长,这个可以用。真正不能用的是“事后统计”字段,比如“已经使用过的服务次数”“投诉次数”,如果这些是在流失后才汇总的,就是泄露。

5.2 类别不平衡:准确率虚高而召回率惨不忍睹

现象:测试集准确率 82%,但一看混淆矩阵,流失用户一个都没抓到,全部预测成“不流失”。原因:流失样本只占 25%,默认模型只要全猜“不流失”就能有 75% 准确率。解决:不用准确率评估,改用 ROC-AUC、召回率、F1。训练层面可以做 SMOTE 过采样,或者给模型加样本权重。朴素贝叶斯没有class_weight参数,但你可以对训练集重采样。最简单的操作是train_test_split时用stratify,不要手工抽样。实验报告里建议同时给出混淆矩阵和不同阈值下的精确率-召回率曲线,这样才能看出模型在“尽力抓流失”和“避免误报”两个方向上的取舍。

5.3 字符串特征编码顺序导致模型假设被破坏

现象:LabelEncoder把Contract变成 0、1、2,模型效果突然变差。原因:0代表“按月”,1代表“一年”,2代表“两年”,朴素贝叶斯不知道这个顺序,它只会机械计算每个数值在两类中的分布。如果编码顺序和业务顺序不一致,比如“两年”被编成 1,“一年”被编成 2,模型就把两个类别的位置颠倒了,概率估计会受到干扰。解决:类别特征一律用OneHotEncoder或pd.get_dummies。如果一定要用标签编码,只对有序类别如Contract使用,并且手动指定顺序。对于InternetService这种纯名义变量,绝对不能标签编码。

5.4 报告与代码不一致:实验报告里写的结果复现不出来

现象:实验报告截图显示 AUC 0.86,但自己跑代码只有 0.78。原因:报告可能用了不同版本的包、不同的随机种子,或者删除了某些离群样本,再或者报告里的模型是调参后的最优结果,而代码里写的是基线。解决:实验报告里必须固定random_state,并写明所有关键参数,包括测试集比例、是否标准化、数据清洗规则。压缩包里的代码如果有random_state=42,你也用 42。如果你发现报告结果复现不了,不要直接改大数字,而是检查数据预处理步骤是不是少了一步。比如报告里可能先删除了tenure=0的用户,代码里却没删,结果差异就来了。这是血泪经验。

5.5 混淆矩阵和 ROC 曲线只看一个指标

现象:只贴一张 ROC 曲线图,算了个 AUC 就收工。原因:ROC 曲线对类别不平衡不敏感,正样本即使只有 1%,曲线还是很“漂亮”。但业务上你需要知道具体哪些用户被误判。解决:画出混淆矩阵,并计算“流失用户的召回率”和“不流失用户的误报率”这两个数字。下面这段代码可以帮你快速输出关键指标:

from sklearn.metrics import recall_score, precision_score, f1_score print('流失用户召回率:', recall_score(y_test, y_pred, pos_label=1)) print('流失用户精确率:', precision_score(y_test, y_pred, pos_label=1)) print('F1:', f1_score(y_test, y_pred, pos_label=1))

如果召回率低于 0.5,说明模型对真正流失的用户没有敏感度,这个模型在业务上基本不可用。这时候要回头检查特征,而不是继续调参。

6. 验证模型与进阶:从跑通代码到讲清楚业务价值

6.1 用留存验证集和线下评估确认模型稳定性

很多人把训练测试集切一次就结束了,我更习惯再留一份“留存验证集”。具体做法是:先把全量数据按时间排序,用最后 20% 的用户作为留存集,前面的 80% 再按随机方式切训练和验证。电信用户流失有明显的月份周期性,如果用随机划分,训练集和测试集可能混着同一时期的用户,模型会记住那个时期的营销活动特征。按时间切分才能模拟真实的“用上个月数据预测下个月流失”场景。

df_time = df_encoded.sort_values('tenure', ascending=False) # 假设 tenure 大的用户更早入网,取最后 20% 作为留存验证 split_idx = int(len(df_time) * 0.8) train_part = df_time.iloc[:split_idx] holdout = df_time.iloc[split_idx:] # 训练部分内部再切 80/20 X_tr, X_val, y_tr, y_val = train_test_split( train_part.drop('Churn', axis=1), train_part['Churn'], test_size=0.2, random_state=42, stratify=train_part['Churn'] ) # 训练、调参后用 holdout 做最终验证

按tenure排序并不是严格按时间,但在没有真实时间戳的数据集里,这个近似比随机划分更可靠。如果数据里有tenure字段,业务上是“在网月数”,它可以近似代表用户入网早晚。注意不要把tenure放进特征里再排序,排序后特征不变,只是划分不同。用留存集验证时,如果 AUC 比验证集低不少,大概率是过拟合了,这时候需要减少特征或者增加正则化(朴素贝叶斯这边就是加大平滑系数)。

6.2 把预测结果映射成挽留策略:价值分层与行动阈值

模型最终不是用来发论文的,而是要告诉运营团队:“这 1000 个用户下个月可能流失,建议优先挽留这 100 个。”操作上,我把预测概率按从高到低排序,再结合用户月费用做一个价值分层:

results = pd.DataFrame({ 'customer_id': df_time['customerID'].values[split_idx:], 'churn_prob': model.predict_proba(holdout_scaled)[:, 1], 'monthly_charges': holdout['MonthlyCharges'].values }) # 高风险 = 流失概率大于 0.6 且月费高于中位数 high_value_risk = results[ (results['churn_prob'] >= 0.6) & (results['monthly_charges'] > results['monthly_charges'].median()) ].sort_values('churn_prob', ascending=False) print('高风险高价值用户数:', len(high_value_risk))

这个逻辑的价值在于,它把机器学习输出和业务成本直接挂钩。给一个月费 20 的低价值用户发优惠券,成本可能超过收益;但一个月费 100 的两年合同用户,值得用专属客服去谈续约。实验报告里如果能加一张“风险分层表”,说明不同阈值下需要投入的挽留预算,整个项目的说服力会强很多。这也是我做完这个分析后最想对新手说的一件事:模型准确率不是交差指标,能不能告诉业务方“下一周该给谁打电话”,才是这个项目值不值得做的标准。希望这些细节能帮你在自己的电信数据集上少走几步弯路。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询