交叉验证(Cross-Validation)是机器学习中用于评估模型泛化能力的关键技术。它将数据集划分为互补的子集,在部分子集上训练模型,在剩余子集上评估性能,通过多次重复来获得稳定可靠的性能估计。核心目标是避免过拟合评估,即防止模型在训练集上表现良好但在新数据上泛化失败。
最常见的评估方式是简单地将数据划分为训练集和测试集(如 70:30 比例),但这存在几个严重问题:
| 问题 | 说明 | 后果 |
|---|---|---|
| 高方差 | 不同的随机划分会导致完全不同的评估结果 | 模型性能估计不可靠 |
| 数据浪费 | 测试集数据不参与训练 | 小数据集上训练不充分 |
| 样本偏差 | 特定划分可能恰好"幸运"或"不幸" | 对真实泛化能力误判 |
示例:数据集随机划分的影响
假设有一个包含 1000 个样本的二分类数据集,正负样本各 500 个。如果随机划分训练-测试集,可能产生如下结果:
| 划分次数 | 训练集正样本数 | 测试集正样本数 | 测试准确率 |
|---|---|---|---|
| 第 1 次 | 352 | 148 | 0.85 |
| 第 2 次 | 349 | 151 | 0.82 |
| 第 3 次 | 355 | 145 | 0.88 |
| 第 4 次 | 341 | 159 | 0.79 |
| 第 5 次 | 358 | 142 | 0.90 |
可以看到,仅仅因为随机划分不同,测试准确率在 0.79 到 0.90 之间波动。一个模型在"好"划分上可能看起来表现优异,但在真实部署中却令人失望。
交叉验证通过多次重复评估来解决上述问题:
| 方法 | 原理 | 适用场景 | 计算成本 |
|---|---|---|---|
| K 折交叉验证 | 均分 K 份,轮流用 K-1 份训练、1 份验证 | 通用默认选择 | 中等(K 次训练) |
| 分层 K 折 | 保持每折的类别比例与整体一致 | 分类不平衡数据 | 中等 |
| 留一法(LOOCV) | 每次只留一个样本作为验证集 | 非常小的数据集 | 极高(N 次训练) |
| 重复 K 折 | 多次重复 K 折,随机划分不同 | 需要更稳定估计时 | 高(R × K 次训练) |
| 时间序列交叉验证 | 按时间顺序递增训练窗口 | 时间序列数据 | 中等 |
这是最常用的交叉验证方法,也是最基础的变体。
算法步骤:
输入:数据集 D = {x₁, x₂, ..., xₙ},折数 K
输出:K 个模型的平均性能
步骤 1:将 D 随机均匀分为 K 个子集 D₁, D₂, ..., Dₖ(每个大小 ≈ N/K)
步骤 2:for i = 1 to K:
训练集 = D \ Dᵢ(除 Dᵢ 外的所有子集)
验证集 = Dᵢ
在训练集上训练模型 Mᵢ
在验证集上评估,得到性能 Pᵢ
步骤 3:返回 avg(P₁, P₂, ..., Pₖ) 作为最终性能估计
具体数值示例:
假设数据集 N = 150,K = 5。每折大小 = 30 个样本。
| 轮次 | 训练集大小 | 验证集大小 | 验证准确率 |
|---|---|---|---|
| Fold 1 | 120 | 30 | 0.833 |
| Fold 2 | 120 | 30 | 0.867 |
| Fold 3 | 120 | 30 | 0.800 |
| Fold 4 | 120 | 30 | 0.867 |
| Fold 5 | 120 | 30 | 0.833 |
平均准确率 = 0.840,标准差 = 0.027
这个平均结果比单次划分(方差高得多)更可靠。标准差 0.027 还告诉我们性能的稳定性。
如何选择 K?
K 的选择本质上是偏差-方差的权衡:
| K 值 | 偏差(Bias) | 方差(Variance) | 计算成本 |
|---|---|---|---|
| 2 | 最高(训练数据太少) | 最低 | 最低 |
| 5 | 中等 | 中等 | 中等 |
| 10 | 较低 | 中等 | 较高 |
| N(LOOCV) | 最低(几乎无偏) | 最高 | 最高 |
当数据存在类别不平衡时,普通的随机 K 折可能导致某些折中完全缺失某类样本。分层 K 折确保每折中各类别的比例与完整数据集一致。
示例:不平衡数据集的 K 折 vs 分层 K 折
假设一个二分类数据集:正样本 100 个,负样本 900 个(比例 1:9)。
| 折 | 普通 K 折(正样本数) | 分层 K 折(正样本数) |
|---|---|---|
| Fold 1 | 8 | 10 |
| Fold 2 | 15 | 10 |
| Fold 3 | 5 | 10 |
| Fold 4 | 12 | 10 |
| Fold 5 | 60 | 10 |
可以看到,普通 K 折中第 5 折的正样本数是第 3 折的 12 倍,这会导致评估结果在不同折之间剧烈波动。而分层 K 折精确保持了 1:9 的比例。
LOOCV 是 K 折交叉验证的极端形式,其中 K = N(样本总数)。每次只留一个样本作为验证集。
优点:
缺点:
适用场景: N < 100 且模型训练快速的场景(如线性回归、小型决策树)。
通过多次重复 K 折交叉验证(每次随机划分不同)来进一步降低评估方差。
示例: 10 次重复的 5 折交叉验证意味着总共训练 10 × 5 = 50 次。
| 重复次数 | 平均准确率 | 标准差 | 95% 置信区间 |
|---|---|---|---|
| 1 | 0.840 | 0.027 | [0.787, 0.893] |
| 10 | 0.842 | 0.008 | [0.826, 0.858] |
可以看到,重复 10 次后续的标准差从 0.027 降至 0.008,置信区间显著收窄。
时间序列数据具有时间依赖性,不能随机划分。必须保证训练数据在时间上始终在验证数据之前。
前向链式验证:
Trial 1: 训练 [1, 2, ..., t₁] → 验证 [t₁+1, ..., t₂]
Trial 2: 训练 [1, 2, ..., t₂] → 验证 [t₂+1, ..., t₃]
Trial 3: 训练 [1, 2, ..., t₃] → 验证 [t₃+1, ..., t₄]
...
数值示例(股价预测):
假设有 500 天的日收益率数据,使用前向验证:
| 试验 | 训练天数 | 验证天数 | 训练窗 | 验证窗 | MSE |
|---|---|---|---|---|---|
| 1 | 100 | 20 | 第 1-100 天 | 第 101-120 天 | 0.0015 |
| 2 | 120 | 20 | 第 1-120 天 | 第 121-140 天 | 0.0018 |
| 3 | 140 | 20 | 第 1-140 天 | 第 141-160 天 | 0.0012 |
| ... | ... | ... | ... | ... | ... |
| 20 | 480 | 20 | 第 1-480 天 | 第 481-500 天 | 0.0021 |
关键规则: 严禁使用未来数据训练。这也是为什么时间序列建模中,交叉验证的实现不同于标准 K 折。
当数据存在分组结构时(如同一患者的多条医疗记录),需要确保同一组的所有样本都在同一折中,以避免数据泄露。
from sklearn.model_selection import GroupKFold
# 假设有 9 个样本,属于 3 个患者
X = [0, 1, 2, 3, 4, 5, 6, 7, 8]
groups = [0, 0, 1, 1, 1, 2, 2, 2, 2] # 患者 ID
gkf = GroupKFold(n_splits=3)
for train_idx, val_idx in gkf.split(X, groups=groups):
print(f"Train: {train_idx}, Val: {val_idx}")
输出:
Train: [3 4 5 6 7 8], Val: [0 1 2]
Train: [0 1 2 5 6 7 8], Val: [3 4]
Train: [0 1 2 3 4], Val: [5 6 7 8]
注意:同一患者的所有记录始终在同一折中,不会出现同一患者的部分记录在训练集、部分在验证集的情况。
令 为交叉验证估计的泛化误差, 为真实泛化误差:
偏差来源: 训练集大小是 N(K-1)/K,小于 N,因此模型训练的充分度略逊于全数据训练。K 越小,此偏差越大。
方差来源: 各折之间的评估结果会有差异。K 越大(尤其是 LOOCV),各折训练集之间的重叠越大,方差越高。
经验法则: K=5 或 K=10 在大多数场景下提供了偏差和方差的最佳平衡。
基于 K 折的结果,可以构建性能的置信区间:
其中 是平均性能, 是标准差, 是 t 分布的临界值。
示例: K=5 时,平均准确率 0.840,标准差 0.027
这意味着我们有 95% 的把握认为模型的真实准确率落在 80.6% 到 87.4% 之间。
交叉验证常与网格搜索(Grid Search)或随机搜索(Random Search)结合用于超参数调优。
示例:SVM 的 C 参数调优
使用 5 折交叉验证评估不同 C 值:
| C 值 | Fold 1 | Fold 2 | Fold 3 | Fold 4 | Fold 5 | 平均 | 标准差 |
|---|---|---|---|---|---|---|---|
| 0.01 | 0.72 | 0.74 | 0.71 | 0.73 | 0.70 | 0.720 | 0.015 |
| 0.1 | 0.81 | 0.83 | 0.79 | 0.82 | 0.80 | 0.810 | 0.015 |
| 1 | 0.85 | 0.87 | 0.83 | 0.86 | 0.84 | 0.850 | 0.015 |
| 10 | 0.86 | 0.88 | 0.82 | 0.85 | 0.81 | 0.844 | 0.028 |
| 100 | 0.85 | 0.86 | 0.77 | 0.79 | 0.72 | 0.798 | 0.057 |
选择 C=1,因为它在平均准确率最高且标准差较低。
⚠️ 嵌套交叉验证: 当使用交叉验证进行超参数调优时,需要两层交叉验证来获得无偏的性能评估——内层调优超参数,外层评估泛化性能。否则,模型选择过程中的"偷看"数据会导致性能估计过于乐观。
外循环(5 折):
└── Fold 1: 验证集 (20%) | 训练集 (80%)
└── 内循环(3 折):
├── Fold A: 验证 | 训练 → 调参
├── Fold B: 验证 | 训练 → 调参
└── Fold C: 验证 | 训练 → 调参
内循环选出最佳超参数
← 用最佳参数在外循环训练集上训练,在外循环验证集上评估 →
这样,外循环的验证集从未参与过超参数选择,因此给出的性能估计是无偏的。
| 注意事项 | 说明 | 解决方案 |
|---|---|---|
| 数据泄露 | 预处理(如标准化、PCA)应在每折的训练集上拟合,再应用到验证集 | 在交叉验证循环内部进行预处理 |
| 不平衡数据 | 少数类可能在某些折完全缺失 | 使用分层 K 折 |
| 分组依赖 | 相关样本不应被分到不同折 | 使用 GroupKFold |
| 时间相关性 | 未来数据不能用于预测过去 | 使用时间序列交叉验证 |
| 高计算成本 | 深度学习模型训练一次耗时数小时 | 减少折数或仅使用验证集划分 |
| 小数据集 | 少量样本无法支撑较大的验证集 | 使用 LOOCV 或留 P 法 |
# ❌ 错误:在交叉验证前对整个数据集标准化
from sklearn.preprocessing import StandardScaler
scaler = StandardScaler()
X_scaled = scaler.fit_transform(X) # 验证集信息"泄露"到训练集
# ✅ 正确:在每折内部进行标准化
from sklearn.model_selection import cross_val_score
from sklearn.pipeline import make_pipeline
pipeline = make_pipeline(StandardScaler(), LogisticRegression())
scores = cross_val_score(pipeline, X, y, cv=5) # 每折独立标准化
假设备交叉验证结果为 [0.82, 0.85, 0.88, 0.83, 0.87]:
| 方法 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| 简单划分 | 大数据集(>100K) | 计算极快 | 评估有偏、方差高 |
| Bootstrap | 小数据集、置信区间估计 | 适合估计统计量分布 | 有偏采样(约 63.2% 样本出现) |
| K 折交叉验证 | 中等数据集(1K-100K) | 通用、可靠 | 计算成本较高 |
| 重复 K 折 | 需要高精度估计 | 方差最低 | 计算成本最高 |
| LOOCV | 非常小的数据集(<100) | 几乎无偏 | 方差高、计算极慢 |
Bootstrap 与交叉验证不同,它通过有放回采样生成多个训练集。每个 Bootstrap 样本中大约包含 63.2% 的原始样本(遗漏的 36.8% 作为验证集):
Bootstrap 的优点是可以方便地计算统计量的置信区间,但在模型评估中通常不如交叉验证准确(有偏)。
from sklearn.model_selection import (
cross_val_score,
KFold,
StratifiedKFold,
RepeatedKFold,
GroupKFold,
TimeSeriesSplit,
LeaveOneOut
)
from sklearn.ensemble import RandomForestClassifier
from sklearn.datasets import load_iris
# 加载数据
X, y = load_iris(return_X_y=True)
# 1. 基本 K 折(K=5)
kf = KFold(n_splits=5, shuffle=True, random_state=42)
scores = cross_val_score(RandomForestClassifier(), X, y, cv=kf, scoring='accuracy')
print(f"5-Fold CV: {scores.mean():.3f} (±{scores.std():.3f})")
# 2. 分层 K 折(分类默认推荐)
skf = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
scores_s = cross_val_score(RandomForestClassifier(), X, y, cv=skf, scoring='accuracy')
print(f"Stratified 5-Fold: {scores_s.mean():.3f} (±{scores_s.std():.3f})")
# 3. 重复 K 折(降低方差)
rkf = RepeatedKFold(n_splits=5, n_repeats=10, random_state=42)
scores_r = cross_val_score(RandomForestClassifier(), X, y, cv=rkf, scoring='accuracy')
print(f"Repeated 5x10-Fold: {scores_r.mean():.3f} (±{scores_r.std():.3f})")
# 4. 留一法(小数据集)
loo = LeaveOneOut()
scores_loo = cross_val_score(RandomForestClassifier(), X, y, cv=loo, scoring='accuracy')
print(f"LOOCV: {scores_loo.mean():.3f}")
# 5. 分组 K 折
from sklearn.model_selection import GroupKFold
groups = [i // 10 for i in range(len(X))] # 每 10 个样本一组
gkf = GroupKFold(n_splits=5)
scores_g = cross_val_score(RandomForestClassifier(), X, y, cv=gkf, groups=groups)
print(f"Group 5-Fold: {scores_g.mean():.3f} (±{scores_g.std():.3f})")