专栏 AI 与算法

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

说明 · 本站内容均为学习笔记与经验总结,所有菜谱与技法请结合实际食材、季节与个人口味灵活调整。涉及生食、营养与健康的内容仅供参考,特殊体质或疾病请咨询专业营养师/医生。