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

📌 置顶提示|读完你能带走什么
- 徒手算一遍KNN:5瓶葡萄酒、一张草稿纸,把"距离排序→投票判类"的完整链条走通,从此看KNN不再是黑盒;
- 亲眼见证一次特征"独裁"事故:在wine数据集上复现"不标准化准确率0.72、标准化后0.94"的真实对比,并搞懂min-max和Z-score该在什么时候用哪个;
- 学会用GridSearchCV画K值调参曲线,回答"K到底取几"这个面试高频题。
预计阅读时间:15~18分钟
本文是《机器学习-算法模型系列》第 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的完整计算步骤你已经全部亲手走过一遍了:算距离 → 排序 → 投票。没有梯度下降,没有损失函数,就这么直白。
用代码验证一下草稿纸上的结果:
python
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个距离都算出来对比一下:
| 目标 | 原始数据的距离 | 放大10倍后的距离 |
|---|---|---|
| 新样本 → 样本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 怎么选?一张表说清
| 对比维度 | min-max标准化 | Z-score标准化 |
|---|---|---|
| 结果范围 | 固定压缩到 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),唯一的变量是做不做标准化,比较测试集准确率。
python
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当超参数,用交叉验证扫一遍:
python
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都低,不是巧合,是平票裁决在捣乱。
十、参考资料
- scikit-learn官方文档·最近邻算法:https://scikit-learn.org/stable/modules/neighbors.html
- 维基百科·k-nearest neighbors algorithm:https://en.wikipedia.org/wiki/K-nearest_neighbors_algorithm
- 周志华,《机器学习》(西瓜书),清华大学出版社------第10章"降维与度量学习"对k近邻学习器与维度灾难有系统论述。
十一、互动引导
三个问题,欢迎在评论区聊聊:
- 你在自己的项目里遇到过"特征独裁"吗?是哪个特征、量级差了多少,标准化之后指标变化了多少?
- 文中K=2的交叉验证分数低于K=1和K=3,除了平票问题,你觉得还有别的解释吗?
- 如果两个特征量纲相同、但其中一个其实和标签毫无关系(纯噪声特征),标准化救得了KNN吗?该用什么办法处理?
觉得这篇把"标准化为什么是KNN的救命稻草"讲透了的话,点赞、收藏、关注本专栏三连走一波。下一篇(本系列第5篇)我们进入树模型的世界:《一个数字决定分裂生死:决策树的基尼系数与信息熵对决》------同样是"挑特征",决策树用的是完全不同的一把尺子,不见不散。