☰
kNN算法详解:原理、特征缩放与scikit-learn实战调参
2026/10/15 11:26:53 网站建设 项目流程

机器学习系列写到第二篇,这次选了kNN(k近邻算法)。它是很多人入门的第一个模型,逻辑简单到可以用一句话讲完,但在scikit-learn里真正用好的时候,能处理不少实际问题。相比第一篇讲线性回归时的公式推导,kNN几乎不需要训练过程,理解起来非常直观,但它陷阱一点都不少。这篇我会从原理、数据预处理、手写实现、scikit-learn调参、常见坑五个部分展开,代码都是可以直接跑的,适合刚学完基本语法、想系统做一遍机器学习的读者,也适合已经用过KNeighborsClassifier但没仔细想过里面参数的人。

1. kNN到底在解决什么问题

1.1 从“近朱者赤”到最近邻投票

kNN的核心思想很朴素:一个样本的类别,由它最近的k个邻居投票决定。比如你在街上看到一个人手里拿着网球拍,穿着运动服,你大概率判断他是去运动的,因为和你的生活经验里“去运动的人”最接近。kNN就是把这种经验用距离计算量化了。

在分类任务里,给定一个未知样本,计算它和训练集里每个样本的距离,选出最近的k个,看这k个邻居里哪一类最多,就预测为哪一类。k取1时就是最近邻,只认最像的一个。k取5时,就是让5个邻居投票。这个“投票”过程听起来简单,却构成了一个完整的非参数分类器,不需要假设数据符合某个分布,也不需要像逻辑回归那样去学习权重参数。

正因为没有训练阶段,kNN也被叫做惰性学习。它把所有计算推迟到了预测时。这意味着训练很快,但预测时要把未知样本和每个训练样本算一遍距离,数据量一大就慢了。这也是kNN最明显的取舍。

1.2 三个关键要素:k值、距离度量、决策规则

kNN要想真正跑起来,有三个要素缺一不可。

第一个是k值,它决定了你考虑多少个邻居。k太小,模型容易受到单点噪声干扰,决策边界会很复杂,出现过拟合。k太大,会把距离很远的样本也拉进来投票,边界过于平滑,欠拟合。所以k是kNN里最需要调的参数。

第二个是距离度量,默认使用的是欧氏距离,也就是直线距离。但欧氏距离并不是唯一选择。曼哈顿距离在城市街区路网里很自然,适合特征之间在坐标轴上独立变化的情况。闵可夫斯基距离则可以看成欧氏距离和曼哈顿距离的推广,当p=2时就是欧氏,p=1时就是曼哈顿。scikit-learn里可以通过metric参数直接指定。

第三个是决策规则。分类问题默认用的是多数投票,为了避免平票,通常会把k设成奇数。scikit-learn里还可以给邻居加上距离权重,距离越近的票数越重,这等于给远近信息加了权重,后面会细说。回归问题则用k个邻居的平均值或者距离加权平均值作为预测结果。

这三个要素听起来都好理解,但实践中它们互相牵制,比如数据特征的量纲不同,会直接影响距离计算,从而让整个投票结果失效。所以要真正用好kNN,数据预处理往往是决定成败的第一步。

2. 动手前先处理数据:特征缩放与数据划分

2.1 为什么kNN不做特征缩放会翻车

kNN完全依赖距离来判断相似度,而距离计算要求所有特征必须处于可比较的尺度。否则,量纲大的特征会彻底主导距离值,量纲小的特征几乎不发挥作用。

举个例子,假设你要根据“年龄”和“年收入”来预测一个人是否会购买某产品。年龄范围大概是0到80,收入范围可能是0到100万。如果直接算欧氏距离,年龄差的贡献可能只有几十,收入差却可能达到几万几十万。结果就是收入这个特征完全主导了投票,年龄特征形同虚设。这就是kNN不做特征缩放的典型翻车现场。

