跳到主要内容

交叉验证

本节定位

只用一次 train/test 分割来评估模型,结果可能受随机划分的影响很大。交叉验证让每个数据都有机会被用作训练和测试,给出更稳定、可靠的评估结果。

学习目标

  • 理解留出法的局限性
  • 掌握 K 折交叉验证
  • 掌握分层 K 折交叉验证
  • 了解留一法和时间序列交叉验证
  • 会用 cross_val_scorecross_validate

一、留出法的问题

1.1 一次划分够吗?

from sklearn.datasets import load_iris
from sklearn.model_selection import train_test_split
from sklearn.tree import DecisionTreeClassifier
import numpy as np

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

# 不同 random_state 导致不同结果
scores = []
for seed in range(50):
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=seed)
model = DecisionTreeClassifier(max_depth=3, random_state=42)
model.fit(X_train, y_train)
scores.append(model.score(X_test, y_test))

import matplotlib.pyplot as plt

plt.figure(figsize=(10, 4))
plt.bar(range(50), scores, color='steelblue', alpha=0.7)
plt.axhline(y=np.mean(scores), color='red', linestyle='--', label=f'平均: {np.mean(scores):.3f}')
plt.xlabel('随机种子')
plt.ylabel('准确率')
plt.title(f'50 次不同划分的准确率(标准差: {np.std(scores):.3f})')
plt.legend()
plt.grid(axis='y', alpha=0.3)
plt.show()

print(f"最低: {min(scores):.3f}, 最高: {max(scores):.3f}, 差距: {max(scores)-min(scores):.3f}")
问题

一次划分的结果不稳定——不同的随机种子可能差异很大。我们需要更可靠的评估方式。


二、K 折交叉验证

2.1 原理

把数据分成 K 份,每次用 1 份做测试、其余 K-1 份做训练。重复 K 次,取平均。

2.2 sklearn 实现

from sklearn.model_selection import cross_val_score, KFold
from sklearn.tree import DecisionTreeClassifier

model = DecisionTreeClassifier(max_depth=3, random_state=42)

# 最简单的用法
scores = cross_val_score(model, X, y, cv=5, scoring='accuracy')
print(f"5 折交叉验证:")
print(f" 每折分数: {scores}")
print(f" 平均: {scores.mean():.4f} ± {scores.std():.4f}")

2.3 手动控制 KFold

from sklearn.model_selection import KFold

kf = KFold(n_splits=5, shuffle=True, random_state=42)

# 可视化每折的划分
fig, axes = plt.subplots(5, 1, figsize=(12, 6), sharex=True)

for fold, (train_idx, test_idx) in enumerate(kf.split(X)):
ax = axes[fold]
ax.scatter(train_idx, [0]*len(train_idx), c='steelblue', s=3, label='训练')
ax.scatter(test_idx, [0]*len(test_idx), c='red', s=10, label='测试')
ax.set_ylabel(f'折 {fold+1}')
ax.set_yticks([])
if fold == 0:
ax.legend(loc='upper right', ncol=2)

axes[-1].set_xlabel('样本索引')
plt.suptitle('5 折交叉验证的数据划分', fontsize=13)
plt.tight_layout()
plt.show()

2.4 K 值怎么选?

K 值优点缺点
K=3速度快方差大,不够稳定
K=5常用默认值平衡了速度和稳定性
K=10更稳定速度稍慢
K=n(留一法)最稳定非常慢

三、分层 K 折交叉验证

3.1 为什么需要分层?

普通 KFold 随机划分,可能导致某一折的类别比例与整体不同(尤其不平衡数据)。

分层 KFold 保证每折的类别比例与整体一致。

from sklearn.model_selection import StratifiedKFold

# 模拟不平衡数据
from sklearn.datasets import make_classification
X_imb, y_imb = make_classification(n_samples=100, n_features=5,
weights=[0.9, 0.1], random_state=42)

print(f"正类比例: {y_imb.mean():.1%}")

# 对比 KFold 和 StratifiedKFold
kf = KFold(n_splits=5, shuffle=True, random_state=42)
skf = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)

print("\n普通 KFold 每折正类比例:")
for fold, (_, test_idx) in enumerate(kf.split(X_imb)):
print(f" 折 {fold+1}: {y_imb[test_idx].mean():.1%}")

print("\n分层 StratifiedKFold 每折正类比例:")
for fold, (_, test_idx) in enumerate(skf.split(X_imb, y_imb)):
print(f" 折 {fold+1}: {y_imb[test_idx].mean():.1%}")

3.2 sklearn 中的默认行为

# cross_val_score 对分类任务默认使用 StratifiedKFold
# 你可以显式指定
from sklearn.model_selection import cross_val_score

scores = cross_val_score(
DecisionTreeClassifier(max_depth=3, random_state=42),
X_imb, y_imb,
cv=StratifiedKFold(n_splits=5, shuffle=True, random_state=42),
scoring='f1'
)
print(f"分层 5 折 F1: {scores.mean():.4f} ± {scores.std():.4f}")
最佳实践
  • 分类任务:始终使用 StratifiedKFoldcross_val_score 默认就是)
  • 回归任务:使用普通 KFold
  • 时间序列:使用 TimeSeriesSplit

四、留一法(LOO)

