尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

K近邻(KNN)算法详解:原理、距离度量与工程实践

K近邻(KNN)算法详解:原理、距离度量与工程实践 1. 从一个最不像算法的算法说起KNNK-Nearest NeighborsK近邻是很多人入门机器学习接触的第一个算法也是最容易被低估的一个算法。它看起来实在太简单了给定一批已经标好类别的样本来了一个新样本我只需要看它距离最近的K个样本是什么类别然后投票决定这个新样本属于哪一类。没有复杂的数学推导没有多层非线性变换甚至连“训练”这个环节都显得多余。但就是这样一个“简单到怀疑人生”的算法在实际业务里却出奇地扛打。我早期在一个电商推荐项目里做过用户分类当时试了逻辑回归、朴素贝叶斯、SVM效果都不太理想最后换了一个带权重的KNN准确率直接提升了好几个百分点。原因也简单KNN是纯非参数模型它不做任何假设数据分布长什么样它就学成什么样在特征空间结构复杂、样本量又不是特别大的场景下反而比那些强假设模型更灵活。这篇内容适合几类人看正在准备机器学习面试的人KNN是高频考点刚学机器学习、想搞懂算法原理和代码对应关系的人还有在工作中想快速搭一个baseline模型、又不想一开始就上深度学习的同学。我会把KNN的核心原理、工程细节、调参经验、常见坑点一次说清楚代码用Python和scikit-learn实现每段代码都带着解释保证你能直接抄作业。2. 算法思想与数学原理拆解2.1 核心思想近朱者赤物以类聚理解KNN先记住一句话一个样本的类别由它最近的K个邻居投票决定。这句话背后其实藏着一个很重要的假设——特征空间中的距离越近样本越相似。换句话说如果两个样本在特征向量上的欧氏距离很小那么它们大概率属于同一个类别。这个假设在很多场景下是成立的比如判断一瓶红酒的品种如果用颜色深度、酒精含量、苹果酸含量这些特征来表示一瓶酒那么同一品种的酒在特征空间里自然会聚成一团不同品种的酒之间距离较远。KNN做的事情就是新来一瓶酒看它落在哪一团附近然后跟这一团里的多数派归为一类。这里有一个很多人一开始容易误解的点KNN到底有没有训练过程严格来说KNN在训练阶段做的事情只是“记住”所有训练样本的特征和标签并不学习任何参数。这种学习方式叫“惰性学习”或“基于实例的学习”。真正的计算全部发生在预测阶段每来一个预测样本都要实时计算它和所有训练样本之间的距离然后排序、取前K个、投票。这也是KNN最被人诟病的点——预测慢尤其是训练数据量大的时候。顺带说一个面试高频追问KNN和K-Means有什么关系两者的名字里都有个K但逻辑完全不同。KNN是监督学习数据有标签做的是分类或回归K-Means是无监督学习数据没有标签做的是聚类——把数据自动分成K个簇。KNN里的K是“邻居个数”K-Means里的K是“簇的个数”。千万不要在面试里把这两个说混了。2.2 距离度量不是只有欧氏距离一种选择KNN的核心计算是“距离”。最常见的距离度量方式是欧氏距离也就是我们在中学就学过的两点间直线距离d(x, y) sqrt(Σ(xi - yi)^2)其中xi和yi分别表示两个样本在第i个特征维度的取值。欧氏距离直观、计算简单也是sklearn里KNN默认的距离度量。但它有一个问题它对特征的量纲很敏感。拿红酒数据来说“颜色深度”的取值范围可能是1-6“酒精含量”可能是11%-15%如果直接用原始数值算欧氏距离量级大的特征会主导距离计算结果。这个问题的解决方案后面会详细讲但先记住一个结论用KNN前几乎一定要做特征归一化。除了欧氏距离还有几种常用的距离度量不同场景下效果差异很大距离名称公式核心适用场景欧氏距离各维度差值的平方和开根号特征维度连续、各维度量纲一致时最常用曼哈顿距离各维度差值的绝对值之和特征维度彼此独立、更关注绝对偏差的场景切比雪夫距离各维度差值的最大值棋盘走法、只关注最坏偏差的场景余弦相似度向量夹角余弦值关注方向不关注长度文本向量、用户行为向量等高维稀疏数据闵可夫斯基距离欧氏和曼哈顿的一般化形式p2时退化为欧氏p1时为曼哈顿可以通过调参p来选择更合适的度量这里说一个实际踩坑经验。有一年我在做一个文本分类的baseline特征是用TF-IDF向量表示的每个样本是一个几千维的稀疏向量。我最初用欧氏距离跑KNN效果很差准确率一直上不去。后来把距离度量换成余弦相似度准确率瞬间涨了十几个百分点。原因在于TF-IDF向量里样本的“长度”受文档长短影响很大而文本语义的相似性更多取决于词项分布的“方向”而不是“长度”。欧氏距离对长度差异太敏感余弦相似度恰好只关心角度、不关心长度所以更适合这类数据。这个经验后来帮我在好几次文本相关的项目里少走了很多弯路。2.3 K值的选择越小越敏感越大越平滑K值是KNN里唯一的超参数它的大小直接决定模型的决策边界复杂程度。K值太小比如K1模型只会看最近的那一个样本。结果就是决策边界极其细腻容易把噪声样本也学进去发生过拟合。举个例子如果某个样本的标签本身标错了K1时它周围所有点的预测都会被它带偏。K值太大比如K训练集大小模型的预测结果永远是整个训练集中样本数量最多的那个类别。决策边界变得非常平滑相当于放弃了对细节的区分能力模型严重欠拟合。实际工程中怎么选K最靠谱的办法是交叉验证。把K的候选值从小到大排一排比如[1, 3, 5, 7, 9, 11, 15, 19, 21]对每个K值做5折交叉验证看平均准确率选效果最好的那个。我在实战中有一个经验参考K一般不要超过训练样本数的平方根。假如训练集有1000条样本sqrt(1000)≈31那么优先在[1, 31]这个范围内搜K。这个经验值不是严格定理但它能帮你快速缩小搜索空间避免从1试到几百的尴尬。另一个经验是K尽量选奇数。尤其在二分类场景下选偶数K可能导致投票出现平局。比如K4两个类别各得2票那就只能靠随机或者按照距离加权来打破平局。选奇数可以天然避开这个问题。3. 工程实现中的关键细节3.1 特征归一化不做这一步KNN等于白跑这可能是KNN实践里最重要也最容易被忽略的一步。前面说过欧氏距离对特征量纲极其敏感特征值的绝对值差异会直接扭曲距离计算。我还是拿红酒分类举例。红酒数据集包含酒精含量约11%-15%、苹果酸含量约0.7-1.7、颜色深度约1.2-6.5等特征。如果我们不做归一化直接算距离“颜色深度”这个特征对距离的贡献会明显大于“酒精含量”因为它的数值范围大结果模型相当于只看了部分特征其他特征全部被压掉了。常用的归一化方法有两种Min-Max标准化把数据缩放到[0, 1]区间公式是 (x - min) / (max - min)。适合特征分布有明确上下界的情况但对离群点敏感一个极端值就能把整个缩放比例带偏。Z-Score标准化把数据变成均值为0、方差为1的分布公式是 (x - mean) / std。适合特征分布接近正态的场景对离群点的鲁棒性比Min-Max好一些。sklearn里分别对应MinMaxScaler和StandardScaler。我的习惯是除非特征有明确的业务上下界否则优先用StandardScaler。一方面它不需要知道min和max用训练集算出的mean和std可以直接复用在测试集上另一方面KNN距离计算面对标准化后的数据各特征量纲一致模型效果更稳定。有一个细节必须提醒scaler只能在训练集上fit然后用同样的scaler去transform测试集。很多人容易把训练集和测试集合在一起做归一化这属于信息泄露会导致模型在评估阶段的准确率虚高上线后瞬间被打回原形。3.2 惰性学习与KD树KNN的效率瓶颈从哪来KNN训练快、预测慢这句话要正确理解。所谓“训练快”是因为它压根没有训练过程只是把数据存起来。所谓“预测慢”是因为每次预测都要扫描整个训练集计算一遍距离。假设训练集有N条样本特征维度是D那么单次预测的时间复杂度就是O(N * D)。在数据量小的时候无所谓但当N达到百万量级D有几百维时一次预测就要做几亿次浮点运算这在实时服务里是灾难性的。常见的优化方案是KD树K-Dimensional Tree。KD树的思想是对特征空间做递归划分先选一个维度把数据分成两半再在子区域里选另一个维度继续分直到每个节点包含的样本数足够少。预测时利用这棵树的结构快速排除掉距离较远的区域不需要跟全量样本算距离平均时间复杂度降到了O(log N)左右。sklearn里通过设置参数algorithmkd_tree来启用。需要注意KD树在高维场景下会退化。当特征维度D远大于样本量N的对数时树的分支划分效果变得很差搜索效率甚至不如暴力计算。经验上特征维度超过20维KD树的优势就不明显了超过100维基本可以忘记KD树。高维场景下更常用的是Ball Tree它用超球体来做区域划分对高维数据的适应能力比KD树好一些。sklearn里设置algorithmball_tree即可。不过话说回来当数据量大到百万级、实时性要求又很高的时候这些优化都只是杯水车薪。工程上更常见的做法是先用KNN做离线baseline验证数据有效性和特征设计真正上线时换更快的模型或者用局部敏感哈希LSH这类近似最近邻搜索方案去做加速。3.3 用scikit-learn跑通红酒分类全流程下面进入动手环节。我们以scikit-learn自带的红酒数据集为例完整过一遍KNN分类的流程从加载数据、划分训练集、特征标准化、模型训练、交叉验证到最终评估。红酒数据集有178条样本3个类别13个特征是一个非常适合入门的小型数据集。下面是完整代码import numpy as np import matplotlib.pyplot as plt from sklearn.datasets import load_wine from sklearn.model_selection import train_test_split, cross_val_score from sklearn.preprocessing import StandardScaler from sklearn.neighbors import KNeighborsClassifier from sklearn.metrics import accuracy_score, classification_report # 加载数据 wine load_wine() X wine.data y wine.target print(特征矩阵形状, X.shape) print(类别分布, np.bincount(y)) # 划分训练集和测试集 X_train, X_test, y_train, y_test train_test_split( X, y, test_size0.2, random_state42, stratifyy ) print(训练集样本数, X_train.shape[0]) print(测试集样本数, X_test.shape[0]) # 标准化 scaler StandardScaler() X_train_scaled scaler.fit_transform(X_train) X_test_scaled scaler.transform(X_test) # 创建KNN模型先用默认K5 knn KNeighborsClassifier(n_neighbors5) knn.fit(X_train_scaled, y_train) # 在测试集上评估 y_pred knn.predict(X_test_scaled) print(测试集准确率, accuracy_score(y_test, y_pred)) print(classification_report(y_test, y_pred, target_nameswine.target_names))这段代码有几个关键细节值得展开说。stratifyy这个参数很多人会忽略它的作用是让训练集和测试集里各类别的比例与原始数据集保持一致。红酒数据集3个类别的样本数量本身就不完全均衡如果不做分层采样划分结果可能上偏差导致小样本类别的样本在测试集里太少评估结果不可信。标准化这一步注意我是先fit_transform训练集再transform测试集。fit_transform做的事情是计算训练集的均值和标准差然后用它来缩放数据transform只做缩放不再重新计算均值和标准差。这样做保证了测试集用的是“训练集学到的统计量”模拟了真实上线时新样本进入系统的过程。KNN在sklearn里的默认配置是n_neighbors5这只是一个默认值不代表对任何数据都是最优解。我们下面用交叉验证来回答一个问题K取多少效果最好# 通过交叉验证选择K值 k_values list(range(1, 31)) cv_scores [] for k in k_values: knn KNeighborsClassifier(n_neighborsk) scores cross_val_score(knn, X_train_scaled, y_train, cv5) cv_scores.append(scores.mean()) best_k k_values[np.argmax(cv_scores)] print(交叉验证最优K值, best_k) print(最高平均准确率, max(cv_scores)) # 用最优K值重新训练并评估 knn_best KNeighborsClassifier(n_neighborsbest_k) knn_best.fit(X_train_scaled, y_train) y_pred_best knn_best.predict(X_test_scaled) print(最优K在测试集上的准确率, accuracy_score(y_test, y_pred_best))这里cross_val_score的cv5表示做5折交叉验证把训练集均分为5份每次拿4份训练、1份验证轮转5次取平均准确率。这种方式比只划分一次“训练集-验证集”更稳定因为每一份数据都有机会当验证集模型在不同子集上的表现差异能被平均掉。跑完这段代码你会得到一张“K值-准确率”的曲线。典型的结果是K1时准确率比较高但也最不稳定K在3-7之间准确率比较稳定K继续增大到15以上准确率开始明显下滑。这个趋势很有代表性背后原因就是我前面说的——小K容易过拟合大K容易欠拟合总有一个中间区域是相对甜点区。3.4 距离加权给近邻更高的发言权默认的KNN投票方式是“一票一权”K个邻居无论距离远近对最终结果的影响完全相同。但直觉告诉我们距离更近的邻居应该更有发言权。比如一个测试样本周围最近的3个点一个距离是0.05另外两个距离是1.8和2.1如果等权投票那个远距离的噪声点也能跟近距离的样本平起平坐这显然不合理。加权KNN的思路就是按照距离的倒数给每个邻居赋予权重距离越近权重越大。sklearn里只需要设置一个参数knn_weighted KNeighborsClassifier(n_neighbors5, weightsdistance)weights参数有两个选项uniform默认等权投票和distance按距离倒数加权。在实际项目中我强烈建议两个都试一下用交叉验证决定用哪个。加权KNN通常比等权KNN效果好一些但也并非绝对——如果数据里的类别重叠区域比较大距离越近权重越大反而会放大局部噪声的影响。说白了还是要用数据说话不要上来就拍板。3.5 回归场景下的KNNKNN不止能做分类也能做回归。原理一样找到最近的K个邻居预测值就是这K个邻居标签的平均值等权时或加权平均值按距离加权时。sklearn里有KNeighborsRegressor可以直接用from sklearn.neighbors import KNeighborsRegressor knn_reg KNeighborsRegressor(n_neighbors5, weightsdistance) knn_reg.fit(X_train_scaled, y_train) y_pred_reg knn_reg.predict(X_test_scaled)回归场景下K值的影响规律跟分类基本一致K太小预测结果波动大K太大预测结果过于平滑细节信息丢失。此外回归任务对特征标准化的需求比分类更迫切因为均值计算直接受特征取值范围影响。4. 常见问题与排查技巧实录4.1 为什么我的KNN准确率这么低这是最常被问到的问题我从项目经验里总结出三个高频原因按影响程度排序。第一没有做特征标准化。我见过太多同学下载数据、切分一下、丢进KNN出来的准确率稀烂然后开始怀疑人生。用前面红酒数据做个实验对比就知道了不做标准化的测试集准确率比做了标准化的低10到20个百分点极其惨烈。这是KNN最容易踩的坑没有之一。第二特征维度太高。当特征维度达到几十甚至上百欧氏距离的可区分性会严重退化。这不是KNN一个算法的问题而是高维空间里所有样本之间的距离都趋于相等“最近邻”和“最远邻”也拉不开差距。这种情况优先考虑先用PCA或基于特征重要性的方法做降维再跑KNN。第三数据本身线性不可分且类别边界重叠严重。KNN本质上是基于密度的分类器如果两个类别在特征空间中大量重叠切任何K值都很难分干净。这时候要么换更复杂的模型要么增加特征给模型更多信息。很多项目里调参怎么调都上不去问题压根不在模型而在特征设计。4.2 为什么我的模型在训练集上表现很好测试集上一塌糊涂这是典型的过拟合信号。对KNN来说K值越小越容易过拟合尤其K1的时候模型会把训练集里的每一个点都当成自己领域的“独裁者”边界极度扭曲。排查方向有两个先看K值。把K值调大或者用交叉验证重新搜索一遍最优K。再看数据量。如果训练集本身样本就很少比如每类只有几十个样本KNN很容易整体记住训练集。这种情况下要么想办法扩充数据要么考虑降低特征维度、增加正则约束。注意KNN本身没有正则化参数能用的正则手段其实就是调大K值让模型更“平滑”一些。4.3 特征量纲差异巨大标准化之后效果反而变差了标准化不是银弹有一种情况会让标准化帮倒忙特征本身带有业务上的重要度量信息强行统一量纲反而会把这种信息抹掉。举个极端例子假设做房屋估价特征包含“面积平方米”和“房价万元”房价天然比面积的数值大几个量级但“面积”这个特征对房屋类别划分其实更有区分力。标准化后两个特征被拉到相同的尺度KNN计算距离时反而让“房价”这个弱特征占据了跟“面积”同等重要的位置。如果遇到这种情况思路不是放弃标准化而是重新审视特征设计。可以考虑对特征做加权距离给重要的特征更大的权重或者直接用特征选择方法把弱特征去掉。要意识到标准化解决的是量纲问题不是特征重要性问题两者不要混为一谈。4.4 数据类别不平衡的时候怎么用KNN用KNN处理不平衡数据要格外小心。假设训练集中A类有990个样本B类有10个样本新来一个测试样本恰好在A类和B类的边界处周围的邻居大概率全是A类样本B类样本的声音被完全淹没。应对策略有几招都是我在项目里验证过的。第一调整K值。K值越小B类样本被A类“淹没”的概率越低。但这治标不治本K太小又会导致过拟合。第二采用加权投票。不仅按距离加权还可以按类别频率加权类别样本越少单票权重越大。sklearn里没有直接参数支持这个操作但可以手动实现。第三数据层面处理。对多数类做欠采样、对少数类做SMOTE过采样先把数据比例拉平再跑KNN。这一招在工程里最常用也最有效因为改数据比改模型来得直接而且各类算法的受益面都很明显。4.5 一个关于“训练”阶段的特别提醒KNN的“训练”阶段虽然只是存储数据但它有一个非常关键的操作如果你做了特征标准化一定要把scaler也保存下来。上线预测时新样本必须先经过同样的scaler转换才能喂给模型。很多初学同学在离线评估时用jupyter跑流程模型效果不错结果到了部署阶段把训练好的模型导出、丢掉scaler然后新数据直接进模型效果暴跌排查半天才发现是这个环节出了问题。保存scaler的方式跟保存模型类似import joblib # 保存模型和scaler joblib.dump(knn_best, knn_model.pkl) joblib.dump(scaler, scaler.pkl) # 加载模型和scaler loaded_model joblib.load(knn_model.pkl) loaded_scaler joblib.load(scaler.pkl) # 预测新样本 new_sample [[13.5, 1.8, 2.3, ...]] # 注意特征个数和顺序必须与训练时一致 new_sample_scaled loaded_scaler.transform(new_sample) prediction loaded_model.predict(new_sample_scaled)这个坑我当年踩过现在分享出来希望大家不要重复交这笔学费。4.6 KNN适合解决什么样的问题最后说说KNN在真实业务中的定位。按我个人的经验KNN最适合的场景有三类小而美的baseline。一个新业务上线数据量不大先跑一个KNN看看特征体系是否有效、类别是否可分几乎零成本。哪怕后面要换复杂模型KNN的baseline结果也是很好的参照物。低维可解释的场景。特征维度低比如个位数数据量中等KNN的分类结果易于可视化和解释。做风控规则、客户分群时经常用这种方式画决策边界。实时更新友好的场景。因为KNN没有训练参数新数据进来直接存入样本库就可以参与预测不需要重新训练模型。在标签快速变化、模型需要频繁迭代的场景下这是一个很大的优势。至于大规模高维数据、要求毫秒级响应、或者类别之间高度重叠的场景KNN不是最优选择考虑换成树模型、SVM、神经网络等更合适的方案。我实际做项目中还有一个小习惯拿到一份新数据、新任务无论做分类还是回归先跑一个带标准化的KNN看效果。它的价值不在于模型本身有多强而在于它给后续所有复杂模型提供了一个认知锚点——在这个数据上一个“不学习”的模型能到什么水平之后任何模型的提升空间是多少心里有数。这个习惯帮我避免了很多“换了个高级模型但几乎没有提升”的尴尬局面也让我在团队里讲清楚“模型效果到底来自哪个环节”的时候手里永远有据可查。希望这篇总结对你也有同样的价值。
返回列表