机器学习的标准工作流程,从数据预处理到模型评估(附可运行代码)
2026/9/3 5:30:08 网站建设 项目流程

翻当时的学习笔记的时候看到这么一页,写得很潦草:数据清洗、标准化、特征工程、编码、过采样欠采样、6:2:2、网格搜索、贝叶斯优化、泛化能力……

每个词单独拎出来我都能说出个大概,但当时有个问题一直没想通:这些东西到底谁先谁后?我记得最清楚的画面是,第一次做课程项目,我把SMOTE过采样跑完了才想起来划分训练集,导师看了一眼说"你这是把答案提前告诉模型了"。

后来自己动手跑了几个项目,才算把这页笔记串成一条线。它其实是一条流水线,前后顺序有明确的道理,乱了就出事。这篇文章就是把这条线从头到尾捋一遍,配一份复制就能跑的完整代码。如果你是刚上完课、准备动手做第一个项目的阶段,应该能少踩几个坑。


一、先把全貌看清楚

整个流程就三件事,但三件事花的时间完全不成比例:数据预处理吃掉整个项目 6~7 成的工作量。一个反直觉的事实是:换算法带来的提升,通常远不如把数据弄好带来的提升。同样的数据,逻辑回归和 XGBoost 的差距可能是三五个点;但把特征工程做对,可能是二十个点。所以第一阶段别偷懒。

一个反直觉的事实:换算法带来的提升,通常远不如把数据弄好带来的提升。同样的数据,逻辑回归和 XGBoost 的差距可能是三五个点;但把特征工程做对,可能是二十个点。所以第一阶段别偷懒。


二、数据预处理:最脏最累但最值钱

2.1 数据清洗

原始数据基本都是脏的。常见四类问题:

问题类型

典型表现

常见处理

缺失值

NaN、空字符串、-999、0

删行 / 填中位数众数 / 填业务默认值

异常值

年龄 250、月消费 999999

业务规则修正 / 当缺失处理 / 保留

重复值

完全重复、主键重复

去重

类型错误

数字存成字符串、日期格式不统一

转类型

缺失值这里有个容易忽略的细节:空字符串不是 NaNpandas读进来的空单元格才是 NaN,业务系统里导出的空字符串往往原样保留,df.isna().sum()查不出来。处理前先统一一下:

df["contract_type"] = df["contract_type"].replace("", np.nan)

异常值我想多说一句,因为这是我踩过的坑。我第一次处理一张电商用户表,用 3σ 把"月消费"字段的离群点全删了,结果把那批消费五万以上的 VIP 全干掉了——模型是变"干净"了,但业务上最值钱的那群人没了。

所以判断异常值,统计方法(3σ、IQR 箱线图)只是给你一个候选名单,删不删要回到业务里问。我的习惯是:明显是录入错误的(年龄 250)改成缺失值交给填充器处理;数值虽然极端但真实的(高消费用户)保留,或者用分位数截断(Winsorize)而不是删掉。

还有个工程上的建议:清洗逻辑写成函数,别在 Excel 里手动改。手动改过一次,下个月数据刷新,你还得手动改一遍,而且改的还不一定是同一套规则。

2.2 数据转换

笔记上我在这条旁边写了个"类似蒸馏",当时没听清老师具体指的啥。后来理解了,大概意思是把量纲千差万别的原始值,蒸馏到同一把尺子上。具体来说就两种:

标准化(Standardization / Z-score)x' = (x - 均值) / 标准差,结果均值 0、方差 1。

归一化(Normalization / Min-Max)x' = (x - min) / (max - min),结果压到 [0, 1]。

区别在于:归一化对异常值很敏感,一个极端值就能把其他所有样本挤到很窄的区间里;标准化因为用的是均值和标准差,抗干扰能力强一些,而且不要求数据服从正态分布(这点经常被人误解)。没有特殊需求的话,默认用标准化

哪些算法必须做转换?判断标准其实很简单:你只需要看这个算法会不会"算距离"或者"用梯度"

必须做:KNN、SVM、神经网络、带正则化的线性模型、PCA、K-Means

不用做:决策树、随机森林、XGBoost、LightGBM(树模型只看分裂阈值,跟量纲无关)

不做会怎样?举个直观的例子:特征里同时有"年龄"(18-75)和"年收入"(0-1000000),KNN 算欧氏距离的时候,收入那一项的差值会完全主导结果,年龄这个特征等于没用。

