news 2026/10/13 22:48:55

客户流失预测不靠玄学:从数据清洗到MLP的完整流程

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
客户流失预测不靠玄学:从数据清洗到MLP的完整流程

简介:这是一份面向大数据技术类专业师生的《Python机器学习编程与实战》第8章教学教案,针对通信运营商客户流失分析与预测场景,讲解从数据预处理到分类建模的完整流程。教案覆盖数据去重、PCA降维、缺失值与异常值处理、独热编码、训练集与测试集划分,以及MLP多层感知器模型的构建与评估,并配有教学目标、重点难点和理论/实验教学过程设计。压缩包内为1个PDF文件,整体约64KB,已有1791人学习浏览。内容还设计了引导性、探究性和拓展性问题,可帮助教师组织课堂互动,也能指导学生围绕客户挽留策略、不同分类算法对比等方向做深入实践,适合作为备课讲义、自学提纲或实验参考。

1. 客户流失预测不是玄学:一套从清洗到 MLP 的完整流程

我拆过两份通信运营商客户流失的教案,这份第 8 章属于那种“看着平平无奇、跑一遍才发现门道不少”的资源。数据里几百个客户、几十个特征,流失用户占比不到 15%,如果你上来就直接丢给模型训练,准确率能到 90% 以上,但那基本等于废话。这套教案真正的价值,是把机器学习里最容易翻车的部分——数据预处理和特征工程——按教学顺序拆成了去重、降维、缺失值与异常值处理、独热编码、MLP 建模、模型评价六个完整环节,配套有数据、代码和引导性问题。适合带课老师、刚入门想做完整案例的新手,以及想复用这套流程做用户运营分析的人。下面按我完整跑通一遍的次序来说。

2. 数据清洗四件套:去重、降维、缺失值异常值一个都别省

2.1 去重:先查完全重复行,再决定 ID 列要不要丢

拿到数据第一步永远是去看有没有重复记录。通信运营商的原始数据经常来自多个系统导出的拼接,同一个用户被记了两遍非常常见。pandas 里用df.duplicated()可以统计完全重复的行,具体做法是:

import pandas as pd df = pd.read_csv("telecom_customer.csv") print("原始行数:", len(df)) print("完全重复行数:", df.duplicated().sum()) df = df.drop_duplicates() print("去重后行数:", len(df))

这里df.duplicated()默认按所有列一起判断是否重复,返回一个布尔序列,sum()统计 True 的个数。drop_duplicates()默认保留第一次出现的行,把后面重复的删掉。如果你的数据里存在“只有某几列相同但其他列不同”的情况,想按指定列去重,可以加subset参数,比如df.drop_duplicates(subset=["customer_id"]),意思是同一个 customer_id 只保留一行。

这一步看起来简单,但有个容易忽略的坑:customer_id 这类标识列不能直接当特征丢给模型。我在实际跑这份教案时见过有人把 ID 列留在特征里,结果 MLP 学到了“ID 是某几个值的人流失”这种完全无意义的规律。教案里把这步放在第(1)位是有道理的,去重不只是为了减少重复样本,更是为了让模型去学习真实的用户行为规律,而不是学习数据录入时的重复痕迹。

2.2 降维:PCA 是手段不是目的,先问列是不是真冗余

降维这一步容易被两种极端处理毁掉:一种是根本不降维,几十个特征直接硬塞给 MLP;另一种是上来就 PCA,完全不看原始列的业务含义。教案把降维放在去重之后,目的是让模型训练更快、也减少过拟合。常见做法是先看数值型特征之间的相关性,高度相关的列考虑删掉,然后再决定要不要 PCA。

我的习惯是先跑一段相关性检查:

corr = df_numeric.corr() high_corr = [(i, j) for i in corr.columns for j in corr.columns if i != j and abs(corr.loc[i, j]) > 0.8] print("高相关特征对数:", len(high_corr))

如果对数很少,说明列本身不算冗余,可以跳过 PCA;如果一堆列互相之间相关系数超过 0.8,比如通话时长和通话费用这种必然强相关的组合,就需要处理。教案里用的是 PCA,实战中我做 PCA 之前一定会先做标准化,否则通话分钟数这种量纲大的列会把整个主成分方向带偏:

from sklearn.preprocessing import StandardScaler from sklearn.decomposition import PCA scaler = StandardScaler() scaled = scaler.fit_transform(df_numeric) pca = PCA(n_components=12) df_pca = pca.fit_transform(scaled) print("累计方差贡献率:", pca.explained_variance_ratio_.cumsum())

解释一下参数:n_components=12表示保留 12 个主成分,具体保留多少个没有定数,一般做法是看累计方差贡献率,加到 85% 以上就够。explained_variance_ratio_输出每个主成分解释的方差比例,cumsum是累计求和。如果第一个主成分就解释了 60% 以上的方差,说明原始列之间确实存在高度冗余,PCA 是合理的;如果前几个主成分都很平均,说明特征各自独立,这时候硬降维反而是损失信息。

这里要特别提醒一句:PCA 出来的主成分是人看不懂的综合变量,教案里做降维更多是为了教学演示,实际业务中如果目标是“根据模型结果反推运营策略”,我一般会优先考虑直接删冗余列,而不是 PCA。这个取舍在教案的探究性问题里也有涉及,降维的影响是“减少过拟合、加速训练”和“损失可解释性”之间的权衡。

2.3 缺失值与异常值:先量化缺失比例,再决定填充策略

缺失值处理是整个数据清洗里最容易“想当然”的一步。教案里要求掌握缺失值和异常值的检测与处理,但我见过不少学生的做法是一上来就df.fillna(df.mean()),完全不管每列缺失了多少、为什么缺失。我的做法是先量化:

missing_ratio = df.isnull().mean() print(missing_ratio[missing_ratio > 0].sort_values(ascending=False))

df.isnull().mean()计算的是每列缺失值占比。参考经验是:缺失比例超过 30% 的列直接删掉,因为一条列缺失三分之一以上,填充出来的值基本是编的;缺失比例在 5% 以内,数值列用中位数填充、分类列用众数填充;中间地带通常看列的业务重要性决定。

填充的代码不复杂:

df["total_charge"].fillna(df["total_charge"].median(), inplace=True) df["international_plan"].fillna(df["international_plan"].mode()[0], inplace=True)

为什么数值列常用中位数而不是均值?因为均值对异常值敏感,如果这个客户的通话费用分布右偏、少数高消费用户拉高了平均值,用均值填充会高估普通用户的费用水平。中位数更稳健。

异常值检测常用的 IQR 方法代码是:

Q1 = df["total_day_minutes"].quantile(0.25) Q3 = df["total_day_minutes"].quantile(0.75) IQR = Q3 - Q1 lower = Q1 - 1.5 * IQR upper = Q3 + 1.5 * IQR outliers = df[(df["total_day_minutes"] < lower) | (df["total_day_minutes"] > upper)] print("IQR法识别异常值数量:", len(outliers))

IQR(四分位距)是第三四分位数减第一四分位数,1.5 倍 IQR 是经典判定区间,落在区间外的视为异常。注意这里有个实战上的坑:不要一识别出异常值就删。在通信运营商场景里,通话分钟数极高的人可能是商务用户或高价值用户,直接删掉反而把最值得关注的客户样本删没了。正确做法是先看异常值占比,如果只有 1%-2%,单独分析他们是否构成一个合理群体;如果异常值很多,说明数据分布本身就偏,考虑换用分位数截断而不是 1.5 倍 IQR。

3. 独热编码与数据合并:把类别列变成模型能读的数字

3.1 数字编码的陷阱:1、2、3 不是大小关系

通信运营商数据里有很多分类列:国际套餐(international_plan)是“是/否”,语音邮箱(voice_mail_plan)也是“是/否”,还有地区州名。初次接触机器学习的同学最容易犯的错误,是直接用 pandas 的factorize或者 sklearn 的LabelEncoder把类别变成 0、1、2、3 的整数。

这个做法在“是/否”这种二分类列上问题不大,但一旦类别超过两个(比如州名有几十个),LabelEncoder 就会给类别赋予大小顺序。模型看到数字 3 > 2 > 1,会默认类别之间存在顺序关系,学到的是根本不存在的规律。这是独热编码存在的根本原因:把每个类别拆成一列独立的 0/1 特征,互不干扰。

