训练机器学习模型时,我们经常会遇到两个问题:

  • 这个模型到底是不是真的好?
  • 参数应该怎么选,比如 KNN 的 $K$、逻辑回归的正则化强度、决策树的最大深度?

如果只把数据随便分成一次训练集和测试集,然后看一次准确率,很容易被偶然性误导。交叉验证(Cross Validation)就是为了解决“评估不稳定”的问题;网格搜索(Grid Search)则是为了解决“超参数怎么选”的问题。

K 折交叉验证示意图

1. 为什么不能只划分一次数据

假设我们有一份数据集,要判断一个模型效果如何。最常见的做法是:

训练集:用来训练模型
测试集:用来评估模型

这听起来很合理,但有一个问题:一次划分可能有运气成分

如果测试集刚好比较简单,模型分数会偏高;如果测试集刚好比较困难,模型分数会偏低。也就是说,一次测试结果可能不能代表模型的真实水平。

可以把它想象成考试:

  • 只考一张卷子,可能刚好考到你熟悉的题。
  • 多考几张不同卷子,平均成绩才更接近真实水平。

交叉验证的思想就是:让模型多考几次,再看平均表现。

2. 什么是 K 折交叉验证

K 折交叉验证(K-Fold Cross Validation)会把数据分成 $K$ 份,每次拿其中一份做验证集,其余 $K-1$ 份做训练集。

假设 $K=5$(上方图中就是5折交叉验证),流程如下:

轮次 训练集 验证集
第 1 轮 第 2、3、4、5 份 第 1 份
第 2 轮 第 1、3、4、5 份 第 2 份
第 3 轮 第 1、2、4、5 份 第 3 份
第 4 轮 第 1、2、3、5 份 第 4 份
第 5 轮 第 1、2、3、4 份 第 5 份

最后会得到 5 个验证分数:

$$
s_1, s_2, s_3, s_4, s_5
$$

通常取平均值作为模型表现:

$$
\bar{s}=\frac{1}{K}\sum_{i=1}^{K}s_i
$$

也可以看标准差:

$$
\sigma=\sqrt{\frac{1}{K}\sum_{i=1}^{K}(s_i-\bar{s})^2}
$$

平均值反映模型总体表现,标准差反映模型表现是否稳定(标准差越小越稳定)。

3. 交叉验证解决了什么问题

交叉验证的好处主要有三个。

3.1 评估更稳定

一次训练/验证划分可能有偶然性,而 K 折交叉验证会让每一部分数据都轮流做一次验证集。这样得到的平均分更可信。

3.2 数据利用更充分

每一轮中,大部分数据都参与训练;从整体看,每个样本也都有机会参与验证。对于数据量不大的场景,这一点很重要。

3.3 方便比较模型

如果要比较 KNN、逻辑回归、决策树等模型,使用同样的交叉验证方式,可以让比较更公平。

4. 什么是超参数

在机器学习中,参数和超参数不是一回事。

参数 是模型通过训练学出来的,例如线性回归中的 $w$ 和 $b$。

超参数 是训练前人为设定的,例如:

  • KNN 中的 $K$
  • 决策树中的 max_depth
  • 逻辑回归中的正则化强度 C
  • 随机森林中的树数量 n_estimators
  • SVM 中的核函数和惩罚系数

超参数不会由模型自动学出来,需要我们提前指定。

问题是:怎么知道哪个超参数最好?

这就需要网格搜索。

5. 什么是网格搜索

网格搜索(Grid Search)就是把可能的超参数组合列成一张“网格”,然后一个个试。

例如用 KNN 做分类时,我们想尝试:

n_neighbors: 3, 5, 7, 9
weights: uniform, distance

这些参数组合起来就是:

$$
4 \times 2 = 8
$$

也就是说,一共有 12 种组合要测试。

网格搜索遍历超参数组合

网格搜索会对每一种组合都做交叉验证,然后选择平均分最高的组合。

6. 网格搜索和交叉验证如何配合

网格搜索通常不会只用一次验证集判断哪个参数好,而是会把交叉验证嵌进去。

可以理解为:

对每一组超参数:
    做 K 折交叉验证
    得到 K 个分数
    计算平均分

选择平均分最高的超参数组合

假设我们有 12 组参数,每组做 5 折交叉验证,那么总共需要训练:

$$
12 \times 5 = 60
$$

次模型。

这也是为什么网格搜索有时会比较慢:它不是训练一次模型,而是训练很多次模型。

7. 一个直观例子:给 KNN 选择 K 值

假设我们用 KNN 做分类,只想调一个参数:

K = 1, 3, 5, 7, 9

我们对每个 $K$ 做 5 折交叉验证,得到结果:

K 值 5 折平均准确率
1 0.86
3 0.90
5 0.93
7 0.92
9 0.89

此时我们会选择:

$$
K=5
$$

因为它的交叉验证平均分最高。

这比“随便选一个 K”可靠得多。

8. Python 实战:cross_val_score

下面先用 cross_val_score 看模型在 5 折交叉验证下的表现(cross_val_score主要用于模型评估,9中使用GridSearchCV用于寻优)。

