学完 KNN 后,一个自然的问题是:邻居数量应该设为 3、5,还是其他值?某一次测试准确率高,就能说明这个参数好吗?

这篇文章从我学习时遇到的几个疑惑出发,梳理交叉验证与网格搜索的关系,再用鸢尾花数据集实现完整流程。

先记住两个分工:交叉验证负责评估一组参数,网格搜索负责尝试不同参数组合。

一、先分清三个概念

数据部分 作用 是否参与当轮模型训练
当轮训练集 拟合模型 是
当轮验证集 评价当前参数配置,辅助选参 否
独立测试集 选参结束后评估最终模型 否

本文采用“先留出测试集,再在训练部分内部做交叉验证”的流程:

1
2
3
全部数据
├── 训练部分 → 交叉验证与参数选择 → 使用全部训练部分重新训练
└── 独立测试集 → 最后评估

交叉验证中的验证集,是从训练部分里轮流划出来的。它与最后保留的独立测试集不是同一回事。

二、5 折交叉验证,是把 20 条再细分吗?

假设参与交叉验证的训练部分有 100 条数据,做 5 折交叉验证:

  1. 将这 100 条分为 5 份,每份 20 条。
  2. 每轮拿其中一份作为验证集。
  3. 剩余四份共 80 条作为当轮训练集。
  4. 一共做 5 轮,让每份都当一次验证集。

那 20 条直接作为验证集,不需要在这轮里再次划分训练集和验证集。

轮次 当轮训练集,80 条 当轮验证集,20 条
第 1 轮 B、C、D、E A
第 2 轮 A、C、D、E B
第 3 轮 A、B、D、E C
第 4 轮 A、B、C、E D
第 5 轮 A、B、C、D E

这里的 100 条是“用于交叉验证的训练部分”,不包含独立测试集。如果总共只有 100 条数据,并先留出 20 条测试,那么参与交叉验证的就只有 80 条。

数据量不能整除折数时,各折大小通常相差不超过一条。

三、是不是每一轮所有数据都参与?

对,但参与的角色不同。

每轮的 100 条数据中,80 条负责训练,20 条只负责验证。全部参与这一轮,不代表全部参与训练。

五轮结束后,每条数据都参与了:

  • 4 次训练;
  • 1 次验证。

每一轮都会重新拟合模型,不会拿上一轮已经训练好的模型继续训练。否则,上一轮见过的样本可能成为这一轮的验证数据,验证就不再独立。

四、怎样选出“最好的参数”?

以 KNN 的邻居数量为例,准备尝试 K=1、K=3、K=5。

KNN 的 K 表示邻居数量;5 折表示数据划分份数。这是两个独立的设置。

先固定 K=1,跑完五折并计算平均准确率;再换成 K=3、K=5,分别重复。

下面的分数是说明原理的假设示例:

邻居数量 第 1 折 第 2 折 第 3 折 第 4 折 第 5 折 平均准确率
K=1 85% 90% 85% 95% 85% 88%
K=3 95% 90% 95% 90% 95% 93%
K=5 90% 90% 95% 90% 85% 90%

这次比较中,K=3 的平均准确率最高,因此选择 K=3。不同候选参数使用相同的折划分和评分指标,才能公平比较。

所谓“最优”,是在当前候选范围、数据划分和评分指标下表现最好,并不保证是所有可能参数中的绝对最优。

准确率越高越好;原始误差通常越低越好。scikit-learn 的搜索评分统一按越大越好处理,因此有些误差评分使用负数形式,例如 neg_mean_squared_error。

五、某一折准确率特别高,会影响平均值吗?

会,但它只贡献五折平均值的五分之一。

例如 K=3 的五折准确率是:

1
100%、80%、80%、80%、80%

平均值为:

1
(100 + 80 + 80 + 80 + 80) / 5 = 84%

如果 K=5 每折都是 90%,平均值就是 90%,仍然更好。

这就是为什么不能说“第 4 折最好,所以选第 4 折”。我们要比较的是不同参数配置的平均分,不是挑一折最高分。

平均分接近时,也可以观察分数的标准差。波动更小,说明模型在这些划分上的表现更稳定;不过,这不是对未来数据表现的保证,五折也不能完全消除偶然性。

