训练一个模型只是第一步。如何可靠地评估模型的表现,如何找到最好的参数组合,如何比较不同模型哪个更优,这些才是机器学习实践中的核心挑战。本文将全面介绍模型评估和调优的方法论。

交叉验证:更可靠的评估

单次划分训练集和测试集存在一个问题:结果可能因为随机划分而产生很大波动。交叉验证通过多次划分取平均值,提供更稳定的评估结果。

K-Fold 交叉验证

将数据平均分为 K 份,轮流用 K-1 份训练、1 份验证。

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from sklearn.datasets import load_diabetes
from sklearn.model_selection import (cross_val_score, cross_validate,
                                     KFold, StratifiedKFold,
                                     LeaveOneOut, TimeSeriesSplit,
                                     learning_curve, validation_curve,
                                     GridSearchCV, RandomizedSearchCV)
from sklearn.linear_model import Ridge, Lasso
from sklearn.ensemble import RandomForestRegressor, GradientBoostingRegressor
from sklearn.metrics import mean_squared_error, r2_score, make_scorer
import warnings
warnings.filterwarnings('ignore')

# 加载数据
diabetes = load_diabetes()
X, y = diabetes.data, diabetes.target

# 5 折交叉验证
kfold = KFold(n_splits=5, shuffle=True, random_state=42)
scores = cross_val_score(Ridge(alpha=1.0), X, y, cv=kfold, scoring='r2')

print(f"5 折交叉验证 R² 分数: {scores}")
print(f"平均 R²: {scores.mean():.4f}{scores.std():.4f})")

Stratified K-Fold

对于分类问题,分层 K 折确保每折的类别比例与原数据一致。

from sklearn.datasets import load_iris
from sklearn.linear_model import LogisticRegression

iris = load_iris()
X_iris, y_iris = iris.data, iris.target

# 分层 K 折
stratified_kfold = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
scores_strat = cross_val_score(LogisticRegression(max_iter=200), 
                                X_iris, y_iris, 
                                cv=stratified_kfold, scoring='accuracy')
print(f"分层 K 折准确率: {scores_strat}")
print(f"平均准确率: {scores_strat.mean():.4f}")

Leave-One-Out (LOO)

每个样本单独作为验证集,适合小数据集(但计算量大)。

loo = LeaveOneOut()
# LOO 在小数据集上演示
scores_loo = cross_val_score(Ridge(alpha=1.0), X[:50], y[:50], 
                              cv=loo, scoring='r2')
print(f"LOO 平均 R²: {scores_loo.mean():.4f}")

时间序列交叉验证

时间序列数据不能随机划分(不能使用未来数据预测过去)。时间序列交叉验证使用逐步向前的方式。

# 模拟时间序列数据
np.random.seed(42)
n = 100
X_ts = np.random.randn(n, 3)
y_ts = np.sin(np.arange(n) * 0.1) + np.random.randn(n) * 0.1

# 时间序列分割
tscv = TimeSeriesSplit(n_splits=5)
for i, (train_idx, test_idx) in enumerate(tscv.split(X_ts)):
    print(f"折 {i+1}: 训练 {len(train_idx)} 个样本, 测试 {len(test_idx)} 个样本")
    print(f"  训练索引范围: {train_idx[0]}...{train_idx[-1]}")
    print(f"  测试索引范围: {test_idx[0]}...{test_idx[-1]}")

cross_validate:获取更多信息

cross_val_score 只返回分数,而 cross_validate 可以返回更多信息。

# cross_validate 获取详细结果
cv_results = cross_validate(
    Ridge(alpha=1.0), X, y, 
    cv=5, 
    scoring={'r2': 'r2', 'neg_mse': 'neg_mean_squared_error'},
    return_train_score=True
)

print("交叉验证详细结果:")
for metric in ['r2', 'neg_mse']:
    train_mean = cv_results[f'train_{metric}'].mean()
    test_mean = cv_results[f'test_{metric}'].mean()
    print(f"  {metric} - 训练: {train_mean:.4f}, 验证: {test_mean:.4f}")

超参数调优

超参数是训练前设定的参数,不是从数据中学习的。找到最佳超参数组合是提升模型表现的关键。

GridSearchCV:网格搜索