教案里的套餐类别就适合做独热编码。下面代码把两列 0/1 特征做独热编码,看起来有点小题大做,但它是理解整个编码思路的起点:

df_encoded = pd.get_dummies( df[["international_plan", "voice_mail_plan"]], drop_first=True ) print(df_encoded.head())

pd.get_dummies是 pandas 自带的一键独热编码,columns参数指定需要转换的列。drop_first=True的意思是:A、B 两个类别会被编码成两列 0/1,但删掉第一列,只保留一列,用 0 和 1 就能区分两个类别,这样能避免多重共线性。对特征少的逻辑回归模型尤其重要,对 MLP 影响也有限,但养成 drop 掉第一列的习惯没有坏处。

3.2 用 OneHotEncoder 处理多分类列:sparse_output 和 handle_unknown

如果分类列超过两个取值,比如 state 有 50 个州,用get_dummies没问题,但工程上更规范的做法是用 sklearn 的OneHotEncoder,它可以和 Pipeline 配合、也能在测试集出现新类别时用handle_unknown="ignore"避免报错。代码写法是:

from sklearn.preprocessing import OneHotEncoder encoder = OneHotEncoder(sparse_output=False, handle_unknown="ignore") state_encoded = encoder.fit_transform(df[["state"]]) print(state_encoded.shape)

这里sparse_output=False是为了返回普通 numpy 数组而不是稀疏矩阵,后续方便和其他 DataFrame 拼接。如果你的 sklearn 版本较老,这个参数叫做sparse=True,注意版本差异。handle_unknown="ignore"的意思是:训练时 state 只有 50 个类别,测试数据里如果冒出一个没见过的州,直接把这列全置 0,而不是报错中断。

需要注意:OneHotEncoder 转换出的列名默认是 0、1、2 这样的序号,丢了原来的类别名。想保留列名,可以用get_feature_names_out()方法拿到新列名,再套回 DataFrame。这个细节在教案的实验环节没细讲,但实际做特征工程时几乎一定会遇到。

3.3 合并数据:concat 按行对齐,merge 按键连接,别搞混

数据预处理到这里,手里大概有三个部分:清洗过的数值特征、PCA 降维后的分量(或者直接是标准化数值列)、独热编码后的类别特征。下一步是把它们拼回一张表。这一步看着简单,却是我见过翻车率最高的环节之一。

X_final = pd.concat([df_numeric, df_encoded], axis=1) print(X_final.shape)

pd.concat的axis=1是按列方向拼接,条件是好几张表的行顺序完全一致。这里只要保证都来自同一个原始 DataFrame 的索引,通常没问题。但如果你是从不同的 CSV 文件里分别读出特征和标签,再用 concat 拼接,行顺序万一不一样,模型就在错误的对齐关系上训练了。

那种场景应该用pd.merge,按共同键连接:

X = pd.merge(df_features, df_labels, on="customer_id", how="inner")

merge的on参数指定连接键,how参数指定连接方式。inner表示只保留两边都有的 customer_id。什么时候该用 concat、什么时候该用 merge,判断标准就一条:行之间有没有业务上的对应关系。有共同 ID 就用 merge;只是同一个数据集拆出来再拼回去,用 concat。

合并之后有一个必须做的检查:打印 shape 确认行数没变、列数符合预期。concat 默认是 outer join,万一两张表索引没对齐,行数会暴涨,很多人直到训练时报维度错误才发现数据已经烂了。这个习惯直接来自我自己的翻车经历。

4. 数据集划分与 MLP 训练:参数怎么设才能让模型真的收敛

4.1 train_test_split 的 stratify 参数:类别不均衡时一定要用

流失预测场景里,流失用户占比通常只有 10% 左右。直接随机划分训练集和测试集,有可能出现训练集里流失用户只剩 5%、测试集里占了 15% 的情况,模型和评价结果都失真。train_test_split的stratify参数就是干这个的:

