跳到主要内容

模型选择与评估

模型选择与评估是机器学习工作流程中至关重要的一环。训练出模型只是第一步,如何科学地评估模型性能、选择最佳模型、调整超参数,直接影响最终的应用效果。本章将系统介绍 sklearn 中的模型评估方法和超参数调优技术。

为什么需要科学的模型评估?

训练误差与泛化误差

机器学习的目标是让模型在未见过的数据上表现良好,这称为泛化能力。如果只关注训练数据上的表现,很容易陷入过拟合的陷阱。

训练误差是模型在训练数据上的预测误差。训练误差低并不意味着模型好,因为模型可能只是"记住"了训练数据。

泛化误差是模型在未见数据上的预测误差。这才是我们真正关心的指标。

评估模型的关键是用独立的数据来估计泛化误差。简单地将数据分为训练集和测试集是一种方法,但对于更可靠的评估,我们需要交叉验证。

数据泄露问题

数据泄露是指在模型训练过程中无意中使用了不应该使用的信息。这是机器学习中最常见且最危险的错误之一。

常见的数据泄露场景:

  • 预处理泄露:在整个数据集上进行标准化,然后再划分训练集和测试集。这样测试集的信息泄露到了预处理参数中。
  • 特征选择泄露:在整个数据集上进行特征选择,然后再划分数据。
  • 时间泄露:在时间序列预测中,使用未来的数据预测过去。

正确的做法是:任何数据转换都应该只从训练集学习参数,然后应用到测试集。使用 Pipeline 可以自动保证这一点。

交叉验证

交叉验证(Cross-Validation,简称 CV)是一种更稳健的模型评估方法,充分利用有限的数据进行训练和验证。

K 折交叉验证

K 折交叉验证将数据分成 K 份,每次用 K-1 份训练,1 份验证,重复 K 次,取平均作为最终评估结果。

为什么 K 折交叉验证更可靠?

单次划分可能导致偶然性的偏差。比如划分恰好把难分类的样本都放到了测试集,评估结果就会偏低。K 折交叉验证通过多次划分取平均,降低了这种偶然性。

from sklearn.model_selection import cross_val_score, KFold
from sklearn.ensemble import RandomForestClassifier
from sklearn.datasets import load_iris

iris = load_iris()
X, y = iris.data, iris.target

clf = RandomForestClassifier(n_estimators=100, random_state=42)

# 5折交叉验证
scores = cross_val_score(clf, X, y, cv=5)

print(f"各折得分: {scores}")
print(f"平均得分: {scores.mean():.3f}")
print(f"标准差: {scores.std():.3f}")

分层 K 折交叉验证

对于分类问题,如果某些类别样本很少,普通 K 折可能导致某些折中缺少某些类别。分层 K 折保证每折中各类别的比例与完整数据集相同。

from sklearn.model_selection import cross_val_score, StratifiedKFold

# 分层 K 折(推荐用于分类)
skf = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
scores = cross_val_score(clf, X, y, cv=skf)

print(f"分层交叉验证得分: {scores.mean():.3f} (+/- {scores.std()*2:.3f})")

分类任务应该使用 StratifiedKFold,而不是普通的 KFold。

留一法交叉验证

留一法(Leave-One-Out,LOO)是 K 折的特例,K 等于样本数。每次只留一个样本作为验证集。

优点是充分利用数据,缺点是计算量大。

from sklearn.model_selection import LeaveOneOut

loo = LeaveOneOut()
scores = cross_val_score(clf, X, y, cv=loo)

print(f"留一法得分: {scores.mean():.3f}")
print(f"总测试次数: {len(scores)}")

重复交叉验证

为了获得更稳定的估计,可以多次重复 K 折交叉验证。

from sklearn.model_selection import RepeatedStratifiedKFold

# 重复 5 折交叉验证 10 次
rskf = RepeatedStratifiedKFold(n_splits=5, n_repeats=10, random_state=42)
scores = cross_val_score(clf, X, y, cv=rskf)

print(f"重复交叉验证得分: {scores.mean():.3f} (+/- {scores.std()*2:.3f})")

交叉验证的注意事项

计算成本:K 折交叉验证需要训练 K 次模型,对于复杂模型或大数据集,计算成本较高。可以通过 n_jobs=-1 并行化。

数据泄露风险:如果在交叉验证外进行预处理,仍然会发生数据泄露。正确做法是使用 Pipeline:

from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.linear_model import LogisticRegression

# 正确:预处理在交叉验证内部
pipeline = Pipeline([
('scaler', StandardScaler()),
('clf', LogisticRegression())
])