2.3 特征工程

这是我认为最见功力的地方。笔记里写的"构建特征字典",一开始我以为是某种数据结构,后来发现是工程习惯——给每个特征建一份档案。做项目的时候我都会先列这么一张表:

字段名

类型

业务含义

取值范围

缺失率

处理方式

来源

age

数值

用户年龄

18-75

6%

中位数填充 + 标准化

user_profile

contract_type

类别

合约类型

月付/年付/两年付

3%

众数填充 + 独热

order_info

tenure_months

数值

已使用月数

0-72

0%

标准化

user_profile

看着像形式主义,实际上有几个很实在的用处:一是强迫你想清楚每个字段到底是干嘛的,经常填着填着就发现"这个字段模型不该看"(后面会说的泄露问题);二是别人接手你的代码时不至于抓瞎;三是上线的时候,这张表就是特征服务的接口文档。

至于怎么造特征,几个我常用的套路:

# 1. 比值 / 人均:绝对值往往不如相对值有区分度 df["tickets_per_month"] = df["support_tickets"] / (df["tenure_months"] + 1) # 工单总数 10 单,对用了 3 个月和用了 3 年的用户,含义天差地别 # 2. 时间差:注册到现在多久、上次登录距今天数 df["days_since_last_login"] = (pd.Timestamp.now() - df["last_login"]).dt.days # 3. 分箱:把连续值离散化,能捕捉非线性关系 df["age_bin"] = pd.cut(df["age"], bins=[18, 25, 35, 50, 100], labels=False) # 4. 交叉组合:两个特征单独看没用,组合起来有用 df["high_value_new_user"] = ((df["monthly_charges"] > 200) & (df["tenure_months"] < 3)).astype(int) # 5. 聚合统计:从明细表造出用户粒度的行为特征 # "近 30 天登录次数"、"近 7 天平均停留时长" 这类,表格数据里收益最高的一类特征

第 5 类(聚合统计)在真实业务里收益最高,但有个前提:只能用预测时点之前的数据算。你要预测用户下个月会不会流失,那"近 30 天登录次数"必须是截止到预测时点的 30 天,不能把未来数据算进去。这是时序特征最容易犯的错。

2.4 编码

模型只认数字,类别特征得先翻译一下。三种情况:

有序类别—— 用标签编码,顺序本身携带信息:

# 学历:高中 < 本科 < 硕士 < 博士 edu_map = {"高中": 0, "本科": 1, "硕士": 2, "博士": 3} df["education"] = df["education"].map(edu_map)

无序类别—— 用独热编码(One-Hot)。注意别用标签编码瞎编顺序,"微信=0、支付宝=1、银行卡=2"会让模型误以为微信和支付宝的距离比微信和银行卡近,这纯属无中生有。

from sklearn.preprocessing import OneHotEncoder # handle_unknown="ignore" 一定要加 enc = OneHotEncoder(handle_unknown="ignore", sparse_output=False)

handle_unknown="ignore"这个参数值得单独提。不加的话,训练集没见过、测试集出现的类别会直接报错。真实业务里新类别太常见了(新上线的支付方式、新开的城市),不加就是给自己埋雷。加了以后,未知类别会编码成全 0,模型不会因为一个没见过的取值直接崩掉。

高基数类别—— 取值特别多(比如城市、商品 SKU、用户 ID),独热编码会把维度撑爆。这时候用目标编码(Target Encoding),用该类别对应的目标变量均值来替代。但目标编码有严重的泄露风险,它用到了标签信息,必须在交叉验证的每一折内部单独计算,不能先算好再划分数据集。sklearn 里对应的是TargetEncoder(1.3+ 版本),或者用category_encoders这个库。

2.5 类别不平衡

这个坑我印象最深。第一次做二分类任务,跑出来准确率 95%,我还挺高兴,结果一看混淆矩阵——正样本一个都没预测对。因为正样本只占 5%,模型只要无脑预测"全是负样本",准确率就是 95%。

所以遇到不平衡数据,第一件事是把准确率这个指标拉黑。三种应对方式:

过采样:复制或者合成少数类样本。SMOTE 是经典方法,它不只是复制,而是在少数类样本之间插值合成新样本:

