KNN(K-Nearest Neighbors,K 近邻算法)是我面试机器学习岗位时最常拿来“送分”的一个问题,也是我在实际项目里几乎每次都会先跑一遍的 baseline 算法。它不需要训练梯度,不需要假设数据分布,甚至连“训练”这个动作都显得有些敷衍——模型只是把样本存下来,预测时现算距离,然后让邻居们投个票。但恰恰是这种“朴实无华”的设计,让 KNN 成了新手理解机器学习的一扇绝佳窗口:距离怎么度量、特征怎么缩放、超参数怎么选、缺失值怎么影响判断,这些贯穿整个机器学习生涯的核心问题,在这一个算法里几乎全都能碰到。
如果你正在入门机器学习,或者临近期末想复习 KNN 却不希望去啃一堆晦涩公式,这篇文章应该能帮到你。我会少绕弯子,直接从 KNN 的投票逻辑讲起,把距离度量、特征标准化、K 值选择这些关键点拆开说透,再用 sklearn 把“红酒分类”这个经典案例完整跑一遍,最后聊聊维度灾难和预测速度这些工程上容易踩的坑。看完之后,你对 KNN 的理解不会只停在“少数服从多数”这句话上。
1. “佛系训练”背后:KNN 只做投票,不做建模
1.1 一个手算就能懂的投票例子
很多人第一次看到 KNN 的直觉是“这不就是找几个离得近的样本投票吗?”,说实话,本质确实如此。我先给一个完全可以纸上手算的例子,你感受下它有多直白。
假设平面上有三条已知样本,都是二维特征:
- 点 A:(-2, -1),标签“甲类”
- 点 B:(-2, 1),标签“甲类”
- 点 C:(2, 0),标签“乙类”
现在来一个新样本 X = (0, 0),我们想判断它属于哪一类。用欧氏距离算一下:
- X 到 A 的距离是 sqrt((-2-0)^2 + (-1-0)^2) = sqrt(5),约等于 2.236
- X 到 B 的距离也是 sqrt((-2-0)^2 + (1-0)^2) = sqrt(5),约等于 2.236
- X 到 C 的距离是 sqrt((2-0)^2 + 0^2) = 2
如果取 K=1,最近的是 C 点,X 会被判成“乙类”;如果取 K=3,最近的三个点里 A、B 都投“甲类”,C 投“乙类”,于是变成 2:1,X 被判成“甲类”。同样的样本,不同的 K 值,结论完全变了。这就是为什么 KNN 调参的核心是 K,而不是什么隐藏层的权重。
从数学角度看,KNN 只依赖两个东西:一是特征空间中点与点的距离,二是 K 取多大。它没有显式的决策函数,没有损失函数,也不需要梯度下降。判断类别时,它把训练数据当成一本随时可以翻的“历史档案”,新样本来了就翻档案找相似案例,然后让历史案例投票。
1.2 分类、回归都能做:KNN 不只是“投票机”
很多人提到 KNN 只会想到分类,其实 KNN 做回归同样自然。新增一个样本时,找出它的 K 个近邻,分类任务让邻居们按照标签投票,回归任务则可以直接取这 K 个邻居目标值的平均数(或者距离加权平均数)。sklearn 里对应的类是 KNeighborsClassifier 和 KNeighborsRegressor,使用方式几乎一致。
这里有一个细节值得体会:KNN 在“训练”阶段几乎不做任何计算,只是把样本和标签原封不动存起来,所以它常被称为“惰性学习”(lazy learning)或者“基于实例的学习”(instance-based learning)。这意味着训练时间几乎为零,但预测阶段要老老实实把新样本跟所有历史样本算一次距离。这种“训练轻松、预测昂贵”的特性,决定了它在中小型数据集上很讨喜,但一旦参考样本量到百万级,预测效率会让人头大。后面我会专门展开聊这个问题。
1.3 “朴素”能成立,前提是特征真的能刻画相似性
KNN 的朴素假设可以概括成一句话:如果两个样本的特征足够接近,它们的标签也应该接近。这个假设在生活中很多场景都成立——判断一个人喜欢什么类型的电影,看他身边朋友喜欢什么类型的电影,往往八九不离十;判断一种葡萄酒像不像某个品种,比较它的化学成分是否接近,也说得通。
但“假设成立”不等于“自动成立”。如果给的数据里全是无关特征、噪声特征,比如用“用户 ID 的奇偶性”去判断用户是否会流失,那么距离算出来没有意义,邻居关系会被无关维度干扰。这也是新手最容易踩的坑:KNN 本身对特征的质量非常敏感,它不会像决策树那样自动挑特征,也不会像线性模型那样给特征赋予学出来的权重。做 KNN 之前,特征选择或降维往往是绕不开的一步。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 同样叫“近邻”,换种距离测法结论可能完全不同
2.1 欧氏距离只是默认项,不是唯一解
KNN 里的“近邻”到底怎么定义?绝大多数教材默认用欧氏距离,也就是我们从小到大最熟悉的直线距离。在二维坐标下,两个点 (x1, y1) 和 (x2, y2) 的欧氏距离是 sqrt((x1-x2)^2 + (y1-y2)^2);推广到 D 维特征,就是每个维度差值的平方和再开根号。
欧氏距离直观,但不该无脑用。常见的替代方案至少还有这几种:
- 曼哈顿距离:sum(|xi - yi|),可以想象成城市里只能沿着横竖街道走,不能斜穿街区。它不像欧氏距离那样把差值平方后放大,所以对个别维度上突然出现的离群点不那么敏感。
- 闵可夫斯基距离:p=2 时就是欧氏,p=1 时就是曼哈顿。sklearn 里 KNeighborsClassifier 默认 metric='minkowski' 且 p=2,所以本质用的是欧氏。
- 余弦相似度/余弦距离:更多用于文本和高维稀疏向量,它关注的是方向一致性而不是绝对数值差异。比如两篇文章的词频向量长度差很多,但用余弦相似度可能非常接近,这时候用它来衡量语义邻近距离会比欧氏更合理。
很多人学完 KNN 只知道有个欧氏距离,这是不够的。真实业务里选哪种距离,本身就是一个需要对照业务语义来决定的问题。
2.2 特征量纲不一致,会让“近邻”失真
这是 KNN 最经典的隐性坑:如果不做特征缩放,量纲更大、数值范围更宽的特征,会在距离计算里天然获得更大的话语权,甚至完全压制其他特征。
举一个特别生活化的例子。假设我们有两列特征:身高(单位用厘米,数值大概在 160 到 185 之间)和体重(单位用公斤,数值大概在 50 到 90 之间)。计算两个人之间的欧氏距离时,身高维度上差了 25 厘米,平方一下就贡献了 625;体重维度即使差了 20 公斤,平方后是 400。看起来两个维度都在起作用,但如果把身高单位改成米,这个维度上的差值最多只有 0.25,平方后约 0.06,于是身高基本等于从距离公式里消失了,体重几乎一票说了算。同一批人、同一种距离公式,因为单位不同,邻居关系就大变样,这显然不合理。
进入真实数据后问题会更隐蔽。比如 sklearn 自带的红酒数据集 wine,前几个特征分别是酒精、苹果酸、灰分、灰分碱度,后面的特征里有“镁”,其取值范围可能到 70 到 160;而“酒精”大概只有 11 到 15。如果不做标准化,直接算欧氏距离,镁这个维度的波动会在距离里占主导,其他化学特征的作用被严重稀释,哪怕这些特征对区分红酒品种更重要。
2.3 StandardScaler 的正确打开方式:先切分,再 fit
针对量纲问题,最常见的做法是标准化(StandardScaler)或者归一化(MinMaxScaler)。我在实际项目里更常用 StandardScaler,因为它把每列特征变成均值约 0、标准差约 1,对异常值的敏感度也还可以接受。使用代码很简单:
python复制from sklearn.preprocessing import StandardScaler
scaler = StandardScaler()
scaler.fit(X_train)
X_train_scaled = scaler.transform(X_train)
X_test_scaled = scaler.transform(X_test)
但这里有个许多人第一次接触时栽跟头的点:必须先切分训练集和测试集,再用训练集去 fit 标准化器,最后对测试集只做 transform。也就是说,不要拿全部数据一起算均值、方差,否则测试集的信息在预处理阶段就已经偷看过了,后面的评估结果会被污染。
如果只用一句话记住这个流程,那就是:scaler 是模型预处理管线的一部分,它的“训练”只能发生在训练集上,测测集只能被转换,不能参与参数估计。更加工程化的做法是把它直接写进 sklearn 的 Pipeline 里,让交叉验证过程在每一折内部重新 fit,细节我们第四节用红酒数据实战时会说到。
3. K 值、平票和加权:真正决定 KNN 手感的三件事
3.1 K 太小是过分相信单点,K 太大又会抹掉局部信息
K 值的选择直接决定模型复杂度。K 很小的时候,比如 K=1,预测只看最近的一个样本,决策边界会非常细碎,很容易被单个噪声样本带跑,这是典型的过拟合;K 继续增大,邻居范围变广,决策边界会被磨得越来越平滑,抗噪声能力也会增强,但 K 如果大到接近全部训练样本,模型基本变成“永远预测训练集中最多的类别”,那预测结果就不再依赖局部邻域,变成了一个没有意义的全局统计。
你可以在二维平面上脑补一下这个画面:两类样本互相交错分布,K=1 时边界跟着每个点的局部起伏走,像用一支极细的笔画等高线,转弯极多;K=5 或 K=7 时边界会变得光滑不少;K=20 甚至更大时,边界继续变平滑,但也可能把一些本该保留的局部凹凸结构直接磨平。所以,K 本质上是一个控制“看多近”的旋钮,不是越大越好,也不是越小越聪明。
业内有一个简单粗暴的经验值是 K 约等于 sqrt(N),N 是样本数。但经验值只能用来定起始搜索范围,不能拍脑袋直接采用。真正可靠的方式,是用交叉验证去枚举一批候选 K,选择泛化表现最好的那个。我在做项目时,一般会从 1 开始慢慢往上试,隔几个取值跑一遍交叉验证,画出“K 值 vs 平均准确率”的曲线,看它在哪个区间开始稳定、在哪个点之后开始明显下降,然后选择一个让模型又准又稳的 K。
3.2 平票问题:奇数不是万能药
很多初学者迷信“二分类时 K 选奇数就不会打平”,这句话只说对了一半。二分类时 K 取奇数确实避开了 K=4、K=6 这类可能出现的 2:2、3:3 平票,但只要做的是三分类或更多分类,奇数 K 照样可能平票。比如 K=5,三个类别的票数如果是 2:2:1,那就没有一个类别拿到绝对多数。
sklearn 默认遇到平票时会按训练集中类别的排列顺序来决定,或者是随机选一个,这显然不可控。解决问题的思路不应该局限于“改 K 的奇偶性”,而是改成用距离加权投票,或者把待分类样本的输出改成“每个类别的概率”,比如统计 K 个邻居中每类占比,做出软判断。真正上线时,平票只是表面的尴尬,背后更深的问题是:每个邻居投出的票是否应该一样重?
3.3 距离加权投票:让“住得更近”的邻居更有发言权
在 KNN 里,默认的投票方式是所有 K 个邻居一视同仁,一人一票,sklearn 里对应 weights='uniform'。但直观想一想,距离第 1 名的邻居和距离第 15 名的邻居,对当前样本的判断说服力显然不应该一样。于是就有了 weights='distance' 的选项,此时每个邻居的投票权重通常是 1/距离,距离越近权重越大。
这类设计在样本类别边界处尤其有价值。比如 K 取 11 时,如果 11 个邻居里有 6 个来自绿色类别,但距离最近的 3 个都是红色类别,红色类别的总权重大概率会超过绿色类别,模型会更能反映局部信息,降低远处的“凑票”样本产生的影响。
不过加权重也不是无代价的。如果训练集里存在异常点,distance 权重会让这些异常点附近的小区域被放大,导致模型更不稳定,反而比 uniform 更容易过拟合。实际项目中,我会把 weights 也当成一个超参放进网格搜索里调,而不是拍脑袋觉得“加权一定更好”。
3.4 K 值选择的标准姿势:交叉验证
把 K 当成一个需要搜索的超参数,用 sklearn 的交叉验证来做选择,是最常见也最稳的方法。基本流程就是:在训练集上再切出若干折,每次用其中 K-1 折训练、留一折验证,算平均准确率,然后换一个 K 值重复执行。这样做比只看一次训练集/测试集切分的结果更稳健,尤其在小数据集上,一次随机切分带来波动可能跟不同 K 值之间的差异一样大。
具体代码下一节会有完整演示。这里先给你一个关键心法:不要用测试集去反复试 K。测试集应该被当作“最终考题”,模型开发阶段只能消费验证集或交叉验证的分数。如果同一个测试集被你反复用来调参,它会渐渐变成第二个训练集,测试分数就不干净了。
4. 用 sklearn 对红酒数据集做一次完整 KNN 实战
聊了这么多原理,接下来我用 sklearn 自带的红酒数据集把 KNN 完整跑一遍。这个数据集非常经典,共 178 条样本,13 个化学成分特征,标签是 3 个红酒品种。数据量不大,特征维度适中,很适合拿来演示从数据切分到模型评估的标准流程。
4.1 为什么红酒分类适合当 KNN 案例
红酒数据集的 13 个特征全部是连续值,包括酒精、苹果酸、灰分、颜色强度、脯氨酸等化学指标。不同品种的红酒在某些化学成分上有稳定差异,所以“化学成分相似的红酒大概率是同一品种”这个假设很自然,跟 KNN 的核心思想完美契合。
但正因为特征都是连续值且量纲差异不小,如果直接拿原始特征去算欧氏距离,结果会被个别数值范围大的特征主导。我之前见很多新手用红酒数据集练手时准确率怎么也上不去,最后发现就是少了标准化这一步。所以这个数据集非常适合用来展示一个完整流程:切分、标准化、调参、评估。
4.2 数据切分与标准化:先切分再放进 Pipeline
先加载数据并切分训练集和测试集:
python复制from sklearn.datasets import load_wine
from sklearn.model_selection import train_test_split
wine = load_wine()
X, y = wine.data, wine.target
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.3, random_state=42, stratify=y
)
print(X.shape)
# (178, 13)
注意两个细节:random_state 固定下来,方便复现;stratify=y 让训练集和测试集里的三类红酒比例保持一致,避免随机切分时某一类数量过少。数据量越小,分层抽样越重要。
为什么不在这里先各自 fit 再分开转换,我前面已经解释过;一个更省心、更不容易出错的方案是直接把 StandardScaler 和 KNeighborsClassifier 放进 Pipeline。Pipeline 会把“先标准化再计算 KNN”这两步打包成一个整体,交叉验证时每一折内部只用在那一折的训练子集上重新 fit 标准化器,信息泄漏风险降到最低。
python复制from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.neighbors import KNeighborsClassifier
pipe = make_pipeline(
StandardScaler(),
KNeighborsClassifier()
)
很多教程喜欢提前手动做一次 StandardScaler,再把缩放后的数据丢给 cross_val_score,这在小规模演示里危害不明显,但严格来说不够严谨。既然 Pipeline 可以直接解决,为什么不养成好习惯呢。
4.3 用交叉验证对比不同 K 值
下面这段代码会对几个 K 值做 5 折交叉验证,并且在训练集上重新 fit 一次,用测试集给出最终准确率:
python复制from sklearn.model_selection import cross_val_score
from sklearn.metrics import accuracy_score
for k in [1, 3, 5, 7, 9, 11, 15]:
pipe = make_pipeline(
StandardScaler(),
KNeighborsClassifier(n_neighbors=k, weights='uniform')
)
cv_score = cross_val_score(pipe, X_train, y_train, cv=5).mean()
pipe.fit(X_train, y_train)
y_pred = pipe.predict(X_test)
test_score = accuracy_score(y_test, y_pred)
print(f"k={k:2d} cv_score={cv_score:.4f} test_score={test_score:.4f}")
在我本地一次运行里,结果大致集中在 0.95 到 0.99 之间,不同 K 值的差距可能不是特别大。这是红酒数据本身可分性较强的体现,但注意不要因为一次运行里 K=7 得分高,就觉得 K=7 是“最好参数”。单次测试集准确率本身波动很大,真正的依据还得看交叉验证的平均分。
如果敏感一点,你还会发现 wine 数据集毕竟只有 178 条样本,切出的测试集才 54 条左右,一个样本被预测错,准确率就下降约 2 个百分点。所以在这个数据集上,不同随机种子下的准确率浮动 1% 到 2% 非常正常,不要被“某一次跑出 100%”冲昏头脑。
4.4 把 K、权重、距离度量一起丢进网格搜索
接下来我用 Pipeline 配合 GridSearchCV,把 n_neighbors、weights、p 一起搜索一遍。p=1 对应曼哈顿距离,p=2 对应欧氏距离,这样可以把“距离度量”也纳入调参范围:
python复制from sklearn.model_selection import GridSearchCV
from sklearn.pipeline import Pipeline
pipe = Pipeline([
("scaler", StandardScaler()),
("knn", KNeighborsClassifier())
])
param_grid = {
"knn__n_neighbors": [3, 5, 7, 9, 11, 15],
"knn__weights": ["uniform", "distance"],
"knn__p": [1, 2]
}
grid = GridSearchCV(
pipe,
param_grid,
cv=5,
scoring="accuracy"
)
grid.fit(X_train, y_train)
print("best_params:", grid.best_params_)
print("best_cv_score:", grid.best_score_)
注意 Pipeline 里两个步骤取了名字 "scaler" 和 "knn",所以超参名字要用双下划线连接:knn__n_neighbors。这是 sklearn 里非常常用的语法。网格搜索本质上是一种“暴力枚举”,在小数据集上很划算,但样本量大了之后,可以考虑 RandomizedSearchCV 或者 Optuna 这类贝叶斯优化工具。
在红酒数据集上,最后选出来的参数通常是曼哈顿距离或欧氏距离之间差异不大,关键还是先做好标准化。如果跑出来 K=15 比 K=3 还好,也别惊讶,这和数据集本身的区分度、样本量都有关系。你的任务不是背一个“最佳 K”,而是理解“为什么通过交叉验证选 K 是合理的”。
5. KNN 真正的软肋:维度爆炸、预测耗时与不均衡数据
5.1 惰性学习的代价:训练瞬间完成,预测却要全局扫描
KNN 的“训练快”听起来像是优点,但工程上往往被低估的是它的“预测慢”。假设你的产品里有 100 万个参考样本,每个在线请求到了之后,KNN 都要先计算新样本和这 100 万个历史样本的距离,才能找出 K 个最近邻居。这个成本是 O(N×D),N 是样本量,D 是特征维数。数据规模上去之后,在线接口很容易被拖垮。
很多人一开始会觉得“数据量不大时挺快的”,但随着业务累积参考样本越来越多,预测越来越慢,最后才发现问题不是出在模型精度,而是出在推断效率。要缓解这个问题,sklearn 里提供了 KD-Tree、Ball Tree 这类索引结构,它们能在低维或中维场景下把最近邻搜索加速到接近 O(D logN) 的平均复杂度。参数 Algorithm 可以设成 'auto'、'kd_tree'、'ball_tree' 或 'brute',我建议新手先用 'auto',让库内部选择一个在当前数据规模下更合适的策略。
如果数据量已经大到百万、千万级,精确 KNN 往往已经排不上用场,工程上会转向 ANN(近似最近邻)工具,比如 Faiss、Annoy、HNSW 等。它们是“以少量精度换取数量级速度提升”的典型思路,做推荐、向量检索的场景非常常见。但注意,一旦变成“近似”,KNN 原本那种清晰、可解释的语义就不再完整了,你需要额外评估近似误差对业务指标的影响。
5.2 高维特征会让“最近”变得不近
KNN 在低维空间里表现很直觉,但随着特征维数升高,会出现一个反直觉的现象:所有样本之间的距离越来越接近,最近的那个邻居也未必“真的近”。这就是所谓“维度灾难”。
高维空间里点的分布极度稀疏,点到点的距离差异会被稀释。你可以想象在一个巨大的超立方体里面随机撒点,维度越高,这些点越像均匀铺在“表面”而不是“内部”,于是任何一个新点到这些点的距离都差不多。KNN 依赖的“最近邻”在这种场景里变得毫无区分度,预测效果随之显著下降。
所以,如果数据有几万维(比如某些文本或图片特征),直接用 KNN 做精确搜索基本是灾难。靠谱的做法是先做特征选择、PCA 降维、或者用 embedding 把数据映射到更紧凑的低维空间,再使用近邻算法。KNN 之所以在词向量语义检索上有用,前提往往就是向量维度不算极端,或者已经用索引结构做了专门优化。
5.3 样本不均衡时,少数类容易被“多数邻居”绑架
KNN 的本质是最多数表决,这天然偏向高频类别。假设二分类问题里 A 类占 95%,B 类只占 5%,那么在一个待预测样本的 K 个邻居里,即使它并不属于 A 类,A 类也很容易因为数量优势赢得投票。少数类样本往往处在“周围都是别人类别”的尴尬境地,分类结果被多数类吞掉。
针对这种情况,常见做法包括:使用 weights='distance' 让近处样本权重更高;改用 RadiusNeighborsClassifier,以固定半径圈定邻域而不是固定 K 个样本,避免在稀疏区域硬凑 K 个邻居;或者在预处理阶段对少数类做重采样、SMOTE,或者反过来对多数类降采样,把样本分布先调到相对均衡再跑 KNN。现实业务中“样本不均衡”太常见,因此不要以为 KNN 只能“一人一票”,它的权重机制和邻域定义都需要配合业务调整。
5.4 KNN 对噪声和缺失值也不够坚强
因为 KNN 没有任何参数化模型去吸收噪声,训练集里一个错误标注的样本,很可能会污染它周围所有待测样本的分类结果。这在 K 很小时尤其严重。另一个麻烦是缺失值:KNN 算距离时,如果某几个特征缺失,直接套距离公式会报错或者算出不合理结果。实际项目中,要么先把缺失值用均值/中位数填充,要么对某些含缺失的样本直接不参与计算,或者使用能够处理缺失距离的变种算法。
这也解释了为什么我会把 KNN 定位成一个“对数据清洗要求极高”的算法。它没有训练过程帮你自动纠偏,一切都是“垃圾进,垃圾出”。把数据整理干净、做特征缩放、合理选择 K,这些前置工作一点都不能省。
6. 期末、面试和实战后记:关于 KNN 的几个高频考点与个人习惯
6.1 高频考点快速问答
以下是这几年我在看候选人、帮同学复习时经常被问到的几个问题,我按“问题 + 一句话答案 + 补充理由”的方式整理出来,供你作为快速回顾清单。
Q1:为什么说 KNN 是“惰性学习”?
因为它没有显式训练过程,fit 阶段只是把训练数据存起来,预测时才做距离计算。对比逻辑回归或神经网络,它们的训练阶段会拟合出权重参数,而 KNN 几乎没有参数需要学习。
Q2:K 值怎么选最稳?
用交叉验证枚举候选值,不能用测试集反复尝试。K 太小容易过拟合,太大容易欠拟合。经验上可以从 sqrt(N) 附近开始搜索,但不能不看数据乱拍。
Q3:为什么 KNN 对特征尺度敏感?
因为距离计算会直接累加