Leave-One-Out:每次只留 1 个样本做测试,其余 n-1 个做训练。重复 n 次。

from sklearn.model_selection import LeaveOneOut, cross_val_score

# 用小数据集演示(LOO 在大数据集上太慢)
from sklearn.datasets import load_iris
X_small, y_small = load_iris(return_X_y=True)

loo = LeaveOneOut()
model = DecisionTreeClassifier(max_depth=3, random_state=42)

scores = cross_val_score(model, X_small, y_small, cv=loo)
print(f"LOO 交叉验证:")
print(f" 总轮数: {len(scores)}")
print(f" 平均准确率: {scores.mean():.4f}")
优点缺点
训练数据量最大化计算开销大(n 次训练)
评估偏差最小方差可能较大
大数据集上不实用

五、时间序列交叉验证

5.1 为什么不能随机划分?

时间序列数据有时间顺序——用未来数据训练去预测过去,是"数据泄漏"。

5.2 TimeSeriesSplit

from sklearn.model_selection import TimeSeriesSplit
import numpy as np

# 模拟时间序列数据
n = 100
X_ts = np.arange(n).reshape(-1, 1)
y_ts = np.sin(X_ts.ravel() / 10) + np.random.randn(n) * 0.1

tscv = TimeSeriesSplit(n_splits=5)

fig, axes = plt.subplots(5, 1, figsize=(12, 8), sharex=True)

for fold, (train_idx, test_idx) in enumerate(tscv.split(X_ts)):
ax = axes[fold]
ax.scatter(train_idx, y_ts[train_idx], c='steelblue', s=10, label='训练')
ax.scatter(test_idx, y_ts[test_idx], c='red', s=20, label='测试')
ax.set_ylabel(f'折 {fold+1}')
if fold == 0:
ax.legend(loc='upper left', ncol=2)

axes[-1].set_xlabel('时间步')
plt.suptitle('时间序列交叉验证(训练集逐步扩大)', fontsize=13)
plt.tight_layout()
plt.show()

六、cross_validate——更丰富的输出

from sklearn.model_selection import cross_validate
from sklearn.ensemble import RandomForestClassifier

model = RandomForestClassifier(n_estimators=50, random_state=42)

# cross_validate 比 cross_val_score 返回更多信息
results = cross_validate(
model, X, y, cv=5,
scoring=['accuracy', 'f1_macro'],
return_train_score=True
)

print("5 折交叉验证详细结果:")
print(f" 训练准确率: {results['train_accuracy'].mean():.4f} ± {results['train_accuracy'].std():.4f}")
print(f" 测试准确率: {results['test_accuracy'].mean():.4f} ± {results['test_accuracy'].std():.4f}")
print(f" 测试 F1: {results['test_f1_macro'].mean():.4f} ± {results['test_f1_macro'].std():.4f}")
print(f" 每折耗时: {results['fit_time'].mean():.3f}s")

七、综合对比

from sklearn.model_selection import cross_val_score
from sklearn.tree import DecisionTreeClassifier
from sklearn.linear_model import LogisticRegression
from sklearn.ensemble import RandomForestClassifier
from sklearn.svm import SVC

models = {
'决策树': DecisionTreeClassifier(max_depth=5, random_state=42),
'逻辑回归': LogisticRegression(max_iter=1000, random_state=42),
'随机森林': RandomForestClassifier(n_estimators=100, random_state=42),
'SVM': SVC(random_state=42),
}

results = {}
for name, model in models.items():
scores = cross_val_score(model, X, y, cv=10, scoring='accuracy')
results[name] = scores
print(f"{name:10s} | {scores.mean():.4f} ± {scores.std():.4f}")

# 箱线图对比
fig, ax = plt.subplots(figsize=(8, 5))
data = [results[name] for name in models]
bp = ax.boxplot(data, labels=models.keys(), patch_artist=True)

colors = ['steelblue', 'coral', 'seagreen', 'gold']
for patch, color in zip(bp['boxes'], colors):
patch.set_facecolor(color)
patch.set_alpha(0.7)

ax.set_ylabel('准确率')
ax.set_title('10 折交叉验证对比(箱线图)')
ax.grid(axis='y', alpha=0.3)
plt.tight_layout()
plt.show()

八、小结

方法说明适用
Hold-out一次 train/test 划分快速实验
K-FoldK 次划分取平均通用(K=5 或 10)
Stratified K-Fold保持类别比例的 K-Fold分类(默认)
LOO每次留 1 个样本小数据集
TimeSeriesSplit按时间顺序划分时间序列
连接后续
  • 下一节:偏差-方差权衡——为什么交叉验证和单次评估结果不同
  • 4.4 节:超参数调优——用交叉验证来选最优参数

动手练习

练习 1:K 值对比

用 Iris 数据集和决策树,对比 K=3, 5, 10, 20 的交叉验证结果(平均准确率和标准差)。K 越大,标准差越小吗?

练习 2:多指标评估

cross_validate 在乳腺癌数据集上同时评估 accuracy、precision、recall、f1,返回训练集和测试集分数。哪个模型过拟合最严重?

练习 3:分层 vs 非分层

创建一个严重不平衡的数据集(正负比 9:1),对比 KFoldStratifiedKFold 的评估结果差异。