from imblearn.over_sampling import SMOTE from imblearn.pipeline import Pipeline as ImbPipeline # 注意:用 imblearn 的 Pipeline,不是 sklearn 的 pipe = ImbPipeline([ ("prep", preprocessor), ("smote", SMOTE(random_state=42)), ("clf", RandomForestClassifier()), ])

欠采样:删掉一部分多数类样本。数据量大的时候挺好用,缺点是丢信息。

改权重(最省事):不动数据,直接告诉模型少数类更重要。sklearn 里大部分分类器都支持:

RandomForestClassifier(class_weight="balanced") LogisticRegression(class_weight="balanced") XGBClassifier(scale_pos_weight=负样本数/正样本数)

我个人习惯先试class_weight="balanced",一行代码,几乎零成本,还不用担心过拟合。这不是偷懒,我在同一份数据上对比过:一行class_weight="balanced"能拿到测试集 ROC-AUC 0.8395 / PR-AUC 0.5577,换成 SMOTE 反而只有 0.8198 / 0.4751。

不是说 SMOTE 没用,而是它合成的样本会引入噪声,在类别边界重叠严重的表格数据上容易帮倒忙。所以我的顺序永远是:先试零成本的权重法,效果不够再考虑采样。

这里有个必须记住的顺序问题:采样只能在训练集上做。先划分数据集,再对训练集过采样。如果先过采样再划分,同一个样本的"合成兄弟"会同时出现在训练集和测试集里,测试成绩会虚高,这就是为什么我第一次做项目被导师说的那个点。


三、模型训练

3.1 数据集划分

为什么非要分三份?因为三件事需要三份不同的数据:

训练集:喂给模型学习参数

验证集:用来选模型、调超参数

测试集:只在最后评估一次,模拟"真实上线后遇到的新数据"

比例用 6:2:2 还是 8:1:1?看数据量:

数据量小(几千条):6:2:2,验证集和测试集都需要足够样本,指标才稳定

数据量大(十万以上):8:1:1 甚至 98:1:1,1% 也够用了,多留点给训练

数据特别小(几百条):别硬分三份,直接用 K 折交叉验证

划分的时候记得加stratify保持各集合里类别比例一致:

X_train, X_temp, y_train, y_temp = train_test_split( X, y, test_size=0.2, stratify=y, random_state=42 ) X_val, X_test, y_val, y_test = train_test_split( X_temp, y_temp, test_size=0.5, stratify=y_temp, random_state=42 )

不加stratify,运气不好会出现某一折里正样本特别少的情况,训练出来的模型指标会飘得厉害。

还有一种情况要特别注意:时间序列数据不能随机划分。你要用 1-6 月的数据预测 7 月,那就严格按时间切,训练集是 1-4 月,验证集 5 月,测试集 6 月。随机打乱会让模型"看到未来",线下指标好看得离谱,上线一塌糊涂。

3.2 算法选择

我的顺序是:先跑基线,再上复杂模型。基线不是走过场,它有两个作用:一是告诉你"这个问题至少能做到什么程度",二是如果复杂模型打不过基线,说明你的数据或特征有问题。

表格数据(也就是大多数业务场景里的结构化数据)的一个经验结论:梯度提升树基本是天花板

多说一句,很多刚入门的同学(包括当时的我)觉得深度学习一定比随机森林强。在图像、文本这些非结构化数据上确实如此,但在几千到几十万行的表格数据上,LightGBM 通常又快又好,深度网络大概率打不过它,还更难调。选算法之前先认清数据类型。

3.3 超参数调优

先分清两个概念:参数是模型自己学出来的(比如线性回归的系数),超参数是你提前指定的(比如树的最大深度)。调参调的是后者。

网格搜索:把所有候选值排列组合,穷举一遍。

param_grid = { "clf__max_depth": [6, 10, None], "clf__min_samples_leaf": [1, 5, 20], "clf__n_estimators": [300], } # 3 × 3 × 1 = 9 种组合,配合 5 折交叉验证 = 45 次训练

问题在于组合爆炸:3 个参数各 3 个候选值是 27 次,5 个参数各 5 个候选值就是 3125 次,配上交叉验证直接跑到天荒地老。

随机搜索:在候选范围内随机采样 N 组。Bergstra 和 Bengio 2012 年那篇论文的核心结论是——通常只有少数几个超参数真正重要,随机搜索因为采样更分散,在同样的计算预算下反而更容易找到好的组合。