六、网格搜索到底在搜索什么?

超参数是训练前设置的配置,例如 KNN 的 n_neighbors。它不会像线性回归的权重那样,在普通拟合过程中直接学出。

网格搜索会枚举给定的候选组合,逐一评估。

1
param_grid = {'n_neighbors': [i for i in range(1, 11)]}

这里的列表推导式:

1
[i for i in range(1, 11)]

等价于:

1
2
3
numbers = []
for i in range(1, 11):
numbers.append(i)

得到 [1, 2, 3, 4, 5, 6, 7, 8, 9, 10],也可以写成 list(range(1, 11))。range 包含起点,不包含终点。

如果再加入另一个超参数:

1
2
3
4
param_grid = {
'n_neighbors': [1, 3, 5],
'weights': ['uniform', 'distance'],
}

就有 3×2=6 种组合。uniform 表示邻居等权投票,distance 表示距离近的邻居权重更大。

七、原始代码里需要修正的两个地方

1. 不要在交叉验证之前拟合整个训练集的标准化器

原来的写法是:

1
2
3
x_train = transfer.fit_transform(x_train)
x_test = transfer.transform(x_test)
estimator.fit(x_train, y_train) # estimator 是 GridSearchCV

虽然独立测试集没有参与标准化器的拟合,但仍然存在交叉验证内部的数据泄漏。

以鸢尾花为例:先划出 120 条训练数据,再做四折,每轮应该只用当轮的 90 条计算均值和标准差。但上面提前使用了全部 120 条,因此当轮验证集的 30 条也影响了标准化规则。

正确流程是:每折只在当轮训练部分拟合标准化器,然后转换训练部分和验证部分。将 StandardScaler 和 KNN 放进 Pipeline,可以让这个过程随每轮拟合一起执行。官方数据泄漏说明

2. 搜索结束后,不要又手动写死 K=3

原来的最后两行:

1
2
estimator2 = KNeighborsClassifier(n_neighbors=3)
estimator2.fit(x_train, y_train)

有两个问题:

  • 搜索结果不一定是 K=3,写死参数可能丢掉选参结果。
  • 这两行只有拟合,没有预测或评分,不能称为“模型评估”。

GridSearchCV 默认 refit=True,选参后会在传入的全部训练数据上重新拟合最佳配置。因此可以直接调用搜索对象的 predict() 和 score(),不必再手动创建模型。GridSearchCV 文档

八、完整代码:Pipeline + GridSearchCV

下面保留 random_state=22 和 K=1~10、四折交叉验证的设置,并加入分层划分,使各类别比例更均衡。因为划分方式和标准化流程调整了,结果不必与原代码一致。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
import pandas as pd
from sklearn.datasets import load_iris
from sklearn.model_selection import (
train_test_split,
StratifiedKFold,
GridSearchCV,
)
from sklearn.preprocessing import StandardScaler
from sklearn.neighbors import KNeighborsClassifier
from sklearn.pipeline import Pipeline
from sklearn.metrics import accuracy_score

# 1. 加载完整数据集
iris_data = load_iris()

# 2. 留出独立测试集:120 条训练数据,30 条测试数据
x_train, x_test, y_train, y_test = train_test_split(
iris_data.data,
iris_data.target,
test_size=0.2,
random_state=22,
stratify=iris_data.target,
)

# 3. 将标准化与分类器组成一条处理流程
# 此时不执行标准化;每折拟合时才学习相应的均值和标准差
pipeline = Pipeline([
('scaler', StandardScaler()),
('knn', KNeighborsClassifier()),
])

# 4. 定义候选超参数
# 格式:流水线步骤名__该步骤的参数名,中间是两个下划线
param_grid = {
'knn__n_neighbors': list(range(1, 11)),
}

# 5. 定义四折分层交叉验证
cv = StratifiedKFold(
n_splits=4,
shuffle=True,
random_state=22,
)

# 6. 创建搜索对象
search = GridSearchCV(
estimator=pipeline,
param_grid=param_grid,
cv=cv,
scoring='accuracy',
refit=True,
)

# 7. 搜索最佳参数,并使用全部训练数据重新拟合最佳流水线
search.fit(x_train, y_train)