穷举搜索所有参数组合。简单但计算量大。

from sklearn.model_selection import GridSearchCV

# 定义参数网格
param_grid = {
    'alpha': [0.01, 0.1, 1.0, 10.0, 100.0],
    'solver': ['auto', 'svd', 'cholesky']
}

# 网格搜索
grid_search = GridSearchCV(
    Ridge(),
    param_grid=param_grid,
    cv=5,
    scoring='r2',
    n_jobs=-1,
    verbose=1
)
grid_search.fit(X, y)

print(f"最佳参数: {grid_search.best_params_}")
print(f"最佳得分: {grid_search.best_score_:.4f}")
print(f"最佳模型: {grid_search.best_estimator_}")

# 查看所有组合的结果
results_df = pd.DataFrame(grid_search.cv_results_)
print("\n前5个组合的搜索结果:")
print(results_df[['param_alpha', 'param_solver', 'mean_test_score', 'std_test_score']].head())

网格搜索的注意事项

参数搜索空间随着参数数量呈指数增长。实际使用中,建议先用粗粒度搜索确定大致范围,再细化搜索。

# 分阶段搜索策略
# 第一轮: 粗粒度搜索
coarse_grid = {'alpha': [0.001, 0.01, 0.1, 1, 10, 100, 1000]}
coarse_search = GridSearchCV(Ridge(), coarse_grid, cv=5, scoring='r2')
coarse_search.fit(X, y)
print(f"粗搜索最佳 alpha: {coarse_search.best_params_['alpha']:.4f}")

# 第二轮: 在最佳值附近细化
best_alpha = coarse_search.best_params_['alpha']
fine_grid = {
    'alpha': np.linspace(best_alpha * 0.5, best_alpha * 2, 10)
}
fine_search = GridSearchCV(Ridge(), fine_grid, cv=5, scoring='r2')
fine_search.fit(X, y)
print(f"精细搜索最佳 alpha: {fine_search.best_params_['alpha']:.4f}")

RandomizedSearchCV:随机搜索

当参数空间很大时,随机搜索比网格搜索更高效。它从参数分布中随机采样固定数量的组合。

from sklearn.model_selection import RandomizedSearchCV
from scipy.stats import uniform, randint

# 随机搜索
param_dist = {
    'n_estimators': randint(50, 300),
    'max_depth': randint(3, 20),
    'min_samples_split': randint(2, 20),
    'min_samples_leaf': randint(1, 10),
}

random_search = RandomizedSearchCV(
    RandomForestRegressor(random_state=42),
    param_distributions=param_dist,
    n_iter=30,  # 只搜索 30 种组合
    cv=5,
    scoring='r2',
    random_state=42,
    n_jobs=-1
)
random_search.fit(X, y)

print(f"随机搜索最佳参数: {random_search.best_params_}")
print(f"最佳 R²: {random_search.best_score_:.4f}")

网格搜索 vs 随机搜索

关键发现(来自 Bergstra & Bengio 2012 的研究):随机搜索在大部分参数不重要时远比网格搜索高效。

网格搜索:    固定的点阵 → 浪费大量计算在不重要的参数上
随机搜索:    从分布中采样 → 更容易覆盖最优参数所在的区域

贝叶斯优化

贝叶斯优化比随机搜索更智能,它会根据历史结果选择下一组参数。scikit-optimize 提供了现成的实现。

# 需要安装: pip install scikit-optimize
from skopt import BayesSearchCV
from skopt.space import Real, Integer

# 贝叶斯搜索
opt = BayesSearchCV(
    RandomForestRegressor(random_state=42),
    {
        'n_estimators': Integer(50, 300),
        'max_depth': Integer(3, 20),
        'min_samples_split': Integer(2, 20),
    },
    n_iter=20,
    cv=5,
    scoring='r2',
    random_state=42
)
opt.fit(X, y)
print(f"贝叶斯搜索最佳参数: {opt.best_params_}")
print(f"最佳 R²: {opt.best_score_:.4f}")

学习曲线与验证曲线

学习曲线

学习曲线展示了模型表现随训练数据量增加的变化趋势。它可以用来诊断偏差和方差问题。