from sklearn.model_selection import train_test_split X_train, X_test, y_train, y_test = train_test_split( X_final, y, test_size=0.3, random_state=42, stratify=y ) print("训练集流失占比:", y_train.mean()) print("测试集流失占比:", y_test.mean())

参数含义:test_size=0.3表示 30% 数据做测试集,stratify=y表示按 y 中类别的比例分层抽样,保证训练集和测试集里的流失客户占比都和全量一致。random_state=42固定随机种子,让每次运行结果可复现——这个参数在调参时尤其重要,不然你换一次参数就换一批数据分布,没法比较。

输出y_train.mean()看一眼两个集合的流失占比,如果 stratify 没设,这里的比例很可能差出几个百分点。

4.2 特征缩放:只对训练集 fit,测试集只 transform

数值特征如果包含通话时长、费用、客户年龄列,量纲差距很大。MLP 这类基于梯度下降的模型对量纲敏感,不缩放的话,大数值列会主导损失函数。标准化是常见做法,但有一个教学设计里经常不讲的细节:scaler 不能在整个数据集上 fit。

from sklearn.preprocessing import StandardScaler scaler = StandardScaler() X_train_scaled = scaler.fit_transform(X_train) X_test_scaled = scaler.transform(X_test)

逻辑说明:fit_transform在训练集上计算均值和标准差并完成缩放,transform直接沿用训练集的均值和标准差去缩放测试集。如果对测试集也做fit_transform,相当于测试集的统计信息提前泄漏进了模型,测试结果就“虚高”了。这是数据泄漏的一种常见隐蔽形式。

这一点我在用这份教案上课时反复强调,学生最容易犯的错误就是把 scaler 对整个 X 做fit_transform,再切训练集和测试集。从结果看模型指标很好,但部署上线后完全不是那么回事。

4.3 MLPClassifier 参数详解:hidden_layer_sizes、activation、max_iter 怎么配

MLP 是这份教案的核心算法。sklearn 里的 MLPClassifier 是前馈神经网络的最简实现,参数不多但每个都影响训练结果。一份能直接跑的代码如下:

from sklearn.neural_network import MLPClassifier mlp = MLPClassifier( hidden_layer_sizes=(32, 16), activation="relu", solver="adam", max_iter=500, random_state=42 ) mlp.fit(X_train_scaled, y_train)

逐个说参数:hidden_layer_sizes=(32, 16)表示两层隐藏层,第一层 32 个神经元、第二层 16 个。层数和神经元的数量没有标准答案,教案里教的是一般从数据规模出发,特征 30-50 个时两层每层 16 到 64 是常见起步区间,过少学不到规律,过多容易过拟合。activation="relu"是隐藏层的激活函数,ReLU 计算快、能缓解梯度消失,是当前 MLP 的默认选择,相比 tanh 收敛更快。solver="adam"是优化器,Adam 自适应学习率,绝大多数场景比 sgd 省心。max_iter=500是最大迭代轮数,几千样本的训练集通常 500 轮足够收敛,但如果跑了 500 轮还没收敛,sklearn 会弹出 ConvergenceWarning,这时不是报错,而是明示你迭代不够。

训练完之后想确认模型是否真的收敛,看 loss 曲线:

import matplotlib.pyplot as plt plt.plot(mlp.loss_curve_) plt.xlabel("iteration") plt.ylabel("loss") plt.show()

mlp.loss_curve_是训练过程中每个迭代周期的损失值序列。曲线如果在末端已经变得平缓,说明收敛了;如果还在明显下降就停了,说明 max_iter 不够,调大到 1000 再跑。这个可视化是排查训练是否健康最直接的证据。

这里我顺便提一下 activation 和 solver 的搭配。对这份数据量级的分类任务,relu + adam 是稳定组合,tanh + sgd 不是不行,但收敛慢、对学习率敏感,新手几乎必翻车。教案把 MLP 放在重点位置,本质上就是让学习者一遍遍体会参数间的耦合关系。

5. 模型评价与避坑指南:准确率高不代表模型能用

5.1 评价指标怎么读:混淆矩阵、精确率、召回率、F1

流失预测是最典型的类别不均衡分类问题——90% 客户不流失,10% 流失。这时候准确率没有参考价值:模型什么都不学,全部预测“不流失”,准确率也有 90%。教案里教学目标是掌握分类模型评价方法,实际看的就是混淆矩阵和它派生出的三个指标。

