资讯详情

KNN算法入门指南:从原理到手写代码与调参实战

📅 2026/10/10 4:33:40 | 华诺云谱 👁 阅读
KNN算法入门指南:从原理到手写代码与调参实战
1. 为什么我把KNN列为机器学习的第一门课提到KNN算法如果你是一个刚想跨进机器学习大门的初学者很可能已经在教程列表里看到过这个名字。KNN的全称是K-Nearest Neighbors中文一般叫K近邻算法。它是整个机器学习领域里最容易被理解、也最不需要“技术负担”的算法之一同时它又意外地能解决不少实际问题。我见过太多朋友一开始就被神经网络、梯度下降这些概念劝退但KNN几乎没有这个门槛所以我一直认为它是小白入门机器学习的头号选择。先说说KNN到底在干什么。用一句话概括就是“物以类聚人以群分”。你想想判断一个人大概是做什么职业的最简单的方式是看他身边经常混在一起的是些什么人。KNN就是干这个事的给你一个未知的新样本看看在已有的数据里离它最近的K个样本都是什么类别然后让这K个邻居来投票票数最多的类别就是新样本的预测结果。这个算法能做什么随便举几个例子手写数字识别、垃圾邮件分类、用户画像分组、电影推荐、股票K线形态识别甚至医疗初筛场景里用相似病历做参考KNN都被广泛使用过。它的适用范围很广因为思路足够朴素所以几乎任何“特征表达清晰、数据量适中”的分类问题都可以先拿KNN跑一版用来做基线结果。这篇内容适合谁就是完全没有机器学习基础的小白哪怕你连“特征”“训练集”这些术语都只听过没弄明白也能跟上。我会从最基础的概念讲起给出手写代码的完整过程也讲讲那些文档里面不会写的坑。读完这篇文章后你不只是“看过”KNN而是能自己动手写出一个可以跑的分类器。2. 跑通KNN之前必须弄明白的3个核心概念2.1 特征空间每个样本都是一个点KNN的一切都建立在“特征空间”这个说法上。听起来高级但其实很简单。假设我们要根据身高和体重来分类一个人是否偏胖那么每个人都可以表示成“身高体重”这样一对数字。把身高当成横轴、体重当成纵轴每个人就是坐标平面上的一个点。这个点所在的坐标系就是特征空间。特征不一定是两个可以是三个、十个甚至上百个。比如判断一封邮件是不是垃圾邮件特征可以是“包含‘优惠’的次数”“包含链接的个数”“发件人是否在通讯录里”等等。有多少个特征空间就有多少维。低维的时候我们可以画图理解高维的时候虽然画不出来但计算的逻辑完全一样每个样本在所有特征维度上各有一个坐标值组合起来就是一个向量。把数据看成“空间里的点”KNN的核心操作就变得非常直观新来的样本就是空间里一个不知道标签的点我们要看看它周围最靠近的K个已知点都是谁。所以你看整个KNN算法不需要学习出什么复杂的函数只需要把空间概念建立起来事情就顺了。2.2 距离计算怎么才算“近”要让“最近的K个邻居”成立第一步得定义什么叫“近”。最常用的是欧氏距离也就是我们在中学学过的两点间距离公式。如果两个点在二维空间分别是(x1, y1)和(x2, y2)那它们的距离就是横坐标之差的平方加上纵坐标之差的平方再开根号。推广到n维空间公式形式不变把每个维度上的差值都平方相加再开根号就行。除了欧氏距离还有曼哈顿距离。想象你在一个网格状的城市里只能沿着街道横着走、竖着走不能斜穿。那从一个路口到另一个路口需要走的路径长度就是曼哈顿距离。它计算的是每个维度上差值的绝对值之和。在特征取值比较稀疏或者噪声较多的时候曼哈顿距离有时候比欧氏距离更稳。还有一个概念叫余弦相似度它更关心两个向量的方向是否一致而不是大小是否接近。比如做文本分类的时候每篇文章被表示成由词频构成的向量文章长短不一但内容相似那用余弦相似度就更合适。那实战中到底选哪个我的建议是没有先验知识时优先用欧氏距离因为最符合直觉如果特征维度很高且数据比较稀疏可以试试曼哈顿距离文本场景考虑余弦相似度。不要一个距离用到底多切换一下看看验证集的表现哪个好就用哪个。2.3 K值的选择和“投票”机制K是唯一需要你手动决定的参数它是“取几个邻居来投票”的意思。K1就是只看最近的那一个邻居K3就是看最近的三个邻居按少数服从多数给结果。投票机制本身很简单但K值怎么选很讲究。K太小比如K1新样本很容易被一个离群点带偏模型就变得敏感这就是过拟合的雏形。K太大比如把K设成等于所有样本数量那不管来什么新样本结果都是整个数据集里最多的那个类别模型又趋于无脑。所以K是一个需要调的经验值通常选在3到15之间比较常见具体多少要通过实验来定。后面我专门有一章讲怎么系统地选K这里先有个概念就行。3. 手把手代码实战从零手写一个KNN分类器3.1 造一份可以玩的数据集直接上真实数据集容易让新手把注意力放在数据预处理上反而忽略了KNN本身。我们先自己构造一份简单的二维数据方便可视化、方便理解。假设我们要做这样一个分类任务根据“消费次数”和“平均消费金额”两个特征判断一个用户是“普通用户”还是“高价值用户”。我模拟生成两类各30个点高价值用户集中在特征空间的右上角普通用户集中在左下角。代码如下import numpy as np import matplotlib.pyplot as plt np.random.seed(42) # 类别1: 普通用户消费次数集中在1~5平均金额集中在50~200 class1_x np.random.uniform(1, 5, 30) class1_y np.random.uniform(50, 200, 30) class1 np.column_stack((class1_x, class1_y)) # 类别2: 高价值用户消费次数集中在6~10平均金额集中在300~600 class2_x np.random.uniform(6, 10, 30) class2_y np.random.uniform(300, 600, 30) class2 np.column_stack((class2_x, class2_y)) X np.vstack((class1, class2)) y np.array([0] * 30 [1] * 30) # 0表示普通1表示高价值 plt.scatter(class1[:, 0], class1[:, 1], cblue, label普通用户) plt.scatter(class2[:, 0], class2[:, 1], cred, label高价值用户) plt.xlabel(消费次数) plt.ylabel(平均消费金额) plt.legend() plt.show()这个数据集只有两个特征所以我们可以直接画在平面上。可以看到两类数据有明显的分隔趋势但边界上也有交叉这样才更真实。KNN的任务就是在这片有交叉的区域里对新来的用户给出尽可能合理的判断。3.2 核心代码一计算距离手写KNN的第一步是计算未知样本到每个已知样本的距离。这里我以欧氏距离为例直接广播计算即可不需要写循环。def euclidean_distance(point, X): point: 一维数组新的样本 X: 二维数组所有已知样本 返回一维数组表示point到X中每个样本的欧氏距离 diff X - point squared np.sum(diff ** 2, axis1) return np.sqrt(squared)这段代码做的事情非常朴素把新样本的坐标拿到每一个已知样本坐标上做差平方后按行求和再开根号。最终得到一个和样本数量等长的距离数组。这里为什么推荐用numpy的向量化计算而不是写for循环一是代码短二是在样本量变大时性能差异非常明显。KNN本身就是一个需要反复计算距离的算法每预测一个新样本就要算一遍全部的距离能向量化就向量化这个习惯建议从一开始就养成。3.3 核心代码二找邻居并投票有了距离数组之后接下来的操作就三步排序、取前K个、统计票数。这里排序的时候要注意我们不仅需要距离值还需要知道这个距离对应的是哪个样本的标签所以直接用argsort()取索引。def knn_predict(X_train, y_train, test_point, k5): distances euclidean_distance(test_point, X_train) nearest_indices np.argsort(distances)[:k] nearest_labels y_train[nearest_indices] # 投票: 统计每个类别出现的次数 labels, counts np.unique(nearest_labels, return_countsTrue) pred_label labels[np.argmax(counts)] return pred_label就这么几行一个能用的KNN分类器就出来了。让我拆解一下euclidean_distance算出了所有已知样本到测试点的距离np.argsort(distances)[:k]按距离从小到大排序取前K个的索引y_train[nearest_indices]取出这K个邻居的真实标签np.unique(..., return_countsTrue)统计每个标签出现的次数np.argmax(counts)取出现次数最多的那个标签就是预测结果。要注意这里处理的是二分类如果多分类逻辑是一样的投票规则天然支持多类别。只要把y_train里的标签值替换成类别编号即可。3.4 测试一下效果我们造两个新用户来试。假设一个用户消费次数3次、平均金额120元明显靠近普通用户区域另一个用户消费8次、平均金额450元靠近高价值用户区域。test_points np.array([ [3, 120], [8, 450] ]) for p in test_points: pred knn_predict(X, y, p, k5) print(f消费次数{p[0]}, 平均金额{p[1]} - 预测类别: {pred})跑出来的结果应该是左侧输出0、右侧输出1也就是分别被分到普通用户组和高价值用户组符合直观判断。建议你在Jupyter Notebook里把这个结果画出来把测试点标成黑色五角星看看它们周围最近的邻居到底是哪些视觉上的理解会非常深刻。3.5 用真实数据集验证一下手写代码在自制数据上没问题但我还是建议再放到经典数据集上跑一遍。这里用scikit-learn内置的鸢尾花数据集它包含150个样本、4个特征、3个类别是入门机器学习的标准测试题。from sklearn.datasets import load_iris from sklearn.model_selection import train_test_split from sklearn.metrics import accuracy_score iris load_iris() X_iris, y_iris iris.data, iris.target X_train, X_test, y_train, y_test train_test_split( X_iris, y_iris, test_size0.2, random_state42, stratifyy_iris ) # 用我们手写的KNN在全量训练集上做预测 predictions [knn_predict(X_train, y_train, p, k5) for p in X_test] accuracy accuracy_score(y_test, predictions) print(f手写KNN在鸢尾花测试集上的准确率: {accuracy:.4f})通常情况下K5时准确率能到0.93到1.0之间。这个结果在机器学习入门任务里已经很不错了要知道这是没有任何调参、没有特征工程、没有超参数优化的“裸模型”。3.6 用scikit-learn实现同样的事手写一遍是为了理解原理但实际项目中直接用现成库更高效。scikit-learn里的KNeighborsClassifier封装了KNN的全部逻辑还支持并行计算、距离权重等高级选项。from sklearn.neighbors import KNeighborsClassifier clf KNeighborsClassifier(n_neighbors5, metricminkowski, p2) clf.fit(X_train, y_train) score clf.score(X_test, y_test) print(fsklearn KNN准确率: {score:.4f})这里的metricminkowski, p2等价于欧氏距离p1则等价于曼哈顿距离。fit方法在KNN里并不复杂本质上就是把训练数据存储下来。当然真正的库实现会构建空间索引结构以加速预测原理层面和手写一致。4. K值到底怎么选交叉验证才是靠谱的做法4.1 为什么K值这么纠结KNN里面K值几乎是你唯一可以调的旋钮而它又直接决定模型决策边界的平滑程度。想象一下K1时每一个训练样本都被当成一个“势力范围”的中心决策边界会非常曲折紧贴着每个点K很大时边界会变得平滑但也会忽略局部细节。这就是机器学习里经典的偏差和方差权衡在KNN身上的体现。我自己早期踩过一个坑总觉得K越小越“精准”于是用K1去跑一个业务数据训练集准确率接近100%测试集准确率垮掉。原因很简单K1相当于模型把训练集背下来了新样本只要稍有偏离就会被某个离群点带跑。所以千万不要用训练集上的准确率来选K那是自欺欺人。4.2 交叉验证的操作流程选K的正确做法是交叉验证。最简单的叫K折交叉验证把训练数据平均切成K份这里的K是折数注意别和KNN的K混淆我这里改用n_folds称呼每次拿出1份做验证剩下n_folds-1份做训练轮流来一遍。每个候选的KNN的K值都计算它n_folds次验证的平均准确率最后选平均准确率最高的那个。在scikit-learn里可以用GridSearchCV来做清晰且方便from sklearn.model_selection import GridSearchCV, StratifiedKFold from sklearn.neighbors import KNeighborsClassifier param_grid {n_neighbors: [1, 3, 5, 7, 9, 11, 13, 15, 19, 21]} clf KNeighborsClassifier() grid GridSearchCV(clf, param_grid, cv5, scoringaccuracy) grid.fit(X_train, y_train) print(f最佳K值: {grid.best_params_[n_neighbors]}) print(f交叉验证最佳平均准确率: {grid.best_score_:.4f})输出结果里最佳K值通常在5到11之间具体取决于数据集的结构。要注意GridSearchCV默认的逻辑是“在训练集上做交叉验证选参数”选完后你再拿测试集验证一次。这个流程才是规范的训练集内调参测试集只做最终验证防止参数被测试集“偷看”。4.3 绘制K值和准确率的关系曲线如果你不想依赖库也可以手动做一个实验遍历K值从1到30每次都用交叉验证计算平均准确率最后画成曲线。这条曲线通常呈现先上升再下降或者进入平台期的形状。看曲线选K更直观也能帮你判断数据本身的复杂度。import matplotlib.pyplot as plt from sklearn.model_selection import cross_val_score k_range range(1, 31) cv_scores [] for k in k_range: knn KNeighborsClassifier(n_neighborsk) scores cross_val_score(knn, X_train, y_train, cv5) cv_scores.append(scores.mean()) plt.plot(k_range, cv_scores) plt.xlabel(K值) plt.ylabel(交叉验证平均准确率) plt.title(K值与模型表现的关系) plt.show()如果你看到曲线在某个K值后持续下降那很可能不仅是K值变大造成的还可能是特征空间本身噪声太多或者类别重叠严重。这种情况下单纯调K解决不了根本问题需要回到特征工程上想办法。5. 特征缩放KNN最容易忽略的致命细节5.1 量纲不同距离就是乱的这是KNN里最典型的坑没有之一。回到我们刚才的用户分类例子消费次数是1到10的数字平均消费金额是50到600的数字。在计算欧氏距离时(10-1)^2最多81但(600-50)^2是302500那消费次数这个维度的贡献几乎被平均金额完全淹没。也就是说如果两个特征量纲差距悬殊的话距离基本就被数值大的那个特征垄断了KNN的效果自然大打折扣。这不是理论上的“可能出问题”而是你实际跑实验时大概率会踩到的。我在早期的某个项目里特征里既有年龄几十的量级又有年收入几万到几十万的量级KNN准确率一直上不去做了特征缩放后直接提升了十几个百分点。那之后我养成了一个习惯凡是KNN先做特征缩放再谈其他。5.2 标准化和归一化怎么选常见的特征缩放有两种。标准化是把每个特征变成均值为0、方差为1的分布公式很简单(x - mean) / std。归一化是把每个特征缩放到0到1区间公式是(x - min) / (max - min)。scikit-learn里分别对应StandardScaler和MinMaxScaler。那用哪个更好经验法则是如果特征分布近似正态用标准化如果特征有明显边界且你确定上下界有意义用归一化。KNN对缩放方式并不是特别挑剔大部分情况下标准化表现更稳因为它不依赖最大值和最小值这两个极值点不容易被离群值干扰。但你要记住缩放必须是在训练集上计算均值和标准差或最小值和最大值然后用同一套参数去变换测试集绝不能用测试集自己的统计量来缩放否则就是数据泄露。from sklearn.preprocessing import StandardScaler scaler StandardScaler() X_train_scaled scaler.fit_transform(X_train) X_test_scaled scaler.transform(X_test) clf KNeighborsClassifier(n_neighbors5) clf.fit(X_train_scaled, y_train) print(f标准化后KNN准确率: {clf.score(X_test_scaled, y_test):.4f})你可以在鸢尾花数据集上对比标准化的前后准确率。因为鸢尾花本身量纲差异不算大提升可能不明显但换成身高体重这种量纲悬殊的数据效果立竿见影。5.3 维度灾难特征越多未必越好KNN还有一个天生的问题叫维度灾难。随着特征维度增加样本之间的距离会变得越来越“拥挤”点与点之间都差不多远邻居不再具有区分度。比如在低维度空间里最近邻确实很近但在高维空间里“最近”和“最远”的差距会趋近于零KNN的判别能力就会大幅下降。所以KNN在那种只有几个到几十个特征的中小型数据集上表现得最好。遇到几千维的文本向量或者图像像素特征KNN通常不是首选除非你用了一些降维手段或者加速结构。实践中的几条建议如果特征维度超过几十考虑PCA或者特征选择先把维度降下来如果数据量超过十万条KNN的预测速度会明显变慢每个新样本都要计算全部距离这时候可以考虑KD树或球树但数据量极大时还是换用其他模型更实在。6. 手写KNN时容易踩的坑和验证技巧6.1 距离计算时的浮点陷阱手写代码时最容易出问题的不是算法而是距离计算的数值稳定性。比如用欧氏距离时如果特征是标准化之后的每个维度的差值都很小平方累加时可能出现浮点数精度问题。应对办法其实很简单用np.float64类型必要时在距离公式中加一个极小量1e-9防止开根号时的数值溢出。当然在常规数据集上这些很少触发但数据量特别大的时候值得注意。还有一个很隐蔽的问题np.argsort默认返回从小到大的索引这个没问题但如果多个测试样本挨个预测别忘了每预测一个都要重新计算一遍距离。有些人图省事把距离矩阵缓存下来一旦训练集更新缓存就失效了容易制造“结果对不上”的困扰。6.2 预测速度的优化思路KNN在预测阶段的时间复杂度是O(N)N是训练样本数。N一涨预测就慢。遇到这种情况一个代替纯for循环的优化是在手写代码里直接对测试样本批量计算距离矩阵利用numpy的广播机制def knn_predict_batch(X_train, y_train, X_test, k5): # X_test: (m, n), X_train: (N, n) # 计算所有测试点到所有训练点的距离矩阵: (m, N) dist_matrix np.sqrt(((X_test[:, None, :] - X_train[None, :, :]) ** 2).sum(axis-1)) nearest_indices np.argsort(dist_matrix, axis1)[:, :k] nearest_labels y_train[nearest_indices] # 用apply_along_axis或者手动统计投票 preds [] for row in nearest_labels: labels, counts np.unique(row, return_countsTrue) preds.append(labels[np.argmax(counts)]) return np.array(preds)这么做的好处是省掉了逐样本循环一次性把全部距离算出来效率提升明显。当然这只是手写代码的优化方案工业级项目中直接用KD树更可靠。6.3 一个快速验证“代码没写错”的方法新手很容易卡在一个点上手写代码跑出来的结果和sklearn有差异于是怀疑自己哪里写错了。其实绝大多数时候没写错差异来源就两个距离公式的默认参数不同或者平局时投票的处理策略不同。最直接的验证方式是固定K值把手写代码的预测结果和sklearn在相同训练集、相同测试集上的预测结果做逐样本对比统计不一致的样本数量。如果数量为0说明你的实现基本等价如果少量不一致多数是平衡票数时的随机处理差异。这个验证技巧能让你省去大量自我怀疑的时间。7. 从KNN出发下一步该学什么学会了KNN你已经摸到了机器学习的第一块敲门砖。顺着KNN暴露出来的痛点延伸出去就是接下来该学的内容如果你被“K值怎么调”困扰那就去学超参数调优和交叉验证的系统方法比如网格搜索、随机搜索这是机器学习工程化的基础功。如果你发现KNN在数据量变大后跑得慢那可以去了解KD树、球树这些空间索引结构顺便把这个话题延伸到近似最近邻搜索。如果你觉得KNN只会“死记硬背”不太智能那可以去学线性回归、决策树这些算法同样简单但构建了真正的“模型”。等你理解了距离、相似度、偏差方差、交叉验证这些概念再去看支持向量机或神经网络就会发现它们虽然长得不一样但核心思想用的还是这一套语言。我的个人建议是KNN这个算法花三天时间学透就够了——第一天看完原理和手写代码第二天跑几个实验调K值做特征缩放第三天拿一个真实项目里的分类问题试一次完整的建模流程。只要你能完成“数据预处理 交叉验证调参 评估”这一套闭环就已经具备了做机器学习项目的基本能力。之后你想往哪个方向深入都会顺畅很多。最后再分享一个实用小习惯每次用KNN跑完一个项目我都会顺手保存一下训练集的特征均值和标准差以及最优K值方便新数据进来时快速复现预测逻辑。这看起来是个小细节但真到上线部署的时候能省掉不少重推流程的麻烦。好了KNN入门的内容就到这里剩下的时间留给你去写代码。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