scores = cross_val_score(pipeline, X, y, cv=5)
print(f"正确做法得分: {scores.mean():.3f}")

# 错误:预处理在交叉验证外部
scaler = StandardScaler()
X_scaled = scaler.fit_transform(X) # 泄露了测试集信息
clf = LogisticRegression()
scores_wrong = cross_val_score(clf, X_scaled, y, cv=5)
print(f"错误做法得分: {scores_wrong.mean():.3f}")

自定义交叉验证

对于特殊情况,可以自定义交叉验证划分方式。

时间序列交叉验证

from sklearn.model_selection import TimeSeriesSplit
import numpy as np

# 时间序列数据
X_time = np.arange(100).reshape(-1, 1)
y_time = np.random.randn(100)

tscv = TimeSeriesSplit(n_splits=5)

for train_index, test_index in tscv.split(X_time):
print(f"训练: {train_index[:5]}...{train_index[-5:]}, 测试: {test_index[:5]}...{test_index[-5:]}")

分组交叉验证

当数据有分组结构时(如同一个患者的多次测量),需要保证同一组的数据不会同时出现在训练集和测试集中。

from sklearn.model_selection import GroupKFold

# 假设有 5 个组
groups = [0, 0, 0, 1, 1, 1, 2, 2, 2, 3, 3, 3, 4, 4, 4]

gkf = GroupKFold(n_splits=5)

for train_index, test_index in gkf.split(X, y, groups):
print(f"训练组: {set(np.array(groups)[train_index])}, 测试组: {set(np.array(groups)[test_index])}")

评估指标

选择合适的评估指标对于正确评估模型至关重要。不同任务需要不同的指标。

分类评估指标

准确率(Accuracy):正确预测的比例。当类别不平衡时具有误导性。

from sklearn.metrics import accuracy_score

y_true = [0, 1, 0, 1, 0, 1, 0, 1]
y_pred = [0, 1, 0, 0, 0, 1, 1, 1]

print(f"准确率: {accuracy_score(y_true, y_pred):.3f}")

精确率、召回率、F1 分数

from sklearn.metrics import precision_score, recall_score, f1_score

print(f"精确率: {precision_score(y_true, y_pred):.3f}")
print(f"召回率: {recall_score(y_true, y_pred):.3f}")
print(f"F1 分数: {f1_score(y_true, y_pred):.3f}")

这些指标的数学定义:

  • 精确率 = TP / (TP + FP),预测为正的样本中有多少是真正的正样本
  • 召回率 = TP / (TP + FN),真正的正样本有多少被正确预测
  • F1 分数 = 2 × (精确率 × 召回率) / (精确率 + 召回率)

精确率-召回率权衡

在很多分类任务中,精确率和召回率是此消彼长的。提高分类阈值会增加精确率但降低召回率,反之亦然。选择哪个指标更重要取决于业务场景:

  • 疾病筛查:召回率更重要,宁可误诊也不能漏诊
  • 垃圾邮件过滤:精确率更重要,误判正常邮件为垃圾邮件代价很高

分类报告

from sklearn.metrics import classification_report

print(classification_report(y_true, y_pred, target_names=['负类', '正类']))

混淆矩阵

from sklearn.metrics import confusion_matrix, ConfusionMatrixDisplay

cm = confusion_matrix(y_true, y_pred)
print("混淆矩阵:")
print(cm)

# 可视化
disp = ConfusionMatrixDisplay(confusion_matrix=cm, display_labels=['负类', '正类'])
disp.plot(cmap='Blues')

ROC 曲线与 AUC

ROC 曲线展示不同阈值下的真阳性率(TPR)和假阳性率(FPR)。AUC 是曲线下的面积。

from sklearn.metrics import roc_curve, roc_auc_score
import matplotlib.pyplot as plt

# 需要概率输出
y_prob = [0.1, 0.4, 0.35, 0.8, 0.2, 0.7, 0.6, 0.9]

fpr, tpr, thresholds = roc_curve(y_true, y_prob)
auc = roc_auc_score(y_true, y_prob)

plt.figure(figsize=(8, 6))
plt.plot(fpr, tpr, label=f'ROC 曲线 (AUC = {auc:.3f})')
plt.plot([0, 1], [0, 1], 'k--')
plt.xlabel('假阳性率 (FPR)')
plt.ylabel('真阳性率 (TPR)')
plt.title('ROC 曲线')
plt.legend()
plt.grid(True)
plt.show()

回归评估指标

均方误差(MSE)和均方根误差(RMSE)

MSE=1ni=1n(yiy^i)2\text{MSE} = \frac{1}{n}\sum_{i=1}^{n}(y_i - \hat{y}_i)^2

