☰
KNN分类算法从原理到Python实现:手写代码与Scikit-learn调包实战
2026/10/14 4:38:05 网站建设 项目流程

我学机器学习的第一课就是KNN,当时导师说:这个算法你要是能画着图讲清楚,你就真懂了。后来我带过不少零基础的A同学,发现KNN确实是入门最友好的算法没有之一——原理朴素、数学要求低、代码还能一行行手写出来。这篇就拿KNN代码教学当主线,从原理到Python实现,最后再调包用Scikit-learn跑一遍,全程配完整代码和注释,保证小白也能轻松看懂。无论你是刚接触机器学习,还是写代码总感觉“眼睛会了手不会”,这篇文章都适合你。

1. 先搞懂KNN在干什么,再碰代码

1.1 用“朋友圈”理解KNN

KNN全称K-Nearest Neighbors,K最近邻算法。名字翻译成人话就是:一个人是什么样的人,看离他最近的K个朋友就知道了。你窝在图书馆,身边全是备考的,那你是学生的概率就很大;你站在球场边,身边全是穿球衣的,那你是来打球的可能性就高。这就是KNN的核心直觉——“物以类聚”。

放到数据里,一个未知样本是什么类别,就去数据集中找离它最近的K个已知样本,然后让这K个“邻居”投票,票数最多的类别就是这个未知样本的预测类别。整个过程,没有训练参数的那个“训练”过程,或者说它的训练就是把所有样本记住。所以你经常听到一句话:KNN是个惰性学习算法,它本身不学习,就是靠记忆和计算距离。

1.2 KNN的三个核心要素

KNN看似简单,但要做好,有三个东西必须想明白:

  • K值选择:选几个邻居来投票。K太小容易受个别噪声点干扰,K太大又会被远处的大多数拉偏。
  • 距离度量:怎么判断“近”。最常用的是欧式距离,就是两点之间的直线距离,初中几何就学过。
  • 决策规则:分类时用少数服从多数;回归时用K个邻居的平均值。

这三个点,其实是KNN的全部灵魂。代码写起来相当短,但每个环节都藏着细节。我见过很多人调包跑通就觉得自己会了,可一换数据就翻车,就是因为这三个要素没吃透。

2. 动手之前,先把数据准备好

2.1 为什么要“造数据”而不是直接找个大数据集

很多教程一上来就加载手写数字数据集,或者直接拉一个大表格,然后丢给sklearn一行搞定。这对小白并不友好——你连数据长什么样都看不清,谈何理解算法在干什么?

我建议先用假数据。自己用代码生成两类点,二维平面很直观,画个散点图就能看到算法的每一步。等把KNN彻底搞明白了,再上真实数据集,那会儿你自然能驾驭。

2.2 用NumPy生成两类样本

下面这段代码会生成两组二维坐标点,一类以(2, 2)为中心,另一类以(6, 6)为中心,各20个样本,并给它们贴上标签0和1:

import numpy as np import matplotlib.pyplot as plt np.random.seed(42) # 固定随机种子,保证每次运行结果一样 # 第一类:中心在(2, 2)附近 class_0_x = np.random.randn(20) * 0.8 + 2 class_0_y = np.random.randn(20) * 0.8 + 2 class_0 = np.column_stack((class_0_x, class_0_y)) # 第二类:中心在(6, 6)附近 class_1_x = np.random.randn(20) * 0.9 + 6 class_1_y = np.random.randn(20) * 0.9 + 6 class_1 = np.column_stack((class_1_x, class_1_y)) # 合并数据,并生成标签 X = np.vstack((class_0, class_1)) y = np.hstack((np.zeros(20), np.ones(20))).astype(int)

强调一下np.random.seed(42)的作用。很多小白会忽略这行,结果每次运行的数据都不一样,好不容易调通一次,下次再跑又是另一个结果。固定随机种子,相当于给随机数生成器上了锁,让实验可复现。这个习惯从第一天就要养成。

数据生成以后,建议先画个图看看分布:

plt.scatter(class_0[:, 0], class_0[:, 1], c='skyblue', label='class 0') plt.scatter(class_1[:, 0], class_1[:, 1], c='orange', label='class 1') plt.scatter(3.2, 3.2, c='red', marker='x', s=150, label='unknown') plt.xlabel('feature x1') plt.ylabel('feature x2') plt.legend() plt.grid(alpha=0.3) plt.show()

图上能明显看到左下方一团蓝色,右上方一团橙色。那个红色叉号就是我们要预测的新样本,坐标是(3.2, 3.2)。人眼一看就知道它大概率属于橙色那类,但你一会儿会看到代码是怎么“看”出来的。