# 学习曲线
train_sizes, train_scores, test_scores = learning_curve(
    Ridge(alpha=1.0), X, y,
    train_sizes=np.linspace(0.1, 1.0, 10),
    cv=5, scoring='r2', n_jobs=-1
)

train_mean = np.mean(train_scores, axis=1)
train_std = np.std(train_scores, axis=1)
test_mean = np.mean(test_scores, axis=1)
test_std = np.std(test_scores, axis=1)

plt.figure(figsize=(10, 6))
plt.fill_between(train_sizes, train_mean - train_std, train_mean + train_std, 
                 alpha=0.2, color='blue')
plt.fill_between(train_sizes, test_mean - test_std, test_mean + test_std, 
                 alpha=0.2, color='orange')
plt.plot(train_sizes, train_mean, 'o-', label='训练集', color='blue')
plt.plot(train_sizes, test_mean, 'o-', label='验证集', color='orange')
plt.xlabel('训练集大小')
plt.ylabel('R² 分数')
plt.title('学习曲线')
plt.legend()
plt.grid(alpha=0.3)
plt.show()

# 解读:
# 两条曲线都低 → 高偏差(欠拟合)→ 增加复杂特征或使用更复杂模型
# 训练集高但验证集低且差距大 → 高方差(过拟合)→ 增加数据量或减少特征
# 两条曲线都高且差距小 → 理想情况

验证曲线

验证曲线展示模型表现随某个超参数的变化趋势。

# 验证曲线
param_range = np.logspace(-3, 3, 20)
train_scores_v, test_scores_v = validation_curve(
    Ridge(), X, y,
    param_name='alpha',
    param_range=param_range,
    cv=5, scoring='r2', n_jobs=-1
)

train_mean_v = np.mean(train_scores_v, axis=1)
test_mean_v = np.mean(test_scores_v, axis=1)

plt.figure(figsize=(10, 6))
plt.semilogx(param_range, train_mean_v, 'o-', label='训练集', color='blue')
plt.semilogx(param_range, test_mean_v, 'o-', label='验证集', color='orange')
plt.xlabel('Alpha (正则化强度)')
plt.ylabel('R² 分数')
plt.title('验证曲线 - Ridge 正则化')
plt.legend()
plt.grid(alpha=0.3)
# 左侧 alpha 小 → 过拟合
# 右侧 alpha 大 → 欠拟合
plt.show()

模型比较与统计显著性

比较两个模型时,不能只看单次测试的分数差异,需要统计检验来判断差异是否显著。

from sklearn.model_selection import cross_val_score
from scipy import stats

# 比较两个模型
models = {
    'Ridge': Ridge(alpha=1.0),
    'Random Forest': RandomForestRegressor(n_estimators=100, random_state=42)
}

results = {}
for name, model in models.items():
    scores = cross_val_score(model, X, y, cv=10, scoring='r2')
    results[name] = scores
    print(f"{name}: 平均 R² = {scores.mean():.4f}{scores.std():.4f})")

# 配对 t 检验
t_stat, p_value = stats.ttest_rel(results['Ridge'], results['Random Forest'])
print(f"\n配对 t 检验:")
print(f"  t 统计量: {t_stat:.4f}")
print(f"  p 值: {p_value:.4f}")
print(f"  结论: {'差异显著' if p_value < 0.05 else '差异不显著'}")

集成方法

集成学习通过组合多个模型来获得更好的预测性能,是 Kaggle 竞赛的制胜法宝。

Bagging

Bagging 通过并行训练多个模型并取平均来降低方差。随机森林是最典型的代表。

from sklearn.ensemble import BaggingRegressor

# Bagging 回归
bagging = BaggingRegressor(
    estimator=Ridge(alpha=1.0),
    n_estimators=50,
    max_samples=0.8,
    max_features=0.8,
    random_state=42
)
bagging_scores = cross_val_score(bagging, X, y, cv=5, scoring='r2')
print(f"Bagging R²: {bagging_scores.mean():.4f}")

Boosting

Boosting 按顺序训练模型,每个新模型修正前一个的错误。它主要降低偏差。

from sklearn.ensemble import AdaBoostRegressor, GradientBoostingRegressor