from sklearn.model_selection import RandomizedSearchCV from scipy.stats import randint, uniform param_dist = { "clf__max_depth": randint(3, 20), "clf__min_samples_leaf": randint(1, 30), "clf__subsample": uniform(0.6, 0.4), } search = RandomizedSearchCV(pipe, param_dist, n_iter=50, scoring="average_precision", cv=5, n_jobs=-1)

我的实际做法:先用随机搜索大范围扫一遍,锁定大致区间,再用网格搜索在小区间里精调。

贝叶斯优化:它会根据历史试验结果,推测"下一组最值得试的参数",而不是瞎试。适合单次训练很贵的情况(深度学习、大数据集)。常用库是 Optuna,代码量比 GridSearchCV 多一点点,但省时间:

import optuna def objective(trial): params = { "n_estimators": trial.suggest_int("n_estimators", 100, 500), "max_depth": trial.suggest_int("max_depth", 3, 15), "learning_rate": trial.suggest_float("learning_rate", 0.01, 0.3, log=True), } model = LGBMClassifier(**params) score = cross_val_score(model, X, y, cv=5, scoring="average_precision").mean() return score study = optuna.create_study(direction="maximize") study.optimize(objective, n_trials=50)

不管用哪种,都记得配合交叉验证。用单次的训练集-验证集划分来调参,结果会受这一次划分的运气影响,交叉验证取平均才稳。


四、模型评估

4.1 先把混淆矩阵背下来

所有分类指标都是从这四个数推出来的:

精确率 Precision= TP / (TP + FP):你报出来的正例里,有多少是真的

召回率 Recall= TP / (TP + FN):真正的正例里,你抓住了多少

F1:上面两个的调和平均,想要一个综合数字的时候用

怎么选,取决于业务代价:

癌症筛查、金融风控、故障预警——宁可错杀不可放过,要召回率。漏掉一个早期癌症患者的代价,远大于让一个健康人多做一次检查。

垃圾邮件过滤、推荐系统——误杀代价高,要精确率。把老板的重要邮件扔进垃圾箱,比收一封广告严重多了。

4.2 ROC-AUC 和 PR-AUC

这两个都是评估排序能力的指标,不受阈值选择影响。

ROC-AUC:0.5 是随机瞎猜,1.0 是完美。但它有个毛病——类别严重不平衡时会虚高。正样本只占 1% 的时候,模型只要不给正样本排太低,AUC 就能轻松上 0.9,看着很漂亮,实际抓正样本的能力一塌糊涂。

PR-AUC(average precision):只看正样本的表现,在不平衡场景下比 ROC-AUC 诚实得多。

所以我的习惯:不平衡数据上,主看 PR-AUC,ROC-AUC 当辅助

回归任务就三个常用指标:MAE(平均绝对误差,好解释)、RMSE(对大误差更敏感)、R²(拟合优度,越接近 1 越好)。

4.3 泛化能力到底在看什么

泛化能力说白了就是:模型在没见过的数据上还能不能打。判断方法很直接——比训练集和测试集的分数:

训练集分数

测试集分数

诊断

怎么办

很高

很低

过拟合

加数据、简化模型、加正则、减特征

很低

很低

欠拟合

换复杂模型、加特征、减少正则

高(差距小)

正常

挺好,可以上线了

比训练集还高

有泄露

检查数据划分和特征构造

最后一行我加了条"有泄露"的情况,看着违反直觉,但真遇到的时候特别有提示性:测试集分数比训练集还高,八成是哪里出了问题(最常见的是先做采样/标准化再划分数据,或者特征里混进了未来信息)。

顺便说下学习曲线。横轴是训练样本数,纵轴是分数,如果两条线(训练集分数、验证集分数)之间有一条明显的鸿沟且不收敛,就是过拟合;如果两条线都低且贴在一起,就是欠拟合。比看单个数字直观。


五、串起来:一份完整可运行的代码

场景是某 SaaS 产品的用户流失预测,二分类且类别不平衡。数据是我用代码模拟的(会故意塞入缺失值、异常值和重复行),这样你复制过去直接就能跑,不用下载任何数据集。

依赖:pip install pandas scikit-learn