3. 手写KNN:一行一行拆给你看

3.1 欧式距离的计算代码

KNN第一步是算距离。欧式距离的公式是:

d = sqrt((x1 - x2)^2 + (y1 - y2)^2)

扩展到多维特征,就是每个维度差值的平方和,再开根号。代码可以这样写:

def euclidean_distance(a, b): """ 计算两个点之间的欧式距离 a和b都是形如[x, y]的坐标点 """ diff = a - b # 对应坐标相减 squared = diff ** 2 # 求平方 total = np.sum(squared) # 求和 return np.sqrt(total) # 开根号

这块代码我用了几行,是故意不用numpy现成的np.linalg.norm,因为拆开更容易看懂。实际项目里可以直接用norm函数,一步到位。

3.2 找K个最近邻居

有了距离函数,接下来就把所有训练样本过一遍,算出未知样本到每个训练样本的距离,然后排序,取前K个:

def knn_predict(X_train, y_train, x_unknown, k=3): """ X_train: 训练数据特征 y_train: 训练数据标签 x_unknown: 待预测的新样本 k: 取几个邻居投票 """ distances = [] for i in range(len(X_train)): d = euclidean_distance(X_train[i], x_unknown) distances.append((d, y_train[i])) # 按距离从小到大排序 distances.sort(key=lambda tup: tup[0]) # 取前k个 neighbors = distances[:k] labels = [neighbor[1] for neighbor in neighbors] # 投票 count0 = labels.count(0) count1 = labels.count(1) return 0 if count0 > count1 else 1 pred = knn_predict(X, y, np.array([3.2, 3.2]), k=3) print("预测结果:", pred)

每个步骤都可以多说两句:

  • distances里存的是元组(距离, 标签),排序时用lambda tup: tup[0]只按距离排,不按标签排。
  • 取前K个之后,把邻居的标签摘出来,用列表的count方法统计票数。
  • 如果票数一样,代码里简单返回1。实战中遇到平票,通常可以按距离远近给每票加权来解决,或直接随机选。

3.3 手写版完整代码

把上面拼起来,就是一个完整的、虽然朴素但完全能用的KNN分类器。完整代码如下:

import numpy as np def euclidean_distance(a, b): return np.sqrt(np.sum((a - b) ** 2)) def knn_predict(X_train, y_train, x_unknown, k=3): distances = [] for i in range(len(X_train)): d = euclidean_distance(X_train[i], x_unknown) distances.append((d, y_train[i])) distances.sort(key=lambda tup: tup[0]) neighbors = distances[:k] labels = [neighbor[1] for neighbor in neighbors] count0 = labels.count(0) count1 = labels.count(1) return 0 if count0 > count1 else 1 # 数据准备 np.random.seed(42) class_0_x = np.random.randn(20) * 0.8 + 2 class_0_y = np.random.randn(20) * 0.8 + 2 class_0 = np.column_stack((class_0_x, class_0_y)) class_1_x = np.random.randn(20) * 0.9 + 6 class_1_y = np.random.randn(20) * 0.9 + 6 class_1 = np.column_stack((class_1_x, class_1_y)) X = np.vstack((class_0, class_1)) y = np.hstack((np.zeros(20), np.ones(20))).astype(int) # 预测未知点 unknown = np.array([3.2, 3.2]) print(knn_predict(X, y, unknown, k=3))

这段代码在小数据上是能真跑的。我建议你亲手跑一遍,然后把k改成1、5、9,观察结果变化。这个动作虽然简单,但它会帮你把“k值影响分类”这一点焊死在脑子里。

3.4 预测多个样本:真的实战不这么写

单样本预测懂了之后,马上有个问题:我们要预测一批新样本,难道要写循环一个一个算吗?写是可以写,但效率低。更合适的做法是用矩阵运算,一次性算所有样本之间的距离。

我会在后面的优化部分展开,但在这里先埋个伏笔:手写代码是为了理解原理,性能优化的路数也是一种学习。最怕的就是理解原理时只背代码,优化时又忘了原理。

4. 调包Scikit-learn:一行代码背后发生了什么

4.1 一个规范版代码骨架

理解了手写版之后,用Scikit-learn写KNN就轻松了。官方代码骨架如下:

from sklearn.neighbors import KNeighborsClassifier from sklearn.model_selection import train_test_split from sklearn.preprocessing import StandardScaler from sklearn.metrics import accuracy_score # 划分训练集和测试集 X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.3, random_state=42, stratify=y ) # 特征缩放 scaler = StandardScaler() X_train_scaled = scaler.fit_transform(X_train) X_test_scaled = scaler.transform(X_test) # 训练KNN模型 knn = KNeighborsClassifier(n_neighbors=3, p=2, metric='minkowski') knn.fit(X_train_scaled, y_train) # 预测 y_pred = knn.predict(X_test_scaled) # 评估 acc = accuracy_score(y_test, y_pred) print("准确率:", acc)