from sklearn.datasets import load_iris
from sklearn.model_selection import cross_val_score
from sklearn.neighbors import KNeighborsClassifier

# 加载鸢尾花数据集,这是一个经典的多分类数据集
iris = load_iris()
X = iris.data
y = iris.target

# 创建 KNN 模型,这里先人为指定 n_neighbors=5
model = KNeighborsClassifier(n_neighbors=5)

# 对模型做 5 折交叉验证
# scoring="accuracy" 表示使用准确率作为评价指标
scores = cross_val_score(
    model,
    X,
    y,
    cv=5,
    scoring="accuracy",
)

# 每一折都会得到一个分数
print(scores)

# 交叉验证平均分可以看作模型整体表现
print("平均准确率:", scores.mean())

# 标准差越小,说明不同划分下模型表现越稳定
print("标准差:", scores.std())

输出可能类似:

[0.9667 1.0000 0.9333 0.9667 1.0000]
平均准确率: 0.9733
标准差: 0.0249

这表示模型不是只在某一次划分上表现好,而是在多次验证中整体表现比较稳定。

9. Python 实战:GridSearchCV

下面用 GridSearchCV 搜索 KNN 的超参数。

from sklearn.datasets import load_iris
from sklearn.model_selection import GridSearchCV
from sklearn.neighbors import KNeighborsClassifier

# 加载数据
iris = load_iris()
X = iris.data
y = iris.target

# 先创建一个基础模型,具体超参数交给 GridSearchCV 来搜索
model = KNeighborsClassifier()

# 定义要尝试的超参数范围
# GridSearchCV 会把这些参数组合全部遍历一遍
param_grid = {
    "n_neighbors": [1, 3, 5, 7, 9],
    "weights": ["uniform", "distance"],
    "metric": ["euclidean", "manhattan"],
}

# cv=5 表示每一组超参数都做 5 折交叉验证
# scoring="accuracy" 表示用准确率比较不同参数组合
grid_search = GridSearchCV(
    estimator=model,
    param_grid=param_grid,
    cv=5,
    scoring="accuracy",
)

# 开始搜索最佳超参数
grid_search.fit(X, y)

# best_params_ 保存交叉验证平均分最高的参数组合
print("最佳参数:", grid_search.best_params_)

# best_score_ 保存最佳参数组合对应的交叉验证平均分
print("最佳交叉验证分数:", grid_search.best_score_)

# best_estimator_ 是已经用最佳参数重新训练好的模型
print("最佳模型:", grid_search.best_estimator_)

这里 GridSearchCV 做了三件事:

  1. 遍历 param_grid 中的所有参数组合。
  2. 对每个组合做 5 折交叉验证。
  3. 找出平均准确率最高的组合。

训练完成后,可以直接拿最佳模型做预测:

best_model = grid_search.best_estimator_
pred = best_model.predict(X[:5])
print(pred)

10. 混淆矩阵:别只看准确率

交叉验证和网格搜索可以帮助我们选出平均表现更好的模型,但如果只看准确率,有时仍然不够。

比如一个疾病检测模型,准确率是 $95%$,听起来很高。但如果它把很多真正患病的人预测成健康,那这个模型依然很危险。

因此,在分类任务中,我们常常会进一步查看混淆矩阵(Confusion Matrix)。

混淆矩阵不是一个新的模型,而是一张“预测结果对照表”,用来观察模型具体把哪些类别预测对了,哪些类别预测错了。

混淆矩阵示意图

以二分类为例,混淆矩阵中有四个核心概念:

名称 含义
TP True Positive,真实为正类,预测也为正类
TN True Negative,真实为负类,预测也为负类
FP False Positive,真实为负类,但预测成正类
FN False Negative,真实为正类,但预测成负类

如果用疾病检测来理解:

  • TP:病人被正确检测为有病。
  • TN:健康人被正确检测为健康。
  • FP:健康人被误判为有病。
  • FN:病人被误判为健康。

不同业务对错误类型的容忍度不同。比如疾病筛查中,FN 往往比 FP 更严重,因为漏诊可能带来更高风险。

11. 从混淆矩阵得到评价指标

有了 TP、TN、FP、FN,就可以计算多个常见指标。

准确率(Accuracy):

$$
Accuracy=\frac{TP+TN}{TP+TN+FP+FN}
$$

它表示所有样本中预测正确的比例。

精确率(Precision):

$$
Precision=\frac{TP}{TP+FP}
$$

它回答的问题是:模型预测为正类的样本中,有多少真的为正类?

召回率(Recall):

$$
Recall=\frac{TP}{TP+FN}
$$

它回答的问题是:真实正类样本中,有多少被模型找出来了?

F1 分数:

$$
F1=\frac{2 \times Precision \times Recall}{Precision+Recall}
$$

它是精确率和召回率的调和平均,适合在二者都重要时使用。

简单理解:

准确率:整体对不对
精确率:预测为正类时靠不靠谱
召回率:真正的正类有没有找全
F1:精确率和召回率的综合平衡

