如果你刚开始学机器学习,强烈建议把 kNN 作为第二个上手算法。它足够简单,几乎不需要任何数学门槛,却能帮你把“距离”“特征空间”“过拟合”“交叉验证”这些最核心的概念一次性串起来。作为机器学习系列的第二篇,这篇我会从 kNN 的思想讲起,一直写到用 scikit-learn 完成分类与回归任务,再穿插一些只有真正跑过数据才会注意到的坑。不管你是刚转型做算法,还是想快速落地一个分类 Demo,读这一篇基本就够把 kNN 用起来了。
1. 先搞懂 kNN 在做什么:从物以类聚到最近邻投票
1.1 一个不需要“训练”的分类思路
kNN 的全称是 k-Nearest Neighbors,中文叫 k 近邻算法。它的核心思想一句话就能讲完:一个新样本属于哪个类别,看它周围的 k 个已知样本大多数属于哪个类别。
这和我们日常判断事物的方式非常像。你面前放着一个圆形、红色、闻起来有甜味的果子,你不需要先建立一个“什么叫做桃子”的复杂模型,只要回想一下:以前见到的桃子和它长得像,李子和它差得远,那大概率就是桃子。kNN 把这个直觉翻译成了数学语言:把每个样本看成特征空间里的一个点,新样本进入这个空间后,找到离它最近的 k 个点,让这些老邻居投票决定新样本的归属。
这里有个很重要的点,很多人第一次接触时会忽略:kNN 没有传统意义上的“训练过程”。它不会学习出一组权重,也不会生成一棵树,它只是把训练数据原封不动地存下来。真正计算发生在预测阶段——每来一个新样本,都要和所有已存样本逐一算距离,再挑出最近的 k 个邻居。这种“训练时偷懒、预测时拼命”的模式,在机器学习里专门有个名字,叫惰性学习(Lazy Learning)。
因为算法本身不产生产品,所以它的优点是实现极其简单、可解释性强,缺点也很明显:样本量一上来,预测速度会直线下降。这也是我们在实际项目里必须权衡的地方。
1.2 距离度量:选对尺子比什么都重要
既然 kNN 是靠“谁离得近”来判断的,那“距离”怎么算就成了算法的核心。scikit-learn 里最常用的是闵可夫斯基距离(Minkowski Distance),它有一个参数 p 来切换具体形式:
- 当 p = 2 时,就是欧氏距离(Euclidean Distance)。想象在平面上用直尺量两点之间的直线长度,这是最直觉的距离。
- 当 p = 1 时,就是曼哈顿距离(Manhattan Distance)。想象在城市网格中从 A 点走到 B 点,只能沿着横竖街道走,不能斜穿街区,走过的总路程就是曼哈顿距离。
欧氏距离是大多数场景下的默认选择,因为它几何意义清晰,对连续型特征表现平稳。但要注意,欧氏距离对特征的量纲极其敏感,这点我后面会专门展开说。曼哈顿距离在某些高维场景下反而更鲁棒,因为它不像欧氏距离那样会被个别特征的大偏差带跑偏。scikit-learn 的 KNeighborsClassifier 里,参数 p 默认是 2,你可以通过修改 p 来决定用哪个距离。
除了闵可夫斯基距离,kNN 还支持余弦相似度、切比雪夫距离等等,但实际业务中用到最多、效果最稳定的还是欧氏和曼哈顿。刚开始做项目,不需要把距离度量玩出花,先默认 p=2,等你发现模型效果不对劲,再考虑是不是距离定义的问题。
1.3 决策规则与 k 值:少数服从多数,但选几个人有讲究
找到 k 个邻居之后,怎么决定新样本的类别?分类任务用多数投票:k 个邻居里哪个类别出现次数多,就预测为哪个类别。回归任务则用平均值:k 个邻居的目标值的算术平均就是预测结果。这是 kNN 最简单的决策规则。
但这个 k 到底取多少,是整个算法里最让人纠结的地方。k 选小了,比如 k=1,模型会死记硬背训练数据,一个噪声点就能把边界搞得七扭八歪,这在机器学习里叫过拟合。k 选大了,比如 k=50,邻居里可能混入大量远距离甚至不同类别的样本,决策边界变得过度平滑,关键细节被抹掉,这又成了欠拟合。
我自己的经验是:k 的取值要结合样本量和类别分布来看。样本量几千的情况下,先从 k=5 或 k=7 起步,再用交叉验证找最优值,这比拍脑袋猜靠谱得多。另外,二分类问题喜欢把 k 设为奇数,原因很简单,可以尽量避免平票。多分类时平票概率更高,有时候还得准备一个小规则来打破平局,比如按距离加权投票。
2. 动手前必做的两件事:标准化与数据划分
2.1 特征标准化不是可选项,是生死线
kNN 这种基于距离的算法,最怕的就是特征量纲不一致。举个特别现实的例子:假设你做一个用户分类模型,特征里有“年龄”和“年收入”。年龄范围是 20 到 60,年收入范围是 5 万到 100 万。如果你直接拿原始数值去算欧氏距离,那么“年收入”的数值尺度会完全碾压“年龄”,距离的大小基本上只由收入决定,年龄这个特征等于是白加了。
解决办法就是对特征做标准化(Standardization)。scikit-learn 里最常用的是 StandardScaler,它的做法是把每个特征减去均值、再除以标准差,让所有特征都变成均值为 0、方差为 1 的标准正态分布。这样无论原来的单位是元、岁还是厘米,都拉到同一个尺度上比较。
这里必须提醒一个新手很容易犯的错误:标准化只能用训练集的数据去 fit,然后用同一个 scaler 去 transform 测试集。千万别拿全部数据 fit 完之后再划分训练集和测试集,这会造成数据泄露(Data Leakage),让测试集的结果虚高。最干净的做法是把 StandardScaler 和模型放进同一个 Pipeline,让它自动完成“fit 训练集、transform 测试集”这个过程。
2.2 训练集/测试集划分与交叉验证
数据划分对 kNN 的影响比很多人想象得大。kNN 预测时需要遍历所有训练样本,如果训练集太大,每次预测都慢得让人抓狂;如果训练集太小,噪声的干扰又会被放大。常规做法是把数据按 7:3 或 8:2 拆成训练集和测试集,scikit-learn 的 train_test_split 一行就能搞定。
但你需要注意一个参数:stratify。它叫分层抽样,作用是在切分数据时保持训练集和测试集中各类别的比例与原数据一致。比如原数据里 A 类占 80%、B 类占 20%,那切分后训练集和测试集也应该接近这个比例。如果不加这个参数,在类别不均衡的数据集上,很可能出现测试集里全是 A 类、B 类一个都见不到的情况,评估结果完全没有参考价值。
更严谨的做法是交叉验证(Cross Validation)。把训练集再切成 K 份,轮流拿其中 1 份做验证、剩下 K-1 份做训练,最后把 K 次的结果平均。这种做法对 kNN 尤其重要,因为它能比较稳定地评估不同 k 值的效果,而不依赖某一次运气好的数据切分。调参时常用的 GridSearchCV 内部就是交叉验证的机制。
2.3 先用小数据验证思路
我不建议你一开始就在几十万行的大数据集上跑 kNN,那样光是调参就要等到怀疑人生。更稳的做法是:先取一小部分数据,比如几千条,跑通完整流程,再用全量数据去验证。这样既能快速确认代码逻辑没问题,也能提前估算全量数据下 kNN 的预测耗时,避免上线后才发现性能扛不住。
3. 用 scikit-learn 实现 kNN:从最简代码到参数寻优
3.1 最小可用代码:五分钟跑通一个分类任务
我直接给你一段可以完整跑通的代码,用的是 scikit-learn 自带的鸢尾花数据集,三分类问题,每个样本有 4 个特征。这段代码包含了标准化、数据划分、模型训练和评估,算是 kNN 的最小可用模板:
from sklearn.datasets import load_iris from sklearn.model_selection import train_test_split from sklearn.preprocessing import StandardScaler from sklearn.pipeline import Pipeline from sklearn.neighbors import KNeighborsClassifier from sklearn.metrics import accuracy_score, classification_report data = load_iris() X, y = data.data, data.target X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.3, random_state=42, stratify=y ) pipeline = Pipeline([ ("scaler", StandardScaler()), ("knn", KNeighborsClassifier(n_neighbors=5)) ]) pipeline.fit(X_train, y_train) y_pred = pipeline.predict(X_test) print(accuracy_score(y_test, y_pred)) print(classification_report(y_test, y_pred))我逐行说一下关键点。load_iris 加载的是经典的开源示例数据,不需要你准备任何文件。train_test_split 里的 random_state 固定成 42,是为了让结果可复现,你换一个数字会得到不同的切分结果,但只要你把代码发给别人,别人也跑这个数字,就能得到一样的结论。stratify=y 指定按 y 的类别比例进行分层切分,三分类数据量不大,这步尤其重要。
Pipeline 是这段代码里最值钱的部分。它把标准化和分类器串成一条流水线,调用 pipeline.fit 时自动完成训练集标准化和模型训练,调用 pipeline.predict 时自动用训练时的参数去标准化新数据。这样你永远不用担心“测试集用了训练集统计量”这种低级错误。
跑完这段代码,准确率一般能到 0.95 左右。如果你是第一次接触这个结果,会意识到一个问题:一个没有“训练模型过程”的算法,居然能表现得这么好,那机器学习的门槛是不是比想象中低?是的,但低门槛也意味着它不会替你做特征工程和数据处理,这两件事才是真正的分水岭。
3.2 三个关键参数:n_neighbors、weights、p
KNeighborsClassifier 最值得关注的参数有三个。
第一个是 n_neighbors,也就是 k 值。它控制决策边界的平滑程度。k 小,边界复杂、拟合度高;k 大,边界平滑、泛化性好。我建议你画一张不同 k 值下的决策边界图,直观感受一下这个变化。画图时用前两个特征做二维可视化就够了,不用把全部特征画出来。
第二个是 weights,取值有 uniform 和 distance 两种。uniform 代表所有邻居投票权重一样,distance 代表距离越近的邻居话语权越大。如果数据分布比较均匀,uniform 就够了;如果样本密度不均、有些区域邻居很稀疏,用 distance 往往更稳。它的本质是从“少数服从多数”升级成“近者说话更算数”,更符合直觉。
第三个是 p,前面说过,控制距离度量类型。p=2 是欧氏距离,p=1 是曼哈顿距离。我测试下来的经验是,在标准化之后,p=2 对连续特征的表现通常好于 p=1,但如果你特征里有大量稀疏或者零值,曼哈顿距离有时反而更好。具体用哪个,交给网格搜索去比较。
还有一个参数 algorithm 也值得了解,它决定计算邻居的方式:brute 是暴力扫描所有点,kd_tree 是建立 KD 树加速,ball_tree 是建立球树加速,auto 让算法自动选择。样本量小的时候选什么都不重要,样本量大了,auto 通常会帮你选到树上方法。这个我放到后面性能优化部分细说。
3.3 用 GridSearchCV 把参数搜索交给机器
手动试参数费时费力,scikit-learn 提供了 GridSearchCV,可以一次性把参数组合全部跑一遍。下面是配合 Pipeline 使用的写法:
from sklearn.model_selection import GridSearchCV param_grid = { "knn__n_neighbors": [3, 5, 7, 9, 11], "knn__weights": ["uniform", "distance"], "knn__p": [1, 2] } grid_search = GridSearchCV( pipeline, param_grid, cv=5, scoring="accuracy" ) grid_search.fit(X_train, y_train) print(grid_search.best_params_) print(grid_search.best_score_)这里有个新手容易懵的地方:为什么参数名是 "knn__n_neighbors" 而不是 "n_neighbors"?因为 Pipeline 里组件名是 "knn",scikit-learn 用双下划线把组件名和参数名连接起来,这样才能识别这个参数属于 Pipeline 里的哪个环节。如果你在 Pipeline 里把分类器命名为 "clf",那参数名就变成 "clf__n_neighbors"。这个双下划线的规则是所有 Pipeline 参数搜索通用的,一定要记住。
GridSearchCV 的 cv=5 表示做五折交叉验证,也就是说每一种参数组合都会训练 5 次、评估 5 次然后取平均。上面这个网格一共 5 x 2 x 2 = 20 种组合,每种组合跑 5 折,总共要训练 100 次。在小数据集上看不出问题,数据集一大,光是这个网格搜索就能让你等上几个小时。所以实际项目中不要无脑枚举,先用少量参数粗略搜一遍,锁定大致范围后再细化搜索。
如果你的数据集上要搜的参数特别多,可以考虑用 RandomizedSearchCV,它会在参数空间里随机采样,不一定找到全局最优,但常常能在更短的时间里找到足够好的参数。特别是参数组合数量超过几百种时,RandomizedSearchCV 的优势明显。
3.4 kNN 也能做回归:看一下 KNeighborsRegressor
kNN 不只是分类算法,它同样能解决回归问题,对应的是 KNeighborsRegressor。回归的思路是:找到最近的 k 个样本,把它们的平均值作为预测结果;如果用 weights='distance',就是距离加权的平均值。
举个简单例子,你想预测一套房子的价格,已知成交样本里有 5 套离它最近的房子,价格分别是 100 万、110 万、95 万、120 万、108 万,那预测值就是这 5 个数的平均值。如果用距离加权,离得近的那套 110 万说话分量更重,预测结果会更贴近局部行情。
KNeighborsRegressor 的代码和分类器几乎一模一样,区别只在于评估指标。分类看准确率,回归看均方误差(MSE)、均方根误差(RMSE)或 R 方。这里要注意一个坑:回归的预测值可能超出训练数据的取值范围,因为它是邻居目标值的组合,不可能凭空产生一个比所有邻居都高或者都低的值。如果你的业务场景对极值预测很敏感,比如预测洪水峰值、极端温度,kNN 回归就不太合适。
4. 实战中踩过的坑与排查思路
4.1 数据维度过高,kNN 迅速失效
如果你拿 kNN 跑高维数据,比如几百个特征,效果往往会变得很糟。这不一定是数据有问题,而是因为在高维空间里,所有点之间的距离都会趋向于差不多大。想象一下:在二维平面里,最近的两个点可能确实离你很近;但到了 100 维空间,每一个点都像是被扔进了巨大的空旷空间,最近邻和次近邻之间的距离差异变得微乎其微,kNN 的“邻居”概念就失去了意义。这在机器学习里叫维数灾难(Curse of Dimensionality)。
所以实际使用 kNN 时,你要先看一眼特征数量。如果原始特征超过几十个,建议先用 PCA 或特征选择做降维,把数据压缩到低维空间后再交给 kNN。不要觉得降维会丢信息,对于基于距离的算法来说,丢掉一部分噪声维度反而能显著提升稳定性。
4.2 量纲不一致:钱永远在主导你的模型
前面标准化那一节提过这个问题,但因为它太常见了,我再具体说一个场景。我见过有同学做信用评分数据,特征里有“负债金额”,单位是元,范围从 0 到几百万;也有“年龄”,范围只有 18 到 70。直接跑 kNN,算出来的距离几乎等于负债金额的差异,年龄低的人可能因为负债相似被硬生生分到同一组。最后模型的解释性完全失控。
解决办法就是标准化,但要注意一点:如果你的数据里存在极端离群点,StandardScaler 的均值会被带偏,此时用 RobustScaler 更稳,它基于中位数和四分位数,不容易被特大特小值带跑。建议在 Pipeline 里把标准化器换一下再跑一遍,对比一下效果。
4.3 样本不均衡:多数类邻居“霸屏”
类别不平衡是分类任务里的老问题,kNN 尤其受影响。假设你要识别一种罕见疾病,患病样本只占 1%,正常样本占 99%。当真正患病的样本进入预测时,哪怕它离某些患病样本很近,但它周围 100 个邻居里可能只有 3、4 个是同类,其余 96 个全是正常样本。多数投票的规则下,它毫无悬念地被分到正常类别,导致罕见病永远识别不出来。
针对 kNN 的不平衡问题,有几种思路。一种是用 weights='distance',让距离最近的少数类样本获得更大权重,而不是单纯靠出现在邻居列表里的次数。另一种是在训练前对数据进行重采样,对少数类做上采样(比如 SMOTE)或者对多数类做下采样。还有一种做法是完全不用 kNN,换成对类权重更敏感的模型,比如逻辑回归或者 XGBoost。要是你想坚持用 kNN,至少先用交叉验证看一下每一类别的召回率,别只盯着整体准确率看。
4.4 性能问题:预测慢、内存占用高,怎么解决
kNN 在工程落地时最大的痛点是预测耗时。训练阶段它什么都不干,但预测一个样本就要把全部训练样本遍历一遍,计算距离、排序、取前 k 个。当样本量是百万级时,每一次预测的时间开销都非常可观,接口 QPS 直接被打压下去。
scikit-learn 提供了几个灵活的算法选择。默认 auto 模式会在数据量大的时候选择建树方式搜索,比如 KD Tree 或 Ball Tree。KD Tree 通过按维度切分空间来减少无效距离计算,但它在高维数据上效率会退化;Ball Tree 用超球体切分,对高维数据更友好。实际项目里,如果预测延迟已经不可接受,你可以手动指定 algorithm='kd_tree' 或 'ball_tree' 试一下,同时调整 leaf_size 参数。这个参数控制树的叶子节点数量,调大可以减少树的层数、降低建树时间,但会增加每个叶子内部的搜索开销。
还有几个更彻底的方案:一是先对高维特征做 PCA 降维,特征维度降下来,树的搜索效率会高很多;二是如果业务允许,直接不用 kNN,换成一个训练后有固定参数的模型,比如随机森林或逻辑回归,这些模型预测时只需要做一次矩阵运算,延迟稳定得多。kNN 在数据量几万到几十万的规模下可以很快,但你要有“它不适合作为超大流量实时服务的默认选择”这个意识。
下面是个常见问题速查表,遇到异常可以直接对照:
| 现象 | 可能原因 | 解决方式 |
|---|---|---|
| 训练集得分很高,测试集很低 | k 值太小导致过拟合 | 适当调大 n_neighbors,交叉验证重新选参 |
| 不同量纲特征混合,结果被单一特征主导 | 特征没有标准化 | 在 Pipeline 里加入 StandardScaler |
| 少数类样本几乎全被分错 | 类别不均衡 + 多数投票 | 用 distance 加权、重采样或改换模型 |
| 数据集几百万条,预测极慢 | kNN 默认暴力搜索 | 指定 kd_tree / ball_tree,或降低维度 |
| 高维特征下准确率突然崩了 | 维数灾难 | PCA 降维后再跑 kNN |
| 特征存在大量离群点 | 标准化的均值被带偏 | 改用 RobustScaler |
5. 从 kNN 延伸:它是很好的基线与理解入口
5.1 KD Tree 与 Ball Tree:让最近邻搜索更快的前置原理
我在前面反复提了性能问题,这里再稍微展开一下 KD Tree 和 Ball Tree 的工作原理,因为理解了它们,你调参数时就有方向了。
KD Tree 的思路类似于把空间不断二分:先选定一个维度,把样本按这个维度的中位数切成两半,再在子空间里选另一维继续切,直到叶子节点里的样本数足够少。搜索最近邻时,它不需要跟所有点比较,而是根据树的结构快速跳过不可能的子树。这个做法在低维数据上效率非常高,但维度一高,切分空间的效率就会明显退化,因为每个维度上的信息都被稀释了。
Ball Tree 的思路不同。它用一系列超球体把样本包裹起来,搜索时先判断目标点和球心的距离:如果目标点离某个球太远,整个球里的点都可以直接跳过。它在高维、稀疏数据上的表现通常比 KD Tree 更稳定。scikit-learn 里的 KNeighborsClassifier 在 algorithm='auto' 时,会根据数据量和维度自动在 brute、kd_tree、ball_tree 中做选择。大多数情况下 auto 足够用,真遇到性能瓶颈,再手动切换比较一下。
5.2 kNN 与其他模型怎么选:一张对比表说清楚
kNN 不是万能钥匙,它有自己很合适的场景,也有明显不合适的场景。我整理了一个对比维度,帮你快速做模型选型:
| 对比维度 | kNN | 逻辑回归 / SVM | 决策树 / 随机森林 | 神经网络 |
|---|---|---|---|---|
| 训练速度 | 无需训练,瞬间完成 | 需要迭代优化,速度中等 | 训练速度快 | 训练时间随网络规模增长 |
| 预测速度 | 数据量大时慢 | 极快 | 快 | 快 |
| 是否依赖特征缩放 | 非常依赖 | 线性模型和 SVM 也依赖 | 不依赖 | 通常依赖 |
| 可解释性 | 基于邻居,解释性好 | 权重系数好解释 | 特征重要性直观 | 差 |
| 处理非线性边界 | 可以,但依赖局部样本密度 | 线性模型不行,SVM 靠核函数 | 可以 | 可以 |
| 高维数据表现 | 差 | 中等 | 较好 | 好 |
| 大数据量表现 | 差 | 好 | 好 | 好 |
表格看完你应该有个直观结论:kNN 最适合样本量不大、特征维度较低、强调可解释性的场景。一旦数据量突破某个阈值,或者特征维度明显偏高,就应该认真考虑其他模型了。
5.3 把 kNN 当基线模型的实际价值
我在实际项目中养成一个习惯:拿到一个新数据集,先不做复杂模型,第一步就是标准化后跑一个 kNN 当 baseline。为什么?因为 kNN 几乎不引入任何先验假设,它就像一个最朴素的“参照系”。如果连 kNN 在这个数据上表现得都不错,说明数据的信号是清晰、可学习的;如果 kNN 表现很差,那就需要怀疑特征工程或者数据质量出了问题,而不是急着上深度学习。
复杂模型如果连 kNN 的 baseline 都打不过,那大概率是建模流程有问题,而不是模型不够强。这能帮你省下大量无意义的调参时间。同时,kNN 还可以用来验证特征工程的有效性:每做一个新特征,把它加入特征集后 kNN 的准确率有没有提升?这个反馈非常敏感,因为它依赖距离,特征的贡献会被直接算进距离结果里。这比在树模型上看特征重要性的反馈更直接。
5.4 踩过几次坑之后的一点体会
我最初接触 kNN 时也犯过很多低级错误,最典型的就是拿原始数据直接丢给模型,结果发现准确率怎么调都上不去,后来检查才发现两个特征的量纲差了上千倍,标准化之后完全是两个效果。后来我把“先标准化,再跑模型”变成了肌肉记忆,用到任何距离相关的算法,包括聚类、向量检索,都会默认先检查这一步。
个人建议,如果你的目标是快速跑通一个分类 Demo,kNN 绝对是最划算的起点。它让你先理解数据集、先做出一个结果,再慢慢换成更复杂的模型。调整参数时也别一上来就把网格铺满,先固定标准化和距离度量,只调 k 值,找到一个差不多的区间,再考虑 weights 和 p 的组合。这样思路清楚,排错也容易。
如果你继续往机器学习后续内容深入,你会发现 kNN 里用到的距离计算、交叉验证、特征缩放、过拟合判断,几乎每一个知识点都会在后面的模型里复用。把它吃透了,后面学 SVM、学聚类,甚至学推荐系统的相似度计算,都会顺手很多。