# AdaBoost
ada = AdaBoostRegressor(n_estimators=50, random_state=42)
ada_scores = cross_val_score(ada, X, y, cv=5, scoring='r2')
print(f"AdaBoost R²: {ada_scores.mean():.4f}")

# Gradient Boosting
gbr = GradientBoostingRegressor(
    n_estimators=100, max_depth=3, learning_rate=0.1, random_state=42
)
gbr_scores = cross_val_score(gbr, X, y, cv=5, scoring='r2')
print(f"Gradient Boosting R²: {gbr_scores.mean():.4f}")

XGBoost

XGBoost 是梯度提升的高效实现,速度更快、性能更好,是 Kaggle 竞赛的常胜将军。

# 需要安装: pip install xgboost
import xgboost as xgb

xgb_model = xgb.XGBRegressor(
    n_estimators=100, max_depth=3, learning_rate=0.1,
    subsample=0.8, colsample_bytree=0.8,
    random_state=42
)
xgb_scores = cross_val_score(xgb_model, X, y, cv=5, scoring='r2')
print(f"XGBoost R²: {xgb_scores.mean():.4f}")

LightGBM

LightGBM 是微软开发的高效梯度提升框架,比 XGBoost 更快,内存占用更低。

# 需要安装: pip install lightgbm
import lightgbm as lgb

lgb_model = lgb.LGBMRegressor(
    n_estimators=100, max_depth=3, learning_rate=0.1,
    subsample=0.8, colsample_bytree=0.8,
    random_state=42, verbose=-1
)
lgb_scores = cross_val_score(lgb_model, X, y, cv=5, scoring='r2')
print(f"LightGBM R²: {lgb_scores.mean():.4f}")

Stacking

Stacking 用"元学习器"来组合多个基模型的预测结果。

from sklearn.ensemble import StackingRegressor
from sklearn.linear_model import LinearRegression

# Stacking 集成
base_models = [
    ('ridge', Ridge(alpha=1.0)),
    ('rf', RandomForestRegressor(n_estimators=50, random_state=42)),
    ('gbr', GradientBoostingRegressor(n_estimators=50, random_state=42))
]

stacking = StackingRegressor(
    estimators=base_models,
    final_estimator=LinearRegression(),
    cv=5
)
stacking_scores = cross_val_score(stacking, X, y, cv=5, scoring='r2')
print(f"Stacking R²: {stacking_scores.mean():.4f}")

集成方法对比

方法 目标 训练方式 典型算法
Bagging 降低方差 并行 随机森林
Boosting 降低偏差 顺序 AdaBoost, GBDT, XGBoost
Stacking 两者兼顾 分层 元学习器组合基模型

模型可解释性

复杂模型往往被称为"黑箱"。可解释性工具让我们理解模型为什么做出某个预测。

特征重要性

树模型和线性模型直接提供特征重要性。

gbr.fit(X, y)
importance_df = pd.DataFrame({
    'feature': diabetes.feature_names,
    'importance': gbr.feature_importances_
}).sort_values('importance', ascending=False)
print("Gradient Boosting 特征重要性:")
print(importance_df)

SHAP 值

SHAP(SHapley Additive exPlanations)基于博弈论,为每个预测计算每个特征的贡献值。

# 需要安装: pip install shap
import shap

# 训练模型
model = GradientBoostingRegressor(n_estimators=100, max_depth=3, random_state=42)
model.fit(X, y)

# 计算 SHAP 值
explainer = shap.Explainer(model, X)
shap_values = explainer(X)

# 特征重要性总结图
shap.summary_plot(shap_values, X, feature_names=diabetes.feature_names)

偏依赖图(Partial Dependence Plot)

展示某个特征对预测结果的平均边际影响。

from sklearn.inspection import PartialDependenceDisplay

# 偏依赖图
fig, ax = plt.subplots(figsize=(10, 6))
PartialDependenceDisplay.from_estimator(
    model, X, features=[0, 2, (0, 2)],
    feature_names=diabetes.feature_names,
    ax=ax
)
plt.suptitle('偏依赖图')
plt.tight_layout()
plt.show()

LIME(Local Interpretable Model-agnostic Explanations)

LIME 在预测点附近拟合一个简单可解释模型来解释单个预测。

