KNN 是一个适合机器学习入门的算法:遇到一个未知样本,就在已知样本中找出与它最相似的几个,再根据这些邻居的答案做判断。

本文以鸢尾花分类为例,记录从加载数据、划分数据集,到标准化、训练、预测和评估的完整过程,并解释 fit()predict()score() 分别做了什么。

一、KNN 算法是什么?

KNN 全称是 K-Nearest Neighbors,中文叫 K 近邻算法。K 表示参考的邻居数量。

本文讨论的是 KNN 分类:给定一条待预测样本,查找训练集中距离它最近的 K 条样本,根据这些邻居的类别投票。

例如 K=3,最近的三个邻居分别属于:

1
2
3
邻居 A → 类别 1
邻居 B → 类别 1
邻居 C → 类别 2

采用每个邻居一票的规则时,类别 1 获得两票,因此预测结果是类别 1。

KNN 也可以用于回归,通常根据邻居的目标值求平均或加权平均;本文先关注分类。

1. 到底按什么排序?

刚接触 KNN 时,一个容易出现的误解是:“是不是把每个特征分别升序排列?”

实际上,比较的是每条训练样本与待预测样本之间的距离,各个特征列不会被单独排序。 每条样本的全部特征和标签始终对应在一起。

为了方便理解,假设每个样本只有两个特征,待预测样本是 Q=(5, 3):

训练样本 特征 真实类别 到 Q 的欧氏距离
A (6, 3) 0 1
B (5, 5) 0 2
C (8, 7) 1 5

按照距离由近到远,顺序是 A、B、C。若取这三个邻居,类别 0 获得两票,预测为类别 0。

换一条待预测样本,计算出来的距离和邻居顺序也可能改变。实际实现只需要找到最近的 K 个邻居,不一定把全部训练样本完整排序。

2. 距离如何计算?

欧氏距离就是“两点间直线距离”向多个特征的推广。两个特征时:

1
d = √[(x₁ - q₁)² + (x₂ - q₂)²]

例如 C=(8, 7) 与 Q=(5, 3):

1
2
3
d = √[(8 - 5)² + (7 - 3)²]
= √(9 + 16)
= 5

鸢尾花有四个特征,因此将四个对应特征的差平方相加,再开根号:

1
d = √[(x₁-q₁)² + (x₂-q₂)² + (x₃-q₃)² + (x₄-q₄)²]

本文采用 KNeighborsClassifier(n_neighbors=3) 的默认距离配置,即欧氏距离,并使用默认的等权投票。参数说明

3. K 的大小有什么影响?

  • K 较小时,预测更容易受到个别样本和噪声影响,可能过拟合。
  • K 较大时,会参考更大范围的样本,可能忽略局部差异,导致欠拟合。

K=3 是本案例的练习设置,不代表所有任务中都最合适。正式选择 K 时,应使用验证集或交叉验证,测试集保留给最后评估。

二、认识鸢尾花数据集

鸢尾花数据集包含 150 条样本,每条样本有 4 个特征,目标是预测其所属的三个类别之一。

特征名 含义
sepal length (cm) 花萼长度
sepal width (cm) 花萼宽度
petal length (cm) 花瓣长度
petal width (cm) 花瓣宽度

标签与类别名对应如下:

标签 类别名
0 setosa
1 versicolor
2 virginica

这些数字是类别编号,没有“类别 2 比类别 1 更大”的含义。

load_iris() 加载的是完整数据集,随后才划分训练集和测试集。不要把加载完整数据集的注释写成“加载测试集”。

三、为什么 KNN 需要关注标准化?

距离公式会把各个特征的差值平方后相加。如果某一列数值变化很大,它就容易主导距离,其他特征的影响可能被掩盖。

即使各列单位都是厘米,各列的变化范围也可能不同。因此,标准化是在调整距离计算中各个特征的尺度,不只是让代码流程更完整;是否有助于具体任务,仍需要验证。

标准化按列进行:

1
标准化后的值 = (当前值 - 训练集该列均值) / 训练集该列标准差

代码是:

1
2
3
transfer = StandardScaler()
x_train = transfer.fit_transform(x_train)
x_test = transfer.transform(x_test)

这里两个方法的区别是:

方法 做什么
fit_transform(x_train) 计算并保存训练集每列的均值和标准差,然后转换训练集
transform(x_test) 使用之前保存的训练集统计量转换测试集

测试集和未来的新数据,都必须使用训练集确定的同一套标准化规则。 如果在测试集上再次 fit_transform(),就会重新计算统计量,既使用了测试集信息,也会让训练和预测的尺度不一致。

