交叉验证(Cross-Validation)
交叉验证是一种用于评估和验证机器学习模型性能的统计分析方法。其核心目标是在有限的数据样本下,尽可能地获得一个稳定且无偏的模型性能估计,即模拟模型在 未知数据(unseen data) 上的表现。
它解决了传统“一次性划分”训练集和验证集(Hold-out Method)所带来的两个主要问题:
- 数据浪费:一次性划分会使一部分数据永远无法用于模型训练。
- 结果的偶然性:模型的性能评估结果可能严重依赖于数据是如何被随机划分的。一次“幸运”的划分可能导致模型性能被高估,反之则被低估。
交叉验证通过一种 重复使用(Resampling) 数据的策略,让每个数据子集都有机会同时扮演“训练集”和“验证集”的角色,从而得到更鲁棒的评估结果。
K-折交叉验证(K-Fold Cross-Validation)
K-折交叉验证是交叉验证中最常用的一种形式。其流程如下:
-
数据分割(Split): 首先,将整个训练数据集随机地、不重复地划分为 K 个大小相似的子集。这些子集通常被称为“折”(Fold)。通常 K 的取值为5或10。
-
重复训练与验证(Iterate): 进行 K 轮独立的训练和验证过程。在每一轮(第 iii 轮)中:
- 将第 iii 折作为验证集。
- 将剩余的 K−1K-1K−1 折合并作为训练集。
- 使用该训练集训练模型,并在第 iii 折验证集上评估模型性能,记录下本次的评估分数(如准确率、F1分数等)。
-
性能聚合(Aggregate): K 轮结束后,会得到 K 个独立的性能评估分数。最终,将这 K 个分数进行平均,得到的结果即为该模型最终的交叉验证性能评估。
图解:5-折交叉验证过程
假设我们将数据分为5折:
轮次 | 折1 | 折2 | 折3 | 折4 | 折5 |
---|---|---|---|---|---|
第1轮 | 验证 | 训练 | 训练 | 训练 | 训练 |
第2轮 | 训练 | 验证 | 训练 | 训练 | 训练 |
第3轮 | 训练 | 训练 | 验证 | 训练 | 训练 |
第4轮 | 训练 | 训练 | 训练 | 验证 | 训练 |
第5轮 | 训练 | 训练 | 训练 | 训练 | 验证 |
最终性能 = (第1轮分数 + 第2轮分数 + … + 第5轮分数) / 5
与网格搜索的关系
交叉验证是网格搜索(Grid Search)的黄金搭档。在网格搜索中,对于每一个超参数组合,我们不是只进行一次训练和验证,而是执行一次完整的K-折交叉验证。
- 过程:对于网格中的一个超参数组合(例如,
lr=0.001
,optimizer=Adam
),我们用它进行一次K-折交叉验证,得到一个平均性能分数。 - 目的:这样做可以确保我们为这个超参数组合得到的性能分数是稳定和可靠的,而不是源于某次幸运的数据划分。
- 选择:网格搜索最终会选择那个在K-折交叉验证中平均性能分数最高的超参数组合。
Scikit-learn 代码示例
Python的scikit-learn
库提供了非常强大的交叉验证和网格搜索工具。下面的代码展示了如何将它们结合使用。
import numpy as np
from sklearn.model_selection import GridSearchCV, KFold
from sklearn.svm import SVC # 使用支持向量机作为示例模型
from sklearn.datasets import make_classification # 创建虚拟分类数据
# 1. 创建虚拟数据集
X, y = make_classification(n_samples=1000, n_features=20, n_informative=10, n_classes=2, random_state=42)
# 2. 定义模型
# 我们将使用支持向量机(SVC)
model = SVC()
# 3. 定义超参数网格(搜索空间)
# 我们想为SVC的'C'(正则化参数)和'kernel'(核函数)找到最佳值
param_grid = {
'C': [0.1, 1, 10, 100],
'kernel': ['linear', 'rbf']
}
# 4. 定义交叉验证策略
# 我们将使用5-折交叉验证
# shuffle=True确保在分割前数据是随机打乱的
cv_strategy = KFold(n_splits=5, shuffle=True, random_state=42)
# 5. 实例化GridSearchCV对象
# 这个对象将模型、参数网格和交叉验证策略封装在一起
# 'scoring'指定了我们关心的评估指标
# 'n_jobs=-1'表示使用所有可用的CPU核心并行计算,以加快搜索速度
grid_search = GridSearchCV(
estimator=model,
param_grid=param_grid,
scoring='accuracy',
cv=cv_strategy,
n_jobs=-1
)
# 6. 执行搜索
print("--- Starting Grid Search with 5-Fold Cross-Validation ---")
grid_search.fit(X, y)
# 7. 输出最佳结果
print("\n--- Grid Search Finished ---")
print(f"Best parameters found: {grid_search.best_params_}")
print(f"Best cross-validation accuracy: {grid_search.best_score_:.4f}")
# grid_search.best_estimator_ 现在是一个已经用最佳参数在全部数据上重新训练好的模型
# 可以直接用于预测
# best_model = grid_search.best_estimator_
在这个代码中,GridSearchCV
自动处理了所有事情:对于param_grid
中的 4×2=84 \times 2 = 84×2=8 种超参数组合,它都分别执行了一次5-折交叉验证(总计 8×5=408 \times 5 = 408×5=40 次模型训练和评估),并最终报告了平均准确率最高的超参数组合。
网格搜索 (Grid Search)
网格搜索是一种系统性的、详尽的 超参数优化(Hyperparameter Optimization) 技术。它的核心思想是,对于给定的模型,定义一个包含多个超参数候选值的“网格”(即搜索空间),然后通过 交叉验证(Cross-Validation) 或在独立的验证集上,对网格中每一个超参数的组合进行模型训练和评估,最终选出性能表现最优的那一组超参数。
关键组成部分:
-
超参数(Hyperparameters): 这些是在模型训练开始之前需要手动设置的参数,它们控制着训练过程本身,而不是由训练数据直接学习到的模型权重。常见的超参数包括:学习率(Learning Rate)、批处理大小(Batch Size)、优化器类型(如 Adam, SGD)、正则化强度(Regularization Strength)等。
-
搜索空间(Search Space): 这是一个由多个超参数及其各自的候选值列表构成的多维空间。例如,如果我们要调整学习率和批处理大小,搜索空间可能定义为:
- 学习率:
[0.01, 0.001, 0.0001]
- 批处理大小:
[16, 32, 64]
这个搜索空间就构成了一个包含 3×3=93 \times 3 = 93×3=9 个点的“网格”。
- 学习率:
-
评估指标(Evaluation Metric): 用于衡量模型性能的标准,例如准确率(Accuracy)、精确率(Precision)、F1分数(F1-Score)或损失函数值(Loss)。
-
验证集(Validation Set): 一部分独立于训练集和测试集的数据。网格搜索中的每组超参数组合都会在这个数据集上进行评估,以避免在最终的测试集上过拟合,并为超参数选择提供无偏的性能度量。
过程:
网格搜索会遍历搜索空间中的每一个点(即每一组超参数的组合),使用该组合来训练模型,然后在验证集上计算评估指标。整个过程结束后,它会返回在验证集上评估指标得分最高的那一组超参数组合。
实际例子:图像分类模型的超参数调优
假设我们正在训练一个用于识别手写数字(如 MNIST 数据集)的卷积神经网络(CNN),我们不确定哪种 学习率(learning rate) 和 优化器(optimizer) 的组合效果最好。
目标:找到最佳的学习率和优化器组合。
-
定义超参数和搜索空间:
- 学习率: 我们想尝试
0.01
,0.001
和0.0001
。 - 优化器: 我们想尝试
Adam
和SGD
。 - 网格:这就构成了一个 3times2=63 \\times 2 = 63times2=6 种组合的网格。
(lr=0.01, optimizer=Adam)
(lr=0.01, optimizer=SGD)
(lr=0.001, optimizer=Adam)
(lr=0.001, optimizer=SGD)
(lr=0.0001, optimizer=Adam)
(lr=0.0001, optimizer=SGD)
- 学习率: 我们想尝试
-
执行网格搜索:
- 我们依次使用上述6种组合来训练我们的CNN模型。
- 对于每一种组合,模型都在训练集上训练固定的周期(epochs),然后在验证集上计算其分类准确率。
-
选择最佳组合:
- 假设我们得到以下验证准确率:
(lr=0.01, optimizer=Adam)
-> 97.5%- …
(lr=0.001, optimizer=Adam)
-> 98.2%- …
- 我们发现
(learning_rate=0.001, optimizer='Adam')
这组配置在验证集上取得了最高的准确率。因此,我们就选定这组超参数来训练最终的模型。
- 假设我们得到以下验证准确率:
PyTorch 代码示例
下面是一个简化的PyTorch代码,演示了上述例子的网格搜索过程。
import torch
import torch.nn as nn
import torch.optim as optim
from torch.utils.data import DataLoader, TensorDataset
# 1. 准备数据 (为了演示,我们创建虚拟数据)
# 假设是60000个28x28的图像
X_train = torch.randn(60000, 1, 28, 28)
y_train = torch.randint(0, 10, (60000,))
# 验证集
X_val = torch.randn(10000, 1, 28, 28)
y_val = torch.randint(0, 10, (10000,))
# 2. 定义模型 (一个简单的CNN)
class SimpleCNN(nn.Module):
def __init__(self):
super(SimpleCNN, self).__init__()
self.conv1 = nn.Conv2d(1, 16, kernel_size=3, stride=1, padding=1)
self.relu = nn.ReLU()
self.pool = nn.MaxPool2d(kernel_size=2)
self.fc1 = nn.Linear(16 * 14 * 14, 10) # 28x28 -> 14x14 after pooling
def forward(self, x):
x = self.pool(self.relu(self.conv1(x)))
x = x.view(-1, 16 * 14 * 14)
x = self.fc1(x)
return x
# 3. 定义超参数网格
param_grid = {
'lr': [0.01, 0.001],
'optimizer': ['Adam', 'SGD'],
'batch_size': [32, 64]
}
# 存储最佳结果
best_accuracy = 0
best_hyperparams = {}
# 4. 执行网格搜索
print("--- Starting Grid Search ---")
for lr in param_grid['lr']:
for optimizer_name in param_grid['optimizer']:
for batch_size in param_grid['batch_size']:
current_params = {'lr': lr, 'optimizer': optimizer_name, 'batch_size': batch_size}
print(f"\nTesting combination: {current_params}")
# 实例化模型和数据加载器
model = SimpleCNN()
train_dataset = TensorDataset(X_train, y_train)
train_loader = DataLoader(train_dataset, batch_size=batch_size, shuffle=True)
val_dataset = TensorDataset(X_val, y_val)
val_loader = DataLoader(val_dataset, batch_size=batch_size)
# 选择优化器
if optimizer_name == 'Adam':
optimizer = optim.Adam(model.parameters(), lr=lr)
else:
optimizer = optim.SGD(model.parameters(), lr=lr)
criterion = nn.CrossEntropyLoss()
# --- 简化的训练过程 (通常会训练多个epoch) ---
model.train()
for epoch in range(1): # 仅训练一个epoch以简化演示
for data, target in train_loader:
optimizer.zero_grad()
output = model(data)
loss = criterion(output, target)
loss.backward()
optimizer.step()
# --- 验证过程 ---
model.eval()
correct = 0
total = 0
with torch.no_grad():
for data, target in val_loader:
output = model(data)
_, predicted = torch.max(output.data, 1)
total += target.size(0)
correct += (predicted == target).sum().item()
accuracy = 100 * correct / total
print(f"Validation Accuracy: {accuracy:.2f}%")
# --- 检查是否为最佳结果 ---
if accuracy > best_accuracy:
best_accuracy = accuracy
best_hyperparams = current_params
print("\n--- Grid Search Finished ---")
print(f"Best Validation Accuracy: {best_accuracy:.2f}%")
print(f"Best Hyperparameters: {best_hyperparams}")