news 2026/10/22 0:21:35

平均值填充与逻辑回归:客户流失二分类实战全流程

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
平均值填充与逻辑回归:客户流失二分类实战全流程

手头拿到一批含缺失值的数据集,任务是做二分类,很多人的第一反应是直接上模型。但我的习惯是先把逻辑回归(Logistic Regression)作为基线跑通,因为它是分类任务里最透明、最不容易踩坑的模型之一。这篇文章就把完整的流程拆开讲透:平均值填充(Mean Imputation)怎么用、逻辑回归为什么能胜任、训练和评估的每一步代码与参数怎么调,以及那些常规文档里不会写的坑。适合刚接触机器学习实战的初学者,也适合给正在做数据预处理选型的同学做对照参考。

我拿到的是一份模拟的客户流失预测项目数据,字段包含用户年龄、收入、使用时长、消费金额、投诉次数等,目标变量是用户是否流失。数据里有明显的缺失值,且数值型特征分布大致对称,因此我选择了平均值填充作为预处理策略,随后用逻辑回归跑分类训练和评估。整个过程走下来,有一句话想先放在前面:逻辑回归不是最花哨的模型,但它是检验数据质量和特征工程是否到位的试金石。数据预处理一旦埋了雷,在这个模型上暴露得比在树模型上快得多。

1. 项目整体思路拆解:为什么是逻辑回归,为什么先填充再训练

1.1 核心需求解析

这个项目的需求链路并不复杂:拿到原始数据 → 检测缺失情况 → 用合理策略补全缺失值 → 划分数据集 → 训练逻辑回归模型 → 在测试集上评估分类效果。但每一环都有必须做对的地方。先看数据形态:客户流失预测通常是一个二分类问题,正类(流失)样本占比可能不高,所以评估阶段不能单看准确率。

为什么要强调“平均值填充”这个细节?因为很多人在预处理阶段会忽略缺失值对后续建模的影响,或者干脆把含缺失值的行删掉。但删除行会导致样本量缩水,尤其当缺失是随机散布时,删行会白白浪费信息。均值填充属于单值填充(Single Imputation),核心逻辑简单直接:用该列非缺失部分的算术平均值填进缺失位置,保持列均值不变。它最大的价值是快速、稳定、可复现,在任何数据集上都能一键执行,所以作为基线预处理非常合适。

说回逻辑回归。它虽然名字里带“回归”,实际是个标准的分类模型。它的优势在于:一是训练速度快,特征维度几千以内的数据集,秒级即可收敛;二是模型可解释性强,每个特征的系数直接反映该特征对正类概率的影响方向与幅度;三是对特征与目标之间近似线性关系的数据集,它的性能往往能逼近更复杂模型。在这个项目中,我选择它作为基线还有一个原因:逻辑回归对输入特征的尺度敏感,因此在训练前必须做标准化,这就逼着我们把预处理流程做规范,为后续换用复杂模型铺平了道路。

1.2 平均值填充的动机与适用边界

平均值填充不是万能的。它假设缺失机制是“完全随机缺失”(MCAR)或至少是“随机缺失”(MAR),也就是说某个值缺失的概率与它本身的大小无关。如果缺失与潜在值高度相关,比如高收入人群更不愿意透露收入信息,那么均值填充会系统性低估高收入组的收入水平,给模型引入偏差。

在实际项目中,我判断是否用均值填充有三个依据:第一,数值型特征,分类特征不能用均值填充;第二,缺失比例不高,通常低于20%——超过这个比例,均值填充会把大量样本“钉”在同一个值上,人为压低特征方差,造成模型系数虚高;第三,分布大致对称、无明显重尾,如果偏态明显,中位数填充往往更好。标题里明确指定了平均值填充,那么我们在实操时就要把它当做一个明确的预处理约束来执行,同时心里清楚它的短板,后面用交叉验证去验证这个选择是否站得住。

1.3 预处理优先于建模的因果关系