# -*- coding: utf-8 -*- """ 机器学习标准工作流程 —— 完整可运行示例 场景:用户流失预测(二分类,类别不平衡) 依赖:pip install pandas scikit-learn """ import numpy as np import pandas as pd from sklearn.model_selection import train_test_split, GridSearchCV, StratifiedKFold from sklearn.pipeline import Pipeline from sklearn.compose import ColumnTransformer from sklearn.impute import SimpleImputer from sklearn.preprocessing import StandardScaler, OneHotEncoder from sklearn.linear_model import LogisticRegression from sklearn.ensemble import RandomForestClassifier from sklearn.metrics import ( classification_report, confusion_matrix, roc_auc_score, average_precision_score, ) RANDOM_STATE = 42 rng = np.random.default_rng(RANDOM_STATE) # ---------------------------------------------------------------------- # 0. 造一份"脏"数据(这样你复制过去就能跑,不用下载任何数据集) # ---------------------------------------------------------------------- def make_dirty_churn_data(n=8000): age = rng.normal(38, 12, n).clip(18, 75) tenure = rng.exponential(20, n).clip(0, 72) monthly = rng.normal(120, 45, n).clip(20, 400) tickets = rng.poisson(1.6, n) contract = rng.choice(["月付", "年付", "两年付"], n, p=[0.55, 0.28, 0.17]) payment = rng.choice(["微信", "支付宝", "银行卡", "对公转账"], n, p=[0.4, 0.35, 0.2, 0.05]) # 让标签和特征之间有真实的因果关系,否则模型学不到东西 contract_risk = np.select( [contract == "月付", contract == "年付", contract == "两年付"], [1.3, -0.5, -1.1] ) logit = ( -2.6 + 0.070 * (age - 38) - 0.070 * tenure + 0.015 * (monthly - 120) + 0.60 * tickets + contract_risk + rng.normal(0, 0.5, n) ) churn = rng.binomial(1, 1 / (1 + np.exp(-logit))) df = pd.DataFrame( { "age": age, "tenure_months": tenure, "monthly_charges": monthly, "support_tickets": tickets, "contract_type": contract, "payment_method": payment, "churn": churn, } ) # --- 动手把数据弄脏,模拟真实业务表的样子 --- # (1) 数值列随机缺失 for col, ratio in [("age", 0.06), ("monthly_charges", 0.04)]: df.loc[rng.choice(n, int(n * ratio), replace=False), col] = np.nan # (2) 类别列缺失(业务系统里经常是空字符串而不是 NaN) df.loc[rng.choice(n, int(n * 0.03), replace=False), "contract_type"] = np.nan # (3) 异常值:年龄录成了 3 岁和 250 岁 idx = rng.choice(n, 12, replace=False) df.loc[idx[:6], "age"] = 3 df.loc[idx[6:], "age"] = 250 # (4) 重复行 df = pd.concat([df, df.sample(30, random_state=RANDOM_STATE)], ignore_index=True) return df raw = make_dirty_churn_data() print("原始数据:", raw.shape) print("缺失值统计:\n", raw.isna().sum()) print("流失占比: %.2f%%\n" % (raw["churn"].mean() * 100)) # ---------------------------------------------------------------------- # 1. 数据清洗 # ---------------------------------------------------------------------- def clean(df): df = df.drop_duplicates() # 用业务规则修异常:年龄合理区间 [18, 100],超出的一律置为缺失,交给后面的填充 器 df.loc[(df["age"] < 18) | (df["age"] > 100), "age"] = np.nan # 空字符串也当成缺失 df["contract_type"] = df["contract_type"].replace("", np.nan) # 月费不可能超过 1000 df.loc[df["monthly_charges"] > 1000, "monthly_charges"] = np.nan return df df = clean(raw) print("清洗后:", df.shape) # ---------------------------------------------------------------------- # 2. 特征工程(这里只做两件最典型的事:比值 + 分层) # ---------------------------------------------------------------------- def build_features(df): df = df.copy() # 每月工单数 = 工单总数 / 使用时长,比单独的工单数更能反映"暴躁程度" df["tickets_per_month"] = df["support_tickets"] / (df["tenure_months"] + 1) # 是否高价值客户 df["is_high_value"] = (df["monthly_charges"] > df["monthly_charges"].median()).astype(int) return df df = build_features(df) TARGET = "churn" NUM_FEATURES = [ "age", "tenure_months", "monthly_charges", "support_tickets", "tickets_per_month", "is_high_value", ] CAT_FEATURES = ["contract_type", "payment_method"] X = df[NUM_FEATURES + CAT_FEATURES] y = df[TARGET] # ---------------------------------------------------------------------- # 3. 数据集划分 —— 必须在任何"学习数据分布"的操作之前做 # ---------------------------------------------------------------------- X_train, X_temp, y_train, y_temp = train_test_split( X, y, test_size=0.2, stratify=y, random_state=RANDOM_STATE ) X_val, X_test, y_val, y_test = train_test_split( X_temp, y_temp, test_size=0.5, stratify=y_temp, random_state=RANDOM_STATE ) print("\n 训练集 %d / 验证集 %d / 测试集 %d (8:1:1)" % (len(X_train), len(X_val), len(X_test))) print(" 各 集 流 失 占 比 : %.3f / %.3f / %.3f\n" % (y_train.mean(), y_val.mean(), y_test.mean())) # ---------------------------------------------------------------------- # 4. 预处理流水线(缺失填充 -> 标准化 / 独热编码) # ---------------------------------------------------------------------- numeric_pipe = Pipeline([ ("imputer", SimpleImputer(strategy="median")), ("scaler", StandardScaler()), ]) categorical_pipe = Pipeline([ ("imputer", SimpleImputer(strategy="most_frequent")), ("onehot", OneHotEncoder(handle_unknown="ignore", sparse_output=False)), ]) preprocessor = ColumnTransformer([ ("num", numeric_pipe, NUM_FEATURES), ("cat", categorical_pipe, CAT_FEATURES), ]) # ---------------------------------------------------------------------- # 5. 建模:逻辑回归当基线,随机森林当主力 # ---------------------------------------------------------------------- def make_model(clf): return Pipeline([("prep", preprocessor), ("clf", clf)]) lr = make_model( LogisticRegression(max_iter=1000, class_weight="balanced", random_state=RANDOM_STATE) ) lr.fit(X_train, y_train) val_prob_lr = lr.predict_proba(X_val)[:, 1] print("[基线] 逻辑回归 验证集 AUC = %.4f PR-AUC = %.4f" % (roc_auc_score(y_val, val_prob_lr), average_precision_score(y_val, val_prob_lr))) rf = make_model( RandomForestClassifier(n_estimators=300, class_weight="balanced_subsample", random_state=RANDOM_STATE, n_jobs=-1) ) rf.fit(X_train, y_train) val_prob_rf = rf.predict_proba(X_val)[:, 1] print("[主力] 随机森林 验证集 AUC = %.4f PR-AUC = %.4f" % (roc_auc_score(y_val, val_prob_rf), average_precision_score(y_val, val_prob_rf))) # ---------------------------------------------------------------------- # 6. 超参数调优(网格搜索 + 分层 K 折交叉验证,只用训练集 + 验证集) # ---------------------------------------------------------------------- X_tv = pd.concat([X_train, X_val]) y_tv = pd.concat([y_train, y_val]) param_grid = { "clf__max_depth": [6, 10, None], "clf__min_samples_leaf": [1, 5, 20], "clf__n_estimators": [300], } cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=RANDOM_STATE) search = GridSearchCV( make_model(RandomForestClassifier(class_weight="balanced_subsample", random_state=RANDOM_STATE, n_jobs=-1)), param_grid, scoring="average_precision", cv=cv, n_jobs=-1, ) search.fit(X_tv, y_tv) print("\n 调参最佳组合:", search.best_params_) print("交叉验证最佳 PR-AUC = %.4f" % search.best_score_) best_model = search.best_estimator_ # ---------------------------------------------------------------------- # 7. 最终评估 —— 测试集到此只用这一次 # ---------------------------------------------------------------------- test_prob = best_model.predict_proba(X_test)[:, 1] test_pred = (test_prob >= 0.5).astype(int) print("\n===== 测试集最终表现 =====") print("ROC-AUC = %.4f PR-AUC = %.4f" % ( roc_auc_score(y_test, test_prob), average_precision_score(y_test, test_prob))) print("\n 混淆矩阵 (行=真实, 列=预测):") print(confusion_matrix(y_test, test_pred)) print() print(classification_report(y_test, test_pred, target_names=["未 流 失 ", "流 失 "], digits=3)) # ---------------------------------------------------------------------- # 8. 看看泛化能力:训练集 vs 测试集的差距 # ---------------------------------------------------------------------- train_prob = best_model.predict_proba(X_tv)[:, 1] print("训练集 PR-AUC = %.4f" % average_precision_score(y_tv, train_prob)) print("测试集 PR-AUC = %.4f" % average_precision_score(y_test, test_prob)) # ---------------------------------------------------------------------- # 9. 特征重要性 # ---------------------------------------------------------------------- feat_names = best_model.named_steps["prep"].get_feature_names_out() importances = best_model.named_steps["clf"].feature_importances_ order = np.argsort(importances)[::-1] print("\nTop 8 特征重要性:") for i in order[:8]: print(" %-28s %.4f" % (feat_names[i], importances[i]))

