KNN算法从原理到实战:距离度量、K值选择与Sklearn应用

发布时间:2026/9/12 0:44:39
KNN算法从原理到实战:距离度量、K值选择与Sklearn应用 1. 为什么说KNN是最“近朱者赤”的机器学习算法1.1 一句话讲清KNN的核心思想KNN算法全称K-Nearest Neighbors中文叫K近邻。我第一次接触这个算法时最大的感受就是这不就是“近朱者赤近墨者黑”吗它没有复杂的公式推导没有多层网络结构思路直白到可以用一句话说清楚一个新样本属于哪个类别看它离得最近的K个训练样本是什么类别让这些邻居投票决定。举个例子你就明白了。假设你刚搬到一个新小区想知道楼下便利店老板大概是个什么样的人最快的办法是观察他平时和谁走得近。如果经常和他一起喝茶的是老师、医生那你大概率会判断他也是个文化人如果他天天跟一群飙车党混在一起你的判断自然就不一样。KNN就是这种“以身边人判断一个人”的思路只不过把“身边”换成了数学上的“距离最近”。这种算法在机器学习里属于惰性学习Lazy Learning也叫基于实例的学习。它和其他模型最大的区别在于像线性回归、神经网络这类“急切学习”模型在训练阶段会拟合出一套参数学完就扔掉训练数据预测时只需要拿参数算结果。而KNN从头到尾不学习任何参数训练阶段只是把所有数据原封不动地存起来真正的工作发生在预测那一刻——新样本一来现场找邻居、现场投票。这个特点决定了KNN特别适合小样本、低维度的场景。数据量不大时你不需要搞多复杂的模型KNN往往就能给出一个很扎实的基线结果。很多工业项目在做技术选型时第一个跑的模型就是KNN拿它的准确率当作“地板”后面的模型如果连KNN都打不过那基本不用继续做了。1.2 分类、回归、异常检测它都能干别看KNN简单它不是一个只能做分类的小玩具。按任务类型划分KNN可以覆盖至少四类问题分类这是最经典的用法。新样本看K个邻居投什么类别多数获胜。回归把投票换成取平均K个邻居的目标值平均一下就是新样本的预测结果。比如预测房价、温度这种连续值KNN回归的表现很多时候超出预期。异常检测如果某个样本周围K个邻居都离它很远说明这个点跟其他数据都“不熟”很可能就是异常点。这个思路在风控场景里经常用到。缺失值填充某个特征缺失时找到该样本的K个近邻用这些邻居在这个特征上的均值或众数填进去比粗暴地填0或填全局均值要好得多。所以KNN在机器学习里更像一个“万能零件的毛坯”虽然朴素却能应对很多基础需求。对于刚入门机器学习的朋友来说KNN是理解“数据驱动”这个概念最好的教材。真正动手跑一遍KNN你才会直观体会到模型本身不重要数据和距离度量才是决定结果的关键。这个认知会伴随你以后学习所有更复杂的模型。2. KNN的三个核心参数距离、K值、投票规则怎么搭配2.1 距离度量不只有欧氏距离KNN的核心动作是“找邻居”而“邻居”的定义完全取决于你怎么算距离。只要提到KNN默认的距离当然是欧氏距离也就是我们初中就学过的两点间直线距离。对于n维空间里的两个点$x (x_1, x_2, ..., x_n)$和$y (y_1, y_2, ..., y_n)$欧氏距离公式是$$d(x, y) \sqrt{\sum_{i1}^{n}(x_i - y_i)^2}$$这个公式理解起来很直觉。但你需要注意欧氏距离只是闵可夫斯基距离的一个特例。闵可夫斯基距离的通用公式长这样$$d(x, y) \left( \sum_{i1}^{n} |x_i - y_i|^p \right)^{1/p}$$当 p2 时它就是欧氏距离当 p1 时它就是曼哈顿距离$$d(x, y) \sum_{i1}^{n} |x_i - y_i|$$曼哈顿距离这个名字很形象——你从A点到B点不能直线穿楼只能沿着街道网格走走的横向距离加纵向距离就是曼哈顿距离。实际项目里怎么选我带过的经验是场景推荐距离原因特征都是连续的、量纲相近的数值欧氏距离直观且效果好特征包含较多噪声或离群点曼哈顿距离对异常值更鲁棒文本向量、稀疏向量余弦相似度关注方向而非绝对距离混合类型特征马氏距离或Gower距离考虑特征间的相关性和量纲差异我之前在用户行为画像项目里就吃过亏。当时特征里有“活跃天数”和“消费金额”两个字段前者通常是0到30后者可能从0到几万。直接用欧氏距离算消费金额的数值动辄几千上万完全压制了活跃天数的贡献导致KNN找出来的“邻居”几乎等于只按消费金额排序。后面把两列分别做了标准化再算欧氏距离分类准确率直接提升了好几个百分点。关于标准化后面专门细说。2.2 K值太小被噪声带偏太大被远房亲戚淹没K值的选择是KNN调参的核心没有之一。K设得太小模型只参考离得最近的一两个邻居对噪声和异常点极度敏感。比如K1新样本旁边最近的一个点刚好是错误标注的数据那预测就直接错了这叫过拟合——模型过度关注训练数据的局部细节泛化能力差。K设得太大远的近的邻居一视同仁全部拉进来投票那些离样本十万八千里的“远房亲戚”也有了发言权各类别的样本都混进来分类边界被抹得像毛玻璃一样模糊这时就进入了欠拟合状态。怎么选K最靠谱的方法是交叉验证。把训练集切几份轮流拿一部分当验证集其他当训练集跑不同K值的KNN看哪个K的整体准确率最高。实际经验里K通常取一个奇数比如3、5、7、9。为什么是奇数因为两类问题的投票只有奇数才不会出现平票。当然这只是经验法则不是硬性规定遇到多分类问题或加权投票时偶数的K也不一定就出事。K值的另一个直觉参考是样本总量。K不能太大一般来说K不要超过总样本量的5%到10%。如果训练集只有100条K取到20显然不合理几乎所有样本都参与投票了预测结果几乎变成全局众数。2.3 投票规则多数投票与距离加权传统KNN的投票规则是多数投票K个最近的邻居里哪个类别人多新样本就归哪类。规则简单、实现容易但它有个隐蔽的问题距离极近的邻居和距离较远的邻居权重完全相同。想象一下K5的时候离样本0.1的3个同类点和离样本100的2个异类点最后投票结果是2票输给3票但直觉上离得越近的邻居应该越有发言权这个结果显然不合理。改进方案是距离加权投票。每个邻居投票时的权重设为距离的倒数比如权重 1/d距离越近权重越大。这样就算K取大一点远处的邻居也翻不起什么浪花。Sklearn里的KNeighborsClassifier有一个weights参数默认是uniform均匀权重你可以设置成distance就会自动按距离反比加权。我实测过很多数据集weightsdistance在大多数情况下都能把准确率往上抬一点代价是计算量稍微增加因为每个测试样本都要算一遍距离倒数。3. 先别急着调库手写一个KNN把流程彻底搞明白3.1 手写KNN的核心步骤很多教程上来就from sklearn.neighbors import KNeighborsClassifier然后fit一下就能跑通这当然快。但如果你只是这么用很难真正理解KNN到底做了什么。我建议每一位学习者都至少手写一次KNN用不了半小时但对理解算法的本质非常有帮助。KNN的预测过程可以拆成清晰的四步计算待预测样本和所有训练样本之间的距离按距离从小到大排序取距离最小的前K个样本对这K个样本的标签进行投票得票最多的类别就是预测结果。下面是用Python和NumPy实现的完整代码去掉注释也就二十来行import numpy as np from collections import Counter class KNN: def __init__(self, k3): self.k k def fit(self, X, y): # KNN的训练就是“记住”训练数据 self.X_train X self.y_train y def predict_one(self, x): # 1. 计算x与所有训练样本的欧氏距离 distances np.sqrt(np.sum((self.X_train - x) ** 2, axis1)) # 2. 按距离升序排序返回索引 sorted_indices np.argsort(distances) # 3. 取前K个索引对应的标签 k_nearest_labels self.y_train[sorted_indices[:self.k]] # 4. 投票统计每个标签出现次数 votes Counter(k_nearest_labels) # 返回得票最多的标签 return votes.most_common(1)[0][0] def predict(self, X_test): return np.array([self.predict_one(x) for x in X_test])这段代码有几个细节值得琢磨。fit方法里没有任何“学习”动作纯粹是把训练数据赋值给内部变量这就是惰性学习的直观体现。真正干活的是predict_one方法里的四行代码每一行都对应上面说的四步。Counter是Python标准库里用来计数的好东西比手动用字典统计要简洁得多。我用鸢尾花数据集试过这个手写版本K3时准确率大概在95%左右跟Sklearn的标准实现差距并不大。这验证了一个重要结论KNN算法的核心价值就在这四行逻辑里你不需要在算法内部搞什么花活结果的好坏更多取决于数据质量和前面讲的三要素选择。3.2 为什么说KNN“训练快、预测慢”手写一遍KNN之后你会非常直观地理解它的时间复杂度特征。训练阶段基本都是O(1)级别的操作就是存数据。但预测阶段每预测一个样本都要跟全部训练样本算一遍距离假设训练集有N条样本特征维度是D那么预测一个样本的时间复杂度是O(N*D)。如果测试集有M条样本总的预测复杂度就是O(M*N*D)。这意味着当训练集从1万条涨到10万条预测耗时也会跟着涨10倍。在实时预测的在线服务里这个性能瓶颈是致命的。很多刚开始玩机器学习的同学容易忽略KNN这个特性拿KNN直接去跑百万级数据结果训练秒完、预测等到怀疑人生还以为是代码写错了。那生产环境里怎么缓解预测慢的问题Sklearn提供了algorithm参数可以指定kd_tree或ball_tree用树形结构把搜索空间高效剪枝把查找邻居的时间从线性降低到对数级别。但这里有个反直觉的坑KD-Tree在特征维度很高时效率反而下降因为高维空间里的距离区分度变低树的剪枝效果大打折扣。所以Sklearn里还有algorithmbrute选项就是暴力计算全部距离往往在高维场景下反而跟树搜索差不多甚至更快。我在实际调参时一般让Sklearn自动选择algorithmauto它会根据数据和维度自己判断用哪种方案。4. Sklearn实战KNN做鸢尾花分类完整流水线从标准化到调参4.1 数据准备和标准化现在进入实战环节。我们拿最经典的鸢尾花数据集来完整走一遍KNN的机器学习流程。这个数据集有150条样本、4个特征花萼长度、花萼宽度、花瓣长度、花瓣宽度、3个类别量级很小非常适合演示。先写一段完整代码从加载数据到模型评估from sklearn.datasets import load_iris from sklearn.model_selection import train_test_split from sklearn.preprocessing import StandardScaler from sklearn.neighbors import KNeighborsClassifier from sklearn.metrics import accuracy_score, classification_report # 1. 加载数据 iris load_iris() X, y iris.data, iris.target # 2. 划分训练集和测试集比例7:3 X_train, X_test, y_train, y_test train_test_split( X, y, test_size0.3, random_state42, stratifyy ) # 3. 标准化这一步对于KNN几乎是必修课 scaler StandardScaler() X_train_scaled scaler.fit_transform(X_train) X_test_scaled scaler.transform(X_test) # 4. 建模并训练 knn KNeighborsClassifier(n_neighbors5, weightsdistance) knn.fit(X_train_scaled, y_train) # 5. 预测和评估 y_pred knn.predict(X_test_scaled) print(Accuracy:, accuracy_score(y_test, y_pred)) print(classification_report(y_test, y_pred))这里有几个细节必须展开讲。标准化是KNN的生命线。KNN的距离计算对所有特征一视同仁如果某个特征的数值范围天然比其他特征大比如一类特征取值在0到1另一类在0到1000那么距离计算会被后者完全主导。标准化就是让每个特征都变成均值0、方差1的标准正态分布这样每个特征在距离计算中占据的权重才是公平的。上面代码里的StandardScaler就是干这个的。注意标准化有个极其隐蔽的坑fit_transform只能用在训练集上测试集上只能用transform。为什么因为标准化的均值和标准差都是在训练集上计算出来的如果拿着整个数据集去fit测试集的信息提前泄露到模型里了这叫数据泄漏。一旦数据泄漏你的测试集评估结果就会虚高模型上线后真实效果严重缩水。我见过不少新手在这个细节上翻车务必记住测试集的任何统计量都不该参与训练阶段的计算。还有一个细节代码里用了stratifyy给训练集和测试集进行分层采样。因为鸢尾花数据集是按类别排序的如果不打乱可能测试集里拿到的全是某一类样本。分层采样保证训练集和测试集里各类别的比例跟原始数据一致这对样本量小的数据集尤其重要。4.2 怎么确定最优的K值和weights组合上面代码里我随手填了n_neighbors5那这个5是怎么来的其实是通过实验试出来的。机器学习里有个通用方法叫网格搜索——把所有候选参数排列组合跑一遍看效果。我们简单写个循环测试K从1到15在不同weights下的准确率import matplotlib.pyplot as plt k_range range(1, 16) uniform_scores [] distance_scores [] for k in k_range: knn_uniform KNeighborsClassifier(n_neighborsk, weightsuniform) knn_uniform.fit(X_train_scaled, y_train) uniform_scores.append(accuracy_score(y_test, knn_uniform.predict(X_test_scaled))) knn_distance KNeighborsClassifier(n_neighborsk, weightsdistance) knn_distance.fit(X_train_scaled, y_train) distance_scores.append(accuracy_score(y_test, knn_distance.predict(X_test_scaled))) # 打印每个K的准确率 for k, u, d in zip(k_range, uniform_scores, distance_scores): print(fK{k:2d}, uniform{u:.3f}, distance{d:.3f})实际跑出来的结果一般是这样的规律K1时准确率偏低因为单点决策太容易受噪声影响随着K增大到3到7之间准确率会到达一个峰值再往后K太大远处的邻居开始捣乱准确率缓慢下降。这就是K值选择的“黄金区间”。如果只跑这一次划分你可能会怀疑测试集划分的随机性对结果有影响。正规做法是引入交叉验证把训练集再切几折循环验证取平均准确率。Sklearn里可以直接用GridSearchCVfrom sklearn.model_selection import GridSearchCV param_grid { n_neighbors: range(1, 16), weights: [uniform, distance], p: [1, 2] # p1曼哈顿距离p2欧氏距离 } grid GridSearchCV( KNeighborsClassifier(), param_grid, cv5, scoringaccuracy ) grid.fit(X_train_scaled, y_train) print(Best params:, grid.best_params_) print(Best score:, grid.best_score_)网格搜索会自动交叉验证每组参数并给出最优组合。把p也加入待选参数是很多人忽略的技巧因为欧氏距离(i2)之外曼哈顿距离(i1)在某些数据集上效果会更好。我建议做KNN调参时至少把n_neighbors、weights、p这三个变量都放进网格搜索维度不大算起来也很快。5. KNN实战中坑最多的地方标准化、维度灾难与预测速度5.1 不同量纲特征引发的连锁反应前面说过标准化的重要性但这里我想用一个实际的例子说明不标准化的后果有多严重。假设你在做用户流失预测特征有两个用户最近30天登录次数取值0到30和累计消费金额取值100到50000。如果直接用原始数据跑KNN一个登录20次但消费只有200块的用户跟一个登录5次但消费3000块的用户欧氏距离几乎完全被消费金额决定。我在一个电商数据集上做过对比实验同样的KNN模型不标准化准确率只有68%标准化后提升到79%。这11个百分点的差异完全不是模型的问题而是数据预处理的问题。KNN是所有机器学习算法中对数据尺度最敏感的一个因为它的本质是距离计算任何形式的特征缩放或归一化都会直接影响邻居的选择。除了StandardScaler还有一种常用的方法是MinMaxScaler把特征缩放回0到1的区间。两种方案差别不大但StandardScaler对离群值更鲁棒MinMaxScaler容易受极端值影响把正常数据压缩到一个很窄的区间。我在项目里默认用StandardScaler除非明确知道特征分布的范围是固定的比如像素值0到255才会考虑MinMaxScaler。5.2 维度灾难距离在高维空间里会“失灵”这是KNN另一个值得重点说的坑也是很多初学者搞不懂的地方。所谓维度灾难是指当特征数量数据维度不断增多时数据会在高维空间中变得极其稀疏所有样本之间的距离趋向于相等也就是说“最近”和“最远”的邻居之间差距变得微乎其微KNN失去了区分能力。举个例子二维平面上随机撒100个点每个点总能找到一个相对较近的邻居但在100维空间里撒100个点每个点之间的距离都非常接近没有什么点能算得上“真的近”。数据维度增长了需要的样本量必须指数级增长才能维持同样的密度这就是“灾难”的含义。所以KNN在超高维场景比如图像像素级特征、几万维的文本向量下几乎总是表现不佳。解决办法通常有两种降维先用PCA、t-SNE等方法把特征压缩到几十维甚至几维再跑KNN特征选择删掉无关或冗余特征留下真正与目标相关的核心特征。我做过一次文本分类原始特征用TF-IDF向量化后有大概5000维直接跑KNN准确率只有55%比随机猜好不了多少。用PCA降到50维之后KNN准确率反而提升到78%。这听起来违背直觉——信息明明变少了效果反而更好——但高维空间里的距离失真让那些“多余”的特征变成了干扰项。5.3 样本不平衡与预测速度的工程级问题KNN还有两个工程级的问题必须面对。第一个是样本不平衡。想象两类样本各1000条和50条新样本周围如果恰好有3个多数类样本和2个少数类样本多数投票时少数类天然劣势。处理手段有三个方向一是用weightsdistance加权让极近的少数类邻居有更高权重二是在数据层面做采样要么对少数类过采样比如SMOTE要么对多数类欠采样三是调整决策阈值不简单按票数而是按比例。现实中我通常先试weightsdistance它不需要改动数据分布而且往往就够了。第二个是预测延迟。前面讲过KNN训练快预测慢这在生产环境里是要命的。一个模型训练阶段跑3小时没人关心但线上预测如果单次耗时100毫秒面对每秒几千的请求量就直接崩了。Sklearn里有两个实用的加速手段algorithmkd_tree或ball_tree用树结构加速邻居搜索使用n_jobs-1并行计算距离矩阵。但要注意加速手段不是免费的KD-Tree的构建本身也耗时而且维度升高后加速效果急剧衰减。如果项目的数据量大到KNN预测扛不住那就得考虑换模型了比如线性模型、树模型或者干脆把KNN当baseline把预测速度的要求交给其他算法去满足。6. 进阶玩法KNN在回归、缺失值填充和推荐里的应用6.1 KNN回归用邻居的平均值预测连续目标分类只是KNN的一半能力。处理回归问题同样是一把好手。KNN回归的逻辑比分类还要简单——不再投票选类别而是把K个邻居的目标值取平均或者按距离加权平均作为预测结果。Sklearn里的KNeighborsRegressor用起来几乎一模一样from sklearn.neighbors import KNeighborsRegressor from sklearn.datasets import load_diabetes data load_diabetes() X, y data.data, data.target X_train, X_test, y_train, y_test train_test_split( X, y, test_size0.3, random_state42 ) scaler StandardScaler() X_train_scaled scaler.fit_transform(X_train) X_test_scaled scaler.transform(X_test) knn_reg KNeighborsRegressor(n_neighbors5, weightsdistance) knn_reg.fit(X_train_scaled, y_train) y_pred knn_reg.predict(X_test_scaled) from sklearn.metrics import mean_squared_error, r2_score print(MSE:, mean_squared_error(y_test, y_pred)) print(R2:, r2_score(y_test, y_pred))KNN回归对距离的敏感性跟分类一样甚至更强。想一想分类投票时只数类别个数某个特征被主导只会影响邻居是谁回归取平均时K个邻居的目标值是否接近则直接决定了误差。KNN回归在数据量不大、特征和目标之间是平滑非线性关系的数据集上效果很多时候好于线性回归因为它不假设特定的函数形式本质上是用“局部平均”逼近任何连续函数。6.2 KNN缺失值填充比全局均值靠谱得多实际数据清洗时缺失值处理相当麻烦。粗暴填0会引入大量噪声填全局均值又会把数据往中间拉。KNN的思路是找这个样本最相似的K个完整邻居用他们在缺失特征上的均值填进去因为相似样本在同一特征上的分布通常跟我们缺失的字段很接近。比如预测客户逾期数据时收入字段缺失了。如果找到一个跟当前用户年龄、职业、地区都类似的K个客户用他们的收入均值来填显然比用全部人群的收入均值更合理。Sklearn里有现成的KNNImputerfrom sklearn.impute import KNNImputer import numpy as np # 构造含缺失值的示例数据 X np.array([[3, 2, 1], [np.nan, 3, 2], [4, 1, 2], [5, np.nan, 3]]) imputer KNNImputer(n_neighbors2) X_filled imputer.fit_transform(X) print(X_filled)KNNImputer默认用欧氏距离找邻居它会自动忽略当前正在填充的特征列避免了用“待填充的字段本身”去找邻居的逻辑循环。在工业级的数据清洗流程里我一般会把KNNImputer跑一遍再对比一下直接填均值的效果看模型评价指标有没有提升。在很多结构化数据竞赛里KNN填充法经常能让成绩往上走不少。6.3 推荐系统里的近邻思想最后聊聊KNN和推荐系统的关系。你一定听说过协同过滤推荐分为基于用户的协同过滤User-Based CF和基于物品的协同过滤Item-Based CF。核心思路就是近邻思想的一种延伸。基于用户的协同过滤逻辑是给用户A推荐东西前先找到和A行为习惯最相似的一群用户A的近邻看看这些用户在买什么、看什么A没接触过的就排进推荐列表。这不就是KNN吗只不过这里“距离”不是欧氏距离而是用户相似度常用皮尔逊相关系数或余弦相似度来衡量。基于物品的协同过滤则反过来给用户推荐物品之前先找出跟这个物品“最像”的其他物品。本质上也是在所有物品向量里做K近邻搜索。搜索引擎和向量数据库里极常见的ANN近似最近邻算法也是KNN思想在大规模场景下的工业级进化。所以别看KNN基础它的思想贯穿了整个机器学习领域。从最初的鸢尾花分类到推荐系统、知识图谱、向量检索处处都有它的影子。理解了KNN你等于拿到了理解一大票算法的钥匙。我自己做项目的习惯是拿到一个新数据集永远先跑一个KNN当baseline。它不需要复杂的调参几分钟就能出一个结果能帮我快速判断数据的可预测性、特征质量、量纲问题。如果这个数据连KNN都跑不出像样的分数那大概率是特征工程或者数据本身有问题换什么复杂模型都难有奇效。这不是说KNN是万能的而是说它足够简单、足够快像一把又快又准的尺子先量一量水深再决定怎么过河。