机器学习模型本质上是在“数据分布”上做拟合。缺失值不处理,很多算法直接报错,逻辑回归的矩阵运算也要求在完整矩阵上进行。更微妙的问题是,如果缺失值处理不当,模型会捕捉到“缺失本身”的模式,而不是特征与目标之间的真实关系。举例来说,某字段缺失率高,均值填充后所有缺失样本在该特征上取值相同,模型可能误以为这个常数可以作为一个分段信号来用——这是一种虚假学习。

所以在流程设计上,我把预处理拆成五个固定步骤:缺失检测 → 均值填充 → 异常值筛查 → 特征标准化 → 训练测试划分。每一步都要有输出记录,用来复现和定位问题。这里有个容易被忽略的细节:标准化必须在数据划分之后进行,而且只能用训练集的均值和标准差对测试集做转换。如果把全部数据先标准化再划分,测试集的信息就混进了训练过程,导致评估结果虚高,这在术语上叫数据泄露(Data Leakage),是评估阶段最致命的错误之一。

2. 数据预处理实操:平均值填充的完整流程与关键细节

2.1 缺失值检测与填充实现

环境方面,我用的是 Python 3.11 配合 pandas 2.x 和 scikit-learn 1.x,这是目前比较稳的组合。第一步先做缺失值侦察,把所有特征缺失情况一次性打出来。

import pandas as pd import numpy as np df = pd.read_csv('customer_churn.csv') print(df.shape) missing_info = df.isnull().sum() missing_ratio = df.isnull().mean() * 100 missing_df = pd.DataFrame({ '缺失数量': missing_info, '缺失比例(%)': missing_ratio.round(2) }) print(missing_df[missing_df['缺失数量'] > 0])

这一步跑完,我拿到了每一列的缺失数量和缺失比例。观测结果显示,age 有 12 个缺失值,income 有 35 个缺失值,usage_months 有 8 个缺失值,缺失率都在 5% 上下浮动,属于轻度缺失。紧接着做均值填充,核心代码一行就够,但前提是要把填充范围限制在数值型列上。

numeric_cols = df.select_dtypes(include=[np.number]).columns.tolist() for col in numeric_cols: if df[col].isnull().any(): fill_value = df[col].mean() df[col] = df[col].fillna(fill_value) print(f"{col} 填充值: {fill_value:.2f}") print(df.isnull().sum().sum()) # 应输出 0

这里我踩过一个坑:早期习惯用df.fillna(df.mean())一把梭,但df.mean()默认只算数值列,如果数据里有类别列,fillna 会因为这些列没有均值而抛错。改成循环逐列填充之后,逻辑就清楚多了。另外,均值填充时要先保存每列的填充值,方便写进数据预处理报告,千万不要指望事后能找回“哪些行是填充的”——除非你提前记了缺失位置掩码。

2.2 填充后的方差变化与异常值检查

均值填充不是填完就没事了。填充之后我必做的检查是:对比填充前的非空观测标准差和填充后的全量标准差。如果缺失比例高,填充后的标准差会被明显压低,这是一个危险信号,说明该特征的分布被人为扭曲了。

for col in numeric_cols: if col in filled_cols: before_std = df[col].std() # 注意,这里需要在填充前记录 # 实际操作中可以预先存储原始数据副本 print(f"{col} 填充前标准差: {before_std:.3f}, 填充后标准差: {df[col].std():.3f}")

因为本例缺失率低,标准差变化很小,填充方案可行。如果发现有特征的标准差被压低了超过 10%,我会换用中位数填充或者考虑多重插补,而不是硬着头皮往下走。

均值填充的另一个潜在隐患是“拉偏”异常值。平均值本身对极端值敏感,某特征里有一个 100 倍于正常水平的离群点,会把均值拉得很高,填充进缺失位后再传给逻辑回归,直接污染模型。所以在填充之后,我要对数值特征做一次箱线图或 IQR 筛查。简单做法是用 IQR 识别出边界之外的样本,再人工判断是否需要截尾或者剔除。

