拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

KNN算法详解:从距离度量、K值选择到分类回归实战

KNN算法详解:从距离度量、K值选择到分类回归实战

接触机器学习的人,只要不是一上来就直奔神经网络,基本都会先遇到这个算法: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)可根据数据分布调节p=1即曼哈顿,p=2即欧氏
余弦相似度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. 划分数据集,stratify=y 表示分层抽样 X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.2, random_state=42, stratify=y ) # 3. 标准化:训练集 fit_transform,测试集只 transform scaler = StandardScaler() X_train = scaler.fit_transform(X_train) X_test = scaler.transform(X_test) # 4. 训练 model = KNeighborsClassifier(n_neighbors=5) model.fit(X_train, y_train) # 5. 评估 y_pred = model.predict(X_test) print("准确率:", accuracy_score(y_test, y_pred))

有两点细节很多人第一次会忽略。第一,train_test_split里加stratify=y是分层抽样,它让训练集和测试集中三个类别的比例保持和原始数据一致。如果不加,在小数据集上极端情况下某个类别可能从测试集中消失,评估结果就会失真。第二,fit_transform用在训练集上,transform用在测试集上,这个习惯必须从第一天就养成。为什么不能直接在测试集上fit?因为测试集在我们调参过程中应该完全扮演“未来新数据”的角色,一旦在测试集上fit,scaler就等于提前看到了测试集的均值和方差,这是典型的数据泄漏。

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_size=0.2, random_state=42 ) scaler = StandardScaler() X_train = scaler.fit_transform(X_train) X_test = scaler.transform(X_test) model = KNeighborsRegressor(n_neighbors=5) 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, k=3, p=2): self.k = k # 邻居数量 self.p = p # 距离范式,p=2为欧氏距离,p=1为曼哈顿距离 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-5:

def 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, key=weights.get)

手写版能跑,但它的短板也很明显。预测一个样本就要计算n次距离,时间复杂度是O(n×d),还要排序取前K个。如果训练集有10万条样本,每预测一条都要遍历一遍,这种双重循环在数据量稍大时就会慢得让人抓狂。后面第五章我会专门讲工程优化方案。

3.2 换成sklearn:一行代码调用成熟实现

手写版和sklearn版的核心逻辑完全一致,区别主要在于工程优化。sklearn的KNeighborsClassifier原生支持多种搜索结构,参数完整,实际项目中直接使用它就好。

参数说明常用值
n_neighbors邻居数量K5~15,交叉验证确定
weightsuniform / distance噪声大时选distance
algorithmauto / brute / kd_tree / ball_tree默认auto即可
p距离范式,2是欧氏,1是曼哈顿默认2
leaf_size传给KD树的叶子节点大小默认30
n_jobs多核并行数量-1表示全部核心

这里有个实用心得:sklearn的algorithm参数默认是auto,它会在数据规模大和特征维度多时自动选择合适的搜索结构。对于几千到几万条样本的数据,默认配置完全够用,不必手动指定。weights参数值得关注,如果你的数据噪声比较重,选distance加权投票通常比uniform效果好很多,代价是计算开销更大。

3.3 用交叉验证选K:K值不是拍脑袋定的

K是KNN唯一的核心超参数,需要认真调。K值太小和太大的后果都很明显。

K=1时,决策完全依赖离得最近的单个样本,对噪声和数据错误极度敏感,决策边界会非常崎岖,这种状态就是过拟合。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, cv=5, scoring='accuracy' ) 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_neighbors=k) 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, columns=data.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_size=0.2, random_state=42, stratify=y ) pipe = Pipeline([ ('scaler', StandardScaler()), ('knn', KNeighborsClassifier()) ]) param_grid = {'knn__n_neighbors': range(1, 31)} grid = GridSearchCV(pipe, param_grid, cv=5, scoring='accuracy') 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_names=data.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树、向量化计算,或降维
K=1时结果抖动很大对噪声和离群点过度敏感增大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(axis=2)) # 结果形状为 (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的调优,再到模型评估,每一步都会直接影响最终结果。真正想把它用好,至少做三件事:第一,亲手从零实现一遍;第二,在真实数据集上完整跑一个项目;第三,把每个步骤背后“为什么这样做”搞清楚。这个算法虽然简单,但练完这一套,你再去碰其他模型会轻松很多。

返回列表