机器学习-算法模型系列文章:04-KNN 不做标准化就跑KNN?你的模型正在被一个特征“独裁“

不做标准化就跑KNN?你的模型正在被一个特征"独裁"

目录

📌 置顶提示|读完你能带走什么

  1. 徒手算一遍KNN:5瓶葡萄酒、一张草稿纸,把"距离排序→投票判类"的完整链条走通,从此看KNN不再是黑盒;
  2. 亲眼见证一次特征"独裁"事故:在wine数据集上复现"不标准化准确率0.72、标准化后0.94"的真实对比,并搞懂min-max和Z-score该在什么时候用哪个;
  3. 学会用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都低,不是巧合,是平票裁决在捣乱。

十、参考资料

  1. scikit-learn官方文档·最近邻算法:https://scikit-learn.org/stable/modules/neighbors.html
  2. 维基百科·k-nearest neighbors algorithm:https://en.wikipedia.org/wiki/K-nearest_neighbors_algorithm
  3. 周志华,《机器学习》(西瓜书),清华大学出版社------第10章"降维与度量学习"对k近邻学习器与维度灾难有系统论述。

十一、互动引导

三个问题,欢迎在评论区聊聊:

  1. 你在自己的项目里遇到过"特征独裁"吗?是哪个特征、量级差了多少,标准化之后指标变化了多少?
  2. 文中K=2的交叉验证分数低于K=1和K=3,除了平票问题,你觉得还有别的解释吗?
  3. 如果两个特征量纲相同、但其中一个其实和标签毫无关系(纯噪声特征),标准化救得了KNN吗?该用什么办法处理?

觉得这篇把"标准化为什么是KNN的救命稻草"讲透了的话,点赞、收藏、关注本专栏三连走一波。下一篇(本系列第5篇)我们进入树模型的世界:《一个数字决定分裂生死:决策树的基尼系数与信息熵对决》------同样是"挑特征",决策树用的是完全不同的一把尺子,不见不散。

相关推荐
后端小肥肠1 小时前
做个人 IP 不用真人出镜,我做了个一键生成 IP 动画视频的 Skill
人工智能·aigc·agent
墨舟的AI笔记1 小时前
大模型游戏剧情评测:用自动化指标抑制幻觉与 OOC 出戏
人工智能
武子康1 小时前
从世界状态到可执行控制:Cosmos 3 Edge 与机器人控制器之间应建立什么合同
人工智能·agent·nvidia
Warren2Lynch2 小时前
掌握 UML 构造型、标记定义与标记值:面向领域特定建模的 UML 扩展全面指南
大数据·算法·uml
我是大卫2 小时前
【图】解LLM:用图理解大语言模型
人工智能
157092511342 小时前
【无标题】
开发语言·python·算法
勇叔2 小时前
从 LangChain SQLAgent 天生缺陷到五把安全锁落地 — 牧场 AI 查询实战踩坑指南
人工智能
稚南城才子,乌衣巷风流2 小时前
换根法(Rerooting)算法详解
算法
晓子文集2 小时前
Tushare接口文档:期货交易日历(fut_trade_cal)
大数据·算法
Kel2 小时前
GQA 与 KV 缓存(Grouped-Query Attention & KV Cache)
人工智能