
接触机器学习的人只要不是一上来就直奔神经网络基本都会先遇到这个算法KNN。中文叫K近邻英文全称K-Nearest Neighbors逻辑简单到一句话就能说清楚——新样本进来看它在特征空间里离得最近的K个训练样本是谁然后让这些邻居投票决定结果。我一年多前第一次用Python语言实现KNN的时候觉得这太小儿科了没想到越用越发现里面的坑比想象中多距离度量怎么选、K值定多少、数据要不要标准化、分类和回归怎么切换。这篇我会把踩过的坑和整理好的完整流程一次性写清楚适合刚入门机器学习、期末要交课程设计、或者想把KNN用在小型项目上的朋友。1. KNN算法到底在解决什么问题1.1 一句话讲透KNN是在做“物以类聚”KNN属于监督学习同时是典型的惰性学习算法。所谓惰性学习就是训练阶段基本什么都不干把训练数据原样存下来真正的工作全部留到预测时才发生。这和神经网络、决策树这类在训练阶段就要学出一个模型的算法完全不同你调用fit方法时它只是把X_train和y_train保存起来仅此而已。真正的工作都发生在predict阶段拿到一个新样本之后立刻计算它和所有已存样本之间的距离找出距离最近的K个邻居然后让邻居表态。分类任务用多数投票回归任务取均值这就是KNN的全部核心逻辑。为什么这样简单的规则能解决实际问题因为它背后有一个朴素但很强的假设同一个特征空间里彼此距离近的样本标签大概率相同。这个假设在大部分低维到中等维度的表格数据上都站得住脚。比如判断一个人的体重身高相近、年龄相近的人体重往往也相近判断肿瘤良恶性细胞尺寸、密度这些指标相近的样本其结果在统计学上也确实更接近。拿生活场景类比会更直观班级里来了个插班生老师不清楚他的学习水平最直接的办法就是看他周围经常一起玩的那几个同学。如果那些同学成绩都不错那这个插班生的成绩大概率也不会差。KNN做的就是这件事只不过把“玩得好”替换成了“特征距离近”。1.2 分类还是回归KNN两个都能干很多新手以为KNN只能做分类这是最常见的误解之一。KNN实际上是一个通用框架分类和回归只在最后的决策规则上有区别。分类任务中K个邻居通过投票决定结果每个标签投一票票数最多的胜出。回归任务中把投票改成取邻居标签的平均值输出就是一个连续数值。也就是说你只需要把决策规则从“投票”换成“取均值”前面那套距离计算流程原封不动就能复用。典型分类案例有鸢尾花种类判别、手写数字识别、肿瘤良恶性判断典型回归案例有房价预测、气温预测、站点流量预测。正是这种一套思路通吃两类问题的通用性让KNN成为机器学习入门阶段性价比极高的算法。更难得的是KNN在不少真实场景下仍然能扛大梁。消耗小样本数据集没问题需要给业务方给出可解释结果的场合KNN也有天然优势。你可以直接说“这个新用户被判定为高风险是因为他和这几条已知的高风险记录最相似”这种解释性比很多复杂黑盒模型强太多。1.3 距离度量选不对模型直接废一半KNN的根基是距离。如果距离算得不准后面所有投票和均值都没有意义。常用的距离度量主要就四种我整理了一张表方便对比。距离类型公式适用场景备注欧氏距离d √(Σ(xᵢ - yᵢ)²)连续数值特征各维度已标准化最常用sklearn默认曼哈顿距离d Σ⎮xᵢ - yᵢ⎮高维、稀疏或含异常点对离群点更稳健闵可夫斯基距离d (Σ⎮xᵢ - yᵢ⎮ᵖ)^(1/p)可根据数据分布调节p1即曼哈顿p2即欧氏余弦相似度cosθ (x·y)/(‖x‖·‖y‖)文本、稀疏向量、方向敏感场景只关心方向不关心长度实际操作中如果不确定怎么选直接用欧氏距离配合标准化基本不会出大错。如果特征里存在大量稀疏类别或者离群点比较多可以试试曼哈顿距离如果做文本向量余弦相似度则比欧氏距离更契合语义因为它只衡量方向差异忽略向量长度。下面这一小段代码可以帮你快速验证四种距离的数值差异import numpy as np x np.array([1, 2, 3]) y np.array([4, 5, 6]) # 欧氏距离 euclidean np.sqrt(np.sum((x - y) ** 2)) print(欧氏距离:, euclidean) # 曼哈顿距离 manhattan np.sum(np.abs(x - y)) print(曼哈顿距离:, manhattan) # 余弦相似度 cosine np.dot(x, y) / (np.linalg.norm(x) * np.linalg.norm(y)) print(余弦相似度:, cosine)跑一下这个例子你会发现欧氏距离和曼哈顿距离的数值差异很大。这不是谁对谁错的问题而是不同度量方式放大了数据中不同维度的信息选型必须结合业务和数据分布来判断。2. 先跑两个小案例热身分类和回归都行2.1 鸢尾花分类几十行代码跑通第一个案例很多人第一次用机器学习就是在鸢尾花数据集上跑通整个流程的。sklearn内置了这个数据集不需要额外下载非常适合验证KNN的完整链路。鸢尾花数据集共有150条样本、4个特征、3个类别每条样本记录的是花萼长度、花萼宽度、花瓣长度、花瓣宽度标签分别是山鸢尾、变色鸢尾、维吉尼亚鸢尾。完整的上手流程是加载数据、切分训练集和测试集、标准化、创建KNN分类器、训练、预测、计算准确率。下面这段代码可以直接运行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 # 1. 加载数据 iris load_iris() X, y iris.data, iris.target # 2. 划分数据集stratifyy 表示分层抽样 X_train, X_test, y_train, y_test train_test_split( X, y, test_size0.2, random_state42, stratifyy ) # 3. 标准化训练集 fit_transform测试集只 transform scaler StandardScaler() X_train scaler.fit_transform(X_train) X_test scaler.transform(X_test) # 4. 训练 model KNeighborsClassifier(n_neighbors5) model.fit(X_train, y_train) # 5. 评估 y_pred model.predict(X_test) print(准确率:, accuracy_score(y_test, y_pred))有两点细节很多人第一次会忽略。第一train_test_split里加stratifyy是分层抽样它让训练集和测试集中三个类别的比例保持和原始数据一致。如果不加在小数据集上极端情况下某个类别可能从测试集中消失评估结果就会失真。第二fit_transform用在训练集上transform用在测试集上这个习惯必须从第一天就养成。为什么不能直接在测试集上fit因为测试集在我们调参过程中应该完全扮演“未来新数据”的角色一旦在测试集上fitscaler就等于提前看到了测试集的均值和方差这是典型的数据泄漏。2.2 KNN做回归房屋价格预测的均值投票KNN回归和分类的代码框架几乎一样区别只在模型类换成KNeighborsRegressor评估指标从准确率换成均方误差。这里我用sklearn内置的糖尿病数据集演示数据集包含10个特征目标是预测一年后病情进展的量化指标。from sklearn.datasets import load_diabetes from sklearn.model_selection import train_test_split from sklearn.preprocessing import StandardScaler from sklearn.neighbors import KNeighborsRegressor from sklearn.metrics import mean_squared_error data load_diabetes() X, y data.data, data.target X_train, X_test, y_train, y_test train_test_split( X, y, test_size0.2, random_state42 ) scaler StandardScaler() X_train scaler.fit_transform(X_train) X_test scaler.transform(X_test) model KNeighborsRegressor(n_neighbors5) model.fit(X_train, y_train) y_pred model.predict(X_test) mse mean_squared_error(y_test, y_pred) print(均方误差:, mse)KNN回归的预测本质就是K个邻居标签的均值。这里还可以进一步升级比如加权重让距离更近的邻居在均值中占更大比重。在sklearn中直接把参数weights设为distance就能启用距离加权回归。这个思路后面第三章手写实现时也会用到。2.3 标准化不是可选项是KNN的前置条件我见过不少人在KNN项目上准确率死活上不去最后排查发现原因特别蠢没做标准化。比如某个数据集里一个特征是年龄范围20到60另一个特征是收入范围5000到30000欧氏距离计算时收入这个特征的数值天然比年龄大几十倍甚至几百倍距离几乎完全由收入主导年龄对分类没有任何贡献模型直接就废了。举个直观的例子样本A年龄25、收入15000样本B年龄35、收入16000样本C年龄26、收入1000。只看年龄A和C最近看欧氏距离A和B因为收入接近反而被强行划为邻居。这显然不是我们想要的结果。标准化之后每个特征都被拉到差不多的数量级。StandardScaler会把特征变成均值为0、标准差为1的分布距离计算时每个特征才有平等的发言权。在实际项目中我默认直接使用StandardScaler除非明确知道数据分布适合MinMaxScaler。对于KNN这种基于距离的算法这一步做不做往往是准确率60分和90分的分水岭。3. 手写KNN不用sklearn也能跑通的Python实现3.1 从零实现一个KNN分类器有的同学刚入门就问我用KNN要不要调sklearn。我的建议是最好先手写一遍。手写KNN的意义不在于重复造轮子而在于你能真正理解每一条样本在预测时到底发生了什么。一个完整的KNN手写类只需要四个方法。fit方法只存数据predict_one方法完成一次预测先计算待测样本到所有训练样本的距离排序后取前K个索引再对K个标签投票predict方法则循环处理所有测试样本。import numpy as np from collections import Counter class KNN: def __init__(self, k3, p2): self.k k # 邻居数量 self.p p # 距离范式p2为欧氏距离p1为曼哈顿距离 def fit(self, X_train, y_train): # KNN是惰性学习fit不做任何训练只保存数据 self.X_train np.array(X_train) self.y_train np.array(y_train) def _distance(self, x1, x2): # 闵可夫斯基距离的统一实现 return np.sum(np.abs(x1 - x2) ** self.p) ** (1 / self.p) def predict_one(self, x): # 计算x到所有训练样本的距离 distances [self._distance(x, x_train) for x_train in self.X_train] # 取距离最小的前k个索引 k_idx np.argsort(distances)[:self.k] # 统计这k个邻居的标签 labels [self.y_train[i] for i in k_idx] # 多数投票 return Counter(labels).most_common(1)[0][0] def predict(self, X): return np.array([self.predict_one(x) for x in np.array(X)])如果要用距离加权投票也就是距离越近的邻居话语权越大只需在投票环节把票数改为权重即可。权重可以简单地取1除以距离为了让距离为0的样本不产生无限大权重要加一个极小值1e-5def predict_one_weighted(self, x): distances [self._distance(x, x_train) for x_train in self.X_train] k_idx np.argsort(distances)[:self.k] weights {} for i in k_idx: label self.y_train[i] w 1 / (distances[i] 1e-5) weights[label] weights.get(label, 0) w return max(weights, keyweights.get)手写版能跑但它的短板也很明显。预测一个样本就要计算n次距离时间复杂度是O(n×d)还要排序取前K个。如果训练集有10万条样本每预测一条都要遍历一遍这种双重循环在数据量稍大时就会慢得让人抓狂。后面第五章我会专门讲工程优化方案。3.2 换成sklearn一行代码调用成熟实现手写版和sklearn版的核心逻辑完全一致区别主要在于工程优化。sklearn的KNeighborsClassifier原生支持多种搜索结构参数完整实际项目中直接使用它就好。参数说明常用值n_neighbors邻居数量K5~15交叉验证确定weightsuniform / distance噪声大时选distancealgorithmauto / brute / kd_tree / ball_tree默认auto即可p距离范式2是欧氏1是曼哈顿默认2leaf_size传给KD树的叶子节点大小默认30n_jobs多核并行数量-1表示全部核心这里有个实用心得sklearn的algorithm参数默认是auto它会在数据规模大和特征维度多时自动选择合适的搜索结构。对于几千到几万条样本的数据默认配置完全够用不必手动指定。weights参数值得关注如果你的数据噪声比较重选distance加权投票通常比uniform效果好很多代价是计算开销更大。3.3 用交叉验证选KK值不是拍脑袋定的K是KNN唯一的核心超参数需要认真调。K值太小和太大的后果都很明显。K1时决策完全依赖离得最近的单个样本对噪声和数据错误极度敏感决策边界会非常崎岖这种状态就是过拟合。K特别大时比如K等于训练样本总数预测结果就变成训练集中各类别的固定比例模型基本丧失区分能力这是欠拟合。K需要在二者之间找一个平衡点。实际操作中最省心的方法是用GridSearchCV它会对指定的超参数组合做交叉验证from sklearn.model_selection import GridSearchCV from sklearn.neighbors import KNeighborsClassifier param_grid {n_neighbors: range(1, 31)} grid GridSearchCV( KNeighborsClassifier(), param_grid, cv5, scoringaccuracy ) grid.fit(X_train, y_train) print(最佳K值:, grid.best_params_)想更直观地观察K的影响也可以自己循环画一条K值的准确率曲线import matplotlib.pyplot as plt k_range range(1, 31) scores [] for k in k_range: model KNeighborsClassifier(n_neighborsk) model.fit(X_train, y_train) scores.append(model.score(X_test, y_test)) plt.plot(k_range, scores) plt.xlabel(K) plt.ylabel(Accuracy) plt.show()怎么看这条曲线通常K从小到大会先快速上升然后进入一个平台期再缓慢下降。要注意不要机械地选曲线上最高的那个K那往往是噪声的产物。更稳妥的做法是选平台期上较稳定的点同时做一次五折交叉验证确认结果而不是只依赖单次训练测试集的划分。4. 完整实战用KNN做一个可落地的分类项目4.1 场景设定与数据准备跑通一套标准流程到这一章我们不再单纯讲KNN原理而是把它当成工程工具来跑一个完整项目。我选一个贴近真实业务的数据集乳腺癌数据集它是sklearn内置的包含569条样本、30个特征标签是二分类的良性或恶性。业务场景可以理解为医院拿到一批患者的细胞特征指标需要自动判断肿块属于哪一类。之所以选这个数据集是因为它特别适合KNN样本量不大特征基本连续类别均衡程度尚可而且业务天然要求模型结果可解释医生需要知道判断依据是什么。标准流程从加载数据开始先检查形状和类别分布再加缺失值检查然后切分数据、标准化最后进入调参评估环节。内置数据集本身比较干净但真实项目中缺失值这一步绝不能跳过。缺失值如果处理不当KNN距离计算时会直接把缺失值当作0参与运算结果会产生严重偏差。from sklearn.datasets import load_breast_cancer import pandas as pd data load_breast_cancer() X, y data.data, data.target df pd.DataFrame(X, columnsdata.feature_names) print(df.shape) print(df.isnull().sum().sum()) print(pd.Series(y).value_counts())4.2 特征处理与数据集划分三个容易犯的泄漏错误标准化在真实项目里的时机问题值得再强调因为太容易踩坑。下面这几种做法都会造成数据泄漏我逐个说明第一种在划分数据集之前先对整个X做标准化。这样一来标准化公式里用到的均值和方差就包含了测试集的信息相当于在训练阶段偷看了未来的数据。正确做法是先train_test_split再在训练集上fit_transformer在测试集上只transform。第二种在用GridSearchCV做交叉验证时把标准化步骤放在交叉验证外面。交叉验证内部会把训练集再切出一部分当验证集如果标准化在外部就拟合了完整训练集验证集的信息也已经渗入标准化参数。正确做法是把标准化和KNN组合成一个Pipeline让交叉验证在每一折内部重新做标准化。from sklearn.pipeline import Pipeline pipe Pipeline([ (scaler, StandardScaler()), (knn, KNeighborsClassifier()) ])第三种真实业务部署时用包含未来数据的样本做标准化。这在离线训练时很难发现但上线后会出现模型评估很好、线上表现很差的现象。生产环境里应该是只用历史数据拟合scaler然后对实时进来的新数据做transform两者要分开。30个特征要不要全部保留也值得想一下。乳腺癌数据集里半径、周长、面积这组特征本质上是同一物理量的不同度量相关性极高。对KNN这种距离敏感算法冗余特征会放大噪声还增加计算量。入门阶段不必过度加工但用PCA降到两维做可视化或者简单用随机森林的feature_importance筛掉一部分特征都能显著提升KNN的表现。4.3 模型训练与评估用混淆矩阵看问题正式进入模型环节用Pipeline配合GridSearchCV搜索最佳K值然后在测试集上输出完整评估结果。from sklearn.model_selection import train_test_split, GridSearchCV from sklearn.preprocessing import StandardScaler from sklearn.neighbors import KNeighborsClassifier from sklearn.pipeline import Pipeline from sklearn.metrics import classification_report, confusion_matrix X_train, X_test, y_train, y_test train_test_split( X, y, test_size0.2, random_state42, stratifyy ) pipe Pipeline([ (scaler, StandardScaler()), (knn, KNeighborsClassifier()) ]) param_grid {knn__n_neighbors: range(1, 31)} grid GridSearchCV(pipe, param_grid, cv5, scoringaccuracy) grid.fit(X_train, y_train) y_pred grid.predict(X_test) print(最佳参数:, grid.best_params_) print(测试集准确率:, (y_pred y_test).mean()) print(classification_report(y_test, y_pred, target_namesdata.target_names)) print(confusion_matrix(y_test, y_pred))到这里输出的分类报告里会给出精确率、召回率、F1值。只看准确率是不够的必须看每类别的召回率。医学场景中假阴性是最危险的错误——恶性肿块被误判为良性患者可能错过最佳治疗时机。所以在调参时可以把scoring从accuracy改为recall让模型更关注减少假阴性。混淆矩阵的四象限也要会读TN是正确判断的良性TP是正确判断的恶性FP是良性误判为恶性FN是恶性误判为良性。KNN在这个数据集上通常能跑到90%以上的准确率但你真正要盯的是FN那一格什么时候出现、出现的比例有多高。这比一个好看的准确率数字有意义得多。如果你还想把决策边界可视化可以用PCA把数据降到二维再画KNN边界效果直观但不一定完全代表高维空间的真实决策。我的建议是可视化用来向别人解释模型可以用于调参则要谨慎因为降维本身就在丢信息。5. 常见问题与排查技巧实录5.1 典型问题速查表我把实操中常见的KNN问题整理成一张速查表遇到现象可以直接对照解法排查。现象可能原因解决办法ModuleNotFoundError: No module named sklearn环境里没装scikit-learnpip install scikit-learn报错Feature数量不一致训练和预测的特征数对不上检查预处理流程是否一致建议用Pipeline测试集准确率接近随机猜测没标准化、K值没调、特征噪声大加StandardScaler交叉验证选K预测速度极慢样本量大时暴力双重循环用KD树、向量化计算或降维K1时结果抖动很大对噪声和离群点过度敏感增大K并使用distance距离加权类别不均衡时分类偏向大类多数投票天然偏向样本多的类用加权投票、平衡采样或调整K训练集得分高但测试集得分低过拟合增大K检查特征标准化做交叉验证先说最后一个问题这是新手最容易困惑的现象。如果训练集准确率98%测试集只有70%第一步不要怀疑随机种子先检查数据有没有泄漏。例如StandardScaler是在划分前fit的还是划分后fit的。第二步再考虑K值是否太小。很多情况这两步检查完问题就解决了一半。5.2 KNN在大数据场景下的工程优化建议KNN的原生时间复杂度是O(n×d)n是训练样本数d是特征维度。这意味着样本量到几十万甚至上百万时每预测一条都要扫描全部训练样本性能完全跟不上。工程上有几条实际路子可以走。第一条用sklearn自带的algorithm参数让KD树或球树替代暴力搜索。KD树对低维数据效果好但维度超过20左右时性能急剧退化因为高维空间里树的分支修剪效率会变得很差。球树相对更稳一些适合处理维度略高的数据但构建时间也更长。第二条用numpy向量化替代手写Python循环。通过广播机制一次算出整个测试集和训练集的距离矩阵速度比双重for循环快一个数量级。# 一次算出测试集所有样本到训练集所有样本的欧氏距离 distances np.sqrt(((X_test[:, None, :] - X_train[None, :, :]) ** 2).sum(axis2)) # 结果形状为 (n_test, n_train)第三条上近似最近邻方案。在搜索引擎、推荐系统这些真正海量数据的场景里KNN更多是作为baseline或检索层存在这时会用到LSH等近似算法以及FAISS这类专门做向量检索的库。对入门项目来说几万条样本以内直接用暴力搜索没问题几十万以上就要考虑树结构或近似算法这个梯度要心里有数。5.3 环境配置与依赖安装的坑最后补一个环境相关的问题因为我在群里看到很多人在这一步就卡住了。跑KNN最基础的依赖是三件套numpy、scikit-learn、matplotlib有pandas处理数据会更顺手。一条命令就能装齐pip install numpy scikit-learn matplotlib pandas装完之后检验一下版本避免装到太老的numpy导致sklearn报兼容性错误import numpy as np import sklearn print(np.__version__) print(sklearn.__version__)很多奇怪报错比如导入sklearn时报DLL load failed绝大多数情况不是代码问题而是套件库版本冲突。解决办法是新建一个干净的虚拟环境重新安装三件套。Windows系统上尤其要注意别把Python装到奇怪路径也别同时混用多个Python发行版这种环境问题排查起来可比写代码耗时多了。我自己实测下来的感受是KNN作为入门算法最大的价值不是它本身有多强而是它强迫你把整套机器学习流程想清楚。从数据预处理到标准化从距离度量到K的调优再到模型评估每一步都会直接影响最终结果。真正想把它用好至少做三件事第一亲手从零实现一遍第二在真实数据集上完整跑一个项目第三把每个步骤背后“为什么这样做”搞清楚。这个算法虽然简单但练完这一套你再去碰其他模型会轻松很多。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。