12. Python 实战:混淆矩阵与分类报告

下面在 GridSearchCV 找到最佳模型后,继续查看它在测试集上的混淆矩阵和分类报告。

from sklearn.datasets import load_iris
from sklearn.metrics import classification_report, confusion_matrix
from sklearn.model_selection import GridSearchCV, train_test_split
from sklearn.neighbors import KNeighborsClassifier

# 加载鸢尾花数据集
iris = load_iris()
X = iris.data
y = iris.target

# 先划分最终测试集
# stratify=y 可以尽量保持训练集和测试集中的类别比例一致
X_train, X_test, y_train, y_test = train_test_split(
    X,
    y,
    test_size=0.2,
    random_state=42,
    stratify=y,
)

# 创建基础 KNN 模型
model = KNeighborsClassifier()

# 定义要搜索的超参数网格
param_grid = {
    "n_neighbors": [1, 3, 5, 7, 9],
    "weights": ["uniform", "distance"],
    "metric": ["euclidean", "manhattan"],
}

# 只在训练集上做网格搜索和交叉验证
# 这样可以避免提前看到测试集,降低数据泄漏风险
grid_search = GridSearchCV(
    estimator=model,
    param_grid=param_grid,
    cv=5,
    scoring="accuracy",
)

# 在训练集上寻找最佳参数
grid_search.fit(X_train, y_train)

# 取出最佳模型
best_model = grid_search.best_estimator_

# 用最佳模型预测最终测试集
y_pred = best_model.predict(X_test)

# 输出混淆矩阵
# 行表示真实类别,列表示预测类别
print("混淆矩阵:")
print(confusion_matrix(y_test, y_pred))

# 输出分类报告
# precision 是精确率,recall 是召回率,f1-score 是 F1 分数
print("分类报告:")
print(classification_report(y_test, y_pred, target_names=iris.target_names))

这段代码的重点是:网格搜索只在训练集上进行,最终测试集只在最后评估一次

如果在调参过程中反复查看测试集结果,模型选择过程就会间接“记住”测试集,最后的测试分数会偏乐观。

13. 训练集、验证集、测试集的关系

一个容易混淆的问题是:用了交叉验证,还需不需要测试集?

答案是:通常仍然需要。

比较稳妥的流程是:

原始数据
  |
  | 先划分
  v
训练验证部分 + 最终测试集
  |
  | 在训练验证部分做交叉验证和网格搜索
  v
选出最佳超参数
  |
  | 用最佳超参数重新训练模型
  v
在最终测试集上评估一次

原因是:网格搜索已经反复看过验证集,如果最后仍然用验证分数当最终成绩,可能会偏乐观。最终测试集应该只在最后使用一次,模拟模型面对全新数据的表现。

14. 常见注意点

14.1 分类任务建议使用 StratifiedKFold

分类任务中,如果类别比例不均衡,普通 KFold 可能让某些折里的类别比例失衡。

这时可以使用分层交叉验证:

from sklearn.model_selection import StratifiedKFold

cv = StratifiedKFold(
    n_splits=5,
    shuffle=True,
    random_state=42,
)

它会尽量让每一折中的类别比例接近原始数据。

14.2 数据预处理要放进 Pipeline

如果要做标准化、缺失值填补、特征选择等操作,不建议先对全量数据处理,再做交叉验证。

错误做法:

先对全部数据标准化
再做交叉验证

这样会发生数据泄漏,因为验证集的信息提前参与了标准化。

更推荐用 Pipeline

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

pipe = Pipeline([
    ("scaler", StandardScaler()),
    ("model", LogisticRegression(max_iter=1000)),
])

然后对整个 Pipeline 做交叉验证或网格搜索。

14.3 网格不要设得太大

网格搜索会尝试所有参数组合。如果每个参数都设置很多候选值,训练次数会迅速膨胀。

例如:

5 个 K 值 × 4 种距离 × 3 种权重 × 5 折 = 300 次训练

如果模型本身很慢,这会非常耗时。

可以先用粗网格找到大概范围,再用细网格进一步搜索。

网格搜索会穷举所有组合,适合参数数量较少的场景。

如果参数很多,可以考虑随机搜索(Randomized Search)。它不会尝试所有组合,而是随机抽取一部分组合来测试。

对比可以简单理解为:

方法 思路 适用场景
Grid Search 所有组合都试一遍 参数少,训练不太慢
Random Search 随机试一部分组合 参数多,训练成本高

16. 总结

交叉验证和网格搜索经常一起出现,但它们解决的是两个不同问题。

交叉验证解决的是:

模型评估是否稳定可靠?

网格搜索解决的是:

超参数应该怎么选?

二者结合起来就是:

用交叉验证评估每一组超参数,再选择平均表现最好的那一组。

在实际机器学习流程中,可以记住一个简单顺序:

划分最终测试集
    -> 在训练集上做交叉验证
    -> 用网格搜索选择超参数
    -> 用最佳参数重新训练
    -> 在最终测试集上评估

这样得到的模型选择过程,比只看一次训练/测试划分要可靠得多。