我在 Python 3.13 + scikit-learn 1.9.0 + pandas 3.0.5 上跑出来的结果(有节选):

原始数据: (8030, 7) 缺失值统计: age 480 monthly_charges 321 contract_type 242 流失占比: 17.57% 清洗后: (8000, 7) 训练集 6400 / 验证集 800 / 测试集 800 (8:1:1) 各集流失占比: 0.176 / 0.176 / 0.175 [基线] 逻辑回归 验证集 AUC = 0.8681 PR-AUC = 0.5941 [主力] 随机森林 验证集 AUC = 0.8306 PR-AUC = 0.5111 调 参 最 佳 组 合 : {'clf__max_depth': None, 'clf__min_samples_leaf': 20, 'clf__n_estimators': 300} 交叉验证最佳 PR-AUC = 0.5458 ===== 测试集最终表现 ===== ROC-AUC = 0.8395 PR-AUC = 0.5577 混淆矩阵 (行=真实, 列=预测): [[520 140] [ 37 103]] precision recall f1-score support 未流失 0.934 0.788 0.855 660 流失 0.424 0.736 0.538 140 accuracy 0.779 800 训练集 PR-AUC = 0.6586 测试集 PR-AUC = 0.5577 差距 = 0.1009 Top 8 特征重要性: num__tickets_per_month 0.2347 num__age 0.1755 num__tenure_months 0.1749 cat__contract_type_月付 0.1322 num__monthly_charges 0.1032 num__support_tickets 0.0663 cat__contract_type_年付 0.0371 num__is_high_value 0.0307

