1. 为什么KNN到今天还值得单独拎出来讲
KNN,K-Nearest Neighbors,中文叫K近邻,是机器学习里少数几个"不用训练就能预测"的算法。你给它一堆带标签的数据,来了新样本,它就去算这个新样本跟历史数据里每一个点的距离,挑出最近的K个邻居,看这K个邻居里哪个类别最多,就把新样本判成哪一类。回归任务同理,取K个邻居目标值的平均或者加权平均。
我第一次接触KNN是在做一个手写数字识别的练手项目,当时用逻辑回归跑了个基准,准确率卡在92%左右上不去,换成KNN调了调K值和距离度量,直接干到97%。那种"原来这么朴素的思路也能这么能打"的冲击感,让我对这个算法一直有特殊的感情。
它适合谁?如果你是刚入门机器学习的新手,KNN是你理解"距离""相似度""超参数""过拟合"这些概念最直观的入口,没有之一。如果你是有经验的从业者,KNN在推荐系统的召回层、异常检测、缺失值填充、小样本分类这些场景里依然是性价比极高的baseline。甚至在大模型时代,向量检索的本质就是KNN的变体——把"距离"换成"余弦相似度",把"暴力遍历"换成"近似最近邻索引",核心思想一脉相承。
这篇文章我会从零把KNN拆开讲透:原理、距离度量怎么选、K值怎么定、特征怎么处理、代码怎么写、坑怎么避。所有代码都是可以直接复制运行的,所有参数我都会告诉你为什么这么设,而不是甩一个数字给你。
2. KNN的核心原理与关键概念拆解
2.1 一句话说清KNN在干什么
KNN的假设非常朴素:相似的东西,它们的标签也应该是相似的。你住在一个小区里,想知道某套房子大概卖多少钱,最直接的办法就是看看同小区、同户型、同楼层最近成交的几套房子卖了多少钱,取个平均。这就是KNN回归。你想判断一个水果是苹果还是梨,看看它周围特征最接近的几个水果都是什么,多数是什么它就是什么。这就是KNN分类。
用稍微正式一点的话说:给定一个训练集,对于一个新的输入实例,在训练集中找到与它最邻近的K个实例,根据这K个实例的类别(或数值)通过多数表决(或平均)来决定新实例的输出。
这里有个容易被忽略的点:KNN是"懒惰学习"(lazy learning)。它没有显式的训练过程,你把数据喂给它,它就存着,真正干活是在预测的时候。这跟逻辑回归、SVM那种"训练阶段就把模型参数学出来"的"急切学习"(eager learning)完全不同。懒惰学习的好处是训练零成本,坏处是预测成本高——每来一个样本都要跟全部训练数据算一遍距离。
2.2 距离度量:KNN的灵魂参数
KNN里"近"这个字怎么定义,直接决定了模型的表现。常用的距离度量有这么几种:
欧氏距离(Euclidean Distance)是最常用的,就是两点之间的直线距离。二维平面上就是勾股定理,n维就是各维度差值的平方和开根号。公式是:
$$d(x,y) = \sqrt{\sum_{i=1}^{n}(x_i - y_i)^2}$$
曼哈顿距离(Manhattan Distance)也叫城市街区距离,是各维度差值绝对值之和。想象你在一个方格城市里开车,不能穿墙,只能横着走竖着走,走的总路程就是曼哈顿距离:
$$d(x,y) = \sum_{i=1}^{n}|x_i - y_i|$$
闵可夫斯基距离(Minkowski Distance)是前两者的泛化形式,引入一个参数p:
$$d(x,y) = \left(\sum_{i=1}^{n}|x_i - y_i|^p\right)^{1/p}$$
p=1时就是曼哈顿距离,p=2时就是欧氏距离,p趋于无穷时就是切比雪夫距离(各维度差值的最大值)。
余弦相似度(Cosine Similarity)衡量的是两个向量的方向是否一致,跟长度无关。在文本分类、推荐系统里特别常用,因为文档长度差异很大,我们更关心"用词分布像不像"而不是"向量长度差多少"。
那到底怎么选?我的经验是:
- 特征都是连续的、量纲统一的数值,优先用欧氏距离。
- 特征维度很高(比如文本TF-IDF向量、图像特征),余弦相似度往往更稳。
- 特征里有明显的"网格"结构或者稀疏特征,曼哈顿距离值得一试。
- 不确定的时候,把几种距离都跑一遍交叉验证,用数据说话。
注意:用欧氏距离之前,必须做特征标准化。原因很简单:如果一个特征是"年龄"(范围0-100),另一个是"年收入"(范围0-1000000),那距离计算完全被收入主导,年龄这个特征等于白给。标准化之后每个维度都在同一量级,距离才有意义。
2.3 K值的选择:偏差与方差的艺术
K值是KNN里最重要的超参数,没有之一。它直接控制模型的复杂度:
- K太小(比如K=1):模型只看最近的一个邻居,对噪声极其敏感。训练集里有一个标错的样本,正好落在你测试点旁边,你的预测就错了。这是典型的过拟合,方差大、偏差小。
- K太大:模型看太多邻居,把远处不相关的样本也拉进来投票,决策边界变得过于平滑,容易欠拟合,偏差大、方差小。
- K等于训练样本数:不管输入是什么,都预测成训练集里最多的那个类别,模型彻底废掉。
那怎么选?几个实操原则:
- 从K=3开始试,一般取奇数(二分类时避免平票)。
- 用交叉验证找最优K。把K从1到20(或者到sqrt(N),N是样本数)遍历一遍,看验证集准确率。
- 经验值:K通常取训练样本数的平方根附近。比如1000个样本,K取30左右可以试试。
- 类别不平衡时,用加权投票——距离近的邻居权重高,距离远的权重低。权重通常取距离的倒数,即 $w = 1/d$。
我一般会画一条"K值-准确率"曲线,找那个准确率最高且相对平稳的区间,而不是死磕最高点。因为最高点可能是噪声带来的偶然,平稳区间泛化能力更好。
2.4 特征工程:KNN的成败八成在这里
KNN对特征质量极其敏感,因为它直接拿特征算距离。特征没处理好,后面调参都是白搭。几个关键动作:
缺失值处理:KNN本身不能处理缺失值。要么删掉含缺失的样本,要么用均值/中位数/众数填充,要么用KNN自己来填充(对,KNN可以用来做缺失值插补,sklearn里有KNNImputer)。
特征标准化:前面说了,欧氏距离下必须做。常用Z-score标准化(减均值除标准差)或者Min-Max归一化(缩放到0-1)。如果数据有极端离群值,用RobustScaler(基于中位数和四分位距)更稳。
降维:维度太高时,距离计算会失效——这就是所谓的"维度灾难"。在高维空间里,所有点之间的距离都趋于相等,KNN就失去了区分能力。解决办法是用PCA、t-SNE或者UMAP先降维,或者用特征选择方法挑出最重要的特征。
特征加权:如果你知道某些特征更重要,可以给它们乘一个权重系数,让它们在距离计算中占更大比重。这本质上是在注入领域知识。
3. 从零手写KNN与sklearn实战
3.1 手写一个KNN分类器
不依赖任何机器学习库,只用numpy,把KNN的核心逻辑写出来。这样你对它的理解会深刻得多。
import numpy as np from collections import Counter class MyKNN: def __init__(self, k=3, distance='euclidean', weights='uniform'): self.k = k self.distance = distance self.weights = weights def fit(self, X, y): # 懒惰学习:训练阶段只存数据 self.X_train = np.array(X) self.y_train = np.array(y) return self def _compute_distance(self, x1, x2): if self.distance == 'euclidean': return np.sqrt(np.sum((x1 - x2) ** 2)) elif self.distance == 'manhattan': return np.sum(np.abs(x1 - x2)) else: raise ValueError("Unsupported distance metric") def _predict_one(self, x): # 计算x到所有训练样本的距离 distances = [self._compute_distance(x, x_train) for x_train in self.X_train] # 取距离最近的k个样本的索引 k_indices = np.argsort(distances)[:self.k] k_labels = self.y_train[k_indices] if self.weights == 'uniform': # 等权投票 most_common = Counter(k_labels).most_common(1) return most_common[0][0] else: # 距离加权投票:权重为距离的倒数 k_distances = np.array([distances[i] for i in k_indices]) # 防止除零 k_distances = np.where(k_distances == 0, 1e-10, k_distances) weight_dict = {} for label, dist in zip(k_labels, k_distances): weight_dict[label] = weight_dict.get(label, 0) + 1.0 / dist return max(weight_dict, key=weight_dict.get) def predict(self, X): return np.array([self._predict_one(x) for x in np.array(X)])这段代码虽然效率不高(预测时双重循环),但逻辑完整。你可以拿鸢尾花数据集跑一下,准确率应该在95%以上。手写一遍的价值在于:你会真正明白"预测慢"慢在哪里,也会理解为什么工业界要用KD树、Ball Tree这些数据结构来加速。
3.2 sklearn版本:生产可用的写法
实际项目里当然用sklearn,代码简洁、性能好、功能全。
from sklearn.datasets import load_iris from sklearn.model_selection import train_test_split, GridSearchCV from sklearn.preprocessing import StandardScaler from sklearn.neighbors import KNeighborsClassifier from sklearn.metrics import classification_report, accuracy_score from sklearn.pipeline import Pipeline # 1. 加载数据 iris = load_iris() X, y = iris.data, iris.target # 2. 划分训练集和测试集 X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.2, random_state=42, stratify=y ) # 3. 构建Pipeline:标准化 + KNN pipe = Pipeline([ ('scaler', StandardScaler()), ('knn', KNeighborsClassifier()) ]) # 4. 网格搜索找最优K和距离度量 param_grid = { 'knn__n_neighbors': list(range(1, 21)), 'knn__weights': ['uniform', 'distance'], 'knn__metric': ['euclidean', 'manhattan', 'minkowski'] } grid = GridSearchCV(pipe, param_grid, cv=5, scoring='accuracy', n_jobs=-1) grid.fit(X_train, y_train) print(f"最优参数: {grid.best_params_}") print(f"交叉验证最优准确率: {grid.best_score_:.4f}") # 5. 用最优模型预测 y_pred = grid.predict(X_test) print(f"测试集准确率: {accuracy_score(y_test, y_pred):.4f}") print(classification_report(y_test, y_pred, target_names=iris.target_names))这段代码有几个值得说的点:
Pipeline的妙用:把标准化和KNN打包在一起,交叉验证时标准化只在训练折上fit,避免数据泄露。这是很多人容易犯的错误——先对全量数据标准化再划分训练测试集,测试集的统计信息泄露到了训练过程。
GridSearchCV的n_jobs=-1:用满所有CPU核心并行搜索,速度提升明显。
stratify=y:分层抽样,保证训练集和测试集的类别比例跟原始数据一致,类别不平衡时尤其重要。
3.3 一个完整的KNN回归示例
KNN不光能做分类,回归任务同样拿手。下面用波士顿房价的替代数据集(sklearn已移除波士顿数据集,这里用糖尿病数据集演示)跑一个回归。
from sklearn.datasets import load_diabetes from sklearn.model_selection import train_test_split, cross_val_score from sklearn.preprocessing import StandardScaler from sklearn.neighbors import KNeighborsRegressor from sklearn.metrics import mean_squared_error, r2_score import numpy as np # 加载数据 diabetes = load_diabetes() X, y = diabetes.data, diabetes.target X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.2, random_state=42 ) # 标准化 scaler = StandardScaler() X_train_scaled = scaler.fit_transform(X_train) X_test_scaled = scaler.transform(X_test) # 找最优K best_k, best_score = None, -np.inf for k in range(1, 31): knn = KNeighborsRegressor(n_neighbors=k, weights='distance') scores = cross_val_score(knn, X_train_scaled, y_train, cv=5, scoring='r2') mean_score = scores.mean() if mean_score > best_score: best_score = mean_score best_k = k print(f"最优K: {best_k}, 交叉验证R2: {best_score:.4f}") # 用最优K训练并评估 final_model = KNeighborsRegressor(n_neighbors=best_k, weights='distance') final_model.fit(X_train_scaled, y_train) y_pred = final_model.predict(X_test_scaled) print(f"测试集R2: {r2_score(y_test, y_pred):.4f}") print(f"测试集RMSE: {np.sqrt(mean_squared_error(y_test, y_pred)):.4f}")回归任务里,weights='distance'通常比uniform效果好,因为近邻的参考价值确实更大。但要注意,如果测试点跟某个训练点完全重合(距离为0),距离加权会导致权重无穷大,sklearn内部做了处理,但你自己手写时要注意。
4. 性能优化与高阶技巧
4.1 暴力搜索太慢?上KD树和Ball Tree
KNN预测时要算新样本到所有训练样本的距离,复杂度是O(N*D),N是样本数,D是维度。N=10万、D=100的时候,每预测一个样本要算1000万次乘法,根本扛不住。
sklearn的KNeighborsClassifier有个algorithm参数,可以选'brute'、'kd_tree'、'ball_tree'、'auto':
- brute:暴力搜索,适合维度低、样本少,或者距离度量不是欧氏/曼哈顿的情况。
- kd_tree:KD树,把空间递归划分成超矩形,查询时剪枝。适合低维(D<20)数据。
- ball_tree:球树,用超球体划分空间。高维数据比KD树表现好一些,但维度特别高时也会退化。
- auto:让sklearn根据数据自动选。
实测经验:D<20时KD树通常最快;D在20-50之间Ball Tree有优势;D>50基本都退化成暴力搜索,这时候要么降维,要么换近似最近邻库(比如faiss、hnswlib)。
# 指定算法 knn_kd = KNeighborsClassifier(n_neighbors=5, algorithm='kd_tree') knn_ball = KNeighborsClassifier(n_neighbors=5, algorithm='ball_tree')4.2 近似最近邻:大数据时代的必选项
当数据量到百万、千万级别,精确KNN再怎么优化也扛不住。这时候要用近似最近邻(ANN),牺牲一点点精度换巨大的速度提升。主流方案:
- HNSW(Hierarchical Navigable Small World):基于图的索引,查询快、精度高,是目前工业界最流行的ANN算法之一。
- IVF-PQ(Inverted File with Product Quantization):faiss的核心索引,先聚类分桶再量化压缩,内存占用小。
- LSH(Locality Sensitive Hashing):用哈希函数把相似的点映射到同一个桶,理论优雅但实际精度一般。
这些库用起来也不复杂,核心就是"建索引-查询"两步。如果你的场景是向量检索、以图搜图、语义搜索,直接上这些工具,别自己造轮子。
4.3 样本不平衡怎么办
KNN默认是多数表决,类别不平衡时少数类会被淹没。比如正负样本9:1,K=10时,即使新样本明显靠近那个负样本,只要周围有6个正样本,还是判成正类。
解决办法:
- 距离加权:
weights='distance',让近的样本说话更有分量。 - 过采样少数类:SMOTE之类的算法生成合成样本。
- 欠采样多数类:随机丢掉一些多数类样本。
- 调整决策阈值:不取多数,而是看各类的加权得票比例,手动设阈值。
我一般先用距离加权,效果不够再上SMOTE,两者结合往往能解决大部分不平衡问题。
4.4 用KNN做异常检测
KNN有个很自然的异常检测用法:一个样本如果离它最近的K个邻居都很远,那它很可能是异常点。具体做法是算每个样本到第K个近邻的距离(叫k-distance),距离越大越异常。
from sklearn.neighbors import NearestNeighbors import numpy as np # 假设X是标准化后的数据 nbrs = NearestNeighbors(n_neighbors=5).fit(X) distances, indices = nbrs.kneighbors(X) # 每个点到第5个近邻的距离 k_distances = distances[:, -1] # 取95分位数作为阈值 threshold = np.percentile(k_distances, 95) outliers = np.where(k_distances > threshold)[0] print(f"检测到 {len(outliers)} 个异常点")这个方法在欺诈检测、设备故障预警、数据清洗里都很好用。sklearn还有专门的LocalOutlierFactor,考虑的是局部密度而不是绝对距离,对密度不均匀的数据更鲁棒。
5. 常见问题与排查技巧实录
5.1 KNN常见问题速查表
| 问题现象 | 可能原因 | 排查与解决 |
|---|---|---|
| 准确率极低(接近随机) | 特征未标准化;距离度量选错 | 检查各特征量纲,加StandardScaler;换距离度量试试 |
| 训练集准确率100%,测试集很差 | K太小,过拟合 | 增大K值,用交叉验证找最优 |
| 预测速度极慢 | 数据量大且用brute;维度高 | 换kd_tree/ball_tree;降维;上ANN库 |
| 类别不平衡时少数类全错 | 多数表决被多数类主导 | weights='distance';SMOTE过采样;调阈值 |
| 高维数据效果差 | 维度灾难,距离失效 | PCA/UMAP降维;换余弦相似度 |
| 内存爆掉 | 存了全量训练数据 | 用ANN索引;数据分片;减少K |
| 平票无法决策 | K为偶数,二分类 | K取奇数;加权投票打破平局 |
5.2 几个我踩过的坑
坑一:标准化在划分数据集之前做了。这是新手最常犯的错误。正确顺序永远是:先划分训练集和测试集,再在训练集上fit标准化器,然后用这个标准化器transform测试集。用Pipeline可以自动避免这个问题。
坑二:用准确率评估不平衡数据。99%的样本是负类,你全预测负类也有99%准确率,但模型毫无价值。不平衡场景要看precision、recall、F1、AUC这些指标。
坑三:K值调优时在测试集上调。测试集只能用一次,用来做最终评估。调参必须用验证集或者交叉验证。在测试集上调K,等于偷看了答案,报出来的准确率是虚高的。
坑四:忽略了特征的相关性。如果两个特征高度相关(比如"身高cm"和"身高inch"),它们在距离计算里相当于被算了两遍,这个维度的重要性被放大了。要么删掉一个,要么用PCA去相关。
坑五:距离加权时没处理零距离。测试样本跟训练样本完全一样时距离为0,1/0会炸。sklearn内部处理了,但自己写代码要加个极小值保护。
5.3 调参的实操顺序
很多人一上来就GridSearchCV把所有参数一起搜,计算量爆炸。我推荐的顺序是:
- 先固定K=5,weights='uniform',metric='euclidean',跑个baseline,看看数据本身可不可分。
- 调K:从1到30遍历,画K-准确率曲线,锁定大致范围。
- 调weights:在最优K附近比较uniform和distance。
- 调metric:比较euclidean、manhattan、cosine。
- 最后微调:在缩小后的参数空间里做精细网格搜索。
这样一步步来,既快又能理解每个参数的作用,比一次性暴力搜索高效得多。
5.4 关于KNN的几个认知误区
误区一:KNN不需要训练所以很简单。训练确实简单,但预测复杂,而且对特征工程、距离度量、K值极其敏感。把KNN用好,不比调神经网络容易。
误区二:KNN只能做分类。回归、异常检测、缺失值填充、推荐召回,KNN都能干。
误区三:KNN过时了。向量数据库、RAG、以图搜图,底层都是KNN思想。它只是换了个马甲继续活跃在一线。
误区四:K越大越好。K太大模型会欠拟合,决策边界糊成一团。K的选择是偏差方差权衡,没有绝对的好坏。
6. 写在最后的几句实在话
KNN这个算法,我用了这么多年,最大的感受是:它简单到让人轻视,又深刻到让人敬畏。简单在于原理一句话能说清,深刻在于它触及了机器学习最本质的问题——如何定义"相似",如何从邻居的信息推断未知。
如果你正在学机器学习,我强烈建议你把KNN手写一遍,不用库,就用numpy。写完你会对"距离""投票""超参数"这些概念有肌肉记忆般的理解。然后拿真实数据集跑一遍完整的流程:EDA、缺失值处理、标准化、划分数据集、交叉验证调参、评估、错误分析。这一套走下来,你对机器学习的整体流程就通了。
如果你在做实际项目,KNN永远值得作为第一个baseline。它快、可解释、不需要调太多参数,能让你快速知道"这个问题到底难不难"。如果KNN都能跑到90%准确率,那说明特征本身很有区分度,上复杂模型收益有限;如果KNN只有60%,那要么特征不行,要么问题本身很难,得从数据和特征入手,而不是急着换模型。
最后一个实用建议:把KNN和Pipeline绑在一起用。标准化、降维、KNN打包成一个Pipeline,交叉验证、网格搜索、部署上线都省心,还能避免数据泄露。这个习惯一旦养成,你做其他模型时也会受益。