资讯动态

KNN代码教学:从原理到手写实现,小白也能轻松看懂

发布时间:2026/10/14 22:05:36 来源:尧图企业网站定制
把「KNN代码教学小白也能轻松看懂」当作标题来写压力挺大。因为很多人一看到机器学习先怵了觉得必须啃完高数和线性代数才能动手。但KNNK-Nearest NeighborsK个最近邻居是少数几个例外它靠的全是生活常识物以类聚人以群分。你身边最近的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表示“拉几个邻居来投票”。K1的时候完全看最近那一个人很容易被个别离群点带偏K太大也不行比如数据一共10条你非要用K9那少数派基本没有任何翻盘机会因为大部分邻居都是多数类。K值行为特征通俗解释K1只看最近邻身边一个朋友说啥就是啥可能被忽悠K3 或 K5新手首选区间三五个朋友连蒙带猜结果比较稳K9 及以上平滑但迟钝人多了容易随大流丢细节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]。如果K3那么这三个样本就是最近邻居。但是循环里记下距离还不够我们还要知道这些距离对应原数组里的哪个样本。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个邻居已经找到了。顺序是男生、女生、男生。K3的话男生的票数是2女生1所以预测结果为男生。3.3 第三步邻居投票输出预测结果投票在代码里怎么实现最直接的办法是统计 y_train 里那三个邻居标签各自出现的次数。nearest_labels y_train[nearest_indices] print(邻居标签数组, nearest_labels) labels, counts np.unique(nearest_labels, return_countsTrue) print(出现过的标签, labels) print(每个标签出现次数, counts) pred labels[np.argmax(counts)] print(最终预测类别, pred)np.unique的作用是去重并排序。return_countsTrue会额外返回每个去重值出现的次数。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, k3): 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_countsTrue) pred labels[np.argmax(counts)] return pred, distances, nearest_indices调用方式pred, _, _ knn_predict(X_train, y_train, x_new, k3) print(K3预测结果, 男生 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], csalmon, edgecolorsblack, s120, label女生) plt.scatter(X_train[y_train 1, 0], X_train[y_train 1, 1], ccornflowerblue, edgecolorsblack, s120, label男生) plt.scatter(x_new[0], x_new[1], cgold, marker*, s300, edgecolorsblack, label待预测样本) plt.xlabel(身高 (cm)) plt.ylabel(体重 (kg)) plt.legend() plt.grid(alpha0.3) plt.show()图中能明显看出男生集中在右上区域女生在左下区域。那个金色五角星悬在两组之间略微偏上。这就是KNN的“决策依据”空间点与点之间的欧氏距离直接对应平面上的直线长度。4.2 把最近邻居的连线画出来光画点还不够我们把选中的K个邻居和新样本之间连上虚线一眼就能看到“谁在投票”pred, distances, nearest_indices knn_predict(X_train, y_train, x_new, k3) plt.figure(figsize(8, 6)) plt.scatter(X_train[y_train 0, 0], X_train[y_train 0, 1], csalmon, edgecolorsblack, s120, label女生) plt.scatter(X_train[y_train 1, 0], X_train[y_train 1, 1], ccornflowerblue, edgecolorsblack, s120, label男生) plt.scatter(x_new[0], x_new[1], cgold, marker*, s300, edgecolorsblack, label待预测样本) for idx in nearest_indices: plt.plot([x_new[0], X_train[idx, 0]], [x_new[1], X_train[idx, 1]], colorgray, linestyle--, linewidth1.2) plt.xlabel(身高 (cm)) plt.ylabel(体重 (kg)) plt.legend() plt.grid(alpha0.3) plt.show()你会看到三条灰色虚线从五角星出发分别连到三个邻居。线的长度就是欧氏距离。肉眼扫一下两条短线连向蓝色点一条连向红色点投票结果自然就是多数派的蓝色。可视化的意义在于它能帮你验证代码没写错。你在纸上画一遍、在代码里跑一遍、在图上再看一遍三者的结论完全一致KNN的核心机制就彻底过关了。4.3 用scikit-learn交叉验证手写结果手写完了再拿现成库验证一遍防止自己哪里写岔了from sklearn.neighbors import KNeighborsClassifier knn_sk KNeighborsClassifier(n_neighbors3) 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。K1的问题在于决策完全由最近一个样本决定这个样本一旦是个离群点模型就跟着翻了。在我们的数据里如果待预测点附近恰好有一个“女汉子”和一群男生K1可能给出女生结果K5却给男生结果。谁更合理通常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, kk) print(fK {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), k3) print(标准化后的预测结果, 男生 if pred_scaled 1 else 女生)注意fit_transform用在训练数据上transform用在待预测数据上。千万别对训练集和新样本分别 fit 两次那样标准化尺度就不一致了。5.3 坑三平票了不知道怎么办当K取偶数时很容易出现两个类别票数相等的情况。比如K4邻居标签是 [0, 1, 0, 1]那就一班投个2比2平手函数里np.argmax会直接返回最先出现的那个类别相当于“随机偏袒”不够严谨。处理平票常见有三种方案方案一把K设成奇数。简单粗暴从源头上减少平票概率。方案二距离加权投票。让离得近的邻居话语权更大远一点的邻居话语权小这样即使票数平了总权重不一定平。方案三实际业务中如果平票可以标为“不确定”交给人工处理。加权投票代码长这样def knn_predict_weighted(X_train, y_train, x_new, k4): 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, keyscore.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的完整逻辑。比起对着巨厚的教科书啃理论这种“算距离、找邻居、投个票”的三步体验才是入门机器学习最好的姿势。你把这套流程跑通之后下一步可以试着拿它去跑开源数据集或者换曼哈顿距离看结果变化——但那是后话了先把基础代码吃透再说。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