# 需要安装: pip install lime
import lime
from lime.lime_tabular import LimeTabularExplainer

explainer_lime = LimeTabularExplainer(
    X, feature_names=diabetes.feature_names,
    mode='regression', random_state=42
)

# 解释单个预测
i = 0  # 第一个测试样本
exp = explainer_lime.explain_instance(
    X[i], model.predict, num_features=5
)
exp.show_in_notebook(show_table=True)

模型持久化

训练好模型后,保存到磁盘以便后续使用。

import joblib
import pickle

# 训练最终模型
final_model = GradientBoostingRegressor(
    n_estimators=100, max_depth=3, learning_rate=0.1,
    random_state=42
)
final_model.fit(X, y)

# 方法 1: joblib(推荐,对大对象更高效)
joblib.dump(final_model, 'model.joblib')
loaded_model_joblib = joblib.load('model.joblib')
print("joblib 保存/加载成功")

# 方法 2: pickle(Python 标准库)
with open('model.pkl', 'wb') as f:
    pickle.dump(final_model, f)
with open('model.pkl', 'rb') as f:
    loaded_model_pickle = pickle.load(f)
print("pickle 保存/加载成功")

# 验证保存的模型
y_pred = loaded_model_joblib.predict(X)
print(f"加载模型 R²: {r2_score(y, y_pred):.4f}")

保存模型时的建议

  • joblib:推荐用于 scikit-learn 模型,对大数组序列化更高效
  • pickle:Python 标准库,通用但可能较慢
  • 始终保存模型训练时的预处理 Pipeline(编码器、缩放器等),而不是只保存模型
  • 记录模型版本和训练参数
# 正确做法:保存整个 Pipeline
from sklearn.pipeline import Pipeline

pipeline = Pipeline([
    ('scaler', StandardScaler()),
    ('model', GradientBoostingRegressor(n_estimators=100, random_state=42))
])
pipeline.fit(X, y)
joblib.dump(pipeline, 'full_pipeline.joblib')
print("完整 Pipeline 已保存")

实战:完整的调优流程

from sklearn.model_selection import train_test_split

# 1. 数据划分
X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.2, random_state=42
)

# 2. 参数搜索
param_grid = {
    'n_estimators': [50, 100, 200],
    'max_depth': [3, 5, 7],
    'learning_rate': [0.01, 0.05, 0.1],
}

gbr = GradientBoostingRegressor(random_state=42)
grid = GridSearchCV(gbr, param_grid, cv=5, scoring='r2', n_jobs=-1)
grid.fit(X_train, y_train)

print(f"最佳参数: {grid.best_params_}")
print(f"最佳交叉验证 R²: {grid.best_score_:.4f}")

# 3. 测试集评估
test_r2 = grid.score(X_test, y_test)
print(f"测试集 R²: {test_r2:.4f}")

# 4. 特征重要性
best_model = grid.best_estimator_
importance = pd.DataFrame({
    'feature': diabetes.feature_names,
    'importance': best_model.feature_importances_
}).sort_values('importance', ascending=False)
print("\n特征重要性:")
print(importance)

# 5. 保存最终模型
joblib.dump(grid.best_estimator_, 'best_gbr_model.joblib')
print("\n最佳模型已保存为 best_gbr_model.joblib")

总结

这是本系列文章的最后一篇。我们一起走过了模型评估与调优的全流程:

  1. 交叉验证:K-Fold、Stratified K-Fold、LOO、时间序列分割
  2. 超参数调优:网格搜索、随机搜索、贝叶斯优化
  3. 学习曲线与验证曲线:诊断偏差方差问题
  4. 模型比较:统计检验判断差异显著性
  5. 集成方法:Bagging、Boosting(XGBoost、LightGBM)、Stacking
  6. 模型可解释性:特征重要性、SHAP、偏依赖图、LIME
  7. 模型持久化:joblib / pickle

回顾整个"高级篇",我们从机器学习概览出发,深入学习了回归、分类、聚类三大算法体系,掌握了特征工程的核心技能,最终到达模型评估与调优的终点。

机器学习的道路没有终点。这些理论和技能只是基础,真正的成长来自于在真实数据上反复实践。祝你在数据分析的道路上不断进步,从入门走向精通!