几个值得盯着看的数字:

第一,逻辑回归(AUC 0.8681)打赢了随机森林(0.8306)。这不是代码写错了——这份数据的因果关系是我拿线性 logit 函数生成的,逻辑回归天生就适合拟合它。真实业务里很少有这么"讲道理"的数据,但这正好说明基线模型不能跳过:它帮你判断数据长什么样,也帮你在复杂模型翻车时有个参照。

第二,ROC-AUC 0.8395 看着挺体面,PR-AUC 只有 0.5577。同一个模型、同一份数据,两个指标差了快 0.3,差出来的这部分就是类别不平衡的照妖镜。只报 ROC-AUC,很容易让人以为这是个能用的模型。

第三,最反直觉的一条:模型准确率 0.779,比"全部预测为不流失"还低。测试集里流失只占 17.5%,无脑猜"都不流失"的准确率是 82.5%,模型反倒只有 77.9%——是不是觉得白训了?

不是。看混淆矩阵那两行:140 个真正会流失的用户,模型抓出了 103 个(召回率 73.6%),代价是 140 个正常用户被误判成流失。这就是class_weight="balanced"在起作用——它告诉模型"漏掉一个流失用户,比误判一个正常用户更贵",模型照做了,于是拿准确率去换召回率。

准确率低不代表模型差,只代表你的目标和"猜多数类"不是一回事。业务要的是"提前把可能流失的人捞出来去挽回",那 73.6% 的召回率就是值钱的。这也是为什么在不平衡场景下,准确率基本没有参考价值。


你要是真把class_weight="balanced"去掉再跑一遍,会看到这个:

配置

准确率

召回率

精确率

ROC-AUC

PR-AUC

加权(balanced)

0.779

0.736

0.424

0.840

0.558

不加权(默认)

0.850

0.271

0.679

0.832

0.554

全猜"不流失"

0.825

0

去掉权重,准确率从 0.779 涨到 0.850,比"全猜不流失"的 0.825 还高,看着是不是顺眼多了?但召回率从 0.736 掉到 0.271——140 个会流失的用户,只抓出了 38 个。如果你要拿这个模型去做挽回推送,等于四分之三的流失用户你连招呼都没打。

