Day 13|模型评估与调参:交叉验证、网格搜索、过拟合诊断(AI 学习笔记 · 经典机器学习周 · 第 13 篇)
把模型训练出来只是起点,真正决定上线能不能跑的是「用 K-Fold 把评估做稳、用 Grid/Random Search 把超参找对、用学习曲线诊断偏差-方差」三件套;今天要建立的是 ML 工程的「不靠运气」思维——任何性能瓶颈先看学习曲线再对症下药。
1. 泛化误差与三大数据集
1.1 三个集合的分工
| 集合 | 角色 | 用几次 | 漏用会怎样 |
|---|---|---|---|
| 训练集 (train) | 拟合参数 | 多次(每 epoch/每折) | — |
| 验证集 (val) | 选超参 / Early Stopping | 多次 | 用 test 选超参=作弊 |
| 测试集 (test) | 最终一次性评估 | 仅 1 次 | 反复用 test=数据泄漏 |
经典数据划分比例:60/20/20(中小数据)或 98/1/1(大数据)。
1.2 经验风险 vs 期望风险
经验风险(在训练集上算的损失):
R_{\text{emp}}(\theta) = \frac{1}{n} \sum_{i=1}^{n} L(y_i, f_\theta(x_i))
期望风险(真实分布的损失):
R(\theta) = \mathbb{E}_{(x,y) \sim P} [L(y, f_\theta(x))]
训练目标是最小化 R_emp,我们关心的是 R。泛化误差 R - R_emp 就是「训练集上没见过的部分」——这正是验证集和测试集存在的意义。
2. K-Fold 交叉验证
2.1 算法骨架
输入:训练集 D, 折数 K
1. 把 D 随机等分为 K 份 D_1, ..., D_K
2. for i = 1..K:
train_set = D \ D_i
val_set = D_i
model_i = train(train_set)
score_i = evaluate(model_i, val_set)
3. CV_score = mean(score_1, ..., score_K)
4. return CV_score
每次给模型一份不同的「考卷」,避免单次切分的运气成分。
2.2 K 怎么选
| K 值 | 偏差 | 方差 | 计算代价 | 适用 |
|---|---|---|---|---|
| K=2(留一法一半) | 高 | 高 | 最低 | 几乎不用 |
| K=5 | 中 | 中 | 中 | 工业默认 |
| K=10 | 低 | 中 | 较高 | 学术默认 |
| K=N (LOOCV) | 最低 | 最高 | O(N²) | 小数据(< 1000) |
经验:数据 < 1000 用 K=10 或 LOOCV;1000~100000 用 K=5;> 100000 用 K=3 甚至 hold-out(单次划分)。
2.3 三种 CV 变体
| 变体 | 用法 | 场景 |
|---|---|---|
| KFold | 标准随机等分 | 回归 / 平衡分类 |
| StratifiedKFold | 保持每折类别比例 | 分类首选 |
| TimeSeriesSplit | 递增窗口,绝不泄漏未来 | 时间序列唯一允许 |
| GroupKFold | 同组样本不跨折 | 用户级 / 患者级数据 |
| LeaveOneOut | K=N | 小数据 |
时间序列千万别用 KFold:会把「未来」泄漏到「过去」,得到虚高的 CV 分数。
2.4 重复 K-Fold(Repeated K-Fold)
单次 K-Fold 方差大,「重复 K 次」再取平均:
from sklearn.model_selection import RepeatedKFold, cross_val_score
rkf = RepeatedKFold(n_splits=5, n_repeats=3, random_state=42)
scores = cross_val_score(model, X, y, cv=rkf, scoring='accuracy')
对模型选择和显著性检验更稳,代价是计算量 × repeats 倍。
3. 超参搜索
3.1 Grid Search:穷举
笛卡尔积组合,逐个 CV:
from sklearn.model_selection import GridSearchCV
param_grid = {
"C": [0.1, 1, 10, 100],
"gamma": [0.01, 0.1, 1, 10],
}
gs = GridSearchCV(SVC(kernel="rbf"), param_grid, cv=5,
scoring="accuracy", n_jobs=-1)
gs.fit(X, y)
print(gs.best_params_, gs.best_score_)
| 优点 | 缺点 |
|---|---|
| 确定性强,找到的就是搜过的最优 | 维度爆炸(10 个超参各 5 值 = 5^10 = 970 万) |
| 适合低维 | 重要维度浪费预算在不重要维度上 |
3.2 Random Search:随机抽样
Bergstra & Bengio 2012 证明:同等预算下,Random Search 通常比 Grid Search 优——因为不是所有超参都同等重要,随机采样能更快覆盖关键维度。
from sklearn.model_selection import RandomizedSearchCV
from scipy.stats import loguniform
param_dist = {
"C": loguniform(1e-3, 1e3),
"gamma": loguniform(1e-4, 1e1),
}
rs = RandomizedSearchCV(SVC(kernel="rbf"), param_dist, n_iter=50,
cv=5, scoring="accuracy", random_state=42)
rs.fit(X, y)
| 优点 | 缺点 |
|---|---|
| 预算可控 | 漏掉最优组合的概率非零 |
| 维度灾难免疫 | 不直观(没枚举全) |
3.3 Bayesian Optimization:Optuna
把超参搜索建模为「黑盒函数优化」,用历史 trial 的结果选下一个 trial:
import optuna
def objective(trial):
params = {
"learning_rate": trial.suggest_float("lr", 1e-3, 1e-1, log=True),
"max_depth": trial.suggest_int("max_depth", 3, 12),
"subsample": trial.suggest_float("subsample", 0.5, 1.0),
}
score = -cross_val_score(XGBClassifier(**params), X, y,
cv=5, scoring="neg_log_loss").mean()
return score
study = optuna.create_study(direction="minimize")
study.optimize(objective, n_trials=100, show_progress_bar=False)
print(study.best_params)
| 工具 | 算法 | 特点 |
|---|---|---|
| Optuna | TPE / GP | 默认最强,中文文档 |
| Hyperopt | TPE | 老牌,接口稍复杂 |
| scikit-optimize | GP | 简洁,小搜索空间 |
| Ray Tune | 多算法 | 分布式最佳 |
贝叶斯优化在「搜索次数有限」(几十~几百)时是工业首选。
4. 过拟合诊断与对治
4.1 偏差-方差分解
\mathbb{E}[(y - \hat{f}(x))^2] = \underbrace{(\mathbb{E}[\hat{f}(x)] - f(x))^2}_{\text{Bias}^2}
+ \underbrace{\mathbb{V}[\hat{f}(x)]}_{\text{Variance}}
+ \underbrace{\sigma^2}_{\text{Noise}}
- Bias²:模型平均预测偏离真实函数多远(欠拟合)
- Variance:不同训练集学出的模型有多分散(过拟合)
- Noise:数据本身的不可约误差
总误差 = 三者之和。工程目标是在三者间找甜蜜点。
4.2 学习曲线诊断
横轴 = 训练样本数,纵轴 = train_score 和 val_score:
| 现象 | train 曲线 | val 曲线 | 原因 | 对治 |
|---|---|---|---|---|
| 欠拟合 | 低 | 低,贴近 train | 模型容量不够 | 加特征 / 换复杂模型 / 减正则 |
| 过拟合 | 高 | 低,差距大 | 模型太复杂 / 数据太少 | 加数据 / 加正则 / 简化模型 / Dropout / Early Stopping |
| 数据不足 | 高(快速饱和) | 低(差距大) | 样本数不够 | 增广 / 合成 / 半监督 |
| 完美 | 高 | 高,贴近 train | 训练分布 = 测试分布 | — |
4.3 过拟合工具箱
| 工具 | 作用 | 适用 |
|---|---|---|
| L1 正则(Lasso) | 让稀疏权重,把不重要的特征置零 | 高维稀疏特征选择 |
| L2 正则(Ridge) | 让所有权重变小但不置零 | 通用防过拟合 |
| ElasticNet | L1 + L2 混合 | 特征组相关时 |
| Dropout | 训练时随机屏蔽节点 | 神经网络 |
| Early Stopping | val 不再下降就停 | 神经网络 / GBDT |
| Data Augmentation | 人工扩样本 | 图像 / 文本 |
| 简化模型 | 减层数 / 减特征 / 减深度 | 树模型 / NN |
| Batch Normalization | 归一化激活值 | 神经网络 |
| 噪声注入 | 标签平滑 / 输入扰动 | NN |
| 集成 | 多模型平均 | 通用 |
5. PyTorch / sklearn 实战
5.1 完整 K-Fold + 评估指标
import numpy as np
from sklearn.datasets import load_breast_cancer
from sklearn.model_selection import StratifiedKFold, cross_val_score
from sklearn.preprocessing import StandardScaler
from sklearn.pipeline import Pipeline
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import make_scorer, f1_score, roc_auc_score
X, y = load_breast_cancer(return_X_y=True)
pipe = Pipeline([
("scaler", StandardScaler()),
("clf", LogisticRegression(max_iter=5000)),
])
skf = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
# 同时看多个指标
acc = cross_val_score(pipe, X, y, cv=skf, scoring="accuracy")
f1 = cross_val_score(pipe, X, y, cv=skf, scoring=make_scorer(f1_score))
auc = cross_val_score(pipe, X, y, cv=skf, scoring="roc_auc")
print(f"Acc : {acc.mean():.4f} ± {acc.std():.4f}")
print(f"F1 : {f1.mean():.4f} ± {f1.std():.4f}")
print(f"AUC : {auc.mean():.4f} ± {auc.std():.4f}")
5.2 学习曲线与验证曲线
from sklearn.model_selection import learning_curve, validation_curve
import matplotlib.pyplot as plt
train_sizes, train_scores, val_scores = learning_curve(
pipe, X, y, cv=skf, train_sizes=np.linspace(0.1, 1.0, 10),
scoring="accuracy", n_jobs=-1,
)
tr_mean = train_scores.mean(axis=1); tr_std = train_scores.std(axis=1)
va_mean = val_scores.mean(axis=1); va_std = val_scores.std(axis=1)
plt.figure(figsize=(8, 5))
plt.plot(train_sizes, tr_mean, 'o-', label='train')
plt.fill_between(train_sizes, tr_mean - tr_std, tr_mean + tr_std, alpha=0.2)
plt.plot(train_sizes, va_mean, 's-', label='val')
plt.fill_between(train_sizes, va_mean - va_std, va_mean + va_std, alpha=0.2)
plt.xlabel('Training size'); plt.ylabel('Accuracy'); plt.legend(); plt.grid(True)
plt.title('Learning Curve'); plt.show()
# 验证曲线:横轴是某个超参的值
param_range = np.logspace(-3, 3, 7)
tr, va = validation_curve(pipe, X, y, param_name="clf__C",
param_range=param_range, cv=skf, scoring="accuracy")
plt.semilogx(param_range, tr.mean(1), 'o-', label='train')
plt.semilogx(param_range, va.mean(1), 's-', label='val')
plt.xlabel('C'); plt.ylabel('Accuracy'); plt.legend(); plt.show()
5.3 Optuna 调 LightGBM
import lightgbm as lgb
from sklearn.datasets import fetch_california_housing
X, y = fetch_california_housing(return_X_y=True)
def objective(trial):
params = {
"objective": "regression",
"metric": "rmse",
"learning_rate": trial.suggest_float("lr", 1e-3, 1e-1, log=True),
"num_leaves": trial.suggest_int("num_leaves", 15, 255),
"max_depth": trial.suggest_int("max_depth", -1, 12),
"min_data_in_leaf": trial.suggest_int("mdl", 5, 100),
"feature_fraction": trial.suggest_float("ff", 0.5, 1.0),
"bagging_fraction": trial.suggest_float("bf", 0.5, 1.0),
"lambda_l1": trial.suggest_float("l1", 1e-3, 10, log=True),
"lambda_l2": trial.suggest_float("l2", 1e-3, 10, log=True),
"verbose": -1,
}
skf = StratifiedKFold(5, shuffle=True, random_state=42) if False else None
# 回归用 KFold
from sklearn.model_selection import KFold
kf = KFold(5, shuffle=True, random_state=42)
losses = []
for tr, va in kf.split(X):
dt = lgb.Dataset(X[tr], y[tr])
dv = lgb.Dataset(X[va], y[va], reference=dt)
m = lgb.train(params, dt, 2000, valid_sets=[dv],
callbacks=[lgb.early_stopping(30, verbose=False)])
p = m.predict(X[va], num_iteration=m.best_iteration)
losses.append(np.sqrt(((y[va] - p) ** 2).mean()))
return np.mean(losses)
study = optuna.create_study(direction="minimize")
study.optimize(objective, n_trials=80, show_progress_bar=False)
print("Best RMSE:", study.best_value)
print("Best params:", study.best_params)
5.4 时间序列的 TimeSeriesSplit
from sklearn.model_selection import TimeSeriesSplit
import numpy as np
# 模拟 1000 个时间步
n = 1000
X = np.random.randn(n, 5)
y = X[:, 0] * 0.5 + np.random.randn(n) * 0.1
tscv = TimeSeriesSplit(n_splits=5)
for fold, (tr, va) in enumerate(tscv.split(X)):
print(f"Fold {fold}: train {tr.min()}-{tr.max()}, val {va.min()}-{va.max()}")
# Fold 0: train 0..166, val 167..332
# Fold 1: train 0..332, val 333..498
# Fold 2: train 0..498, val 499..665
# ...
# 训练集递增,验证集始终在训练集之后
6. 不同搜索策略对比
| 策略 | 预算 | 适用 | 找到最优 |
|---|---|---|---|
| Grid Search | 维度低(<4) | 小空间 | 找到搜过的最优 |
| Random Search | 预算固定 | 维度高 | 不保证,但常接近 |
| Bayesian (Optuna) | 预算小~中(几十~几百) | 通用 | 通常 5×~10× 样本效率 |
| Hyperband / BOHB | 预算小 + 早停 | 大模型 / 大数据 | 极高效 |
| Population Based Training | 在线训练 | 多任务 | 边训边调 |
6.1 现实工程经验
- 调参预算 50 次:用 Optuna(Bayesian),远比 Grid/Random 强
- 调参预算 500 次:同上,Optuna + 早停组合
- 调参预算 5000+ 次:Grid 也能接受,但 Optuna 更稳
- 调参预算 50000+:用 Hyperband / Population Based Training 边训边筛
7. 常见坑
7.1 用测试集调超参
症状:测试集分数高到离谱,部署上线后表现差 原因:测试集参与了超参选择,不再是「独立评估」 修法:严格分层 train / val / test;测试集只跑一次
7.2 时间序列用 KFold
症状:CV 分数虚高,部署后模型完全失效
原因:训练时偷看了未来,模型学到「未来 → 现在」的伪关系
修法:强制用 TimeSeriesSplit 或自己写递增窗口
7.3 CV 用错折数
症状:K=2 时 CV 方差极大,误判哪个模型好 修法:K=5 起步,数据 < 1000 用 K=10,> 100000 用 K=3 + 大 test
7.4 不平衡数据忘了 StratifiedKFold
症状:某折全是多数类,模型得 0 分,拖垮 CV 均值
修法:StratifiedKFold(n_splits=5, shuffle=True) 分类任务默认
7.5 Grid Search 维度爆炸
症状:GridSearchCV 跑了一周还没完 修法:① 降到 2~3 个关键超参;② 改 RandomizedSearchCV;③ 改 Optuna
7.6 不看 CV 标准差只看均值
症状:两个模型 CV 均值差 0.001 误判胜负
修法:报告 mean ± std,差距 < 2×std 时视为平手
7.7 早停用了 train loss
症状:train loss 一直降,模型训到过拟合
修法:早停必须用 val loss / val score;EarlyStopping(monitor='val_loss', patience=10)
7.8 学习曲线没画就开始调参
症状:拼命调参,但 val 分数死活上不去 修法:画学习曲线先诊断——欠拟合加容量,过拟合加正则/数据,而不是乱调
7.9 同组样本泄漏(Group Leak)
症状:同一患者的多个样本被分到不同折,CV 分数虚高
修法:GroupKFold 或在 KFold 里传 groups 参数
7.10 Grid Search 内部没设 random_state
症状:重新跑结果不一样
修法:所有有随机性的模型设 random_state=42,确保可复现
7.11 用 cross_val_score 直接在原数据上跑,忘了先分层
症状:小类别在某折全无,CV 分数极端
修法:cross_val_score(..., cv=StratifiedKFold(5, shuffle=True, random_state=42))
7.12 Optuna 跑了几次没设 pruner,大模型 trial 全跑完
症状:100 次 trial 用完才筛,实际前 30 次已能区分优劣
修法:HyperbandPruner 或 MedianPruner 在中间 epoch 提前停掉劣质 trial
8. 自检三问
A. K-Fold 的 K 值如何选择?为什么小数据集偏 K=10,大数据集偏 K=5 甚至 K=3?从偏差和方差两个角度回答。
要点:K 大 → 训练集接近全集 → 偏差低;但 K 个验证集彼此相关(重叠多)→ 方差高,且计算 O(NK) 贵。小数据集每次训练集小偏差高,所以 K 大;大数据集每次训练已经够大,K 小也能低偏差且方差可控。详见 §2.2。
B. Grid Search 找到的「最优超参」真的是全局最优吗?贝叶斯优化为什么通常能在更少试验次数内找到更好的解?
要点:Grid 是「在你搜过的网格上最优」,不是全局最优;维度灾难让 Grid 浪费大量预算在不重要的超参上。Bayesian Optimization 把超参搜索建模为「黑盒函数」,用高斯过程 / TPE 估计「下一组最可能好的点」,对 100 次预算通常能找到 Grid 用 1000 次才找到的最优。详见 §3.3。
C. 生产环境里过拟合的真正代价是什么?除了泛化误差,还有哪些「看不见的过拟合」风险?
要点:① 泛化误差(测试集表现差);② 数据漂移(训练分布 ≠ 现实分布);③ 概念漂移(标签语义随时间变);④ 标签泄漏(测试样本在训练时通过特征工程混入);⑤ 模型陈旧(上线后不再重训,模式漂移)。监控:PSI / KS 检验分布漂移;定期重训;A/B test 验真。详见 §4.2 + §4.3。
9. 推荐资源
视频
- StatQuest《Cross Validation》 —— 5 分钟讲清楚 K-Fold
- Andrew Ng CS229 Lecture 11 —— 偏差方差 + 学习曲线完整推导
- 李宏毅《机器学习》模型选择章节 —— 中文实战视角
教科书
- 《统计学习导论》(ISLR)》第 5 章 —— 重采样方法
- 《Pattern Recognition and Machine Learning》(PRML)》第 3 章 —— 线性模型正则化
- 《动手学机器学习》(D2L)》第 4 章 —— 模型选择 + 过拟合
论文
- Bergstra & Bengio 2012《Random Search for Hyper-Parameter Optimization》 —— JMLR 经典
- Bergstra et al. 2011《Algorithms for Hyper-Parameter Optimization》 —— TPE 引入
- Snoek, Larochelle, Adams 2012《Practical Bayesian Optimization of ML Algorithms》 —— GP-BO 工业范本
- Li et al. 2017《A System for Massively Parallel Hyperparameter Tuning》 —— Hyperband / BOHB
博客 / 课程
- Optuna 官方文档 —— 中文,TPE + Pruner 完整教程
- 《sklearn 文档:Model Selection》 —— 交叉验证 + 搜索 + 流水线全集
- 《sklearn 文档:Learning Curve》 —— 学习曲线 + 验证曲线 + 诊断
- 《Google《Rules of Machine Learning》》 —— 工程经验 43 条,工业级必读
代码
- Optuna —— 贝叶斯优化首选,集成 sklearn / PyTorch / XGBoost
- scikit-optimize(
skopt)—— GP-BO,极简 API - Ray Tune —— 分布式超参搜索
- Hyperopt —— 老牌,TPE 实现
- Determined AI —— 完整训练平台,内置 Hyperparameter Search
10. 本节要点
- 三大集合:train 拟合参数,val 选超参 + 早停,test 只用一次评估;数据泄漏就发生在把 val 当 test 用、把 test 当 val 用。
- K-Fold 选 K:小数据 K=10,中等 K=5,大数据 K=3 或 hold-out;分类必走 StratifiedKFold,时间序列必走 TimeSeriesSplit。
- 超参搜索:Grid 适合低维、Random 适合高维、Bayesian(Optuna) 预算有限时几乎必胜;报告
mean ± std,差距 < 2σ 视为平手。 - 过拟合诊断:train 高 val 低差距大 = 过拟合 → 加数据/正则/Dropout/Early Stopping;两条曲线都低 = 欠拟合 → 加容量/换模型/减正则。
- 学习曲线:是偏差-方差诊断的「心电图」,先画它再调参;Random Search 是 Bergstra 2012 的实证结论,同等预算普遍优于 Grid。
- 工程铁律:任何模型上线前必须 CV 验证,任何超参调整不能靠单次 train/val 运气,任何性能瓶颈先看学习曲线。
- 「看不见的过拟合」:数据漂移 PSI、概念漂移、标签泄漏;生产监控 + 定期重训是长期对抗手段。
11. 下一节:Day 14 · 第 2 周复盘 + 完整 ML Pipeline 实战
主题:经典机器学习周收官,完整 ML Pipeline 实战。覆盖:
- 七步标准流程:问题定义 → 数据获取清洗 → 特征工程 → 模型选型 → 训练拟合 → 评估调参 → 部署上线
- 算法工具箱矩阵:监督(Logistic / DT / RF / XGBoost / SVM)、无监督(K-Means / PCA / t-SNE)、评估(K-Fold / GridSearch)
- Pipeline 实战铁律:先 baseline → 再复杂模型 → 最后调参与特征工程迭代
- 把 Day 8~13 的所有工具串成端到端项目,理解「算法只是零件,Pipeline 才是工程」
产出物:在 Kaggle Titanic 数据集上跑完整 ML Pipeline,提交 leaderboard,看从 baseline 到 XGBoost+调参的真实提升曲线;同时生成 confusion_matrix.png 与 feature_importance.png 两张可视化。
作者:林馨予 + 林晓月 最后更新:2026-07-04 版权:CC BY-NC-SA 4.0