4.2 几个关键参数拆解

  • n_neighbors=3:就是K值,和手写版里的k一一对应。
  • p=2, metric='minkowski':这个组合就是欧氏距离。Minkowski距离是个大类,p=1是曼哈顿距离,p=2就是欧氏距离。
  • fit(X, y):对于KNN来说,fit的过程本质是记住训练集,没有任何迭代计算。你可以把它理解成一个“记笔记”的过程。
  • test_size=0.3:把30%的数据留出来考试,不参与“记笔记”。

为什么要划分训练集和测试集?这个问题的答案很关键。你用训练数据训练(记忆)模型,再用同一批数据评估,那准确率毫无意义——就像学生拿着课本开卷考,分数再高也不能证明学会了。必须用没见过的数据测试,模型的真实水平才看得出来。

4.3 手写版和调包版怎么选

如果你只为了完成一个分析任务,当然用Scikit-learn,又快又稳。但如果你处在学习阶段,我强烈建议你两个都写一遍:先用纯手写版跑通流程,再用sklearn版对照同一份数据。很多小伙伴反馈,手写版之后再调包,看文档都顺眼多了,因为终于知道那个n_neighbors参数到底在改什么。

5. k值到底怎么选:小白的第一个坑

5.1 奇怪结果往往出在k值上

刚入门时,用k=1和k=50的结果可能天差地别。为什么?k=1时候,预测结果只由最近的一个样本决定,万一这个样本是噪声,或者样本恰好贴得很近但属于不同类别,一下就带偏了。k太大时,远处的样本也开始投票,类别之间的边界会被抹平。

想象一下班里投票选“最像学霸的人”,如果只问一个人(k=1),恰好问到一个瞎说的;如果问全班五十个人(k=50),那基本分不清学霸和学渣的区别,投票结果接近整体概率。

5.2 用交叉验证找合适的k

选k值没有捷径,业界标准做法是用交叉验证。做法简单说:把训练集分成几份,轮流用其中一份当验证集,剩下当训练集,跑多次评价,取平均效果,然后比较不同k的表现。

from sklearn.model_selection import cross_val_score k_range = range(1, 21) cv_scores = [] for k in k_range: knn = KNeighborsClassifier(n_neighbors=k, p=2, metric='minkowski') scores = cross_val_score(knn, X_train_scaled, y_train, cv=5, scoring='accuracy') cv_scores.append(scores.mean()) best_k = k_range[np.argmax(cv_scores)] print("最佳k值:", best_k)

实际操作中,我一般还会顺手画一条折线图,看看准确率随k的变化曲线长什么样。如果曲线波动剧烈且最高点出现在k=1的位置,说明数据本身噪声大,或者样本太少,光调k不够,要考虑清洗数据或增加样本。

5.3 k值的一点经验建议

  • 经验上,k一般不要超过样本数的开方。
  • 类别多的时候,k不要太接近类别数量,否则边界很糊。
  • 平票时要处理,比如给距离更近的样本更高权重,或者直接剔除这个点的预测。
  • 如果类别不平衡,多数类会天然在投票中占便宜,必要时可以用加权投票。

这些坑,我基本都在实际项目里踩过一轮。

6. 距离计算与数据归一化:代码能跑但效果差的元凶

6.1 特征尺度不一致会把距离“带偏”

假设有两个特征:年龄(20~40)和年收入(50000~1000000)。计算欧式距离时,年龄那点差距放在收入面前微不足道,基本等于没有。实际上可能收入根本不是这个问题的关键特征,但算法天然被它带跑。

我把这个现象叫“数值压迫”。真实项目里这种情况非常常见,尤其是抓来的数据没做过任何处理就直接喂给KNN,效果往往很差。

6.2 归一化实操代码

KNN这类基于距离的算法,几乎都建议做特征缩放。常用办法有两种:

  • 标准化:把数据变成均值0、方差1的标准正态分布。
  • 归一化:把数据缩放到[0,1]区间。

Scikit-learn中分别对应StandardScaler和MinMaxScaler,示例代码:

from sklearn.preprocessing import MinMaxScaler, StandardScaler # 标准化 standard_scaler = StandardScaler() X_standardized = standard_scaler.fit_transform(X) # 归一化 minmax_scaler = MinMaxScaler() X_normalized = minmax_scaler.fit_transform(X)

6.3 为什么测试集要用fit_transform的结果去transform

