KNN大概是整个机器学习里最“没脾气”的算法:没有梯度下降,没有损失函数,甚至连像样的训练过程都没有——把数据存下来就算训练完了。但你真把它学会之后会发现,这个算法是理解“分类问题”最好的起点。这篇文章就用经典的鸢尾花数据集,从距离度量讲起,手写一个可以跑的KNN分类器,再聊清楚K值怎么选、交叉验证怎么用、以及新手最容易忽略的那些坑。适合刚迈进机器学习门槛的同学,也想让已经会调库的朋友从我踩过的坑里省点时间。
1. 没梯度没训练,KNN靠什么把分类做明白
1.1 KNN只要三件东西:样本点、距离、投票规则
很多算法教程喜欢从数学定义开始,结果新手连公式都没看完就放弃了。KNN不需要这样。它的核心逻辑用一句话就能说清楚:物以类聚,找最近的K个邻居,看他们大多是哪类,就把新样本归为哪类。
比如你看到一个陌生人,不知道他是哪边的,但你知道他的几个朋友很少跟“不正确的人”玩,那么大概率他本人也差不多。KNN就是把这个生活直觉翻译成数学。
要让这个逻辑跑起来,需要定义三件事:
- 样本点:每个数据都是多维空间里的一个点。在鸢尾花数据集中,每一朵花就是四维空间中的一个点(四个特征:花萼长度、花萼宽度、花瓣长度、花瓣宽度)。
- 距离:空间里两个点怎么算“远近”。最常见是欧氏距离,也就是高中几何里两点间的直线距离,只是从二维三维推广到四维。
- 投票规则:选出距离最近的K个点后,这K个点所属类别各投一票,票数最高者作为预测结果。
这个过程听起来很笨,但恰恰因为“笨”,它暴露了一个关键事实:KNN属于“懒惰学习”。也就是说,它在训练阶段什么都不做,只是把数据复制一份存起来。真正的计算全部发生在预测阶段——每来一个新样本,都要和数据集里每一个旧样本算一次距离。
这和那些训练阶段辛辛苦苦拟合参数的算法(比如逻辑回归、神经网络)形成鲜明对比。后者的计算成本花在“训练”上,预测时很快;KNN则是“训练”几乎为零成本,预测时反而慢,数据量一上来就明显拖后腿。这也是KNN经常被用来做“基线模型”的原因——它简单、可解释,适合告诉你“一个不聪明的分类器能到多少准确率”,方便后续拿复杂模型去对比。
1.2 鸢尾花数据集为什么“简单到恰到好处”
鸢尾花数据集是几乎每个机器学习入门者都会碰到的数据集,它太经典了。这套数据由一位植物学家在上世纪三十年代整理,收录了三种鸢尾花,每类50条样本,共150条,每条样本记录花萼和花瓣的长宽四个数值特征。
三个类别之间并没有完全混在一起。花瓣长度和花瓣宽度这两个特征对分类非常有区分度,花萼宽度的区分度弱一些。也就是说,即使不使用任何聪明算法,仅凭花瓣长度都能把山鸢尾和另外两种分开,特征分布本身就适合做可视化演示。150条样本的大小也刚刚好——数据量足够让算法跑出有意义的结果,又不至于大到让笔记本风扇狂转。
但有一点容易被忽略:这个数据集太“干净”了。它没有缺失值,没有异常值,类别完全均衡,每个类别恰好50条。新手用这个数据集练手时顺风顺水,容易产生“训练好简单”的错觉。真实世界的生产数据常常有缺失、离群点、类别不平衡,这些在鸢尾花上都遇不到。所以我认为它适合入门,但入门之后一定要尽快换到真正的脏数据上去摔打几次,否则会低估数据清洗的重要性。
另外有个细节值得注意:这150条样本在原始文件里是按类别排序存放的——前50条全是山鸢尾,中间50条全是杂色鸢尾,最后50条全是弗吉尼亚鸢尾。如果划分训练集测试集时不打乱顺序,而是直接取前75%作训练、后25%作测试,那测试集里可能只有两类花,结果准确率再高也没有参考价值。后文我会专门演示shuffle和随机种子怎么处理这个问题。
2. 距离公式和特征量纲:新手翻车率最高的两个细节
2.1 欧氏距离不是唯一选择,但入门阶段选它没错
KNN里最关键的计算就是距离。距离定义不同,算法行为会完全不一样。
最常见的欧氏距离,公式是:
[ d(x, y) = \sqrt{\sum_{i=1}^{n}(x_i - y_i)^2} ]
用代码写几乎不用思考:
import numpy as np def euclidean_distance(x1, x2): return np.sqrt(np.sum((x1 - x2) ** 2))另一种常用的是曼哈顿距离,它不走直线,而是沿着坐标轴一格一格地走:
[ d(x, y) = \sum_{i=1}^{n}|x_i - y_i| ]
什么时候选曼哈顿距离?当特征本身带有明显的“网格”性质时,比如城市里两点的出租车距离、图像识别里的像素差异。欧氏距离对连续数值特征更自然,也是默认选择。还有更通用的闵可夫斯基距离,欧氏和曼哈顿都是它的特例,差别只在一个参数p。
| 距离类型 | 公式 | 适用场景 |
|---|---|---|
| 欧氏距离 | 平方差求和开根号 | 连续数值特征,默认选择 |
| 曼哈顿距离 | 绝对值求和 | 网格状路径、特征含义类似“步数” |
| 闵可夫斯基距离 | 参数p的通用形式 | 需要调节距离尺度时使用 |
对于鸢尾花这种全是连续数值特征的场景,欧氏距离足够用。很多教材直接默认欧氏距离,这没错,但你要知道它为什么是默认——因为几何直觉对我们来说最自然,而且欧氏距离在特征维度不高时表现稳定。一旦特征维度超过几十甚至上百,欧氏距离会逐渐失效,原因是高维空间里所有点的距离都趋向于差不多大,这叫“维度灾难”,后面我会专门讲。
2.2 量纲不同,距离计算就会被大数值特征绑架
这个坑太经典了,几乎所有初学KNN的人都会踩一次。
我先给出一组鸢尾花四个特征的参考范围:
| 特征 | 数值范围 |
|---|---|
| 花萼长度 | 4.3 ~ 7.9 cm |
| 花萼宽度 | 2.0 ~ 4.4 cm |
| 花瓣长度 | 1.0 ~ 6.9 cm |
| 花瓣宽度 | 0.1 ~ 2.5 cm |
注意花瓣宽度最小才0.1,最大也才2.5;而花萼长度动辄四到八。如果直接用原始数值计算欧氏距离,那么花萼长度的差异会在距离里占主导地位,花瓣宽度的差异几乎被淹没。这就等于在投票时让花萼长度拥有决定性话语权,可实际上花瓣宽度对鸢尾花分类的区分度根本不比花萼长度差。
解决办法叫标准化。常见做法是把每个特征转成均值0、标准差1的形式:
[ x_{standardized} = \frac{x - mean}{std} ]
用scikit-learn实现起来非常短:
from sklearn.preprocessing import StandardScaler scaler = StandardScaler() X_train_scaled = scaler.fit_transform(X_train) X_test_scaled = scaler.transform(X_test)这里有三个细节值得说清楚:
- 是fit_transform训练集,但对测试集只调用transform。原因是均值和标准差是训练集的“统计参数”,测试集不能参与计算,否则等于提前知道了测试集的分布信息,这属于数据泄漏。
- 标准化不会改变数据的分布形状,它只是让每个特征在距离计算中“同等竞争”。
- 在鸢尾花数据集上,标准化的影响有时候看起来不大,甚至准确率几乎不变。这会让新手误以为标准化可有可无。别被骗了,那是因为鸢尾花各特征虽然量纲不同,但区分度本身足够强。换一个人口统计数据、房价数据或者用户行为数据,不标准化的话KNN的表现可能会惨不忍睹。
3. 手写一个KNN类:从数据划分到预测准确率
3.1 数据加载和划分:shuffle和随机种子是隐藏考点
先把数据加载进来。scikit-learn自带鸢尾花数据集,一行代码就能拿到:
import numpy as np from sklearn.datasets import load_iris from sklearn.model_selection import train_test_split data = load_iris() X = data.data # 形状 (150, 4) y = data.target # 形状 (150,),取值 0、1、2,对应三种鸢尾花然后划分训练集和测试集:
X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.25, random_state=42, stratify=y )stratify=y的意思是按类别比例分层抽样,保证训练集和测试集里三类花的比例都和原始数据一样,各占三分之一左右。这一步尤其重要,因为原始数据是按类别顺序排列的,如果不shuffle、不stratify,极端的划分会让模型根本没见到某些类别的样本。
random_state=42是随机种子。它的作用是让结果可复现——每次运行代码都得到一模一样的划分方式和结果。调模型时随手把种子修成固定值几乎是必修习惯,否则你每次跑出来的准确率都不一样,根本无法判断是算法改进带来的效果还是随机波动带来的假象。
3.2 完整的手写KNN实现与逐行解释
很多教程用scikit-learn的KNeighborsClassifier一行搞定,但如果你想真正理解KNN在做什么,手写一遍非常值。代码不长,核心逻辑其实就两个循环加一个排序。
from collections import Counter class KNN: def __init__(self, k=3): self.k = k def fit(self, X, y): self.X_train = X self.y_train = y def predict(self, X): return np.array([self._predict_one(x) for x in X]) def _predict_one(self, x): # 计算x到所有训练样本的欧氏距离 distances = np.linalg.norm(self.X_train - x, axis=1) # 取出距离最小的k个样本的索引 k_indices = np.argsort(distances)[:self.k] # 拿到这k个样本的标签 k_labels = self.y_train[k_indices] # 投票:统计每个标签出现次数,选出现最多的 most_common = Counter(k_labels).most_common(1) return most_common[0][0]逐行拆解一下:
np.linalg.norm(self.X_train - x, axis=1)这一行利用NumPy的广播机制,一次性计算待预测样本与所有训练样本的欧氏距离,不需要写for循环。训练集有150条样本,手算一条预测就是150次减法加上开方,速度几乎可以忽略。
np.argsort(distances)[:self.k]是对距离数组从小到大排序,然后取出前k个位置索引。这里有个细节,如果存在距离相等的样本,argsort会按照它们在数组中的先后顺序排列,所以平票时趋向于保留训练集中靠前的样本。这个行为后面讲平票时会再提。
Counter(k_labels).most_common(1)返回格式是[(标签, 次数)]这种列表,所以需要取[0][0]拿到标签本身。
这套写法最大的优点是逻辑透明。每一步你都知道数据变成了什么形状,中间发生了什么。这对初学者建立直觉非常有用。
3.3 跑通后标准答案的验证流程
数据划分好了,KNN类也写好了,接下来的流程是先标准化,再训练,再预测。
knn = KNN(k=3) knn.fit(X_train_scaled, y_train) y_pred = knn.predict(X_test_scaled) accuracy = np.mean(y_pred == y_test) print(f"准确率: {accuracy:.4f}")用随机种子42划分数据时,k=3的准确率大约在0.9667左右,也就是测试集38条样本里大概有一条被预测错。第一次跑出这个结果时,成就感还是有点东西的。
为了对比标准化影响,可以分别用未标准化的数据和标准化后的数据跑一遍。在鸢尾花上,你可能会看到差距并不大,原因前面说过——鸢尾花本身类别区分度高。但训练过程还是要坚持标准化,因为一个成熟的机器学习流程必须对任何数据集都稳健,而不是靠某个数据集恰好“命硬”。
用scikit-learn的官方实现对比一下更安心:
from sklearn.neighbors import KNeighborsClassifier sk_knn = KNeighborsClassifier(n_neighbors=3) sk_knn.fit(X_train_scaled, y_train) sk_accuracy = sk_knn.score(X_test_scaled, y_test) print(f"scikit-learn准确率: {sk_accuracy:.4f}")两者结果一致,说明手写版本逻辑正确。这一步验证非常建议做,它能帮你确定自己的代码没有隐藏在角落里的bug。
4. K值不是拍脑袋决定的:用交叉验证和肘部图挑参数
4.1 为什么奇数k优先,为什么k=1不是最佳
KNN里K是超参数,它控制着算法在“过拟合”和“欠拟合”之间走多远。
可以想一下极端情况。k=1时,新样本的预测结果完全由最近一个邻居决定,决策边界会非常崎岖,把训练集中的每一个噪声点都当真。这本质上是过拟合,模型在训练集上表现极好但在测试集上没那么好。
k非常大时,比如k=100,那就等于把训练集里的大多数类别力量平均化,真实局部的结构被抹平了,模型变得过于简单,欠拟合。
对二分类问题,选奇数k还有一个很实际的原因——避免平票。当只有两个类别时,k=2、4、6这类偶数会在极端情况下出现两方票数相同,无法决定归属。鸢尾花是三分类,平票情况不常见,但仍可能出现一条样本的邻近圈子里“山鸢尾1票、杂色鸢尾1票、弗吉尼亚鸢尾1票”的三方平局,所以奇数并不是绝对护身符。真正优雅的解法是给更近的邻居更大权重,这个第四章会讲。
4.2 用5折交叉验证画一张准确率曲线
在某个固定测试集上调节k,调出来的只能是“对这个测试集最好”的参数,换成其他测试分布可能立刻失效。更稳妥的做法是用交叉验证。
所谓K折交叉验证,是把训练集切成K份,每次拿其中1份当验证集,剩下K-1份当训练集,轮流训练K次,最后把K次准确率的平均值作为该参数下的评分。比如5折交叉验证就是切5份,跑5轮。这样做的好处是每个样本都被当作验证样本用过一次,评分更稳定,不容易被某一次坏运气误导。
用scikit-learn实现很快:
from sklearn.model_selection import cross_val_score from sklearn.neighbors import KNeighborsClassifier k_range = range(1, 21) cv_scores = [] for k in k_range: knn = KNeighborsClassifier(n_neighbors=k) scores = cross_val_score(knn, X_train_scaled, y_train, cv=5) cv_scores.append(scores.mean())把结果画出来,横轴是k值,纵轴是5折交叉验证的平均准确率,你会看到一条典型的曲线:一开始k=1时准确率偏低,k=3附近到达一个小高点,随后持续下降,5到10之间徘徊后缓慢下行。这种像“肘部”一样的形状,就是你选择最优k值的依据。
选择标准很简单:在曲线达到高点后再保持平稳的区间里,选尽量小的k。为什么选小不选大?因为k越小,模型越保留局部细节;当数据量变大时,小k的模型有潜力拟合更复杂的边界。当数据不足时小k容易过拟合,所以要在交叉验证评分相近的前提下挑选最简模型。
4.3 混淆矩阵:KNN到底在哪一类上容易犯错
单一准确率只能告诉你“大部分对”,但没法告诉你“哪一类错得多”。混淆矩阵可以把这个问题摊开看。
from sklearn.metrics import confusion_matrix, classification_report cm = confusion_matrix(y_test, y_pred) print(cm)输出结果大致是这样的矩阵:
[[13 0 0] [ 0 12 1] [ 0 0 12]]行代表真实类别,列代表预测类别。对角线是预测正确的数量。从这个矩阵可以清楚看到,三类中杂色鸢尾和弗吉尼亚鸢尾之间发生了一次混淆——一株实际属于杂色鸢尾的样本被预测成了弗吉尼亚鸢尾。山鸢尾则完全没出过错。
为什么会这样?直观理解是山鸢尾的花瓣长度明显短(一般不超过2厘米),和其他两类拉开明显距离;而杂色鸢尾和弗吉尼亚鸢尾的花瓣特征范围有重叠,在KNN的局部判断上就容易产生界线模糊。
再看分类报告:
print(classification_report(y_test, y_pred, target_names=data.target_names))重点关注每个类别的召回率。如果某个类别的召回率明显偏低,说明大量真实样本被模型漏判了。对于这类细分不充分的数据,你可以增加训练数据量、增加特征,或者干脆换一个更擅长处理边界重叠的算法。
5. 随机种子、数据泄漏、维度灾难:三个进阶坑一次讲清
5.1 平票与距离权重:让邻居按远近说话
简单投票有个逻辑漏洞:离你0.1的邻居和离你1.0的邻居,在投票中拥有完全相同的话语权。直觉上这不合理,尤其在k个邻居距离分布很不均匀时。
改进办法是距离加权投票。让每个邻居的投票权重与距离成反比,距离越近权重越大:
def _predict_weighted(self, x): distances = np.linalg.norm(self.X_train - x, axis=1) k_indices = np.argsort(distances)[:self.k] k_labels = self.y_train[k_indices] k_distances = distances[k_indices] + 1e-5 # 加极小值防止除零 weights = {} for label, dist in zip(k_labels, k_distances): weights[label] = weights.get(label, 0) + 1 / dist return max(weights, key=weights.get)这里的1e-5是小技巧,避免某个邻居恰好距离为0时除零报错。你也可以用1 / (dist + 1e-5)直接计算,效果类似。加了距离权重后,平票难题自然化解——即使票数相同,距离更近的一方权重更高,预测结果更有说服力。
scikit-learn的KNeighborsClassifier本身就支持这个设置,weights='distance'一步到位。手写实现的意义在于让你明白参数背后的原理,调参时心里有底。
5.2 数据泄漏:测试集不能偷看训练集的“均值”
前文提到标准化时只对训练集做fit_transform,对测试集只做transform,这个细节值得再展开讲。
很多人第一次写代码时会图省事,对整个数据集直接调用scaler.fit_transform(X),然后再划分训练集测试集。这种顺序看似结果也差不多,实际却埋了大雷——均值和标准差是使用全部数据计算的,而测试集的信息已经偷偷渗透进来了,这叫“数据泄漏”。它会让你评估出的准确率偏乐观,但换到线上真实新数据后,成绩立刻缩水。
正确的数据流应该是:
- 划分训练集和测试集,测试集从一开始就冻结起来。
- 只基于训练集计算均值和标准差。
- 训练集用这些统计量做标准化。
- 测试集用同样的统计量做标准化(不重新计算)。
这就像考前模拟卷和正式考卷必须分开出题,你不能拿正式考卷的标准答案去辅导模拟考。任何预处理步骤只要涉及统计量,比如归一化、主成分分析、缺失值填充,都要遵循“只在训练集上学习,然后应用到测试集”的原则。
5.3 KNN的算力账户:训练便宜,预测昂贵
KNN看起来实现简单,但它在真实工程项目里有一个明显的算力账单:训练阶段确实一分钱不花,但预测阶段要花大钱。
设想一个电商平台需要对十万个新商品做实时推荐分类,每个商品都要和百万级历史样本逐一计算距离,一次分类请求可能就是百万次运算。如果QPS稍微上来一点,服务器压力立刻爆表。这种场景下,KNN更适合作为离线评分的基线算法,而不是线上实时服务的核心方案。
当数据维度变高时,问题更麻烦。在三维空间里,直觉还管用;到了几十维,每个点之间的距离都趋于相近,欧氏距离的判别力迅速衰减。这就是前面提到的“维度灾难”,KNN在高维数据上表现急剧下降的根源所在。鸢尾花只有四维,恰好处于欧氏距离还有效的范围,这也是它作为入门案例特别友好的原因之一。
如果真要在高维或者大数据集上使用KNN,有几个工程上常见的缓解手段:降维后再计算,比如先用主成分分析把特征压缩到十几维;使用KD树或球树做加速搜索,把最近邻查找从线性复杂度降下来;或者直接放弃KNN,换用基于树的模型,例如随机森林、梯度提升树,甚至线性模型。每种方案都在工程精度和算力成本之间做权衡,不存在一个算法通吃所有场景。
说回我自己的一点体会。KNN是我接触的第一个分类算法,也因为足够简单,它让我在动手实现时真正理解了“特征”“距离”“参数”这些概念在算法里是如何一环扣一环的。之后学更复杂的模型时,总能想起KNN这个最简单的参照物——它提醒我,再花哨的模型也逃不过“用什么特征、怎么度量相似性、如何平衡复杂度”这三个基本问题。如果这篇文章能让你把KNN的代码亲手跑通,并且愿意在输出结果后多问一句“为什么”,那它的价值就已经达到了。试完鸢尾花,建议你马上换一个真实的、不那么干净的数据集,把标准化、交叉验证、数据泄漏这几个坑重新踩一遍,收获会比任何教程都大。