from sklearn.metrics import classification_report, ConfusionMatrixDisplay y_pred = mlp.predict(X_test_scaled) print(classification_report(y_test, y_pred)) ConfusionMatrixDisplay.from_predictions(y_test, y_pred)

classification_report输出精确率、召回率、F1 三列。在流失场景里,召回率意味着“真正流失的客户里模型找回了多少”,精确率意味着“预测会流失的人里有多少真的流失了”,F1 是两者的调和平均,类别不均衡时比准确率可靠得多。ConfusionMatrixDisplay直接画出 2x2 混淆矩阵,左上角是预测正确的不流失客户,右下角是预测正确的流失客户,左下角是“预测不流失但实际流失”的漏报,右上角是“预测流失但实际没流失”的误报。

判断模型好坏不能只看一个数。想降低运营成本就压低误报,想最大限度挽留客户就压低漏报,成本和收益的权重决定了你更该优化哪个指标。

5.2 避坑记录一:标签泄漏让准确率虚高到离谱

现象:模型在测试集上误差极低,准确率接近 95%,但换一批真实数据预测完全不准。原因:特征里混进了“只有在流失发生后才能知道”的变量,比如用户已经产生的退费金额、投诉次数、最后的月消费额。这类列在训练集里和标签天然强相关,模型学到的其实是“事后信息”而不是“事前征兆”。解决:模型训练前逐列做信息保留检查,把任何在预测时点根本拿不到的列全部删掉。这是做这门课实验时最隐蔽也最致命的一个坑。

5.3 避坑记录二:类别不均衡导致模型全预测成多数类

现象:classification_report里流失那一行的召回率接近 0,测试集 90% 以上样本都被预测为不流失。原因:MLP 默认的阈值 0.5 是在假设两类样本数量均衡的前提下设计的,流失样本只有 10% 时,模型输出的概率很难超过 0.5。解决:给 MLPClassifier 加class_weight="balanced",让少数类样本在损失函数里获得更高权重;如果还不够,就调低预测阈值,比如predict_proba输出的概率超过 0.3 就判为流失,用验证集选最优阈值。

5.4 避坑记录三:ConvergenceWarning 被当成报错

现象:训练完弹出一行 “ConvergenceWarning: Maximum iterations (500) reached without convergence”,很多人以为模型坏了。原因:不是报错,是 sklearn 在提示你 max_iter 不够,训练在达到迭代上限时被强制停止,模型还没收敛。解决:看mlp.loss_curve_曲线形状——末端还在下降就把 max_iter 加到 1000 或者 2000,如果曲线已经平了还警告,说明损失函数可能已经进入平坦区,可以忽略警告。这一步属于最常见的“被警告吓住”问题。

5.5 避坑记录四:独热编码后维度爆炸,训练直接变龟速

现象:做完整套预处理,X_train_scaled的列数从 30 涨到 200+,MLP 训练一轮要等很久。原因:state 这种高基数类别列没处理就get_dummies,几十个州变成几十列,加上其他类别列,特征维度爆炸。解决:先对低频类别做合并,出现次数低于阈值(比如总数 1%)的类别统一替换成 “others”,再做独热编码。这个操作能用极小的信息损失换掉大量冗余维度。我在跑这份教案时习惯在 get_dummies 之前加一段value_counts(normalize=True)检查每个类别的占比,而不是直接编码。

6. 进阶用法:换分类器对比验证,再用特征重要性反推业务

6.1 统一数据口径下的算法对比

MLP 不是唯一能解决流失预测的算法。教案的拓展性问题正好问到了“除了 MLP,换其他分类算法是否效果更好”,这个问题在实验里值得认真做一次对比验证。代码写法很简单:

from sklearn.linear_model import LogisticRegression from sklearn.tree import DecisionTreeClassifier models = { "MLP": MLPClassifier(hidden_layer_sizes=(32, 16), max_iter=500), "LogisticRegression": LogisticRegression(max_iter=500), "DecisionTree": DecisionTreeClassifier(max_depth=5) } for name, model in models.items(): model.fit(X_train_scaled, y_train) y_pred = model.predict(X_test_scaled) print(name, classification_report(y_test, y_pred, zero_division=0))

