不做标准化就跑KNN?你的模型正在被一个特征"独裁"
目录
- 不做标准化就跑KNN?你的模型正在被一个特征"独裁"
-
- 一、从"选电影"说起:KNN的朴素直觉
- 二、距离怎么算:从两点公式到n维空间
- 三、5瓶葡萄酒,一张草稿纸:手算KNN全过程
- 四、把酒精含量放大10倍:一个特征的"独裁"现场
- 五、两种标准化:min-max与Z-score
-
- 5.1 min-max标准化(离差标准化)
- 5.2 Z-score标准化(均值归一化)
- 5.3 怎么选?一张表说清
- 六、sklearn实战:wine数据集上的对比实验
- 七、K值怎么选:GridSearchCV调参曲线
- 八、KNN的优缺点、适用场景,以及2026年还学它干嘛
- 九、踩坑实录:新手最容易犯的4个错误
- 十、参考资料
- 十一、互动引导

📌 置顶提示|读完你能带走什么
本文是《机器学习-算法模型系列》第 4 篇。上一篇(本系列第3篇)我们讲了逻辑回归——一个靠学习权重参数来画决策边界的模型。这一篇的主角KNN画风完全不同:它压根不学任何参数,靠"抄邻居作业"就能做分类。但正因为它全靠距离说话,一旦某个特征的数值量级碾压其他特征,整个模型就会被这个特征绑架——这就是标题里说的"独裁"。这篇文章会带你亲眼看到独裁现场,再亲手把它掀翻。
一、从"选电影"说起:KNN的朴素直觉
K近邻算法(K-Nearest Neighbors,简称KNN)可能是所有机器学习算法里最符合人类直觉的一个。它的核心思想一句话就能说完:在已有数据中寻找与新样本最相似(离它最近)的K个数据,这K个数据里哪个类别占多数,新样本就判为哪个类别。
说白了就是"物以类聚":你想知道一个人爱不爱打游戏,看看他最常混在一起的几个朋友就大概知道了。
拿一个经典的电影分类例子来说。假设平面上散落着两类点:五角星代表爱情片,三角形代表科幻片,每部电影按"打斗镜头数"和"接吻镜头数"两个特征落在坐标系里。现在来了一部新电影(一个正方形的点),它是爱情片还是科幻片?
KNN的做法是画个圈,看新样本身边最近的K个点:
- 取K=3:离新样本最近的3个点里,有1个五角星、2个三角形。少数服从多数,三角形赢,新电影判为科幻片;
- 取K=5:把圈再扩大一点,最近的5个点变成了3个五角星、2个三角形。这回五角星反超,同一部电影又被判成了爱情片。
同一个样本、同一批数据,K从3变到5,结论直接翻转。这个例子想告诉你两件事:一是KNN的判类机制就是这么朴素的投票;二是K不是随便拍脑袋定的,它直接左右预测结果——这个坑我们在第七节用调参曲线专门解决。
整个预测流程可以画成一张图:
#mermaid-svg-NkvybSRxKzJTblrd{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-NkvybSRxKzJTblrd .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-NkvybSRxKzJTblrd .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-NkvybSRxKzJTblrd .error-icon{fill:#552222;}#mermaid-svg-NkvybSRxKzJTblrd .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-NkvybSRxKzJTblrd .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-NkvybSRxKzJTblrd .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-NkvybSRxKzJTblrd .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-NkvybSRxKzJTblrd .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-NkvybSRxKzJTblrd .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-NkvybSRxKzJTblrd .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-NkvybSRxKzJTblrd .marker{fill:#333333;stroke:#333333;}#mermaid-svg-NkvybSRxKzJTblrd .marker.cross{stroke:#333333;}#mermaid-svg-NkvybSRxKzJTblrd svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-NkvybSRxKzJTblrd p{margin:0;}#mermaid-svg-NkvybSRxKzJTblrd .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-NkvybSRxKzJTblrd .cluster-label text{fill:#333;}#mermaid-svg-NkvybSRxKzJTblrd .cluster-label span{color:#333;}#mermaid-svg-NkvybSRxKzJTblrd .cluster-label span p{background-color:transparent;}#mermaid-svg-NkvybSRxKzJTblrd .label text,#mermaid-svg-NkvybSRxKzJTblrd span{fill:#333;color:#333;}#mermaid-svg-NkvybSRxKzJTblrd .node rect,#mermaid-svg-NkvybSRxKzJTblrd .node circle,#mermaid-svg-NkvybSRxKzJTblrd .node ellipse,#mermaid-svg-NkvybSRxKzJTblrd .node polygon,#mermaid-svg-NkvybSRxKzJTblrd .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-NkvybSRxKzJTblrd .rough-node .label text,#mermaid-svg-NkvybSRxKzJTblrd .node .label text,#mermaid-svg-NkvybSRxKzJTblrd .image-shape .label,#mermaid-svg-NkvybSRxKzJTblrd .icon-shape .label{text-anchor:middle;}#mermaid-svg-NkvybSRxKzJTblrd .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-NkvybSRxKzJTblrd .rough-node .label,#mermaid-svg-NkvybSRxKzJTblrd .node .label,#mermaid-svg-NkvybSRxKzJTblrd .image-shape .label,#mermaid-svg-NkvybSRxKzJTblrd .icon-shape .label{text-align:center;}#mermaid-svg-NkvybSRxKzJTblrd .node.clickable{cursor:pointer;}#mermaid-svg-NkvybSRxKzJTblrd .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-NkvybSRxKzJTblrd .arrowheadPath{fill:#333333;}#mermaid-svg-NkvybSRxKzJTblrd .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-NkvybSRxKzJTblrd .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-NkvybSRxKzJTblrd .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-NkvybSRxKzJTblrd .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-NkvybSRxKzJTblrd .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-NkvybSRxKzJTblrd .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-NkvybSRxKzJTblrd .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-NkvybSRxKzJTblrd .cluster text{fill:#333;}#mermaid-svg-NkvybSRxKzJTblrd .cluster span{color:#333;}#mermaid-svg-NkvybSRxKzJTblrd div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-NkvybSRxKzJTblrd .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-NkvybSRxKzJTblrd rect.text{fill:none;stroke-width:0;}#mermaid-svg-NkvybSRxKzJTblrd .icon-shape,#mermaid-svg-NkvybSRxKzJTblrd .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-NkvybSRxKzJTblrd .icon-shape p,#mermaid-svg-NkvybSRxKzJTblrd .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-NkvybSRxKzJTblrd .icon-shape .label rect,#mermaid-svg-NkvybSRxKzJTblrd .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-NkvybSRxKzJTblrd .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-NkvybSRxKzJTblrd .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-NkvybSRxKzJTblrd :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}
类别0占多数
类别1占多数
前提
新样本到来
计算新样本与所有已知样本的距离
按距离从近到远排序
取出最近的K个邻居
K个邻居中哪个类别占多数?
新样本判为类别0
新样本判为类别1
⚠️ 各特征量纲相近否则距离会被大数值特征独裁
注意图里那个虚线警告框,它是本文的题眼,第四节会正面撞上它。
另外提一句:KNN属于惰性学习(lazy learning)。逻辑回归训练完会得到一组权重,训练数据可以扔掉;KNN的"训练"却什么都不算,就是把数据原样存起来,所有计算都推迟到预测那一刻。这个特性决定了它训练飞快、预测偏慢,后面讲优缺点时你会再遇到它。
二、距离怎么算:从两点公式到n维空间
“最近的K个邻居”——那"近"怎么定义?KNN里最常用的是欧氏距离,也就是你初中就学过的两点间距离公式。
二维空间里,样本A的两个特征值为
(
X
1
,
Y
1
)
(X_1, Y_1)
(X1,Y1),样本B的两个特征值为
(
X
2
,
Y
2
)
(X_2, Y_2)
(X2,Y2),两个样本的距离是:
d
(
A
,
B
)
=
(
X
1
−
X
2
)
2
+
(
Y
1
−
Y
2
)
2
d(A, B) = \\sqrt{(X_1 – X_2)^2 + (Y_1 – Y_2)^2}
d(A,B)=(X1−X2)2+(Y1−Y2)2
几何上看,就是把两个特征的差值当作直角三角形的两条直角边,距离就是斜边长。它只适用于样本恰好有2个特征变量的情况。
实际业务里特征动辄十几个、几十个,此时把公式推广到n维空间即可:设n维空间内A点坐标为
(
X
1
,
X
2
,
X
3
,
…
,
X
n
)
(X_1, X_2, X_3, \\dots, X_n)
(X1,X2,X3,…,Xn),B点坐标为
(
Y
1
,
Y
2
,
Y
3
,
…
,
Y
n
)
(Y_1, Y_2, Y_3, \\dots, Y_n)
(Y1,Y2,Y3,…,Yn),则A、B两点间的欧氏距离为:
d
(
A
,
B
)
=
(
X
1
−
Y
1
)
2
+
(
X
2
−
Y
2
)
2
+
⋯
+
(
X
n
−
Y
n
)
2
=
∑
i
=
1
n
(
X
i
−
Y
i
)
2
d(A, B) = \\sqrt{(X_1 – Y_1)^2 + (X_2 – Y_2)^2 + \\cdots + (X_n – Y_n)^2} = \\sqrt{\\sum_{i=1}^{n}(X_i – Y_i)^2}
d(A,B)=(X1−Y1)2+(X2−Y2)2+⋯+(Xn−Yn)2
=i=1∑n(Xi−Yi)2
公式长得吓人,本质没变:每个特征各自算差值、平方、加起来、开根号。你可以把它想象成在n维空间里拉一根直线绳子量长度。
盯着这个公式多看两眼,你其实已经能预感到危险了:每个特征的差值是平方后直接相加的。如果某个特征的数值天生比别人大一个数量级,它的平方差就会大两个数量级,求和时其他特征的贡献直接被淹没——这就是"独裁"的数学根源。先按下不表,我们把正常流程走一遍。
三、5瓶葡萄酒,一张草稿纸:手算KNN全过程
现在用一个"判断葡萄酒种类"的例子把KNN的计算步骤完整走一遍。商业实战中评判葡萄酒的指标很多,为了能在草稿纸上算完,这里只用"酒精含量"和"苹果酸含量"2个特征变量,把葡萄酒分为2类:分类0代表葡萄酒A,分类1代表葡萄酒B。原始样本只有5组:
| 样本1 | 5 | 2 | 0(葡萄酒A) |
| 样本2 | 6 | 1 | 0(葡萄酒A) |
| 样本3 | 8 | 3 | 1(葡萄酒B) |
| 样本4 | 10 | 2 | 1(葡萄酒B) |
| 样本5 | 12 | 1 | 1(葡萄酒B) |
现在来了一个新样本:酒精含量7%、苹果酸含量1%,它属于葡萄酒A还是葡萄酒B?
第一步,用欧氏距离公式算新样本和每个已知样本的距离,也就是不同样本间的相似度。以样本1为例:
d
=
(
7
−
5
)
2
+
(
1
−
2
)
2
=
4
+
1
=
5
≈
2.24
d = \\sqrt{(7-5)^2 + (1-2)^2} = \\sqrt{4+1} = \\sqrt{5} \\approx 2.24
d=(7−5)2+(1−2)2
=4+1
=5
≈2.24
同样的算法套在其余4个样本上,5个距离全部算出来(这些数字我都用numpy验算过,你可以放心跟着草稿纸核对):
| 新样本 → 样本1 |
( 7 − 5 ) 2 + ( 1 − 2 ) 2 = 5 \\sqrt{(7-5)^2+(1-2)^2}=\\sqrt{5} (7−5)2+(1−2)2=5 |
≈ 2.24 |
| 新样本 → 样本2 |
( 7 − 6 ) 2 + ( 1 − 1 ) 2 = 1 \\sqrt{(7-6)^2+(1-1)^2}=\\sqrt{1} (7−6)2+(1−1)2=1 |
= 1.00 |
| 新样本 → 样本3 |
( 7 − 8 ) 2 + ( 1 − 3 ) 2 = 5 \\sqrt{(7-8)^2+(1-3)^2}=\\sqrt{5} (7−8)2+(1−3)2=5 |
≈ 2.24 |
| 新样本 → 样本4 |
( 7 − 10 ) 2 + ( 1 − 2 ) 2 = 10 \\sqrt{(7-10)^2+(1-2)^2}=\\sqrt{10} (7−10)2+(1−2)2=10 |
≈ 3.16 |
| 新样本 → 样本5 |
( 7 − 12 ) 2 + ( 1 − 1 ) 2 = 25 \\sqrt{(7-12)^2+(1-1)^2}=\\sqrt{25} (7−12)2+(1−1)2=25 |
= 5.00 |
第二步,按距离由近及远排序:
| 1 | 样本2 | 1.00 | 0(葡萄酒A) |
| 2(并列) | 样本1 | 2.24 | 0(葡萄酒A) |
| 2(并列) | 样本3 | 2.24 | 1(葡萄酒B) |
| 4 | 样本4 | 3.16 | 1(葡萄酒B) |
| 5 | 样本5 | 5.00 | 1(葡萄酒B) |
(样本1和样本3恰好等距,实际工程里这种并列由排序的先后顺序决定谁排前面,对本例的结论没有影响。)
第三步,取K个邻居投票:
- 令K=1:只看离得最近的1个样本,也就是样本2,它的分类是0,所以新样本判为分类0,葡萄酒A;
- 令K=3:最近的3个样本是样本2、样本1、样本3,分类分别是0、0、1,分类0居多,新样本仍判为分类0,葡萄酒A。
到这里,KNN的完整计算步骤你已经全部亲手走过一遍了:算距离 → 排序 → 投票。没有梯度下降,没有损失函数,就这么直白。
用代码验证一下草稿纸上的结果:
import numpy as np
# 5个已知样本:[酒精含量, 苹果酸含量],标签0=葡萄酒A,1=葡萄酒B
X = np.array([[5, 2], [6, 1], [8, 3], [10, 2], [12, 1]])
y = np.array([0, 0, 1, 1, 1])
new = np.array([7, 1]) # 待判类的新样本
d = np.sqrt(((X – new) ** 2).sum(axis=1)) # 一行算出全部欧氏距离
order = np.argsort(d) # 按距离从近到远排出样本下标
for rank, idx in enumerate(order, 1):
print(f"第{rank}近: 样本{idx+1} 距离={d[idx]:.2f} 分类={y[idx]}")
for K in (1, 3):
votes = y[order[:K]] # 取最近K个邻居的标签
result = np.bincount(votes).argmax() # 少数服从多数
print(f"K={K} 时投票结果: 分类{result}")
# 运行输出:
# 第1近: 样本2 距离=1.00 分类=0
# 第2近: 样本1 距离=2.24 分类=0
# 第3近: 样本3 距离=2.24 分类=1
# 第4近: 样本4 距离=3.16 分类=1
# 第5近: 样本5 距离=5.00 分类=1
# K=1 时投票结果: 分类0
# K=3 时投票结果: 分类0
结论和手算完全一致。目前为止一切岁月静好——因为酒精含量和苹果酸含量都是个位数,两个特征的量纲级别相差不大,谁也压不住谁。接下来我们把这份平衡打破。
四、把酒精含量放大10倍:一个特征的"独裁"现场
设想一个非常常见的场景:数据换了个统计口径,“酒精含量"的数值都放大为原来的10倍(比如从"百分比"换成了"千分比”),"苹果酸含量"保持不变。数据变成了这样:
| 样本1 | 50 | 2 | 0 |
| 样本2 | 60 | 1 | 0 |
| 样本3 | 80 | 3 | 1 |
| 样本4 | 100 | 2 | 1 |
| 样本5 | 120 | 1 | 1 |
两个特征的量纲级别一下子拉开了:一个在50120之间蹦跶,一个还在13之间徘徊。此时如果直接拿去搭KNN模型,会发生什么?
对应的新样本变成酒精含量70、苹果酸含量1,算一下它与样本1的距离:
d
=
(
70
−
50
)
2
+
(
1
−
2
)
2
=
400
+
1
=
401
≈
20.02
d = \\sqrt{(70-50)^2 + (1-2)^2} = \\sqrt{400 + 1} = \\sqrt{401} \\approx 20.02
d=(70−50)2+(1−2)2
=400+1
=401
≈20.02
看清楚这笔账:酒精含量贡献了400,苹果酸含量只贡献了1。这个距离几乎完全由酒精含量主导,苹果酸含量因为量纲级别相差较大,几乎不发挥作用。 你算不算它,距离都是20左右——苹果酸这个特征等于被开除了。“酒精含量"在模型中的重要性远远超过"苹果酸含量”,后者这一特征变量的作用就此丧失,预测结果自然有失偏颇。
更隐蔽的伤害在排序上。把5个距离都算出来对比一下:
| 新样本 → 样本1 | 2.24(并列第2近) | 20.02(跌到第3近) |
| 新样本 → 样本2 | 1.00(第1近) | 10.00(第1近) |
| 新样本 → 样本3 | 2.24(并列第2近) | 10.20(升到第2近) |
| 新样本 → 样本4 | 3.16 | 30.02 |
| 新样本 → 样本5 | 5.00 | 50.00 |
原始数据里,样本1和样本3与新样本等距,苹果酸含量的差异是有话语权的;放大之后,样本1(酒精差20)被样本3(酒精差10)远远甩开,邻居的名次完全按酒精含量的差值重排,苹果酸含量彻底失声。本例中投票结果侥幸没变,但只要数据再多一点、类别分布再犬牙交错一点,这种名次重排就会直接改写预测结果。
这就是"独裁"的完整现场:不是酒精含量这个特征有多重要,而是它的数值量级大,就抢走了全部话语权。模型没报错、代码正常跑、准确率也能输出——只是悄悄变成了一个"只看酒精含量的KNN"。这类静默失效比报错可怕得多。
如果不同特征变量的量纲级别相差较大、且在建模时相互影响(KNN、K-Means、SVM这类靠距离吃饭的算法全中招),就必须先做数据预处理。这个手段叫数据标准化,也叫数据归一化。
五、两种标准化:min-max与Z-score
数据标准化的常见方法有两种:min-max标准化和Z-score标准化。
5.1 min-max标准化(离差标准化)
把每个特征线性压缩到
[
0
,
1
]
[0, 1]
[0,1] 区间:
x
′
=
x
−
x
m
i
n
x
m
a
x
−
x
m
i
n
x' = \\frac{x – x_{min}}{x_{max} – x_{min}}
x′=xmax−xminx−xmin
其中
x
m
i
n
x_{min}
xmin、
x
m
a
x
x_{max}
xmax 分别是该特征在训练集里的最小值和最大值。拿放大后的酒精含量列
[
50
,
60
,
80
,
100
,
120
]
[50, 60, 80, 100, 120]
[50,60,80,100,120] 套一下:
x
m
i
n
=
50
x_{min}=50
xmin=50,
x
m
a
x
=
120
x_{max}=120
xmax=120,极差70,变换后得到
[
0
,
0.143
,
0.429
,
0.714
,
1
]
[0, 0.143, 0.429, 0.714, 1]
[0,0.143,0.429,0.714,1]。苹果酸含量列
[
2
,
1
,
3
,
2
,
1
]
[2,1,3,2,1]
[2,1,3,2,1] 同样处理后得到
[
0.5
,
0
,
1
,
0.5
,
0
]
[0.5, 0, 1, 0.5, 0]
[0.5,0,1,0.5,0]。两个特征全部回到0~1的同一起跑线,谁也别想再独裁。
5.2 Z-score标准化(均值归一化)
把每个特征变换成均值为0、标准差为1的分布:
x
′
=
x
−
μ
σ
x' = \\frac{x – \\mu}{\\sigma}
x′=σx−μ
其中
μ
\\mu
μ 是该特征的均值,
σ
\\sigma
σ 是标准差。还是酒精含量列
[
50
,
60
,
80
,
100
,
120
]
[50, 60, 80, 100, 120]
[50,60,80,100,120]:均值
μ
=
82
\\mu = 82
μ=82,标准差
σ
≈
25.61
\\sigma \\approx 25.61
σ≈25.61,变换后得到
[
−
1.25
,
−
0.86
,
−
0.08
,
0.70
,
1.48
]
[-1.25, -0.86, -0.08, 0.70, 1.48]
[−1.25,−0.86,−0.08,0.70,1.48]。数值有正有负,含义是"偏离平均水平几个标准差"——考试里的"你超过了全班多少人"就是这个思路。
5.3 怎么选?一张表说清
| 结果范围 | 固定压缩到
[ 0 , 1 ] [0,1] [0,1] |
均值0、标准差1,无固定边界 |
| 对离群值 | 敏感(一个极端值就把其他数据挤成一团) | 相对稳健 |
| 适用场景 | 数据分布有明确边界、无明显离群值 | 数据近似正态分布或存在离群值 |
| sklearn实现 | MinMaxScaler | StandardScaler |
| 新手默认选 | 图像像素等天然有界数据 | 拿不准就用它 |
一个工程细节必须在这里敲黑板:标准化的参数(min、max、均值、标准差)只能从训练集上计算,再套用到测试集上。也就是先在训练集上 fit,再对训练集和测试集分别 transform。如果拿全量数据一起算,测试集的信息就提前泄露给了模型,评估结果会虚高。第九节的踩坑实录里有这个坑的完整复现方式。
六、sklearn实战:wine数据集上的对比实验
道理讲完了,上真实数据验货。sklearn自带的wine数据集(load_wine())简直是为这篇文章量身定做的:178个葡萄酒样本、13个特征、3个品种类别,正好是第三节手算案例的"工业放大版"。它的特征量纲有多悬殊?"脯氨酸"的取值范围是2781680,而"苹果酸"只有0.745.8,量级差了三个数量级——第四节那个×10的玩具例子,在真实数据里是×300。
实验设计很简单:同一份数据、同一个KNN模型(K=5),唯一的变量是做不做标准化,比较测试集准确率。
from sklearn.datasets import load_wine
from sklearn.model_selection import train_test_split
from sklearn.neighbors import KNeighborsClassifier
from sklearn.preprocessing import StandardScaler, MinMaxScaler
from sklearn.metrics import accuracy_score
X, y = load_wine(return_X_y=True) # 178个样本,13个特征,3个类别
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.3, random_state=42, stratify=y)
# 方案一:原始数据直接跑KNN(错误示范)
knn = KNeighborsClassifier(n_neighbors=5)
knn.fit(X_train, y_train)
print("不标准化:", accuracy_score(y_test, knn.predict(X_test)))
# 方案二:Z-score标准化后再跑(注意只在训练集上fit)
sc = StandardScaler().fit(X_train) # 均值和标准差只从训练集学
knn_std = KNeighborsClassifier(n_neighbors=5)
knn_std.fit(sc.transform(X_train), y_train)
print("Z-score标准化:",
accuracy_score(y_test, knn_std.predict(sc.transform(X_test))))
# 方案三:min-max标准化后再跑
mm = MinMaxScaler().fit(X_train)
knn_mm = KNeighborsClassifier(n_neighbors=5)
knn_mm.fit(mm.transform(X_train), y_train)
print("min-max标准化:",
accuracy_score(y_test, knn_mm.predict(mm.transform(X_test))))
# 运行输出:
# 不标准化: 0.7222222222222222
# Z-score标准化: 0.9444444444444444
# min-max标准化: 0.9629629629629629
结果汇总(实测数字,sklearn 1.6、random_state=42,你可以逐字复现):
| 方案一 | 无(原始数据) | 0.7222 | — |
| 方案二 | Z-score标准化 | 0.9444 | +22.2个百分点 |
| 方案三 | min-max标准化 | 0.9630 | +24.1个百分点 |
同一个模型、同一份数据、同一个K,只是加了两行标准化代码,准确率从0.72跳到0.94以上。反过来读这张表更吓人:不做标准化,你亲手把一个94分的模型开成了72分,而且它不报任何错,你若不做这组对照实验,可能永远不知道自己损失了什么。
0.72是什么概念?wine数据集三个类别的样本数是59/71/48,全猜最大类也有0.40的准确率。也就是说,13个特征被"脯氨酸"们独裁之后,模型只比瞎猜聪明了一半。
七、K值怎么选:GridSearchCV调参曲线
标准化解决了"特征话语权"的问题,还剩下开头挖的那个坑:K取几?第一节的电影例子已经演示过K=3和K=5给出相反结论。K太小,模型跟着个别噪声点起舞,容易过拟合;K太大,连很远的"假邻居"都拉进来投票,决策边界被抹平,容易欠拟合。极端情况下K=样本总数,那所有新样本都会被判成占比最高的类别,模型退化成"报菜名"。
正经做法是把K当超参数,用交叉验证扫一遍:
from sklearn.datasets import load_wine
from sklearn.model_selection import train_test_split, GridSearchCV
from sklearn.neighbors import KNeighborsClassifier
from sklearn.preprocessing import StandardScaler
from sklearn.pipeline import make_pipeline
X, y = load_wine(return_X_y=True)
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.3, random_state=42, stratify=y)
# Pipeline把标准化和KNN捆在一起:交叉验证的每一折都自动
# 只在训练折上fit标准化参数,从机制上杜绝数据泄露
pipe = make_pipeline(StandardScaler(), KNeighborsClassifier())
param_grid = {"kneighborsclassifier__n_neighbors": range(1, 21)} # K从1扫到20
gs = GridSearchCV(pipe, param_grid, cv=5, scoring="accuracy") # 5折交叉验证
gs.fit(X_train, y_train)
print("最优K:", gs.best_params_["kneighborsclassifier__n_neighbors"])
print("交叉验证最高分:", round(gs.best_score_, 4))
print("测试集分数:", round(gs.score(X_test, y_test), 4))
for k, s in zip(range(1, 21), gs.cv_results_["mean_test_score"]):
print(f"K={k:>2d} cv准确率={s:.4f}")
# 运行输出(节选):
# 最优K: 12
# 交叉验证最高分: 0.9677
# 测试集分数: 0.963
# K= 1 cv准确率=0.9513
# K= 2 cv准确率=0.9357
# K= 5 cv准确率=0.9593
# K=12 cv准确率=0.9677
# K=20 cv准确率=0.9513
把这20个点连起来就是K值调参曲线,形状很典型:K=1时0.9513,每个噪声点都握着一票否决权,分数被拖累;K=2反而跌到全场最低的0.9357——偶数K平票率高,是个经典小坑;K=4以后爬上0.959左右的平台期;K=12摸到峰值0.9677;再往后缓慢下滑,K=20回落到0.9513,欠拟合的苗头出来了。整条曲线是两头低、中间高的倒U形,你在自己的数据上扫出来大概率也长这样,选峰值附近的K即可。
顺手把KNeighborsClassifier的常用参数整理成速查表:
| n_neighbors | 5 | 邻居数量K | 核心参数,用交叉验证扫1~20,二分类倾向选奇数防平票 |
| weights | 'uniform' | 投票权重 | 'uniform'一人一票;'distance'离得越近票越重,噪声多时可试 |
| metric | 'minkowski' | 距离度量 | 配合p=2即欧氏距离(默认);p=1为曼哈顿距离 |
| p | 2 | 闵可夫斯基距离的幂 | 高维稀疏数据可试p=1 |
| algorithm | 'auto' | 近邻搜索算法 | 一般不动,大数据量时'kd_tree'/'ball_tree'加速 |
| n_jobs | None | 并行数 | 预测慢时设-1吃满CPU |
八、KNN的优缺点、适用场景,以及2026年还学它干嘛
| 优点 | 原理直白,几乎零门槛;无需训练过程(惰性学习),新增样本即插即用;天然支持多分类;对数据分布不做任何假设;决策边界可以任意复杂 |
| 缺点 | 预测时要和全量训练数据算距离,样本量大时又慢又吃内存;对特征量纲极度敏感(本文主题);高维数据下各点距离趋同,效果崩塌(维度灾难);对类别不平衡敏感,多数类容易霸占投票 |
| 适合场景 | 中小规模、低维、特征物理含义相近的数据;推荐系统里"找相似用户/相似物品"的召回;异常检测(离所有邻居都远的就是异常点);新项目的快速baseline |
| 不适合场景 | 百万级以上样本的在线实时预测;几百维以上的稀疏特征(如文本one-hot);特征量纲混乱又没条件做预处理的数据管道 |
到2026年的今天还要不要学KNN?要。大模型抢走的是非结构化数据的活儿,而银行风控里的邻域欺诈检测、推荐系统粗排阶段的"相似用户召回"、工业质检里"新工件与历史合格品的相似度比对",本质上都是KNN思想在生产环境的变体——向量数据库里的近似最近邻检索(ANN),就是KNN为了对付海量数据演化出的工程形态。你现在做RAG检索,底层逻辑和本文第三节的手算流程是同一件事:算距离、排序、取Top-K。学透这篇,你学的不是一个老算法,是一整类"相似度检索"问题的地基。
九、踩坑实录:新手最容易犯的4个错误
| 1 | 准确率莫名偏低(如wine上只有0.72),换模型也没好转 | 忘了标准化,距离被大量纲特征独裁 | 训练前加StandardScaler,KNN/K-Means/SVM一律先标准化 |
| 2 | 交叉验证分数很高,上线后效果拉胯 | 在全量数据上fit了Scaler,测试信息泄露 | 只在训练集上fit,或直接用Pipeline捆绑 |
| 3 | 二分类任务里预测结果时好时坏、难以复现 | K取了偶数,平票时靠邻居顺序裁决 | 二分类取奇数K,或用weights='distance' |
| 4 | 训练"秒完成"于是拿百万级数据直接上线 | 误以为训练快=预测快,KNN的开销全在预测 | 大数据量换kd_tree/ball_tree、做ANN近似检索或干脆换模型 |
第2个坑展开说两句,因为它最隐蔽。错误写法是scaler.fit(X)之后再切分训练/测试集——测试集的均值方差信息已经混进了标准化参数,模型等于提前偷看了考卷,交叉验证分数虚高,上线见光死。修正代码就是第七节里的make_pipeline(StandardScaler(), KNeighborsClassifier()):Pipeline会保证每一折交叉验证都只用训练折的统计量,想泄露都难。
第3个坑你在第七节的曲线里已经亲眼见过了:K=2的分数比K=1和K=3都低,不是巧合,是平票裁决在捣乱。
十、参考资料
十一、互动引导
三个问题,欢迎在评论区聊聊:
觉得这篇把"标准化为什么是KNN的救命稻草"讲透了的话,点赞、收藏、关注本专栏三连走一波。下一篇(本系列第5篇)我们进入树模型的世界:《一个数字决定分裂生死:决策树的基尼系数与信息熵对决》——同样是"挑特征",决策树用的是完全不同的一把尺子,不见不散。
网硕互联帮助中心






评论前必须登录!
注册