标准化并不会把任意分布变成正态分布,也不是只适用于大数据集。对于训练集中非恒定的列,默认标准化后均值约为 0、方差约为 1;测试集的均值和方差不必恰好是 0 和 1。StandardScaler 文档

四、KNN 的 fit() 算是在训练吗?

下面这一行完成了 scikit-learn 接口意义上的模型拟合:

1
estimator.fit(x_train, y_train)

但不同模型的 fit() 做的事并不相同。

模型 拟合阶段的主要工作
线性回归、逻辑回归 根据训练数据学习参数,使相应目标函数尽量小
KNN 保存训练样本和标签,并根据搜索方式准备邻居查找结构

KNN 不需要像逻辑回归那样,通过梯度下降反复调整一组 w、b。它将针对具体新样本的邻居查找留到了预测阶段,这也是它被称为“惰性学习”的原因。

不过,不能说 fit() 完全没有计算:它仍然要检查数据,并可能建立搜索结构。

1
2
fit():准备好参考样本与答案
predict():针对新样本找邻居,再投票判断

五、完整案例代码

在当前 Python 环境中安装依赖:

1
python -m pip install scikit-learn pandas seaborn matplotlib

下面保留四个演示函数:加载数据、绘图、划分数据、完成分类流程。运行时默认执行第四个函数;需要查看图表时,取消对应函数调用前的注释即可。

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
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
from sklearn.datasets import load_iris
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler
from sklearn.neighbors import KNeighborsClassifier
from sklearn.metrics import accuracy_score
import seaborn as sns
import pandas as pd
import matplotlib.pyplot as plt


def demo01_load_iris():
"""加载并查看鸢尾花数据集。"""
iris_data = load_iris()

print(f'特征矩阵形状:{iris_data.data.shape}')
print(f'前 5 条特征:\n{iris_data.data[:5]}')
print(f'前 5 条标签:{iris_data.target[:5]}')
print(f'类别名称:{iris_data.target_names}')
print(f'特征名称:{iris_data.feature_names}')


def demo02_show_iris():
"""观察花萼长度、宽度与类别的关系。"""
iris_data = load_iris()
iris_df = pd.DataFrame(
iris_data.data,
columns=iris_data.feature_names,
)
iris_df['label'] = iris_data.target_names[iris_data.target]

sns.lmplot(
data=iris_df,
x='sepal length (cm)',
y='sepal width (cm)',
hue='label',
fit_reg=False,
)
plt.title('Iris data')
plt.tight_layout()
plt.show()


def demo03_split_iris():
"""按照 8:2 划分训练集和测试集。"""
iris_data = load_iris()
x_train, x_test, y_train, y_test = train_test_split(
iris_data.data,
iris_data.target,
test_size=0.2,
random_state=23,
)

print(f'训练集特征形状:{x_train.shape}')
print(f'训练集标签数量:{len(y_train)}')
print(f'测试集特征形状:{x_test.shape}')
print(f'测试集标签数量:{len(y_test)}')


def demo04_iris_evaluate_test():
"""标准化、拟合 KNN、评估并预测新样本。"""
# 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=23,
)

# 3. 只在训练集上拟合标准化规则
transfer = StandardScaler()
x_train = transfer.fit_transform(x_train)
x_test = transfer.transform(x_test)

# 4. 拟合 KNN 模型,参考最近的 3 个邻居
estimator = KNeighborsClassifier(n_neighbors=3)
estimator.fit(x_train, y_train)

# 5. 预测测试集
y_pred = estimator.predict(x_test)
print(f'测试集预测标签:\n{y_pred}')
print(f'测试集真实标签:\n{y_test}')

# 6. 两种方式计算测试集准确率
score_direct = estimator.score(x_test, y_test)
score_manual = accuracy_score(y_test, y_pred)
print(f'score() 准确率:{score_direct:.2%}')
print(f'accuracy_score() 准确率:{score_manual:.2%}')

# 7. 新样本必须保持相同的特征顺序和单位
# 顺序:花萼长度、花萼宽度、花瓣长度、花瓣宽度,单位 cm
my_data = [[7.8, 2.1, 3.9, 1.6]]
my_data_scaled = transfer.transform(my_data)

y_pred_new = estimator.predict(my_data_scaled)
print(f'新样本预测标签:{y_pred_new[0]}')
print(f'新样本预测类别:{iris_data.target_names[y_pred_new[0]]}')

# 8. 概率列按 estimator.classes_ 的顺序排列
probabilities = estimator.predict_proba(my_data_scaled)[0]
print('新样本的类别概率估计:')
for label, probability in zip(estimator.classes_, probabilities):
print(f' {iris_data.target_names[label]}{probability:.2%}')