解决办法很简单,用StandardScaler做标准化,让每个特征变成均值为0、方差为1的分布。或者用MinMaxScaler把特征压缩到0到1之间。两种方式都行,但要注意StandardScaler对离群值更稳健一些,MinMaxScaler如果存在极端离群值,会把大部分正常值的间距压得很小。实际项目中我一般优先用StandardScaler,除非有明确理由要限制取值范围。

还有一个细节是,一定先切分训练集和测试集,再在训练集上fit缩放器,再transform训练集和测试集。不能把整个数据集一起fit,因为那样相当于让缩放器偷看了测试集的信息,得到的测试集误差会偏低,上线后效果会变差。

2.2 用train_test_split划分数据,别把未来数据带进来

数据划分看起来很简单,但很多人会在这步犯一个隐蔽的错误。直接用train_test_split把数据随机切一份,不检查时间顺序或者其他依赖关系。如果数据里包含时间序列,或者同一用户存在多条记录,直接随机切分就会造成信息泄漏,让模型在训练时就已经接触到了与测试样本高度相似的邻居,测试结果虚高。

这里要说清楚,kNN对信息泄漏特别敏感。因为它本身不学习抽象规律,它是直接“记住”训练样本的。如果测试集里某些样本和训练集样本来自同一条用户行为序列,距离会非常近,几乎等于开卷考试。所以我在做业务数据时都会先问一句:每条样本是不是独立的?如果存在用户维度、时间维度,就要用GroupShuffleSplit或者按时间做前切训练、后切验证。

切分之后,一个标准的流程是这样:

from sklearn.model_selection import train_test_split from sklearn.preprocessing import StandardScaler X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.2, random_state=42, stratify=y ) scaler = StandardScaler() X_train_scaled = scaler.fit_transform(X_train) X_test_scaled = scaler.transform(X_test)

这里用stratify=y是为了在分类问题里保持训练集和测试集的类别比例一致,尤其是类别不均衡的时候,这一步非常重要。random_state固定了随机数种子,保证结果可以复现。

好,数据准备好了,下面我们先手写一个kNN,把原理彻底走一遍,再对照scikit-learn的实现,这样后面调参心里就有底了。

3. 手写kNN代码,把原理落到Python上

3.1 手写一个可用的kNN分类器

手写kNN不需要什么高深技巧,核心就三步:算距离、排序取前k个、投票。用Python的NumPy可以很简洁地写出来。

import numpy as np from collections import Counter def knn_predict(X_train, y_train, x_test, k=5): distances = [] for i, x_train in enumerate(X_train): dist = np.sqrt(np.sum((x_train - x_test) ** 2)) distances.append((dist, y_train[i])) distances.sort(key=lambda x: x[0]) k_nearest = distances[:k] labels = [label for _, label in k_nearest] majority = Counter(labels).most_common(1)[0][0] return majority

就这样一个函数,对单个测试样本做预测。它先遍历所有训练样本,逐条计算欧氏距离,然后排序,取出距离最小的k个,用Counter做多数投票。逻辑和kNN定义完全一致。

这段代码的问题也很明显:两层循环,速度极慢。而且我没有处理k为偶数时的平票问题,也没考虑距离权重。所以手写代码的意义在于理解原理,真正工程使用时还是要靠scikit-learn。

如果你希望手写版本更接近scikit-learn的效果,可以加入距离权重:

def knn_predict_weighted(X_train, y_train, x_test, k=5): distances = [] for i, x_train in enumerate(X_train): dist = np.sqrt(np.sum((x_train - x_test) ** 2)) distances.append((dist, y_train[i])) distances.sort(key=lambda x: x[0]) k_nearest = distances[:k] score = {} for dist, label in k_nearest: weight = 1 / (dist + 1e-6) score[label] = score.get(label, 0) + weight return max(score, key=score.get)

这里用距离的倒数作为权重,距离越近票数权重越大,分母加1e-6为了防除零。这个版本在多类问题中更容易稳定,但也让“投票”不再是等权,而是按相似度打分。

3.2 用scikit-learn实现同样功能,对比来看差距

用scikit-learn做同样的事情,只需要几行:

from sklearn.neighbors import KNeighborsClassifier from sklearn.metrics import accuracy_score model = KNeighborsClassifier(n_neighbors=5, weights='distance') model.fit(X_train_scaled, y_train) y_pred = model.predict(X_test_scaled) print(accuracy_score(y_test, y_pred))

大家注意,我这里fit用的X_train_scaled,不是原始的X_train。因为前面已经提到,kNN必须做特征缩放,否则距离会被大数值特征主导。

对比手写版本,scikit-learn的KNeighborsClassifier有几个明显优势。第一,它内置了kd树或球树的搜索结构,不用每次预测都遍历全部训练样本,在大数据量上快很多。第二,它支持多种距离度量、距离权重、并行计算,甚至可以直接输出邻居索引。第三,它的算法实现经过了大量优化,数值稳定性和边界处理都比自己写要可靠。

我建议每个初学者都手写一遍kNN,但真正做项目时,还是要基于scikit-learn。手写一次,你就知道fit到底存了什么,predict在算什么,后面遇到报错也不会慌。

3.3 用kneighbors查看实际邻居:模型解释的钥匙

scikit-learn的KNeighborsClassifier里有个很实用的方法叫kneighbors,它能返回每个样本最近的k个邻居的距离和索引。很多人不知道它,但这是排查问题的利器。

distances, indices = model.kneighbors(X_test_scaled[:1], n_neighbors=5) print(distances) print(indices)

indices就是邻居在训练集里的行号。我经常用这个方法去看预测结果到底由哪些训练样本投票决定。如果预测错了,就把这几个邻居的原特征拉出来对比,很快就能发现问题,比如某些特征异常、某些标签标错了。这种“看邻居”的方式,比盯着准确率纠结半天要有效得多。

kNN本身就是一种可以解释的模型,因为它可以直接给出相似样本作为证据。面对业务方质疑时,你能指着一个预测结果说:“它和这五条历史记录最像,所以被判为这个类别”,这比黑盒模型好沟通得多。

4. 调参实战:k值、距离权重与搜索策略

4.1 怎么选出最优k值

k值可以说是kNN最重要的超参数。选k没有固定经验值,最常见的做法是画出不同k值下的准确率曲线,观察曲线变化。

from sklearn.model_selection import cross_val_score import matplotlib.pyplot as plt k_range = range(1, 31) cv_scores = [] for k in k_range: model = KNeighborsClassifier(n_neighbors=k) scores = cross_val_score(model, X_train_scaled, y_train, cv=5) cv_scores.append(scores.mean()) plt.plot(k_range, cv_scores, marker='o') plt.xlabel('k') plt.ylabel('CV Accuracy') plt.show()

我看图的时候一般会找两类点:一类是准确率最高的点,另一类是准确率开始稳定、不再剧烈波动的点。如果最高点在某个k值上,但旁边的k值掉得很快,我会更倾向于选一个略微保守、泛化更稳的k,而不会死磕最高点。因为交叉验证里的最高点可能只是这个数据划分下的偶然结果。

除了手动画曲线,也可以用GridSearchCV直接搜索k和其他参数的组合。

from sklearn.model_selection import GridSearchCV param_grid = { 'n_neighbors': [3, 5, 7, 9, 11], 'weights': ['uniform', 'distance'], 'p': [1, 2] } grid = GridSearchCV( KNeighborsClassifier(), param_grid, cv=5, scoring='accuracy' ) grid.fit(X_train_scaled, y_train) print(grid.best_params_) print(grid.best_score_)

注意,GridSearchCV在fit的时候会再次对X_train_scaled做交叉验证,这是没问题的,因为缩放器已经fit过,训练集内部再切分不会有新的信息泄漏。但如果你把缩放器也放进参数搜索,就要用Pipeline来避免错误。

关于k的奇偶性,很多人直接默认设奇数,怕偶数平票。在二分类问题里,奇数确实可以避免平票。但我用过weights='distance'之后,偶数k其实也没那么可怕,因为每个邻居的权重不同,票数被拉开的概率大很多。后面我会专门讲平票问题。

