欢迎光临
我们一直在努力

机器学习的标准工作流程,从数据预处理到模型评估(附可运行代码)

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

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

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


一、先把全貌看清楚

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

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


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

2.1 数据清洗

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

问题类型

典型表现

常见处理

缺失值

NaN、空字符串、-999、0

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

异常值

年龄 250、月消费 999999

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

重复值

完全重复、主键重复

去重

类型错误

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

转类型

缺失值这里有个容易忽略的细节:空字符串不是 NaN。pandas 读进来的空单元格才是 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_tickets 和 tenure_months)单独用都强。这就是特征工程的价值——你自己改改 build_features 函数,加几个新特征进去,看看能不能把 PR-AUC 再推高一点。


六、几个容易翻车的地方

按我踩坑的频率排个序:

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

  • 写在最后

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

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

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

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


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

    赞(0)
    未经允许不得转载:171主机测评 » 机器学习的标准工作流程,从数据预处理到模型评估(附可运行代码)
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址