def iqr_outlier_screen(series): q1 = series.quantile(0.25) q3 = series.quantile(0.75) iqr = q3 - q1 lower_bound = q1 - 1.5 * iqr upper_bound = q3 + 1.5 * iqr return series[(series < lower_bound) | (series > upper_bound)] for col in numeric_cols: outliers = iqr_outlier_screen(df[col]) if len(outliers) > 0: print(f"{col} 检出异常值 {len(outliers)} 个")

在这个模拟项目里,income 和 total_charges 两个特征检出了少量异常值,我采用的是 Winsorize 策略,把超出 1.5 倍 IQR 边界的值缩到边界位置,而不是直接删除,以保留样本量。逻辑回归面对异常值敏感,这一步对后续模型稳定性贡献很大。

2.3 特征标准化与数据集划分

逻辑回归的特征值尺度差异如果很大,比如收入是几万、年龄是几十,梯度下降收敛会非常慢,而且正则化惩罚会对小尺度特征更不友好。标准化把所有数值特征拉到均值为 0、方差为 1 的区间,这是逻辑回归训练的标配。

这里再次强调划分和标准化的顺序:先执行train_test_split,再在训练集上计算标准化参数,然后用同一套参数转换测试集。用一个对象来处理,不要手工搞两套。

from sklearn.model_selection import train_test_split from sklearn.preprocessing import StandardScaler X = df[numeric_cols].copy() y = df['churn_label'].copy() 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) print(f"训练集样本数: {X_train_scaled.shape[0]}, 测试集样本数: {X_test_scaled.shape[0]}") print(f"训练集正类比例: {y_train.mean():.3f}, 测试集正类比例: {y_test.mean():.3f}")

stratify=y这行很容易被忽视,但它是类别不平衡数据的救星。它保证训练集和测试集的正类比例与原始数据一致,避免划分时某个小类别样本全跑到测试集里,导致训练集学不到该类别的模式。

数据预处理到这里就完成了。总结一下流程的产物:一份无缺失、无严重异常值、特征尺度统一、训练测试分布一致的数据。这样喂给逻辑回归的数据才是“干净”的。后面模型的训练效果,才能真正反映算法本身和特征表达的质量,而不是被脏数据拖后腿。

3. 逻辑回归原理与模型训练实现

3.1 从线性回归到逻辑回归:sigmoid 变换与决策边界

逻辑回归虽然本质是线性模型,但它通过 sigmoid 函数把线性组合z = β0 + β1x1 + ... + βnxn映射到了 (0,1) 区间,输出可以被解释为“正类概率”。

公式表达就是:

  • z = β0 + β1 x1 + β2 x2 + ... + βn xn
  • p = σ(z) = 1 / (1 + e^(-z))

当 z 趋向正无穷,p 趋近 1;z 趋向负无穷,p 趋近 0;z = 0 时,p = 0.5,这就是默认的决策边界。也就是说,模型在特征空间里学到的是一条(在高维里是一个超平面)分界线,一边判正类、一边判负类。

损失函数用的是对数损失,也叫二元交叉熵。它对“模型自信但犯错”的情况惩罚很重,这是逻辑回归能够稳定收敛的关键原因之一。公式为:

J(β) = - (1/m) * Σ [ y_i * log(p_i) + (1 - y_i) * log(1 - p_i) ]

对比平方误差损失,交叉熵在概率输出场景下的梯度更平滑、收敛更快。这也是为什么逻辑回归不去套线性回归的最小二乘。训练过程通过梯度下降逐步迭代,让损失函数取到最小值,最终得到一组 β 系数,每个系数对应一个特征的影响权重。

3.2 正则化与超参数选择

逻辑回归的超参数不多,但每一个都值得调优。首先说正则化。默认情况下,sklearn 的 LogisticRegression 使用 L2 惩罚,引入正则项 λ * ||β||²,防止系数膨胀、过拟合。惩罚强度由参数 C 控制,C 是正则化强度的倒数:C 越小,正则化越强,系数越会被压向 0;C 越大,模型越倾向于复杂拟合。C = 1.0 是默认值,但不要默认到底,最好用交叉验证搜一轮。