from sklearn.metrics import mean_squared_error
import numpy as np

y_true = [3.0, -0.5, 2.0, 7.0]
y_pred = [2.5, 0.0, 2.0, 8.0]

mse = mean_squared_error(y_true, y_pred)
rmse = np.sqrt(mse)

print(f"MSE: {mse:.3f}")
print(f"RMSE: {rmse:.3f}")

平均绝对误差(MAE)

MAE=1ni=1nyiy^i\text{MAE} = \frac{1}{n}\sum_{i=1}^{n}|y_i - \hat{y}_i|

from sklearn.metrics import mean_absolute_error

mae = mean_absolute_error(y_true, y_pred)
print(f"MAE: {mae:.3f}")

决定系数(R²)

R2=1(yiy^i)2(yiyˉ)2R^2 = 1 - \frac{\sum(y_i - \hat{y}_i)^2}{\sum(y_i - \bar{y})^2}

from sklearn.metrics import r2_score

r2 = r2_score(y_true, y_pred)
print(f"R²: {r2:.3f}")

R² 表示模型解释目标变量方差的比例。R² = 1 表示完美预测,R² = 0 表示模型等于简单预测均值。

指标选择

指标特点适用场景
MSE/RMSE对大误差敏感需要惩罚大误差
MAE对异常值稳健数据有异常值
可解释性好需要解释模型拟合程度

使用交叉验证计算多个指标

from sklearn.model_selection import cross_validate

scoring = ['accuracy', 'precision_macro', 'recall_macro', 'f1_macro']

results = cross_validate(clf, X, y, cv=5, scoring=scoring)

for metric in scoring:
scores = results[f'test_{metric}']
print(f"{metric}: {scores.mean():.3f} (+/- {scores.std()*2:.3f})")

超参数调优

超参数是在训练前需要设置的参数,不同于模型训练过程中学习的参数。选择合适的超参数对模型性能至关重要。

网格搜索(GridSearchCV)

网格搜索穷举所有参数组合,找到最优解。

from sklearn.model_selection import GridSearchCV
from sklearn.ensemble import RandomForestClassifier
from sklearn.datasets import load_iris

iris = load_iris()
X, y = iris.data, iris.target

# 定义参数网格
param_grid = {
'n_estimators': [50, 100, 200],
'max_depth': [3, 5, 10, None],
'min_samples_split': [2, 5, 10]
}

# 网格搜索
grid_search = GridSearchCV(
RandomForestClassifier(random_state=42),
param_grid,
cv=5,
scoring='accuracy',
n_jobs=-1,
verbose=1
)

grid_search.fit(X, y)

print(f"最佳参数: {grid_search.best_params_}")
print(f"最佳得分: {grid_search.best_score_:.3f}")

# 使用最佳模型
best_model = grid_search.best_estimator_

随机搜索(RandomizedSearchCV)

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

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

# 定义参数分布
param_dist = {
'n_estimators': randint(50, 300),
'max_depth': [3, 5, 10, None],
'min_samples_split': randint(2, 20),
'min_samples_leaf': randint(1, 10)
}

# 随机搜索
random_search = RandomizedSearchCV(
RandomForestClassifier(random_state=42),
param_dist,
n_iter=50, # 尝试 50 个组合
cv=5,
scoring='accuracy',
n_jobs=-1,
random_state=42
)

random_search.fit(X, y)

print(f"最佳参数: {random_search.best_params_}")
print(f"最佳得分: {random_search.best_score_:.3f}")

嵌套交叉验证

当需要在调参的同时估计泛化误差时,使用嵌套交叉验证。外层循环评估模型,内层循环进行超参数调优。

from sklearn.model_selection import cross_val_score, GridSearchCV

# 外层交叉验证
outer_cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)

# 内层网格搜索
inner_search = GridSearchCV(
RandomForestClassifier(random_state=42),
param_grid,
cv=3,
scoring='accuracy'
)

# 嵌套交叉验证
nested_scores = cross_val_score(inner_search, X, y, cv=outer_cv)

print(f"嵌套交叉验证得分: {nested_scores.mean():.3f} (+/- {nested_scores.std()*2:.3f})")

嵌套交叉验证的结果是对泛化误差的无偏估计。普通网格搜索的最佳得分往往过于乐观,因为它用验证集选了最优参数。

超参数调优的最佳实践

搜索顺序:先用粗粒度搜索确定大致范围,再用细粒度搜索精确调优。

# 第一阶段:粗搜索
param_grid_coarse = {
'n_estimators': [10, 50, 100, 200],
'max_depth': [3, 5, 10, 20, None]
}