# 8. 查看选参结果
print(f'最佳交叉验证平均准确率:{search.best_score_:.2%}')
print(f'最优超参数组合:{search.best_params_}')
print(f'最优模型流水线:\n{search.best_estimator_}')

# 9. 将每个候选参数的交叉验证结果整理为表格
results = pd.DataFrame(search.cv_results_)
columns = [
'param_knn__n_neighbors',
'split0_test_score',
'split1_test_score',
'split2_test_score',
'split3_test_score',
'mean_test_score',
'std_test_score',
'rank_test_score',
]
print('\n各候选参数的交叉验证结果:')
print(results[columns].sort_values('rank_test_score').to_string(index=False))

# 10. 在独立测试集上评估最终模型
# 流水线会自动标准化,传入原始特征即可
# 不要手动再次对 x_test 做标准化

y_pred = search.predict(x_test)
print(f'\n测试集真实标签:{y_test}')
print(f'测试集预测标签:{y_pred}')
print(f'测试集准确率:{accuracy_score(y_test, y_pred):.2%}')

# 与上一行计算的是相同指标,但会再预测一次
print(f'score() 测试集准确率:{search.score(x_test, y_test):.2%}')

# 11. 预测新样本,同样传入未标准化的原始特征
my_data = [[7.8, 2.1, 3.9, 1.6]]
new_label = search.predict(my_data)[0]
print(f'新样本预测类别:{iris_data.target_names[new_label]}')

为什么多了 knn__?

因为这次搜索对象是包含两个步骤的流水线,要告诉它调整哪个步骤的参数:

1
2
3
knn__n_neighbors
│ └── KNN 的参数名
└─────── Pipeline 中给 KNN 步骤起的名字

如果搜索对象直接是 KNN,就用 n_neighbors;如果是本文这条流水线,就用 knn__n_neighbors。

cv=4 与本文写法的关系

对于本例这种分类任务,GridSearchCV(..., cv=4) 默认使用不打乱顺序的分层四折划分。本文显式使用 StratifiedKFold,便于设置打乱和随机种子。

两种都可以;关键是理解验证数据怎样分配,以及不同候选参数应共享同一组折划分。

九、四个结果属性应该怎么看?

属性 含义
best_score_ 最佳参数组合的交叉验证平均分
best_params_ 选出的参数组合
best_estimator_ 使用最佳参数重新拟合后的模型;本文中是一整条流水线
cv_results_ 全部候选组合的逐折分数、均值、标准差、排名与耗时等

例如,假设 best_params_ 显示:

1
{'knn__n_neighbors': 5}

就表示这次搜索选择了 5 个邻居,而不是说“五折中的第 5 折最好”。本文实际上设置的是四折。

特别注意:cv_results_ 中的 split0_test_score、mean_test_score,指的是交叉验证内部留出折的评分,不是最后那 30 条独立测试集上的分数。这里 API 名称里的 test 容易造成混淆。

best_score_ 也不是最终独立测试准确率。它经过了选参筛选,可能偏乐观;最后应另外报告测试集上的结果。

十、这一段代码实际拟合了多少次?

本例有 10 个候选邻居数量,每个做四折交叉验证:

1
10 组参数 × 4 折 = 40 次交叉验证拟合

选出最佳参数后,refit=True 再用全部 120 条训练数据拟合一次:

1
共 41 次流水线拟合

前 40 次,每次只用当轮 90 条训练数据拟合标准化器和 KNN;最后一次用全部 120 条训练数据拟合。30 条独立测试数据始终不参与拟合。

best_estimator_ 是最后这次重新拟合得到的结果,不是从 40 个折模型里直接挑出来的某一个。

十一、学完后应当能讲清楚的流程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
先留出独立测试集
↓
固定一组候选超参数
↓
每折重新拟合标准化器与 KNN,用当轮验证集评分
↓
计算这组参数的平均验证分数
↓
对其他候选组合重复以上过程
↓
选择平均验证分数最好的参数
↓
在全部训练数据上重新拟合最佳流水线
↓
使用独立测试集做最终评估

交叉验证让一组参数经历多次划分的检验,网格搜索让多组参数在相同规则下比较。选好之后,才训练最终模型。

测试集用于最后检验,不应根据测试分数反复调整 K 或随机种子;否则它也会逐渐变成参与选参的验证集。