我见过很多新手犯同一个错误:对训练集fit_transform,对测试集也fit_transform。这是错的。

fit是在让缩放器学习训练集的均值和方差(或最大最小值),如果对测试集再fit一次,就是用测试集自己的统计数据来缩放,相当于测试答案也漏题了,评估结果虚高。

正确做法是:训练集用fit_transform(先学习再转换),测试集只用transform(直接套用训练集学到的参数)。

这个细节,面试常问,项目里也常常是性能差异的导火索。

7. 常见问题与排查技巧实录

7.1 问题速查表

我把教学和实际项目里常见的跑偏问题整理成一张速查表,供你对照检查:

现象可能原因排查方向
准确率很低特征没归一化检查是否做了StandardScaler或MinMaxScaler
结果随随机种子波动大数据量太少或划分不平衡增大数据量,划分时加stratify
k=1时总是预测成某一类类别不平衡考虑加权投票,或调整样本比例
高维数据下效果差维度灾难考虑降维(PCA等),或改换其他算法
训练很快但预测极慢KNN惰性计算特性使用KDTree或BallTree加速,数据大时换模型
平票导致分类结果不稳定投票规则太朴素按近邻距离加权投票

7.2 几个隐蔽的坑

速查表之外,我再分享几个不动手根本发现不了的坑。

第一,数据分布重叠严重的时候,KNN很难救场。如果两类样本本身就是一团乱麻,KNN再调参也难分清楚。这不是你的问题,是数据可分离性不够高。

第二,KNN对特征数量很敏感。维度一高,样本之间的距离普遍变大,区分度下降,这就是“维度灾难”。最简单的缓解方式是先用PCA等降维手段。

第三,KNN的预测阶段很慢,因为它没有模型,预测一个点都要把训练集全部算一遍。数据量到了万级以上,这种算法的代价就开始凸显。实际项目中一般配合KDTree来加速,或者干脆换更轻的模型。

7.3 优化手写版:用向量化替代循环

前面说过,手写版用循环可以一步步看清原理,但性能不行。如果数据量稍大,一定要改成向量化计算。这里给一个简单高效的写法:

def knn_predict_vectorized(X_train, y_train, X_test, k=3): # 计算所有训练样本与所有测试样本之间的欧式距离 diff = X_train[:, np.newaxis, :] - X_test[np.newaxis, :, :] squared = diff ** 2 dist_matrix = np.sqrt(np.sum(squared, axis=-1)) # 对每个测试样本,取距离最近的k个样本的标签 k_nearest_idx = np.argsort(dist_matrix, axis=0)[:k, :] k_nearest_labels = y_train[k_nearest_idx] # 投票:统计每列中最常见的标签 pred = np.apply_along_axis(lambda x: np.bincount(x).argmax(), axis=0, arr=k_nearest_labels) return pred

这里跳到三维数组可能有点晕,你可以打印diff.shape看看,它会告诉你形状是(训练样本数, 测试样本数, 特征数)。这一步的直觉是:一次性把每个训练样本和每个测试样本的差值都算出来,然后统一平方求和。

真正的进阶路线是:先把原理拆明白,再把循环改写向量化,最后让别人帮你调参调优时,你能一眼看出问题在哪。

7.4 当数据不均衡时,投票可能“一边倒”

如果不均衡情况很严重,比如0类有5000个样本,1类只有50个,那么就算未知点旁边确实围了几个1类点,也可能被大量的0类“淹没”。

解决思路:

  • 权衡投票权:距离越近的邻居,投票权重越大。
  • 过采样/欠采样:把少数类复制多份,或从多数类里抽样,让两类数量接近。

在sklearn中,可以设置weights='distance'让投票按距离加权。这个参数常常被忽略,但处理不均衡数据时非常好用。

8. 写到最后,聊聊我的建议

我当初学KNN的体会是:它作为机器学习第一课再合适不过,因为它逼你去理解“距离”、“邻居”、“投票”这些最朴素的概念,而这些概念在更深度的模型里会以更复杂的形式反复出现。

给看到这里的小白一个行动清单:

  1. 把文章第3节的手写版代码,亲手敲一遍,不要复制。
  2. 改k值,观察结果变化。
  3. 用matplotlib画一下决策边界。
  4. 再跑一遍sklearn版,一步步对比。
  5. 把每次实验的错误记录下来,形成自己的“避坑清单”。

最后分享一个我一直在用的小技巧:调KNN模型时,别一次性奔着最优参数去,先固定k=5,跑通全流程,再老老实实用交叉验证选k。一次只改一个参数,你才能真正看到效果是怎么被影响的。把基础打牢了,后面学逻辑回归、决策树才会真正“轻松看懂”。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询