# 第二阶段:细搜索(基于粗搜索结果)
param_grid_fine = {
'n_estimators': [80, 100, 120],
'max_depth': [8, 10, 12]
}

常用超参数优先级

对于随机森林:

  1. n_estimators(树的数量,通常越多越好,但有计算成本)
  2. max_depth(最大深度,控制过拟合)
  3. min_samples_splitmin_samples_leaf(控制分裂条件)

对于 SVM:

  1. C(正则化参数)
  2. kernel(核函数类型)
  3. gamma(RBF 核参数)

避免过拟合验证集:如果尝试了太多参数组合,可能会过拟合到验证集。使用嵌套交叉验证或保留一个独立的测试集进行最终评估。

学习曲线与验证曲线

学习曲线

学习曲线展示模型性能随训练数据量的变化,帮助判断模型是否需要更多数据。

from sklearn.model_selection import learning_curve
import numpy as np
import matplotlib.pyplot as plt

train_sizes, train_scores, test_scores = learning_curve(
RandomForestClassifier(n_estimators=100, random_state=42),
X, y,
cv=5,
train_sizes=np.linspace(0.1, 1.0, 10),
n_jobs=-1
)

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

plt.figure(figsize=(10, 6))
plt.plot(train_sizes, train_mean, 'o-', label='训练得分')
plt.plot(train_sizes, test_mean, 'o-', label='验证得分')
plt.fill_between(train_sizes, train_mean - train_std, train_mean + train_std, alpha=0.1)
plt.fill_between(train_sizes, test_mean - test_std, test_mean + test_std, alpha=0.1)
plt.xlabel('训练样本数')
plt.ylabel('得分')
plt.title('学习曲线')
plt.legend()
plt.grid(True)
plt.show()

如何解读学习曲线

  • 高偏差(欠拟合):训练得分和验证得分都很低,增加数据不会有帮助,需要更复杂的模型或更好的特征
  • 高方差(过拟合):训练得分高,验证得分低,增加数据可能有帮助
  • 理想情况:训练得分和验证得分都高且接近

验证曲线

验证曲线展示模型性能随超参数值的变化,帮助选择最优超参数。

from sklearn.model_selection import validation_curve

param_range = [1, 3, 5, 10, 20, 50, 100]

train_scores, test_scores = validation_curve(
RandomForestClassifier(n_estimators=50, random_state=42),
X, y,
param_name='max_depth',
param_range=param_range,
cv=5,
n_jobs=-1
)

train_mean = train_scores.mean(axis=1)
test_mean = test_scores.mean(axis=1)

plt.figure(figsize=(10, 6))
plt.plot(param_range, train_mean, 'o-', label='训练得分')
plt.plot(param_range, test_mean, 'o-', label='验证得分')
plt.xlabel('max_depth')
plt.ylabel('得分')
plt.title('验证曲线')
plt.legend()
plt.grid(True)
plt.xscale('log')
plt.show()

模型持久化

训练好的模型需要保存以便后续使用。

import joblib
from sklearn.ensemble import RandomForestClassifier
from sklearn.datasets import load_iris

# 训练模型
iris = load_iris()
X, y = iris.data, iris.target
model = RandomForestClassifier(n_estimators=100, random_state=42)
model.fit(X, y)

# 保存模型
joblib.dump(model, 'random_forest_model.joblib')

# 加载模型
loaded_model = joblib.load('random_forest_model.joblib')

# 使用模型预测
predictions = loaded_model.predict([[5.1, 3.5, 1.4, 0.2]])
print(f"预测结果: {predictions}")

保存 Pipeline

from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler

pipeline = Pipeline([
('scaler', StandardScaler()),
('clf', RandomForestClassifier(n_estimators=100, random_state=42))
])

pipeline.fit(X, y)

# 保存整个 Pipeline
joblib.dump(pipeline, 'full_pipeline.joblib')

# 加载后会自动进行预处理和预测
loaded_pipeline = joblib.load('full_pipeline.joblib')

小结

模型选择与评估是机器学习工作流程的核心环节:

  1. 交叉验证:使用 K 折交叉验证获得更稳健的模型评估,分类任务使用分层 K 折
  2. 评估指标:根据任务特点选择合适的指标,不要只看准确率
  3. 超参数调优:网格搜索适合小参数空间,随机搜索更适合大参数空间
  4. 避免数据泄露:预处理必须在交叉验证内部进行,使用 Pipeline
  5. 学习曲线:帮助判断是需要更多数据还是更复杂的模型
  6. 模型持久化:使用 joblib 保存训练好的模型

这些技能的掌握将帮助你更科学地评估和优化机器学习模型。