4.2 距离权重weights和算法algorithm参数怎么选

weights参数是kNN里一个很容易提分的选项。取'uniform'时,所有邻居一视同仁。取'distance'时,距离近的权重更高。实际数据里的规律往往是近邻比远邻更有参考价值,所以distance权重在很多数据集上表现更好,尤其是样本分布不均衡时。

但distance权重也有副作用:如果数据里存在离群点,这个离群点离待测样本特别近,它的影响力会被放大。还有一种情况是,数据里重复样本很多,同一个点出现多次,distance权重会把这些重复样本的票数权重叠加得特别高,导致最终结果偏向重复样本。所以它并不是万能药,最好和k值一起用交叉验证确认。

algorithm参数是另一个值得了解的关键参数。scikit-learn里有四个选项:'auto'、'ball_tree'、'kd_tree'、'brute'。brute就是暴力计算,一个个算距离,适合小数据量。kd_tree和ball_tree都是空间索引结构,适合数据量大但维度不高的场景。

实际使用中,我大部分时候直接设algorithm='auto',让库自己去选。只有当我明确知道数据维度很低,比如三维坐标点数据,我会手动指定kd_tree。维度很高的时候,比如文本TF-IDF特征几千维,kd树和球树性能都会下降,这时反而brute暴力搜索更可靠。不过对于高维向量,更好用的其实是余弦相似度,这时候需要考虑换成其他距离度量。

另外还有两个参数容易被忽略。一个是leaf_size,它影响树的构建速度和查询速度,默认30一般够用。另一个是metric,除了欧氏距离和曼哈顿距离,还可以用'cosine'等。不同的metric配合不同的数据特点,能在实际项目中拉开不少差距。

4.3 用Pipeline打包缩放和模型,避免重复劳动

特征缩放和模型调参都很重要,但它们最好作为一个整体来管理。scikit-learn里可以用Pipeline把StandardScaler和KNeighborsClassifier串起来,然后直接对Pipeline做网格搜索。

from sklearn.pipeline import Pipeline pipe = Pipeline([ ('scaler', StandardScaler()), ('knn', KNeighborsClassifier()) ]) param_grid = { 'knn__n_neighbors': [3, 5, 7, 9], 'knn__weights': ['uniform', 'distance'] } grid = GridSearchCV(pipe, param_grid, cv=5) grid.fit(X_train, y_train)

这样做的最大好处是,GridSearchCV在每一折交叉验证里都会重新用当前训练折fit缩放器,避免把验证折的信息透传进去。如果你先在外面fit好Scaler再切折,验证折的信息其实已经参与过缩放器的均值和方差计算,等于引入了一条极隐蔽的信息泄漏通道。

我第一次意识到这个问题,是在一个特征方差特别大的数据集上调参,结果发现GridSearchCV选出的参数和我在验证集上手动验证的不一致。后来排查原因,就是因为我提前在全量训练集上fit了StandardScaler,再拿去交叉验证。改成Pipeline之后,结果才真正稳下来。

5. 常见问题和排查技巧实录

5.1 数据没归一化,模型分数忽高忽低

很多人第一次用kNN,跑出来的准确率总是和预期差一大截,或者稍微改动几个训练样本,结果波动很大。最普遍的原因就是没有做特征缩放。

kNN对特征尺度极其敏感,这不是玄学。距离是所有特征相对位置的综合结果,某几个特征的数值范围一拉大,其他特征距离权重立刻失灵。你可以在原来的数据上试一下,用StandardScaler前后各跑一次交叉验证,对比准确率,大部分数据集会有一个明显差异。如果差异不大,说明该数据本身各特征尺度本来就接近,或者某些特征本来就是噪音。

我习惯把特征缩放作为kNN流程里的固定动作,不抱着侥幸心理。如果你发现缩放之后效果反而变差,那需要检查是不是训练集和测试集缩放方式不一致,或者数据本身是分类型特征,被当成数值型特征直接缩放。分类型特征应该用独热编码后再缩放,或者直接不缩放但改用适合的距离度量。