if __name__ == '__main__':
# demo01_load_iris()
# demo02_show_iris()
# demo03_split_iris()
demo04_iris_evaluate_test()

这里把原来绘图中的 fit_reg=True 改成了 False:目的是观察散点,不额外拟合回归直线。该图只展示两个特征,后面的 KNN 仍然使用全部四个特征。

六、逐步理解关键代码

1. 划分数据后的四个变量

1
2
3
4
5
6
x_train, x_test, y_train, y_test = train_test_split(
iris_data.data,
iris_data.target,
test_size=0.2,
random_state=23,
)
变量 含义 本例形状
x_train 训练集特征 (120, 4)
x_test 测试集特征 (30, 4)
y_train 训练集真实标签 (120,)
y_test 测试集真实标签 (30,)

150 是样本数量,4 才是特征数量。x_train[0] 的正确答案是 y_train[0],特征与标签会对应划分。

random_state=23 用来固定随机划分,方便复现实验。23 本身没有特殊含义,也不应该通过反复换随机种子来挑选最好看的测试分数。

本例保留原来的划分设置。分类任务也常添加 stratify=iris_data.target,使各类别在训练集和测试集中的比例更接近原数据;添加后具体划分和结果可能变化。

2. 为什么新样本使用两层方括号?

1
my_data = [[7.8, 2.1, 3.9, 1.6]]

模型接收的是二维特征表:每一行是一条样本,每一列是一个特征。因此一朵花也需要写成“一行、四列”,不能省掉外层方括号。

新样本不知道真实类别,也不需要先提供标签。模型正是要根据这四个特征预测类别。

3. predict_proba() 的概率从哪里来?

在本例的等权投票规则下,类别概率估计来自最近 K 个邻居中各类别的占比。

例如最近三个邻居的标签为 [1, 1, 2],那么类别 0、1、2 的概率估计依次是:

1
[0/3, 2/3, 1/3]

这只是投票原理的示例,不是对代码中新样本运行结果的预告。概率列的类别顺序应查看 estimator.classes_

即使三个邻居全部投给同一类,输出概率为 1,也只表示这些邻居一致,不代表现实中的分类绝对不会出错。

4. 两种模型评估方式有什么区别?

第一种,让模型自己预测再评分:

1
estimator.score(x_test, y_test)

第二种,直接比较已经得到的预测标签与真实标签:

1
2
y_pred = estimator.predict(x_test)
accuracy_score(y_test, y_pred)

对于这里的 KNN 分类器,同一份数据、同一个模型下,两者得到相同的准确率:

1
准确率 = 预测正确的样本数 / 测试样本总数

假设 30 条测试样本预测对 28 条,准确率就是 28/30,约为 93.33%。这是计算示例,实际分数以程序输出为准。

score() 会重新执行预测,不会自动使用之前的 y_pred。因此已有预测结果时,使用 accuracy_score() 可以省去一次重复预测。其他模型的 score() 不一定代表准确率,例如回归模型通常返回 R²。

5. 两个 fit() 不要混淆

代码中有两类对象,它们都使用了拟合接口:

对象 拟合时确定什么
transfer:标准化器 训练数据每列的均值和缩放尺度
estimator:KNN 分类器 保存训练样本、标签,准备查找邻居

前者负责把数据转换到一致的尺度,后者负责分类。预测新样本时,两个对象都需要保留并配套使用。

七、这个案例之后可以练习什么?

先尝试不看代码,独立写出下面这条流程:

1
2
3
4
加载数据 → 划分训练集和测试集 → 仅用训练集拟合标准化器
→ 拟合 KNN → 预测测试集 → 计算准确率

新样本 → 使用同一标准化器转换 → 使用同一 KNN 预测

接下来可以尝试打印新样本的邻居距离与标签,验证“近邻投票”到底在参考哪些样本:

1
2
3
4
5
# 放在 demo04_iris_evaluate_test() 中,创建 my_data_scaled 之后
distances, indices = estimator.kneighbors(my_data_scaled)
print('最近邻的距离:', distances[0])
print('最近邻在训练集中的位置:', indices[0])
print('最近邻的真实标签:', y_train[indices[0]])

这里的位置指向传入 fit() 的训练集行号,不是原始完整数据集的行号。距离是在标准化后的四维特征空间中计算的。

KNN 的优点是直观、容易实现;局限是需要保留训练数据,数据规模变大时预测可能变慢,而且容易受特征尺度、无关特征和高维数据影响。

学完这个案例,应当能够解释:模型参考什么数据、为什么要标准化、预测时如何找邻居,以及准确率是怎样算出来的。能把这些问题讲清楚,比仅仅记住 fit()predict() 两个函数名更有用。