把「KNN代码教学,小白也能轻松看懂」当作标题来写,压力挺大。因为很多人一看到机器学习先怵了,觉得必须啃完高数和线性代数才能动手。但KNN(K-Nearest Neighbors,K个最近邻居)是少数几个例外,它靠的全是生活常识:物以类聚,人以群分。你身边最近的K位朋友经常点什么外卖,你大概率也喜欢点那家。
这篇博文就围绕这个逻辑展开。我会用一份身高体重预测性别的示例数据,把KNN从原理到手写代码每一步都拆开,连中间打印出来的距离、邻居下标都给你看,再画图把整个预测过程可视化,最后聊几个新手必踩的坑。适合刚入门机器学习的同学,适合准备面试想手撕KNN的求职者,也适合任何想用代码验证“直觉”的人。只要你会写基本的 for 循环,就能跟完。
1. 先花5分钟弄懂KNN到底在算什么
1.1 用“朋友圈投票”理解分类
KNN是一个典型的监督学习分类算法,意思是它需要一批“已经知道答案”的数据做参考。放在生活里就是:你想判断一个新朋友是户外运动派还是宅家派,不用问他自己,看看他玩得最好的3个朋友平时都在干什么,大概率就能猜个八九不离十。
放到数据上,这套逻辑一模一样。假设你已经收集了10个人的身高、体重,并且明确知道每个人的性别。现在来了一个新人,身高172cm、体重68kg,你只知道他的身高体重,想知道他是男生还是女生。KNN的做法就是:
- 计算这个新人和已知10个人各自的身高体重距离;
- 按距离从小到大排序,挑出最近的K个人;
- 让这K个“邻居”投票,票数多的那一边赢。
比如K取3,新样本距离最近的三个人里有两个男生、一个女生,那这个新人就被判成男生。整个过程不需要训练什么模型参数,所以KNN也经常被叫做“懒惰学习”(Lazy Learning),因为它本质上只是在“记住”数据,而不是在反复迭代中学习一套规则。
1.2 KNN三要素:距离、K值、投票规则
要真正看懂KNN代码,你只要抓住三样东西就够了。
第一是距离怎么算。最简单、最常用的是欧氏距离,多维空间里的“直线长度”。二维情况下就是中学学过的两点间距离公式:根号下横坐标差的平方加纵坐标差的平方。放在身高体重这个场景,就是在一个以身高为X轴、体重为Y轴的平面上,看两个点相隔多远。
第二是K值怎么定。K表示“拉几个邻居来投票”。K=1的时候完全看最近那一个人,很容易被个别离群点带偏;K太大也不行,比如数据一共10条,你非要用K=9,那少数派基本没有任何翻盘机会,因为大部分邻居都是多数类。
| K值 | 行为特征 | 通俗解释 |
|---|---|---|
| K=1 | 只看最近邻 | 身边一个朋友说啥就是啥,可能被忽悠 |
| K=3 或 K=5 | 新手首选区间 | 三五个朋友连蒙带猜,结果比较稳 |
| K=9 及以上 | 平滑但迟钝 | 人多了容易随大流,丢细节 |
| K=样本总数 | 永远投多数类 | 直接等于“哪类人多站哪边”,分类器废了 |
第三是投票规则。最常见的是多数表决,简单粗暴。后面你也会看到,当K是偶数的时候容易平票,处理平票有几种办法,第五部分我会专门展开。
2. 开始动手:数据、工具、环境一次备齐
2.1 用到的库只有两个,别去背框架
我见过太多新手一上来就打听“学机器学习是不是得先装个深度学习框架”,真不是。手写KNN只需要两个库:NumPy 和 Matplotlib。NumPy用来算数组、算距离;Matplotlib用来把预测过程画成图。甚至极端一点,不用NumPy,靠纯Python里头的列表和 math.sqrt 也能写出来,只是代码会啰嗦一些。
所以这里先破除一个心理障碍:KNN之所以适合手写,是因为它压根没有需要“训练”的权重参数。模型的表现完全取决于训练数据本身和你的K值,所以不需要GPU,不需要反向传播,也不用碰那些动辄几十层的网络结构。你要是能在一张纸上画出点来,就能在代码里把它实现。
顺带一提,scikit-learn里确实有封装好的 KNeighborsClassifier,但建议你先手写一遍。一方面考试、面试经常让人手撕KNN;另一方面,手写能让你真正看清每个步骤在干什么,之后再去调库,你会知道参数背后到底是什么含义。
2.2 构造一份适合新手观察的数据集
我特意选了身高、体重、性别这个组合,原因有三个。第一,二维数据可以直接画在平面上,肉眼就能验证算法结果;第二,身高和体重的量纲不同,身高动辄一百多厘米,体重一般只有几十公斤,正好用来演示“特征标准化”的重要性;第三,性别分类结果直观,预测出来是男是女一眼就能判断。
注意,下面这份数据是我随手编造的模拟数据,纯粹为了教学演示,别拿它当真实现实规律。10个样本,前4条标为女生,后6条标为男生:
import numpy as np import matplotlib.pyplot as plt # 特征:每行是 [身高(cm), 体重(kg)] X_train = np.array([ [160, 55], [165, 60], [155, 50], [170, 66], [175, 70], [180, 80], [185, 85], [190, 92], [188, 88], [182, 82] ]) # 标签:0 代表女生,1 代表男生 y_train = np.array([0, 0, 0, 0, 1, 1, 1, 1, 1, 1]) # 待预测的新样本:身高172cm,体重68kg x_new = np.array([172, 68])一眼看过去,男生样本集中分布在右上区域,女生样本在左下区域。那个172cm、68kg的新人正好卡在两组数据的交界处,所以你光靠“感觉”也能猜个大概——但我们要用代码让这个“感觉”变得可计算。
2.3 搭建运行环境:Jupyter Notebook最省心
我建议你打开Jupyter Notebook来跟这篇文章,因为KNN教学非常适合一格一格地跑:每一步都能看到距离数组、邻居下标这些中间结果。你要是用普通IDE写.py文件也可以,但记得用 print 多输出中间信息,别一口气把整个脚本闷到底。
先确认环境没问题:
import sys import numpy as np import matplotlib.pyplot as plt print("Python版本:", sys.version) print("NumPy版本:", np.__version__) print("Matplotlib版本:", plt.matplotlib.__version__)我这边测试用的是Python 3.9以上版本、NumPy 1.20以上版本,功能上没什么特殊要求,只要不是远古版本都能跑。装环境这件事就不展开说了,用哪个包管理器顺手装哪个。
3. 手写KNN核心代码,每一步都拆给你看
3.1 第一步:写欧氏距离函数
先别急着看整段代码,我们把距离计算单拎出来。欧氏距离的公式是:
[ d = \sqrt{(x_1 - x_2)^2 + (y_1 - y_2)^2} ]
推广到多维就是每个维度上相减、平方、求和、再开根号。在NumPy里,这四步可以写成一行:
def euclidean_distance(x1, x2): diff = x1 - x2 # 对应维度相减 squared = diff ** 2 # 每个差值平方 total = np.sum(squared) # 所有维度求和 return np.sqrt(total) # 开根号得到最终距离你可能会问:为什么要拆成四行写,不能一行搞定?对初学者来说,四行才是“人话”。一行写法虽然在高手眼里很简洁,但你把每步拆开,结合注释看,就能理解** 2是平方、np.sum是求和。等你看懂了,再缩成一行也不迟。
用我们手头的数据验算一下:[172, 68] 和 [160, 55] 的距离是多少?
diff = [172-160, 68-55] = [12, 13] squared = [144, 169] total = 313 distance = sqrt(313) ≈ 17.69这17.69到底是什么单位?严格说,它是由“身高差12cm”和“体重差13kg”混合出来的一个综合距离,没有现实物理单位。但它能稳定地衡量两个样本之间的相似程度。距离越小,说明身高体重越接近,也就越有可能是同一个性别群体。
3.2 第二步:算出所有距离,找到最近的K个邻居
接下来遍历全部10个训练样本,分别计算新样本到它们的距离,并把结果打印出来:
distances = [] for x in X_train: distances.append(euclidean_distance(x_new, x)) distances = np.array(distances) print("每个训练样本与x_new的距离:", np.round(distances, 2))输出大致是:
[17.69 12.21 18.03 6.32 6.08 11.31 18.38 25.61 23.32 18.11]看一眼这个数组,最小距离大概是6.08(对应样本 [175, 70]),然后是6.32(对应 [170, 66])、11.31(对应 [180, 80])。如果K=3,那么这三个样本就是最近邻居。
但是循环里记下距离还不够,我们还要知道这些距离对应原数组里的哪个样本。Python里有个常用的排序函数,叫np.argsort,它对新手来说稍微有点绕。它返回的不是排序后的值,而是“排序后每个元素在原数组中的下标”。
举例来说:
arr = np.array([15, 3, 8]) np.argsort(arr) # 结果是 [1, 2, 0]意思是按从小到大排列,排在最前面的是原数组下标1的元素(数值3),第二是下标2的元素(数值8),第三是下标0的元素(数值15)。我们之所以需要下标,是因为只有拿到下标,才能去 y_train 里查对应样本的标签是男还是女。
所以完整代码是:
sorted_indices = np.argsort(distances) print("按距离从近到远排序后的样本下标:", sorted_indices) k = 3 nearest_indices = sorted_indices[:k] print("距离最近的", k, "个邻居下标:", nearest_indices) for i in nearest_indices: print(f"样本{i}:身高{X_train[i][0]}, 体重{X_train[i][1]},标签{y_train[i]},距离{distances[i]:.2f}")你运行之后能看到类似这样:
样本4:身高175, 体重70,标签1,距离6.08 样本3:身高170, 体重66,标签0,距离6.32 样本5:身高180, 体重80,标签1,距离11.31到这里,最近K个邻居已经找到了。顺序是:男生、女生、男生。K=3的话,男生的票数是2,女生1,所以预测结果为男生。
3.3 第三步:邻居投票,输出预测结果
投票在代码里怎么实现?最直接的办法是统计 y_train 里那三个邻居标签各自出现的次数。
nearest_labels = y_train[nearest_indices] print("邻居标签数组:", nearest_labels) labels, counts = np.unique(nearest_labels, return_counts=True) print("出现过的标签:", labels) print("每个标签出现次数:", counts) pred = labels[np.argmax(counts)] print("最终预测类别:", pred)np.unique的作用是去重并排序。return_counts=True会额外返回每个去重值出现的次数。np.argmax(counts)返回的是 counts 数组里最大值所在的位置,也就是众数对应的标签。
在我的模拟数据上,这三个邻居的标签是[1, 0, 1],去重后标签是[0, 1],次数是[1, 2],argmax指向次数2,对应标签1。最终预测结果是:男生。
这里先留一个悬念:如果K取4,邻居标签会变成[1, 0, 1, 1],还是男生赢;但某些数据分布下,K取偶数就可能出现“一边两票、另一边两票”的平票局面。平票怎么处理?第五部分会讲。
3.4 第四步:封装成完整函数,两行代码出结果
现在把前三步合成一个函数,以后想预测新样本,一行代码调用就行:
def knn_predict(X_train, y_train, x_new, k=3): distances = np.array([euclidean_distance(x, x_new) for x in X_train]) sorted_indices = np.argsort(distances) nearest_indices = sorted_indices[:k] nearest_labels = y_train[nearest_indices] labels, counts = np.unique(nearest_labels, return_counts=True) pred = labels[np.argmax(counts)] return pred, distances, nearest_indices调用方式:
pred, _, _ = knn_predict(X_train, y_train, x_new, k=3) print("K=3预测结果:", "男生" if pred == 1 else "女生")函数返回三个东西:预测标签、全部距离、最近邻居下标。后面画图的时候还要用到后两个,所以一并返回。
写到这里,一个可运行的KNN已经成型了。它跟scikit-learn里的 KNeighborsClassifier 核心逻辑是一致的,只是没有做加速优化和边界处理。接下来我们用画图把整个过程铺开看看。
4. 画图验证,让预测过程被眼睛看见
4.1 把训练数据画成散点图
先把10个训练样本画出来,红色点代表女生,蓝色点代表男生,金色五角星放着待预测的新样本:
plt.figure(figsize=(8, 6)) plt.scatter(X_train[y_train == 0, 0], X_train[y_train == 0, 1], c='salmon', edgecolors='black', s=120, label='女生') plt.scatter(X_train[y_train == 1, 0], X_train[y_train == 1, 1], c='cornflowerblue', edgecolors='black', s=120, label='男生') plt.scatter(x_new[0], x_new[1], c='gold', marker='*', s=300, edgecolors='black', label='待预测样本') plt.xlabel('身高 (cm)') plt.ylabel('体重 (kg)') plt.legend() plt.grid(alpha=0.3) plt.show()图中能明显看出男生集中在右上区域,女生在左下区域。那个金色五角星悬在两组之间,略微偏上。这就是KNN的“决策依据”空间:点与点之间的欧氏距离直接对应平面上的直线长度。
4.2 把最近邻居的连线画出来
光画点还不够,我们把选中的K个邻居和新样本之间连上虚线,一眼就能看到“谁在投票”:
pred, distances, nearest_indices = knn_predict(X_train, y_train, x_new, k=3) plt.figure(figsize=(8, 6)) plt.scatter(X_train[y_train == 0, 0], X_train[y_train == 0, 1], c='salmon', edgecolors='black', s=120, label='女生') plt.scatter(X_train[y_train == 1, 0], X_train[y_train == 1, 1], c='cornflowerblue', edgecolors='black', s=120, label='男生') plt.scatter(x_new[0], x_new[1], c='gold', marker='*', s=300, edgecolors='black', label='待预测样本') for idx in nearest_indices: plt.plot([x_new[0], X_train[idx, 0]], [x_new[1], X_train[idx, 1]], color='gray', linestyle='--', linewidth=1.2) plt.xlabel('身高 (cm)') plt.ylabel('体重 (kg)') plt.legend() plt.grid(alpha=0.3) plt.show()你会看到三条灰色虚线从五角星出发,分别连到三个邻居。线的长度就是欧氏距离。肉眼扫一下:两条短线连向蓝色点,一条连向红色点,投票结果自然就是多数派的蓝色。
可视化的意义在于,它能帮你验证代码没写错。你在纸上画一遍、在代码里跑一遍、在图上再看一遍,三者的结论完全一致,KNN的核心机制就彻底过关了。
4.3 用scikit-learn交叉验证手写结果
手写完了,再拿现成库验证一遍,防止自己哪里写岔了:
from sklearn.neighbors import KNeighborsClassifier knn_sk = KNeighborsClassifier(n_neighbors=3) knn_sk.fit(X_train, y_train) sk_pred = knn_sk.predict(x_new.reshape(1, -1)) print("手写KNN预测结果:", pred) print("sklearn预测结果:", sk_pred[0])这里有个细节要注意:sklearn要求输入是二维数组,所以x_new.reshape(1, -1)把原本的[172, 68]变成[[172, 68]]。如果你的新样本有多个,直接传二维数组即可。
两个结果应该完全一致。原因也很简单:KNeighborsClassifier的fit阶段其实没在“训练”什么权重,它只是把训练数据存起来了,等predict的时候现算距离。这也是为什么KNN被称为懒惰学习:真正的计算发生在预测阶段,而不是训练阶段。当然,scikit-learn的实现里还有KD树、Ball Tree这类空间索引加速机制,处理大样本时比我们这版朴素写法快得多,但核心思想完全一样。
5. 小白最容易踩的四个坑,我替你踩了一遍
先说明白,下面这些坑是我在实际带朋友学KNN时发现的高频问题,有些在10条样本的小demo上不明显,但一上真实数据集就会原形毕露。
5.1 坑一:K值随便拍脑袋
最常见的错误就是K值不经过思考直接填1。K=1的问题在于决策完全由最近一个样本决定,这个样本一旦是个离群点,模型就跟着翻了。在我们的数据里,如果待预测点附近恰好有一个“女汉子”和一群男生,K=1可能给出女生结果,K=5却给男生结果。谁更合理?通常K大一点更稳。
怎么选K?最简单实用的办法是交叉验证。把训练数据切几份,轮流拿一份当验证集,剩下的当训练集,看哪个K准确率更高。新手阶段也可以从K = sqrt(样本数)附近开始尝试,样本数10的话,sqrt(10)约等于3.16,那就试试3和5。
我建议你亲手把这段跑一遍:
for k in [1, 3, 5, 9]: pred, _, _ = knn_predict(X_train, y_train, x_new, k=k) print(f"K = {k} → 预测结果 {'男生' if pred == 1 else '女生'}")你会发现K从1到9输出可能都相同,也可能在某一个点突变。这就是K值敏感性的直观体现。
5.2 坑二:不标准化特征,量纲悄悄操纵结果
这是新手最容易忽略、却影响巨大的问题。身高是用厘米表示的,数值一百多;体重用公斤表示,数值几十。两者直接算欧氏距离,身高的差异天然主导了距离值。什么意思?身高差1cm和体重差1kg,在距离公式里贡献一样大的平方,但现实中1cm的身高差异和1kg的体重差异根本不是同一个量级。
更刺激的是,如果你把身高的单位从厘米改成米,数值从172变成1.72,那么身高维度对整个距离的贡献会被压得非常小,预测结果就可能反转。同一个道理,同一批人,只换了个单位,输出就变了,这显然不对劲。
解决办法是标准化。最常用的是Z-score标准化:每个特征减去均值、除以标准差,让所有特征都变成“均值为0、方差为1”的分布。scikit-learn里直接有现成类:
from sklearn.preprocessing import StandardScaler scaler = StandardScaler() X_scaled = scaler.fit_transform(X_train) x_new_scaled = scaler.transform(x_new.reshape(1, -1)) pred_scaled, _, _ = knn_predict(X_scaled, y_train, x_new_scaled.reshape(-1), k=3) print("标准化后的预测结果:", "男生" if pred_scaled == 1 else "女生")注意,fit_transform用在训练数据上,transform用在待预测数据上。千万别对训练集和新样本分别 fit 两次,那样标准化尺度就不一致了。
5.3 坑三:平票了不知道怎么办
当K取偶数时,很容易出现两个类别票数相等的情况。比如K=4,邻居标签是 [0, 1, 0, 1],那就一班投个2比2平手,函数里np.argmax会直接返回最先出现的那个类别,相当于“随机偏袒”,不够严谨。
处理平票常见有三种方案:
- 方案一:把K设成奇数。简单粗暴,从源头上减少平票概率。
- 方案二:距离加权投票。让离得近的邻居话语权更大,远一点的邻居话语权小,这样即使票数平了,总权重不一定平。
- 方案三:实际业务中如果平票,可以标为“不确定”,交给人工处理。
加权投票代码长这样:
def knn_predict_weighted(X_train, y_train, x_new, k=4): distances = np.array([euclidean_distance(x, x_new) for x in X_train]) nearest_indices = np.argsort(distances)[:k] score = {} for idx in nearest_indices: label = y_train[idx] dist = distances[idx] weight = 1.0 / (dist + 1e-6) # 距离越近,权重越大,加极小值防除零 score[label] = score.get(label, 0) + weight return max(score, key=score.get)原理很好理解:距离近的邻居用较大的 1/d 当权重,距离远的用较小的权重。这样即便两个类别邻居数量相等,距离更近的那一方通常会赢,符合“越近越像”的直觉。
5.4 坑四:直接上真实数据集却不做检查
把demo跑通之后,很多人会兴奋地拿一堆真实数据来预测,结果发现效果稀烂,然后开始怀疑算法。其实问题往往出在数据本身。
第一个问题是类别不平衡。比如100个A类和5个B类,K取9的时候,就算最近的全是B类,也会被远处一大片A类拖下水,因为B类根本凑不齐9个邻居。这种情况下得考虑过采样、欠采样,或者至少别用太大的K。
第二个问题是样本量太少。K一旦大于某个类别的总样本数,投票就自动偏向多数类,模型失去意义。所以K千万别超过样本量,也别超过最少数类别的样本量。
第三个问题是高维数据。当特征维度从2变成20、50甚至更多,欧氏距离会变得不那么直观,也就是所谓的“维数灾难”。新手阶段建议先用二维三维数据练手,理解KNN的空间直觉,之后再考虑降维或者尝试其他距离度量,比如曼哈顿距离、余弦相似度等。
一句话总结坑四:先在小而干净的数据上把KNN跑通,再逐步增加复杂度。直接拿垃圾数据测试算法,然后赖算法不好用,这是新手最容易陷入的误区。
我在带零基础朋友学这个demo时发现,只要把每个中间结果打印出来,再配一张连线图,几乎人人都能在半小时内理解KNN的完整逻辑。比起对着巨厚的教科书啃理论,这种“算距离、找邻居、投个票”的三步体验,才是入门机器学习最好的姿势。你把这套流程跑通之后,下一步可以试着拿它去跑开源数据集,或者换曼哈顿距离看结果变化——但那是后话了,先把基础代码吃透再说。