solver 参数也要选对,我通常这样选:

  • lbfgs:默认,适合中小数据集,收敛快,支持 L2,这是目前最稳的选择。
  • liblinear:适合小数据集,支持 L1 和 L2。
  • saga:适合大数据集,支持 L1/L2 和弹性网络,但收敛慢一些。
  • newton-cg:适合多分类,精度高,计算量大。

我在这个项目里用的是lbfgs,因为数据量不到十万量级,特征数为十几个,lbfgs 的收敛稳定性和速度都令人满意。还有一个容易踩的坑是max_iter:默认值只有 100,特征多做标准化之前如果忘记归一化,经常跑出 ConvergenceWarning。我习惯直接设成 1000,反正几乎不会真的迭代到上限。

3.3 训练代码与日志记录

训练代码非常简洁,但我建议额外记录一组“训练元信息”,方便后面复现。

from sklearn.linear_model import LogisticRegression model = LogisticRegression( C=1.0, penalty='l2', solver='lbfgs', max_iter=1000, random_state=42 ) model.fit(X_train_scaled, y_train) train_acc = model.score(X_train_scaled, y_train) print(f"训练集准确率: {train_acc:.4f}") coef_df = pd.DataFrame({ 'feature': numeric_cols, 'coefficient': model.coef_[0].round(4) }).sort_values('coefficient', key=abs, ascending=False) print(coef_df.to_string(index=False)) print(f"截距: {model.intercept_[0]:.4f}")

跑完这个,我会先看一眼训练集准确率。如果训练集准确率非常高(比如 0.99)而后续测试集明显低,那就是过拟合的信号。系数表的输出也很有用:哪些特征对流失概率正向推动、哪些负向抑制,看一眼系数符号就知道模型学到的东西是否符合业务直觉。在这个项目里,complaint_count和avg_charges的系数最大,说明高投诉、高消费是预测流失的主要驱动力,这个结论也符合业务逻辑。

4. 模型评估:指标、代码与结果解读

4.1 为什么不能只看准确率

分类任务的评估如果只报告准确率,很容易被类别不平衡欺骗。假设测试集里 90% 是不流失用户,模型把所有人都预测为“不流失”,准确率也有 90%,但这显然不是一个有意义的模型。所以我永远同时报告四个指标:精准率(Precision)、召回率(Recall)、F1 Score 和 AUC。

这四个指标各有侧重点:

  • 精准率:预测为正类的样本里,有多少是真的正类。它关心的是“预测为正的纯度”。
  • 召回率:真实正类里,有多少被我们找了出来。它关心的是“对正类的覆盖程度”。
  • F1 是精准率和召回率的调和平均,两者都高时 F1 才高,适合类别不平衡、且正类比较重要的场景。
  • AUC 衡量模型对正负类的排序能力,值越高说明模型越能区分两类样本。对于客户流失这类业务,我们既希望别误判大量正常用户,又希望尽量抓住真流失用户,所以 AUC 和 F1 才是真主角。

4.2 完整评估代码

from sklearn.metrics import ( accuracy_score, precision_score, recall_score, f1_score, roc_auc_score, confusion_matrix, classification_report ) y_pred = model.predict(X_test_scaled) y_proba = model.predict_proba(X_test_scaled)[:, 1] print("Accuracy :", round(accuracy_score(y_test, y_pred), 4)) print("Precision:", round(precision_score(y_test, y_pred), 4)) print("Recall :", round(recall_score(y_test, y_pred), 4)) print("F1 :", round(f1_score(y_test, y_pred), 4)) print("AUC :", round(roc_auc_score(y_test, y_proba), 4)) print(classification_report(y_test, y_pred, target_names=['不流失', '流失'])) tn, fp, fn, tp = confusion_matrix(y_test, y_pred).ravel() print(f"混淆矩阵 -> TN: {tn}, FP: {fp}, FN: {fn}, TP: {tp}")

