Machine Learning: 模型评估与调优 — 评估方法论与超参数优化指南
评估方法论不对,一切优化都是空中楼阁——先测准,再调优。
1. 你将学到
- 评估方法论:Hold-out/K-Fold/Stratified K-Fold/时间序列交叉验证(TimeSeriesSplit)
- 分类评估:混淆矩阵、Precision-Recall曲线、F1-β、多分类指标
- 回归评估:MAE/MSE/RMSE/MAPE/R²,业务指标对齐
- 超参数优化:GridSearchCV/RandomizedSearchCV/Optuna贝叶斯优化
- 过拟合诊断:学习曲线(Learning Curve)、验证曲线(Validation Curve)
2. 一个ML工程师的真实故事
(1) 痛点:模型在测试集上95%准确率,上线后降到75%
Bob用train_test_split评估模型,测试集accuracy高达95%。但上线一周后,实际准确率只有75%——因为时间序列数据中,训练集和测试集的分布重叠,模型"偷看"了未来信息。错误的评估方法导致虚高的指标和上线的灾难。
(2) 正确评估方法论的解法
时间序列数据必须用TimeSeriesSplit——训练集只用过去数据,测试集是未来数据,模拟真实场景。
PYTHON
from sklearn.model_selection import TimeSeriesSplit
tscv = TimeSeriesSplit(n_splits=5)
for train_idx, test_idx in tscv.split(X):
# train_idx always < test_idx (no future leakage)
model.fit(X[train_idx], y[train_idx])
score = model.score(X[test_idx], y[test_idx])
(3) 收益:评估指标准确反映真实性能
Bob用TimeSeriesSplit重新评估,准确率从"虚高"的95%降到"真实"的82%。虽然数字下降了,但上线后实际准确率也是82%——评估和真实一致,不再踩坑。
3. 评估方法论
(1) 四种交叉验证策略
graph TB
DATA[Dataset] --> HO[Hold-out<br/>Single Train/Test Split]
DATA --> KF[K-Fold CV<br/>Random Splits]
DATA --> SKF[Stratified K-Fold<br/>Class-Balanced Splits]
DATA --> TSS[TimeSeriesSplit<br/>Chronological Splits]
HO --> HO_USE[Quick baseline<br/>Large dataset]
KF --> KF_USE[General purpose<br/>Stable estimate]
SKF --> SKF_USE[Classification<br/>Imbalanced data]
TSS --> TSS_USE[Time series<br/>No future leakage]
▶ 示例:四种CV策略对比
PYTHON
from sklearn.model_selection import (train_test_split, KFold, StratifiedKFold,
TimeSeriesSplit, cross_val_score)
from sklearn.ensemble import RandomForestClassifier
from sklearn.datasets import load_iris
import numpy as np
X, y = load_iris(return_X_y=True)
model = RandomForestClassifier(n_estimators=50, random_state=42)
# 1. Hold-out
X_tr, X_te, y_tr, y_te = train_test_split(X, y, test_size=0.2, random_state=42, stratify=y)
model.fit(X_tr, y_tr)
holdout_score = model.score(X_te, y_te)
# 2. K-Fold
kf_scores = cross_val_score(model, X, y, cv=KFold(n_splits=5, shuffle=True, random_state=42))
# 3. Stratified K-Fold
skf_scores = cross_val_score(model, X, y, cv=StratifiedKFold(n_splits=5, shuffle=True, random_state=42))
# 4. TimeSeriesSplit (for time-ordered data)
tss_scores = cross_val_score(model, X, y, cv=TimeSeriesSplit(n_splits=5))
print(f"Hold-out: {holdout_score:.4f}")
print(f"K-Fold: {kf_scores.mean():.4f} +/- {kf_scores.std():.4f}")
print(f"Stratified K-Fold: {skf_scores.mean():.4f} +/- {skf_scores.std():.4f}")
print(f"TimeSeriesSplit: {tss_scores.mean():.4f} +/- {tss_scores.std():.4f}")
输出:
TEXT
📖 仅展示
# 执行成功
| 方法 | 数据类型 | 优点 | 缺点 |
|---|---|---|---|
| Hold-out | 任意 | 快 | 结果依赖split |
| K-Fold | 任意 | 稳定 | 随机划分 |
| Stratified K-Fold | 分类 | 类别比例一致 | 仅分类 |
| TimeSeriesSplit | 时序 | 无未来泄露 | 训练量递增 |
4. 分类与回归评估指标
(1) 分类评估全指标
▶ 示例:全面分类评估
PYTHON
from sklearn.metrics import (accuracy_score, precision_score, recall_score,
f1_score, roc_auc_score, classification_report,
confusion_matrix)
from sklearn.linear_model import LogisticRegression
from sklearn.datasets import load_breast_cancer
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler
from sklearn.pipeline import Pipeline
X, y = load_breast_cancer(return_X_y=True)
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42, stratify=y)
pipe = Pipeline([("scaler", StandardScaler()), ("model", LogisticRegression(max_iter=500))])
pipe.fit(X_train, y_train)
y_pred = pipe.predict(X_test)
y_prob = pipe.predict_proba(X_test)[:, 1]
print(f"Accuracy: {accuracy_score(y_test, y_pred):.4f}")
print(f"Precision: {precision_score(y_test, y_pred):.4f}")
print(f"Recall: {recall_score(y_test, y_pred):.4f}")
print(f"F1: {f1_score(y_test, y_pred):.4f}")
print(f"AUC-ROC: {roc_auc_score(y_test, y_prob):.4f}")
print(f"\nConfusion Matrix:\n{confusion_matrix(y_test, y_pred)}")
print(f"\nDetailed Report:\n{classification_report(y_test, y_pred)}")
输出:
TEXT
📖 仅展示
# 执行成功
(2) 回归评估与业务指标对齐
▶ 示例:回归指标与业务含义
PYTHON
from sklearn.metrics import mean_absolute_error, mean_squared_error, r2_score
import numpy as np
y_true = np.array([100, 150, 200, 250, 300]) # thousand USD
y_pred = np.array([95, 160, 190, 260, 310])
mae = mean_absolute_error(y_true, y_pred)
rmse = np.sqrt(mean_squared_error(y_true, y_pred))
r2 = r2_score(y_true, y_pred)
mape = np.mean(np.abs((y_true - y_pred) / y_true)) * 100
print(f"MAE: {mae:.2f} k USD")
print(f"RMSE: {rmse:.2f} k USD")
print(f"R²: {r2:.4f}")
print(f"MAPE: {mape:.1f}%")
# Business impact calculation
avg_monthly_revenue = 1000 # thousand USD
mape_pct = mape / 100
inventory_cost_pct = 0.3 # 30% of overstock is waste
annual_loss = avg_monthly_revenue * mape_pct * inventory_cost_pct * 12
print(f"\nAnnual inventory loss from prediction error: {annual_loss:.0f} k USD")
输出:
TEXT
📖 仅展示
# 执行成功
| 指标 | 公式 | 业务含义 |
|---|---|---|
| MAE | 平均|y-ŷ| | 平均偏差(k USD) |
| RMSE | √(平均(y-ŷ)²) | 大误差惩罚重 |
| R² | 1 - SS_res/SS_tot | 解释方差比例 |
| MAPE | 平均|y-ŷ|/y × 100% | 相对误差(%) |
5. 超参数优化
(1) 三种搜索策略
▶ 示例:GridSearch vs RandomSearch
PYTHON
from sklearn.model_selection import GridSearchCV, RandomizedSearchCV
from sklearn.ensemble import RandomForestRegressor
from sklearn.datasets import fetch_california_housing
from sklearn.model_selection import train_test_split
import time
X, y = fetch_california_housing(return_X_y=True)
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42)
param_grid = {
"n_estimators": [50, 100, 200],
"max_depth": [5, 10, 15, None],
"min_samples_leaf": [1, 5, 10],
}
# GridSearch: exhaustive search
start = time.time()
grid = GridSearchCV(RandomForestRegressor(random_state=42), param_grid, cv=3, scoring="r2", n_jobs=-1)
grid.fit(X_train, y_train)
grid_time = time.time() - start
# RandomSearch: random sampling
start = time.time()
random = RandomizedSearchCV(RandomForestRegressor(random_state=42), param_grid,
n_iter=15, cv=3, scoring="r2", n_jobs=-1, random_state=42)
random.fit(X_train, y_train)
random_time = time.time() - start
print(f"GridSearch: R²={grid.best_score_:.4f}, Time={grid_time:.1f}s, Trials={len(grid.cv_results_['mean_test_score'])}")
print(f"RandomSearch: R²={random.best_score_:.4f}, Time={random_time:.1f}s, Trials=15")
输出:
TEXT
📖 仅展示
# 执行成功
| 方法 | 搜索方式 | 覆盖率 | 速度 | 适合 |
|---|---|---|---|---|
| GridSearch | 穷举所有组合 | 100% | 慢 | 少参数 |
| RandomSearch | 随机采样 | 部分 | 快 | 多参数 |
| Optuna | 贝叶斯优化 | 智能引导 | 高效 | 复杂搜索空间 |
(2) Optuna贝叶斯优化
▶ 示例:Optuna超参数优化
PYTHON
# pip install optuna
import optuna
from sklearn.ensemble import RandomForestRegressor
from sklearn.model_selection import cross_val_score
from sklearn.datasets import fetch_california_housing
from sklearn.model_selection import train_test_split
import numpy as np
X, y = fetch_california_housing(return_X_y=True)
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42)
def objective(trial):
n_estimators = trial.suggest_int("n_estimators", 50, 500)
max_depth = trial.suggest_int("max_depth", 3, 20)
min_samples_leaf = trial.suggest_int("min_samples_leaf", 1, 20)
max_features = trial.suggest_float("max_features", 0.3, 1.0)
model = RandomForestRegressor(
n_estimators=n_estimators, max_depth=max_depth,
min_samples_leaf=min_samples_leaf, max_features=max_features,
random_state=42, n_jobs=-1,
)
scores = cross_val_score(model, X_train, y_train, cv=3, scoring="r2")
return scores.mean()
study = optuna.create_study(direction="maximize")
study.optimize(objective, n_trials=30, show_progress_bar=False)
print(f"Best R²: {study.best_value:.4f}")
print(f"Best params: {study.best_params}")
输出:
TEXT
📖 仅展示
# 函数定义成功
6. 过拟合诊断
(1) 学习曲线与验证曲线
▶ 示例:绘制学习曲线
PYTHON
from sklearn.model_selection import learning_curve, validation_curve
from sklearn.ensemble import RandomForestRegressor
from sklearn.datasets import fetch_california_housing
import matplotlib.pyplot as plt
import numpy as np
X, y = fetch_california_housing(return_X_y=True)
# Learning curve: performance vs training set size
train_sizes, train_scores, val_scores = learning_curve(
RandomForestRegressor(n_estimators=50, random_state=42),
X, y, train_sizes=np.linspace(0.1, 1.0, 10),
cv=3, scoring="r2", n_jobs=-1,
)
fig, axes = plt.subplots(1, 2, figsize=(14, 5))
# Learning curve
axes[0].plot(train_sizes, train_scores.mean(axis=1), "o-", label="Training")
axes[0].plot(train_sizes, val_scores.mean(axis=1), "o-", label="Validation")
axes[0].fill_between(train_sizes, train_scores.mean(axis=1) - train_scores.std(axis=1),
train_scores.mean(axis=1) + train_scores.std(axis=1), alpha=0.1)
axes[0].fill_between(train_sizes, val_scores.mean(axis=1) - val_scores.std(axis=1),
val_scores.mean(axis=1) + val_scores.std(axis=1), alpha=0.1)
axes[0].set_xlabel("Training Size")
axes[0].set_ylabel("R² Score")
axes[0].set_title("Learning Curve")
axes[0].legend()
axes[0].grid(True, alpha=0.3)
# Validation curve: performance vs hyperparameter
param_range = [3, 5, 7, 10, 15, 20, None]
train_scores2, val_scores2 = validation_curve(
RandomForestRegressor(n_estimators=50, random_state=42),
X, y, param_name="max_depth", param_range=param_range,
cv=3, scoring="r2", n_jobs=-1,
)
axes[1].plot(range(len(param_range)), train_scores2.mean(axis=1), "o-", label="Training")
axes[1].plot(range(len(param_range)), val_scores2.mean(axis=1), "o-", label="Validation")
axes[1].set_xticks(range(len(param_range)))
axes[1].set_xticklabels([str(p) for p in param_range])
axes[1].set_xlabel("max_depth")
axes[1].set_ylabel("R² Score")
axes[1].set_title("Validation Curve")
axes[1].legend()
axes[1].grid(True, alpha=0.3)
plt.tight_layout()
plt.savefig("learning_validation_curves.png", dpi=150)
输出:
TEXT
📖 仅展示
# 执行成功
| 诊断 | 曲线特征 | 含义 | 解决方案 |
|---|---|---|---|
| 欠拟合 | 训练/验证都低 | 模型太简单 | 增加复杂度/特征 |
| 过拟合 | 训练高、验证低 | 模型太复杂 | 正则化/简化/加数据 |
| 刚好 | 两者接近且高 | 适中 | 无需改动 |
| 需要更多数据 | 验证曲线还在上升 | 数据不够 | 收集更多数据 |
❓ 常见问题
Q 交叉验证的fold数怎么选?
A 常用5或10。5折在速度和稳定性间平衡好;10折更稳定但慢2倍。数据量大(>50k)用3折就够,数据少(<1k)用10折。
Q 时间序列数据为什么不能用K-Fold?
A K-Fold随机打乱数据,可能用"未来"数据训练预测"过去",导致数据泄露。TimeSeriesSplit保证训练集始终在测试集之前,模拟真实预测场景。
Q GridSearchCV太慢怎么办?
A 三种策略——1) 缩小搜索空间(先粗搜再细搜);2) 用RandomizedSearchCV(n_iter=20);3) 用Optuna贝叶斯优化(智能搜索)。
Q MAPE的缺点是什么?
A 当y_true接近0时MAPE会爆炸(除以接近0的数)。解决——用SMAPE(对称MAPE)或MASE(平均绝对标准化误差)。
Q 学习曲线的训练和验证分数差距大怎么办?
A 差距大=过拟合。解决方案——1) 增加正则化;2) 减少模型复杂度;3) 增加训练数据;4) 使用Dropout/早停。
Q Optuna比GridSearch好多少?
A Optuna用贝叶斯优化智能选择下一组参数,通常用30-50次试验就能找到GridSearch 500+次试验才能找到的结果,节省80%以上计算资源。
📖 小节
- 评估方法论是ML项目的基础:分类用StratifiedKFold,时序用TimeSeriesSplit
- 分类指标选择取决于业务:流失→Recall,垃圾邮件→Precision,推荐→F1
- 回归指标要对齐业务成本:MAPE 10%在百万级预测中意味着10万USD误差
- 超参数优化:GridSearch(穷举)→RandomSearch(采样)→Optuna(智能)
- 过拟合诊断:学习曲线看数据量是否够,验证曲线看参数是否最优
- 正确评估比高指标更重要——虚高的指标上线后会崩塌
📝 作业
- 基础题(难度⭐):用5折和10折交叉验证对比RandomForestClassifier在Iris上的accuracy均值和标准差。提示:cross_val_score(cv=5/10)。
- 进阶题(难度⭐⭐):用GridSearchCV搜索XGBoost的最优参数(max_depth/learning_rate/n_estimators),输出最佳参数和CV R²。提示:Pipeline + param_grid。
- 挑战题(难度⭐⭐⭐):绘制Learning Curve和Validation Curve诊断一个模型的欠拟合/过拟合状态,提出具体改进方案并验证改进效果。提示:learning_curve + validation_curve + 改进后对比。