5.2 k值投票平局,怎么破

二分类问题里,如果k是偶数,且最近的两类邻居数量相等,模型就必须想办法打破平局。scikit-learn的默认行为是看哪一类在训练集总体中数量更多,选那个。这会让结果偏向样本量大的类别。

解决办法很简单,首选把k设成奇数。但在多分类问题里,奇数也不能保证不平局,比如k=5,三类样本出现了2:2:1。这时候更好的办法是用weights='distance'。因为它把距离远近变成连续权重,几乎不会出现完全相等的情况,平局问题自然消失。

如果你还是想用多数投票但希望结果更准确,可以比较邻居的距离之和,选总距离更近的那一类。这个策略在有些分析场景里比单纯数票更合理,因为5个邻居如果四个很近但属于A类,另一个很远属于B类,直觉上应该判给A类。distance权重其实就是在做这件事。

5.3 类别不均衡,少数类被淹没

kNN是局部方法,它的分类主要看局部邻居。如果训练集里某个类别样本特别少,那在大多数区域里,少数类的邻居数量可能永远达不到多数派的票数,导致少数类几乎不会被预测出来。

这种情况下,最简单的办法是调整weights='distance',让近邻的作用加大,远距离多数类样本的干扰降低。还有更主动的办法是使用SMOTE这类过采样方法,给少数类生成合成样本。但我建议先不要急着采样,可以看少数类的召回率。如果业务本身要求少数类不能漏,就需要用class_weight或阈值移动。

还有一个思路是用kNN的predict_proba输出概率,而不是直接用predict拿类别结果。predict_proba会返回每个类别的邻居比例。你可以基于这个概率,人为把少数类的判定阈值调低。scikit-learn的KNeighborsClassifier还有predict_proba方法,因为其实它计算的是邻居类别占比,实现起来非常简单。

5.4 数据量太大,预测变慢

kNN的预测复杂度是O(N),也就是要遍历所有训练样本。训练集到了一定量级,比如百万条,线上做实时预测会非常吃力。

遇到这种情况,第一选择是确认是否真的需要traintime保持不变。如果是,可以缩小搜索范围,比如对数据做聚类或降维,在局部范围内搜索。第二选择是使用scikit-learn的algorithm='kd_tree'或'ball_tree',对低维数据能明显加速。第三选择是换模型。如果特征是稠密向量且数据量很大,可以考虑用线性模型或树模型,它们预测速度更快,效果也未必差。

我自己的经验是,kNN适合样本量适中的项目,比如几万条以下,或者需要频繁解释推荐原因的场景。到了几十万上百万条,我会优先考虑其他模型,或者把kNN当成基线模型来对比。

这里有一份问题速查表,方便你之后排查:

现象常见原因解决方向
准确率忽高忽低特征没有缩放使用StandardScaler做标准化
k偶数的平票问题票数相等设k为奇数或weights='distance'
少数类总被淹没类别不均衡distance权重、过采样、阈值调整
预测非常慢特征维度过高/数据量过大使用kd树、降维或换模型
交叉验证结果和实际不符缩放器提前fit了全量数据用Pipeline在每折内做缩放
predict_proba概率不协调距离没做权重处理试试weights='distance'

这些坑我基本都踩过一遍。最早做kNN的时候,我甚至没做特征缩放就直接跑,准确率卡在70%上不去,后来加了StandardScaler直接跳到82%,当时才明白距离模型对尺度有多敏感。之后每次用kNN,我都会先跑一遍缩放前后的对比,这条经验帮我在好几个数据集上都少走了弯路。

最后再分享一个小技巧。刚开始学kNN的时候,我把预测结果错误的样本全部抽出来,用model.kneighbors看它们的最近邻居,发现很多错误其实是因为训练集里本身就存在互相冲突的样本,比如同一个特征位置被标成了两个类别。这种数据噪声问题,你调再多的k也没有用,最该做的反而是把这类样本检查一遍。kNN因为直接依赖邻居,会把这些矛盾和噪声暴露得很明显,这也算是一种辅助数据清洗的方式。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询