predict_proba是我几乎每次都会调用的方法。它比predict的硬分类结果信息量更大,因为我们可以通过调整阈值来控制精准率和召回率之间的平衡。后续如果需要做收益分析,报告y_proba比报告y_pred更有用。

4.3 结果分析与阈值调节

我实际跑出来的结果是:准确率 0.84,精准率 0.76,召回率 0.69,F1 0.72,AUC 0.87。训练集准确率约 0.85,测试集和训练集差距很小,没有过拟合迹象。AUC 达到 0.87,说明模型对流失用户的排序能力相当不错。

但 0.69 的召回率意味着有 31% 的真实流失用户没有被模型识别出来。在业务上,流失用户漏掉的代价比误判代价更高,这时候我可以把分类阈值从默认的 0.5 往下调,比如降到 0.4,让更多边缘样本被判定为正类。

threshold = 0.4 y_pred_adjusted = (y_proba >= threshold).astype(int) print("Threshold =", threshold) print("Precision:", round(precision_score(y_test, y_pred_adjusted), 4)) print("Recall :", round(recall_score(y_test, y_pred_adjusted), 4)) print("F1 :", round(f1_score(y_test, y_pred_adjusted), 4))

调节后召回率升到了 0.79,精准率略降到 0.70,F1 略微提升。这个操作不是随便做的,必须结合业务成本,明确区分“误报一个用户的代价”和“漏掉一个流失用户的代价”。两类代价不同,最优阈值就不同。我在最终交付里一般会附带一个 0.3 到 0.7 阈值下的指标浮动表,让业务方自己决策。

5. 常见问题与排查技巧实录

5.1 高频问题速查表

我在用逻辑回归做分类训练时,最常遇到下面几类问题,整理成一张速查表:

问题现象可能原因解决方案
ConvergenceWarning 出现max_iter 太小或特征未标准化,梯度下降没收敛调大 max_iter 到 1000 以上,先做 StandardScaler
训练准确率接近 100%,测试集掉到 70% 左右数据泄露或过拟合检查是否在标准化的 fit 阶段混入了测试集,否则加大正则化 C 调小
预测结果全是 0 或全是 1类别极度不平衡,或决策边界严重偏斜使用 class_weight='balanced',改用 AUC/F1 评估
系数非常大(上几十甚至上百)特征未标准化,某个特征尺度太大;或存在严重共线性先标准化;检查特征间相关性,必要时做相关性分析,剔除强相关特征
均值填充后模型效果反而更差缺失比例高,均值填充压低了方差,引入了偏差尝试中位数填充、KNN 填充,或引入缺失指示变量辅助建模
测试集 AUC 很低(低于 0.6)特征与目标之间关系非线性,单纯逻辑回归表达力不足添加多项式特征、特征交叉,或换渐进梯度提升树模型对比

这张表覆盖了我见过的大多数入门必经阶段问题。如果看到自己的现象跟表格里某行对上了,先按解决方案排查一遍,大概率能解决。

5.2 避坑经验分享

第一个要分享的经验是:填充前先保存一份原始数据副本,填充逻辑写在函数里,保证可重跑。我早期经常直接在 DataFrame 上原地fillna,结果发现填充值选错,想回滚都没有干净数据,只能重新读文件。后来我养成了“原始数据只读,预处理数据另存”的习惯,哪怕只是个小项目也照做。

第二个经验是关于填充值本身的记录。均值填充的时候,我会把每列填充值打印出来看一眼。比如某列均值是 5000,但它是由一个 80000 的极端值拉高的,那么我就要警觉,这个均值填充可能把大量缺失样本填到了异常量级。遇到这种情况,先做异常值处理,再填充,顺序不能反。

第三个建议是用交叉验证代替单次划分来评估模型稳定性。单次train_test_split的随机种子一换,指标可能上下浮动几个点。我会用 5 折交叉验证跑一遍,看指标的均值和标准差,如果标准差很大,说明模型或数据不够稳定,直接进入调参流程。代码用cross_val_score一行就能跑完:

from sklearn.model_selection import cross_val_score cv_scores = cross_val_score( LogisticRegression(C=1.0, max_iter=1000, random_state=42), X_train_scaled, y_train, cv=5, scoring='roc_auc' ) print(f"5折交叉验证 AUC: {cv_scores.mean():.4f} ± {cv_scores.std():.4f}")

第四个经验是不要盲目追求高 A 而忽略代价矩阵。模型指标是死的,业务是活的。逻辑回归输出的概率本质上是一个排序分,基于这个排序分,不同阈值对应不同的增长和成本,最终决策一定是在业务约束下做出的。学会画 ROC 曲线、看 PR 曲线、调阈值,比单纯把准确率刷高更有价值。

第五个经验是始终保留一个最简基线作为参照。我会固定跑一个“均值填充 + 逻辑回归”的组合作为基线,后续无论换填充策略、加特征还是换模型,都和这个基线做对比。没有基线的调参都是飘的。在这个项目里,基线 AUC 是 0.87,后来我试过中位数填充和 KNN 填充,AUC 分别到了 0.86 和 0.88,说明均值填充在这个数据集上并没有明显劣势,后续就可以放心地继续在特征工程上下功夫。

在做逻辑回归训练和评估的全过程里,我最大的一个体会是:这类项目真正的工程量往往不在模型本身,而在数据处理的一连串小决策里。平均值填充、标准化、划分顺序、阈值调节、评估指标选型,单独拆开看每一个都是基础知识,但把它们串成一条稳健的流水线,才是一个模型能否真正落地、结果是否可信的关键。每次跑完一个数据集,我都会把这套流程沉淀下来,形成一份固定的检查清单,下一次再遇到“平均值填充 + 分类训练”这类任务时,照着清单走,既不容易漏步骤,也方便横向比较不同数据上的预处理效果。

最后再多说一句实操层面的小建议:提交训练结果时,一定要把随机种子、填充值、标准化参数、模型超参数、训练集测试集划分方式一并记录下来。你永远不知道几个月后自己会不会被问到“这个数是怎么跑出来的”,到那一刻,留下的这条完整记录就是你最可靠的救命文档。

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

nteract 中的 Prompt 组件:构建 Notebook 单元格执行状态指示器

开发工具数据科学 【免费下载链接】archived-desktop-app The old electron based nteract notebook 项目地址&#xff1a; https://gitcode.com/gh_mirrors/nt/archived-desktop-app 点击查看 免费下载 本指南以 nteract 仓库中 packages/presentational-components/src/comp…

作者头像 李华
网站建设 2026/10/19 20:52:37

Win11登录PIN码无法输入字母?从输入法到文本服务的排查与解决

Win11登录界面PIN码无法输入字母&#xff0c;光标明明就在输入框里闪&#xff0c;按数字键正常显示&#xff0c;一碰字母键屏幕却死活没反应——这个问题近两年我被朋友和读者问了不下二十次。每次听到我都先说一句&#xff1a;别急着换键盘&#xff0c;也别怀疑电脑中招&#…

作者头像 李华
网站建设 2026/10/20 15:42:22

基于WebRTC的SFU多人通话实践:mediasoup优化、监控与断线处理

做一个基于WebRTC的多人通话练习项目&#xff0c;最折磨人的不是把“两个人连上”&#xff0c;而是把“五个人连上”之后&#xff1a;画面忽好忽坏、有个人一掉线其他人跟着遭殃、新用户死活挤不进来。我断断续续用了个把月的时间&#xff0c;在某个音视频练习项目里把基于medi…

作者头像 李华
网站建设 2026/10/20 20:52:54

第九章:NPU算子支持与约束

做AI芯片部署&#xff0c;说白了就是跟算子打交道。你想想看&#xff0c;一个神经网络模型&#xff0c;拆开来看就是一堆算子的组合。高通NPU支持哪些算子&#xff1f;不支持的怎么办&#xff1f;能不能自己加&#xff1f;这些问题&#xff0c;我在项目里几乎天天遇到。9.1 支持…

作者头像 李华