注意这里统一用同一套缩放后的训练数据,保证了对比的公平性。你会发现逻辑回归在类别不均衡时常常比 MLP 更容易调教,而决策树的优势是能直接看feature_importances_反推业务含义。对运营团队来说,特征重要性往往比模型准确率更有价值,因为它告诉你该优先给哪些用户发挽留策略。

如果想把 MLP 参数摸得更透,网格搜索是省事的选择:

from sklearn.model_selection import GridSearchCV param_grid = { "hidden_layer_sizes": [(64,), (64, 32), (32, 16)], "alpha": [0.0001, 0.001] } grid = GridSearchCV( MLPClassifier(max_iter=500), param_grid, cv=3, scoring="f1" ) grid.fit(X_train_scaled, y_train) print(grid.best_params_) print(grid.best_score_)

GridSearchCV会在参数组合里逐个训练并交叉验证,cv=3表示 3 折交叉验证,足够评估且不会等太久。scoring="f1"表示用 F1 作为择优指标——再次强调,流失预测这类不均衡场景如果写scoring="accuracy",搜索出的参数会偏袒多数类。这份教案跑完,我自己的感受是:从清洗到建模的每一步都可以通过指标对比来验证必要性,某一列不删、某个填充方式换一下,F1 的波动都比想象中大。从那以后,我每次做完一个模型,都强制自己再跑一遍特征删减实验,用对比结果来说服自己而不是靠感觉。希望这次梳理能帮你在跑这个案例时少走几条弯路。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/13 22:48:24

制造业进销存选型:适配性比功能全更重要

制造业老板们聊起进销存软件&#xff0c;第一句话往往是“我想要一套功能全的”。但这几年我越来越觉得&#xff0c;选型真正该盯着的不是功能全不全&#xff0c;而是适配性够不够。同样是进销存&#xff0c;贸易商拿来管一买一卖&#xff0c;制造企业要管的是采购计划、生产领…

作者头像 李华
网站建设 2026/10/13 22:47:59

HDwiki 6.0 部署实战:PHP+MySQL 中文维基系统落地指南

简介&#xff1a;互动百科HDwiki 6.0 UTF8 是一套面向中文站长与PHP开发者开源的维基建站系统&#xff0c;专为构建专业级中文百科、知识库或企业内部Wiki平台设计&#xff0c;兼顾二次开发灵活性与生产环境安全性。资源为完整可部署的PHP源码包&#xff0c;含核心程序、后台管…

作者头像 李华
网站建设 2026/10/13 22:47:28

Vue3+ExcelJS前端导出Excel:样式控制与常见坑位全解析

做后台管理系统开发的朋友应该都有同感&#xff0c;"把当前表格导出成Excel"这个需求&#xff0c;几乎在每个项目里都会撞见。以前我习惯把活甩给后端&#xff0c;后端拼文件、返回地址、前端触发下载&#xff0c;一条链路看似顺畅&#xff0c;但一旦碰样式就难受了。…

作者头像 李华
网站建设 2026/10/13 22:46:45

AI搜索时代,官网内容优化与企业级RAG谁先做?

企业官网的定位&#xff0c;最近两年其实一直在被重新定义。过去我们说官网是企业对外展示的门面&#xff0c;做得好一点能带来线索&#xff0c;做得平庸一点就只是个“在线宣传册”。但到了AI搜索逐渐成为用户获取信息主入口的今天&#xff0c;很多企业开始发现&#xff0c;官…

作者头像 李华
网站建设 2026/10/13 22:46:23

COMSOL超表面仿真:从S参数相位到GH位移计算的完整流程

做超表面仿真的人迟早会遇到这样一个场景&#xff1a;透过率、反射率的谱线调得很顺眼&#xff0c;但对方突然一句“相位响应呢”&#xff0c;整个思路就开始冒汗。我最近在做一个光子晶体超表面的透反射分析时&#xff0c;被问到了一个更具体的量——光束斜入射后反射光在界面…

作者头像 李华