更值得琢磨的是最后两列:ROC-AUC 和 PR-AUC 几乎没动。因为class_weight本质上只是在移动决策阈值(就是"概率大于 0.5 判为正"那条线),并没有改变模型把正负样本排序的能力。模型的底子没变,变的只是你在哪个位置切一刀。

所以结论是:准确率、召回率这些指标会跟着阈值剧烈摆动,AUC 类的指标才是稳的那个。调阈值或者调权重属于最后的业务决策(我愿意用多少误判换多少召回),不该拿来评判模型本身的好坏。

第四,tickets_per_month在特征重要性里排第一(0.2347),而它的两个原材料support_tickets只有 0.0663、tenure_months(0.1749)排第三。一个除法算出来的新特征,比原始字段更能打,这就是特征工程的实际收益。

最后,训练集 PR-AUC 0.6586、测试集 0.5577,差 0.1,属于正常范围。要是哪天你看到测试集分数反而比训练集高,先回去查数据泄露,别高兴得太早。

再看代码本身,有几个地方是刻意这么写的:

一是整个预处理和模型都塞进了Pipeline。这不是为了好看,而是为了防止数据泄露——SimpleImputer的中位数、StandardScaler的均值方差,全都是在训练集上算出来的,然后原样应用到验证集和测试集。如果你手动先fit_transform整个数据集再划分,测试集的统计信息就混进训练过程了,指标会虚高。

二是class_weight="balanced"处理不平衡,一行搞定,没引入任何新样本。

三是调参阶段用的是训练集+验证集(合并后做 5 折交叉验证),测试集从头到尾只在最后被碰了一次。这个习惯一定要养成:每用测试集做一个决策,它就"脏"一分。用测试集反复调参选模型,等于把测试集变成了验证集,最后那个漂亮的数字没有意义。

四是最后那段特征重要性输出,tickets_per_month这个我自己造的特征直接排在了第一,比它的两个原材料(support_ticketstenure_months)单独用都强。这就是特征工程的价值——你自己改改build_features函数,加几个新特征进去,看看能不能把 PR-AUC 再推高一点。


六、几个容易翻车的地方

按我踩坑的频率排个序:

  1. 先采样/先标准化,后划分数据—— 最经典的泄露。train_test_split必须是全流程的第一道工序。
  2. 用整个数据集的均值填充缺失值—— 同上,属于泄露。放进 Pipeline 里就不会犯。
  3. 类别不平衡还盯着准确率看—— 95% 的准确率可能一个正样本都没抓到,先看混淆矩阵和 PR-AUC。
  4. One-Hot 忘了加handle_unknown="ignore"—— 训练没问题,上线遇到新类别直接报错。
  5. 测试集被反复使用—— 调一次参看一次测试集,最后报告的数字其实是过拟合到测试集上的。
  6. 特征里混入了未来信息—— 比如用"订单总金额"预测"用户是否下单",这种特征线下 AUC 能到 0.99,上线就废。特征字典里"来源"那一列就是用来排查这个的。
  7. 忽略业务规则—— 模型说这个用户会流失,但你去看数据发现他的账号三天前就被封了。这种样本应该提前剔除。

写在最后

回头看那页笔记,它其实给了正确的顺序,只是我当时不知道每一步为什么在那里。真正让我理解这个流程的,不是背下来"清洗→转换→特征→编码→采样"这个顺序,而是想明白了一件事:

凡是"从数据里学出来"的东西(均值、方差、类别映射、采样策略、超参数),都只能在训练集上学习,然后应用到其他集合上。

理解了这一条,整个流程的顺序就能自己推出来了——标准化为什么在划分之后,SMOTE 为什么只能在训练集上做,Pipeline 为什么非写不可,全是同一个道理的不同表现。

代码在上面,我是跑通了的。建议你动手改几个地方试试:把class_weight="balanced"去掉,看准确率是不是会涨回 0.82 以上、召回率又会掉到多少;或者把build_features里的tickets_per_month删掉,看 PR-AUC 掉几个点。动手改一遍,比看十遍记得牢。


如果这篇文章对你有帮助,欢迎点赞收藏。有说错的地方也欢迎评论区指出,我改。

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

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

立即咨询