戰(zhàn):鳶尾花數(shù)據(jù)集全流程調(diào)參與優(yōu)化)
1. 先把KNN這件事想明白一個(gè)不訓(xùn)練的算法憑什么上臺(tái)面K近鄰算法K-Nearest Neighbors簡(jiǎn)稱KNN在機(jī)器學(xué)習(xí)里是個(gè)特別有意思的存在。你把訓(xùn)練集喂給它它幾乎什么都不干——不擬合參數(shù)、不更新權(quán)重、不建梯度、不迭代。fit()一調(diào)用時(shí)間基本花在把數(shù)據(jù)存進(jìn)內(nèi)存上。真正干活是在predict()那一刻來一個(gè)新樣本它把訓(xùn)練集里所有點(diǎn)和這個(gè)新樣本逐個(gè)算一遍距離找出最近的K個(gè)鄰居看這些鄰居大多屬于哪一類就把新樣本判成哪一類。這套邏輯樸素得像小區(qū)里問路——你不知道某個(gè)地方在哪就問身邊最近的幾個(gè)人多數(shù)人說往東你大概就往東走。我在帶新人的時(shí)候常講一句話KNN的全部智能都藏在距離和K這兩個(gè)詞里。它沒有學(xué)到特征和標(biāo)簽之間的映射函數(shù)它學(xué)到的其實(shí)是整個(gè)訓(xùn)練集本身就是模型。這個(gè)特性決定了它的優(yōu)缺點(diǎn)都極其鮮明——實(shí)現(xiàn)簡(jiǎn)單、無需訓(xùn)練、天然支持多分類、對(duì)非線性邊界有一定適應(yīng)力但同時(shí)內(nèi)存占用隨數(shù)據(jù)線性增長、預(yù)測(cè)慢、對(duì)特征尺度極其敏感、在高維空間里幾乎失效。這篇文章講的是鳶尾花Iris數(shù)據(jù)集上的KNN完整實(shí)現(xiàn)。這個(gè)數(shù)據(jù)集小得可愛150條樣本4個(gè)特征花萼長度、花萼寬度、花瓣長度、花瓣寬度3個(gè)類別三個(gè)不同品種每類50條。很多人嫌它太簡(jiǎn)單一行代碼準(zhǔn)確率就96%但我偏偏喜歡拿它做教學(xué)載體因?yàn)樗蓛舻娇梢园衙恳粋€(gè)環(huán)節(jié)單獨(dú)拎出來反復(fù)折騰——你怎么選K、怎么縮放、怎么切分、怎么驗(yàn)證、怎么解釋結(jié)果全都看得清清楚楚不會(huì)有一堆臟數(shù)據(jù)干擾你的判斷。適合誰看寫過一點(diǎn)Python、聽過KNN但沒真正跑通過全流程的人調(diào)包能出結(jié)果但說不清為什么準(zhǔn)確率是96%而不是99%的人想手寫一遍KNN理解底層的人。如果你已經(jīng)能熟練做網(wǎng)格搜索和特征工程這篇可能偏基礎(chǔ)但里面關(guān)于標(biāo)準(zhǔn)化順序、維度災(zāi)難、距離度量選擇的那些坑大概率還是能幫你省點(diǎn)時(shí)間。1.1 距離即投票KNN的核心假設(shè)到底是什么任何算法背后都有一套對(duì)世界的假設(shè)KNN的假設(shè)直白得不像話特征空間里挨得近的樣本標(biāo)簽大概率相同。這句話聽起來像廢話但它成立是需要條件的。第一個(gè)條件特征必須能構(gòu)成一個(gè)有意義的度量空間。也就是說兩個(gè)樣本之間的距離要真的能反映它們的相似程度。如果你的特征里混了用戶ID這種純編號(hào)ID1001和ID1002看起來距離是1但它們之間沒有任何語義關(guān)聯(lián)這種距離就是假的會(huì)把整個(gè)模型帶偏。第二個(gè)條件各特征的量綱要可比?;ㄝ嚅L度單位是厘米取值范圍4.3到7.9如果我還加入一個(gè)花瓣面積單位是平方厘米取值可能到20以上。那么在歐氏距離的平方和里面積這個(gè)維度會(huì)完全壓制長度維度長度特征等于白給。這就是為什么KNN幾乎必須配標(biāo)準(zhǔn)化不是建議是必須。第三個(gè)條件樣本密度要足夠。KNN是純局部方法它只看鄰居。如果某個(gè)區(qū)域訓(xùn)練樣本極其稀疏那最近的K個(gè)鄰居可能其實(shí)離得很遠(yuǎn)投票結(jié)果的可靠性就大打折扣。所以KNN在樣本分布均勻、類別邊界清晰的問題上表現(xiàn)好在長尾、稀疏、類別極不平衡的問題上容易翻車。想清楚這三點(diǎn)你就明白為什么很多人拿KNN跑出來的結(jié)果忽高忽低——不是算法玄學(xué)是這三個(gè)前提被破壞了。我見過有人把KNN直接用在幾千維的文本TF-IDF向量上然后抱怨KNN效果太差其實(shí)問題不在KNN在于高維空間里最近這個(gè)概念已經(jīng)失真了這個(gè)后面會(huì)專門講。1.2 它和線性模型、樹模型的根本差別在哪拿KNN和邏輯回歸對(duì)比差別不只在效果更在模型的形態(tài)。邏輯回歸訓(xùn)練完得到的是一組權(quán)重模型被壓縮成幾個(gè)數(shù)字你甚至可以把它寫在紙上KNN訓(xùn)練完得到的是一整個(gè)訓(xùn)練集模型體積等于數(shù)據(jù)體積。這是參數(shù)化模型和非參數(shù)化模型的分水嶺。和決策樹對(duì)比更有意思。決策樹是在特征空間上切格子切完之后每個(gè)格子給一個(gè)標(biāo)簽切法由訓(xùn)練數(shù)據(jù)決定但一旦確定就固定了KNN不切格子它的決策邊界是隨著查詢點(diǎn)移動(dòng)的每個(gè)新樣本都會(huì)臨時(shí)劃出一片鄰域。所以KNN的決策邊界可以非常曲折、非常非線性代價(jià)是它沒法給你一個(gè)可解釋的規(guī)則表。這個(gè)差別帶來的直接影響是預(yù)測(cè)成本。邏輯回歸預(yù)測(cè)是幾次乘加微秒級(jí)決策樹預(yù)測(cè)是幾次比較也是微秒級(jí)KNN預(yù)測(cè)要遍歷訓(xùn)練集算距離10萬條樣本就是10萬次距離計(jì)算。線上服務(wù)里如果QPS高、延遲敏感KNN基本不適合直接懟在請(qǐng)求鏈路上得配近似最近鄰索引或者做預(yù)計(jì)算。1.3 哪些場(chǎng)景該用它哪些場(chǎng)景趁早換我自己的判斷標(biāo)準(zhǔn)很簡(jiǎn)單看三條數(shù)據(jù)量中等偏小萬級(jí)到十萬級(jí)特征維度不高幾十維以內(nèi)且特征都是數(shù)值型、量綱可以統(tǒng)一——用KNN很合適甚至經(jīng)常能打贏一些復(fù)雜模型。樣本量極大或者維度極高幾百上千維——先別急著上KNN除非你愿意上近似檢索。需要給出為什么這么判的業(yè)務(wù)解釋——KNN能給出因?yàn)楹瓦@5個(gè)歷史樣本最像這種解釋在某些風(fēng)控、推薦場(chǎng)景反而比權(quán)重系數(shù)更好用。實(shí)際落地里KNN出現(xiàn)頻率最高的地方往往不是主模型而是推薦系統(tǒng)的召回層找相似物品/相似用戶、圖像檢索的相似度匹配、異常檢測(cè)離所有鄰居都遠(yuǎn)的點(diǎn)判為異常、缺失值填補(bǔ)用鄰居的均值填、以及小樣本冷啟動(dòng)場(chǎng)景。這些場(chǎng)景有一個(gè)共同點(diǎn)——相似本身就是業(yè)務(wù)語義不需要額外解釋。2. 核心參數(shù)與原理細(xì)節(jié)K值、距離度量、決策規(guī)則2.1 K值怎么選偏差和方差的那桿秤K是KNN里唯一一個(gè)真正意義上的超參數(shù)它的作用是把局部的尺度調(diào)大調(diào)小。K1的時(shí)候模型完全信任最近的那一個(gè)點(diǎn)。訓(xùn)練集里只要有一個(gè)噪聲點(diǎn)或者標(biāo)錯(cuò)的樣本它周圍就會(huì)被劃出一小塊錯(cuò)誤的領(lǐng)地。這叫過擬合方差極大換一批訓(xùn)練數(shù)據(jù)決策邊界抖得厲害。準(zhǔn)確率在訓(xùn)練集上永遠(yuǎn)是100%因?yàn)樽罱泥従泳褪撬约簻y(cè)試集上往往明顯掉一檔。K取很大比如K100那就等于每次預(yù)測(cè)都看一大片區(qū)域。極端情況KN所有樣本都參與投票預(yù)測(cè)結(jié)果對(duì)所有輸入都一樣永遠(yuǎn)是多數(shù)類。這叫欠擬合偏差極大。所以K的選擇本質(zhì)就是在偏差和方差之間找平衡點(diǎn)。經(jīng)驗(yàn)做法有這么幾條從K sqrt(N)開始試N是訓(xùn)練樣本數(shù)。150條樣本開方約12所以鳶尾花上可以先試K11或13這類奇數(shù)。二分類問題盡量取奇數(shù)避免平票。多分類問題平票的可能性低但sklearn內(nèi)部會(huì)按距離加權(quán)或取索引小的類行為不一定符合你的預(yù)期能避開就避開。不要用訓(xùn)練集選K。必須用驗(yàn)證集或者交叉驗(yàn)證否則你會(huì)選出一個(gè)過擬合的極小K。K通常不會(huì)取到樣本量的很大比例一般不超過樣本數(shù)的十分之一到五分之一。在鳶尾花上做5折交叉驗(yàn)證掃一遍K你會(huì)看到一個(gè)很典型的U形曲線K從1漲到5左右準(zhǔn)確率快速上升5到15之間平臺(tái)區(qū)很平超過25之后開始緩慢下滑。平臺(tái)的平坦程度說明這個(gè)數(shù)據(jù)集類別邊界很干凈實(shí)際上K取3到15結(jié)果都差不多在0.95以上。這時(shí)候真正的決策依據(jù)不是哪個(gè)準(zhǔn)確率高0.01而是哪個(gè)更穩(wěn)——我一般會(huì)選平臺(tái)區(qū)中間偏小的值兼顧穩(wěn)定性和局部性。2.2 距離度量不是只有歐氏距離默認(rèn)的歐氏距離是L2范數(shù)公式是各維度差值的平方和再開根號(hào)。它在特征維度獨(dú)立、量綱統(tǒng)一、分布比較團(tuán)狀的時(shí)候最合適。曼哈頓距離是L1范數(shù)各維度差值絕對(duì)值求和。它對(duì)異常值更不敏感因?yàn)檎`差不被平方放大。如果某一維偶爾出現(xiàn)離譜的值L1會(huì)比L2穩(wěn)。余弦相似度衡量的是方向而不是長度。文本向量、embedding向量這種長度不重要、方向才重要的場(chǎng)景必須用余弦。你在高維稀疏向量上算歐氏距離結(jié)果基本被向量模長主導(dǎo)完全沒有意義。閔可夫斯基距離是把上面這些統(tǒng)一起來的通式參數(shù)p決定具體形態(tài)p1是曼哈頓p2是歐氏p→∞是切比雪夫距離取各維度差值的最大值。sklearn里metricminkowski, p2就是歐氏距離。還有幾個(gè)值得知道但少用的馬氏距離考慮了特征之間的協(xié)方差能消除特征相關(guān)性影響但對(duì)小樣本估計(jì)協(xié)方差矩陣不穩(wěn)漢明距離專門給離散/二值特征用Jaccard用于集合相似度。選距離度量的判斷順序我一般是這樣的特征有明確物理量綱且相互獨(dú)立→歐氏存在離群點(diǎn)或分布偏斜→曼哈頓向量方向比大小重要→余弦特征間強(qiáng)相關(guān)→考慮馬氏或先做PCA去相關(guān)。2.3 投票規(guī)則與距離加權(quán)標(biāo)準(zhǔn)做法是等權(quán)投票K個(gè)鄰居一人一票。但這里有個(gè)隱含問題——K5的時(shí)候最近的鄰居距離可能是1.0最遠(yuǎn)的那個(gè)可能是3.0它們的話語權(quán)完全一樣這顯然不太合理。weightsdistance就是來解決這個(gè)的每個(gè)鄰居的票權(quán)按距離的倒數(shù)分配。距離越近權(quán)重越大距離趨近于0時(shí)權(quán)重趨于無窮大。實(shí)現(xiàn)上sklearn用的是1/d而不是嚴(yán)格的1/d2避免數(shù)值爆炸。效果上距離加權(quán)在小K時(shí)通常能提升一點(diǎn)準(zhǔn)確率尤其在類別邊界附近但在噪聲多的數(shù)據(jù)上反而可能放大單個(gè)異常點(diǎn)的影響。我的實(shí)測(cè)經(jīng)驗(yàn)是數(shù)據(jù)干凈、邊界清晰比如鳶尾花等權(quán)和距離加權(quán)差別在1個(gè)百分點(diǎn)以內(nèi)數(shù)據(jù)噪聲大、密度不均等權(quán)反而更穩(wěn)?;貧w任務(wù)里KNN就是取鄰居標(biāo)簽的均值或加權(quán)均值邏輯完全對(duì)應(yīng)。分類投票、回歸平均這是同一套思想的兩種輸出形式。2.4 維度災(zāi)難KNN在高維空間為什么會(huì)失靈這一條很關(guān)鍵但很多教程一筆帶過。高維空間里距離這個(gè)概念會(huì)鈍化。設(shè)想在一維區(qū)間[0,1]里隨機(jī)撒點(diǎn)最近鄰的距離大概在1/N量級(jí)在100維的單位超立方體里撒點(diǎn)任意兩點(diǎn)之間的距離會(huì)高度集中在一個(gè)很窄的范圍內(nèi)最近鄰和最遠(yuǎn)鄰的距離比值趨近于1。這意味著什么意味著最近的那個(gè)鄰居并不比隨便挑一個(gè)點(diǎn)近多少KNN的局部性假設(shè)直接崩塌。有個(gè)粗略的經(jīng)驗(yàn)說法KNN在維度超過20左右就要小心了超過50基本只能靠降維或者換算法。這不是硬閾值跟樣本量有關(guān)——樣本量隨維度指數(shù)增長才夠用所以維度和樣本量的關(guān)系比單純的維度數(shù)字更重要。對(duì)策很實(shí)在先做特征選擇把明顯無關(guān)的維度砍掉。用PCA或者別的降維手段壓到低維再跑KNN很多時(shí)候比直接堆全特征效果還好。如果維度確實(shí)高比如embedding檢索不要用暴力搜索改用近似最近鄰HNSW、IVF之類的索引結(jié)構(gòu)現(xiàn)代向量檢索基本都是把這個(gè)思路工程化。2.5 特征縮放不做這一步后面全是白干標(biāo)準(zhǔn)化公式很基礎(chǔ)但值得寫清楚因?yàn)楹芏嗉?xì)節(jié)在為什么這么算上$$z \frac{x - \mu}{\sigma}$$其中 μ 是訓(xùn)練集上該特征的均值σ 是訓(xùn)練集上該特征的標(biāo)準(zhǔn)差。變換之后每個(gè)特征的均值是0標(biāo)準(zhǔn)差是1。標(biāo)準(zhǔn)化不改變分布形狀只改尺度和位置。這里有兩個(gè)必須記住的操作禁忌計(jì)算 μ 和 σ 只能用訓(xùn)練集然后把這個(gè) μ、σ 應(yīng)用到驗(yàn)證集和測(cè)試集上。如果用全體數(shù)據(jù)算均值和方差再切分測(cè)試集的信息就泄漏進(jìn)訓(xùn)練過程了評(píng)估結(jié)果會(huì)虛高。正確做法是scaler.fit(X_train)后scaler.transform(X_test)或者用Pipeline把縮放和模型綁在一起。StandardScaler 對(duì)常數(shù)列標(biāo)準(zhǔn)差為0處理時(shí)會(huì)把該列置0不會(huì)報(bào)錯(cuò)但那一維就廢了。如果某個(gè)特征在訓(xùn)練集里只有一個(gè)值請(qǐng)?zhí)崆白R(shí)別出來刪掉。除了StandardScaler還有幾種常見選擇MinMaxScaler把值壓到[0,1]適合需要固定范圍或者稀疏數(shù)據(jù)RobustScaler用中位數(shù)和四分位距對(duì)離群點(diǎn)更穩(wěn)Normalizer是行歸一化改的是樣本向量的模長而不是列的分布別和標(biāo)準(zhǔn)化搞混。在鳶尾花上標(biāo)準(zhǔn)化的影響其實(shí)不大因?yàn)樗膫€(gè)特征本來就在同一量級(jí)厘米歐氏距離不會(huì)嚴(yán)重失衡。但為了養(yǎng)成習(xí)慣也因?yàn)檫@是通用流程的一部分正式代碼里我還是會(huì)加上。這個(gè)習(xí)慣在你自己接手的真實(shí)數(shù)據(jù)上價(jià)值巨大——我踩過的最隱蔽的一次坑就是一個(gè)以元為單位的價(jià)格特征和一個(gè)以百分比為單位的比率特征混在一起KNN的準(zhǔn)確率一直在60%左右徘徊標(biāo)準(zhǔn)化之后直接跳到88%。3. 鳶尾花數(shù)據(jù)集實(shí)操從加載到評(píng)估的完整流程3.1 加載數(shù)據(jù)與第一輪觀察先用sklearn自帶的版本省去下載和清洗的功夫。import numpy as np import pandas as pd from sklearn.datasets import load_iris iris load_iris() X pd.DataFrame(iris.data, columnsiris.feature_names) y pd.Series(iris.target, nametarget) print(X.shape) # (150, 4) print(y.value_counts()) # 每類各50 print(X.describe().round(2))跑出來你會(huì)看到各特征的范圍花萼長度4.3到7.9花萼寬度2.0到4.4花瓣長度1.0到6.9花瓣寬度0.1到2.5。四個(gè)特征量綱一致但分布寬度差了一倍多花瓣寬度的標(biāo)準(zhǔn)差比花萼寬度小不少說明花瓣相關(guān)的特征區(qū)分度可能更高。再看一下類別分布三個(gè)類各50條完全均衡。這一點(diǎn)很重要——均衡數(shù)據(jù)上準(zhǔn)確率這個(gè)指標(biāo)才可信如果是9:1的不均衡數(shù)據(jù)全猜多數(shù)類也有90%準(zhǔn)確率那時(shí)候必須看混淆矩陣、看每類的precision和recall。還有一個(gè)必做的動(dòng)作看特征之間的相關(guān)性。print(X.corr().round(2))在鳶尾花上花瓣長度和花瓣寬度的相關(guān)系數(shù)大約0.96花萼長度和花瓣長度大約0.87。高度相關(guān)意味著信息冗余對(duì)KNN來說不致命它不像線性回歸那樣有共線性問題但確實(shí)意味著你在重復(fù)計(jì)權(quán)同一個(gè)維度。這也是后面能不能做PCA降維的依據(jù)之一。我一般還會(huì)畫一下散點(diǎn)圖用花瓣長度和花瓣寬度做二維投影肉眼就能看出三類基本是分開的其中一類setosa和另外兩類完全線性可分另外兩類之間只有少量交疊。這解釋了為什么這個(gè)數(shù)據(jù)集的準(zhǔn)確率天花板在96%到98%之間——那2%到4%的錯(cuò)誤幾乎全部來自后兩類的邊界樣本無論你怎么調(diào)K都是這個(gè)數(shù)。3.2 數(shù)據(jù)劃分與標(biāo)準(zhǔn)化的正確姿勢(shì)劃分訓(xùn)練測(cè)試集固定隨機(jī)種子保證可復(fù)現(xiàn)from sklearn.model_selection import train_test_split X_train, X_test, y_train, y_test train_test_split( X, y, test_size0.2, random_state42, stratifyy )stratifyy這個(gè)參數(shù)一定要加。它保證訓(xùn)練集和測(cè)試集里的類別比例與原數(shù)據(jù)一致。150條數(shù)據(jù)、3個(gè)類如果不分層隨機(jī)切出30條測(cè)試集有概率某一類只分到6條甚至更少評(píng)估結(jié)果波動(dòng)會(huì)很大。分層之后每類在測(cè)試集里穩(wěn)定出現(xiàn)10條。然后是標(biāo)準(zhǔn)化注意順序from sklearn.preprocessing import StandardScaler scaler StandardScaler() X_train_scaled scaler.fit_transform(X_train) # fit和transform都在訓(xùn)練集 X_test_scaled scaler.transform(X_test) # 只用transform這里有個(gè)細(xì)節(jié)fit_transform(X_train)之后scaler.mean_和scaler.scale_就固定了transform(X_test)用的是訓(xùn)練集的統(tǒng)計(jì)量。如果你寫成scaler.fit_transform(X_test)那就是徹頭徹尾的數(shù)據(jù)泄漏測(cè)試集的均值方差參與了變換評(píng)估結(jié)果會(huì)偏樂觀。這個(gè)坑極其常見我在代碼review里見過不止一次。更穩(wěn)妥的寫法是直接用Pipeline把縮放和模型串成一體交叉驗(yàn)證的時(shí)候sklearn會(huì)自動(dòng)保證每一折的縮放只在該折的訓(xùn)練部分?jǐn)M合from sklearn.pipeline import Pipeline from sklearn.neighbors import KNeighborsClassifier pipe Pipeline([ (scaler, StandardScaler()), (knn, KNeighborsClassifier(n_neighbors5)) ])用Pipeline最大的好處不是代碼短是從結(jié)構(gòu)上消滅了忘記順序的可能性。我現(xiàn)在的習(xí)慣是只要涉及任何需要fit的預(yù)處理一律走Pipeline不給自己犯錯(cuò)的機(jī)會(huì)。3.3 建模、預(yù)測(cè)與評(píng)估from sklearn.metrics import accuracy_score, classification_report, confusion_matrix pipe.fit(X_train, y_train) y_pred pipe.predict(X_test) print(f準(zhǔn)確率: {accuracy_score(y_test, y_pred):.4f}) print(confusion_matrix(y_test, y_pred)) print(classification_report(y_test, y_pred, target_namesiris.target_names))默認(rèn)K5跑出來測(cè)試集準(zhǔn)確率通常在0.93到0.97之間浮動(dòng)取決于random_state?;煜仃嚂?huì)顯示錯(cuò)誤集中在那兩個(gè)容易混的類之間。光看一個(gè)測(cè)試集的結(jié)果不夠30條樣本錯(cuò)一條就是3.3個(gè)百分點(diǎn)波動(dòng)太大。養(yǎng)成習(xí)慣用交叉驗(yàn)證from sklearn.model_selection import cross_val_score scores cross_val_score(pipe, X, y, cv5, scoringaccuracy) print(f5折CV: {scores.mean():.4f} ± {scores.std():.4f})5折交叉驗(yàn)證用的是全部150條數(shù)據(jù)每一折都有120條訓(xùn)練、30條驗(yàn)證最后取5次平均。這個(gè)數(shù)字比單次train_test_split穩(wěn)定得多也是我用來判斷模型好壞的主要依據(jù)。注意這里的cross_val_score傳入的是完整的X和y內(nèi)部會(huì)自己切分——Pipeline保證了每一折的縮放只在訓(xùn)練折上擬合所以沒有泄漏。還有一個(gè)指標(biāo)值得看一眼predict_proba。proba pipe.predict_proba(X_test[:5]) print(proba.round(3))KNN輸出的概率就是K個(gè)鄰居里各類的占比。K5的話概率只可能是0、0.2、0.4、0.6、0.8、1.0這幾檔粒度非常粗。如果你需要比較細(xì)的概率估計(jì)比如做排序或者閾值調(diào)整KNN不是好選擇它的概率沒經(jīng)過校準(zhǔn)??梢杂肅alibratedClassifierCV包一層或者干脆換邏輯回歸。3.4 手寫一遍KNN把黑箱拆開調(diào)包調(diào)通了不代表懂了。我強(qiáng)烈建議每個(gè)人手寫一遍代碼不超過40行import numpy as np from collections import Counter class SimpleKNN: def __init__(self, k3, p2): self.k k self.p p def fit(self, X, y): self.X_train np.asarray(X, dtypefloat) self.y_train np.asarray(y) return self def _dist(self, a, b): if self.p 1: return np.abs(a - b).sum(axis1) if self.p 2: return np.sqrt(((a - b) ** 2).sum(axis1)) return np.max(np.abs(a - b), axis1) def predict(self, X): X np.asarray(X, dtypefloat) preds [] for x in X: d self._dist(x, self.X_train) idx np.argsort(d)[:self.k] votes Counter(self.y_train[idx]) preds.append(votes.most_common(1)[0][0]) return np.array(preds)寫完自己手動(dòng)標(biāo)準(zhǔn)化一下再跑mu, sigma X_train.mean(0), X_train.std(0) Xtr (X_train - mu) / sigma Xte (X_test - mu) / sigma knn SimpleKNN(k5).fit(Xtr.values, y_train.values) pred knn.predict(Xte.values) print((pred y_test.values).mean())結(jié)果應(yīng)該和sklearn基本一致。手寫的價(jià)值在于你會(huì)親眼看到預(yù)測(cè)這一步是O(N×D)的計(jì)算N是訓(xùn)練樣本數(shù)D是維度。這解釋了為什么KNN預(yù)測(cè)慢、為什么大數(shù)據(jù)量下必須上索引結(jié)構(gòu)。你也會(huì)看到argsort是整個(gè)流程的瓶頸——實(shí)際上只需要前K個(gè)用argpartition會(huì)快很多工業(yè)實(shí)現(xiàn)里還會(huì)用堆或者樹結(jié)構(gòu)。順便說一個(gè)優(yōu)化點(diǎn)很多人不知道sklearn的KNN可以預(yù)計(jì)算距離矩陣在特征不變、只反復(fù)調(diào)K的場(chǎng)景下能省一半時(shí)間還有一個(gè)更實(shí)用的技巧是設(shè)置algorithmauto之外顯式指定kd_tree或ball_tree在低維中等數(shù)據(jù)量下比暴力搜索快一個(gè)數(shù)量級(jí)。4. 調(diào)參與性能優(yōu)化把準(zhǔn)確率從96%推到98%4.1 用網(wǎng)格搜索找K和權(quán)重手工試K效率太低直接上GridSearchCV。注意要搜的參數(shù)在Pipeline里帶前綴from sklearn.model_selection import GridSearchCV param_grid { knn__n_neighbors: list(range(1, 31, 2)), knn__weights: [uniform, distance], knn__p: [1, 2] } grid GridSearchCV(pipe, param_grid, cv5, scoringaccuracy, n_jobs-1) grid.fit(X, y) print(grid.best_params_) print(f{grid.best_score_:.4f})參數(shù)空間是15×2×260組每組5折一共300次訓(xùn)練加預(yù)測(cè)在鳶尾花這種規(guī)模上幾秒鐘就跑完了。一個(gè)觀察跑完之后你大概率會(huì)發(fā)現(xiàn)最優(yōu)的K落在3到11之間weights是uniform還是distance差距很小p是1還是2差距也很小。這說明在這個(gè)數(shù)據(jù)集上這些超參并不敏感。這時(shí)候別再糾結(jié)了挑一個(gè)泛化誤差曲線平臺(tái)區(qū)靠中間的值定下來把精力放到別的地方去。但有一種情況需要注意GridSearchCV用的是同一份數(shù)據(jù)做交叉驗(yàn)證選參數(shù)然后再用同一個(gè)best_score_報(bào)告結(jié)果這個(gè)數(shù)字會(huì)略微偏樂觀因?yàn)閰?shù)是在這同一批數(shù)據(jù)上挑出來的最優(yōu)值。嚴(yán)格的評(píng)估應(yīng)該留一個(gè)從不參與調(diào)參的測(cè)試集最后單獨(dú)跑一次。我一般這么切先分出20%測(cè)試集鎖起來剩下80%做交叉驗(yàn)證調(diào)參最后用那20%報(bào)最終結(jié)果且只報(bào)一次。4.2 縮放方式對(duì)結(jié)果的對(duì)比實(shí)驗(yàn)這個(gè)實(shí)驗(yàn)值得親手做一遍體會(huì)一下預(yù)處理選擇會(huì)直接影響指標(biāo)。from sklearn.preprocessing import MinMaxScaler, RobustScaler from sklearn.model_selection import cross_val_score for name, sc in [(none, None), (standard, StandardScaler()), (minmax, MinMaxScaler()), (robust, RobustScaler())]: steps [(knn, KNeighborsClassifier(n_neighbors5))] if sc: steps.insert(0, (scaler, sc)) p Pipeline(steps) s cross_val_score(p, X, y, cv5) print(f{name:9s} {s.mean():.4f} ± {s.std():.4f})在鳶尾花上你會(huì)看到不縮放的結(jié)果其實(shí)只差一點(diǎn)點(diǎn)因?yàn)樗膫€(gè)特征量綱本來就接近。但如果我把花萼寬度乘以100模擬量綱差異不縮放的準(zhǔn)確率會(huì)立刻掉到0.6以下而標(biāo)準(zhǔn)化版本紋絲不動(dòng)。這個(gè)對(duì)比實(shí)驗(yàn)我每次帶人做都會(huì)做沖擊感比講十遍原理都強(qiáng)。順便提一個(gè)邊界情況如果數(shù)據(jù)里有明顯的極端離群點(diǎn)StandardScaler會(huì)被拉偏因?yàn)榫岛蜆?biāo)準(zhǔn)差都受離群值影響這時(shí)候RobustScaler通常更穩(wěn)。判斷方法很簡(jiǎn)單畫個(gè)箱線圖看看就清楚了。4.3 大數(shù)據(jù)量下的加速思路鳶尾花只有150條談性能優(yōu)化有點(diǎn)奢侈。但既然講到這把思路說清楚對(duì)將來有用。暴力搜索的復(fù)雜度預(yù)測(cè)單個(gè)樣本是O(N×D)N條查詢就是O(N×M×D)。N到十萬級(jí)、D到百級(jí)單次預(yù)測(cè)就要上千萬次運(yùn)算這是不可接受的。第一條路是空間索引。KD樹在低維經(jīng)驗(yàn)上D20能顯著加速本質(zhì)是把空間遞歸二分查詢時(shí)剪枝掉不可能包含近鄰的分支。Ball Tree對(duì)高維稍好一些用超球體代替超矩形切分。sklearn里algorithm參數(shù)支持這兩種設(shè)置auto會(huì)讓它自動(dòng)選。但注意維度一高這些樹的剪枝效率急劇下降最后退化成接近暴力搜索。第二條路是近似最近鄰ANN。放棄精確找到最近的K個(gè)轉(zhuǎn)而以很高概率找到足夠近的K個(gè)換來數(shù)量級(jí)的速度提升。常見思路有基于圖的把樣本連成近鄰圖查詢時(shí)在圖上貪心游走、基于量化的把向量空間分桶只在附近桶里找、基于哈希的。這些在檢索場(chǎng)景已經(jīng)成為標(biāo)配文本、圖像、向量數(shù)據(jù)庫底層基本都是這套東西。第三條路是降維。PCA降到幾十維再跑既緩解維度災(zāi)難又直接降低每次距離計(jì)算的成本雙贏。前提是降維后保留的方差足夠一般看累計(jì)解釋方差到95%以上。第四條路是工程手段預(yù)計(jì)算距離矩陣、批量向量化用矩陣運(yùn)算代替循環(huán)、把訓(xùn)練集常駐內(nèi)存避免重復(fù)加載、用更緊湊的數(shù)據(jù)類型float32代替float64內(nèi)存減半、速度提升。這些都是實(shí)現(xiàn)層面的小優(yōu)化累加起來也很可觀。5. 常見問題與排查實(shí)錄5.1 準(zhǔn)確率異常低按這個(gè)順序排查我整理了一個(gè)排查順序從最常見的開始第一檢查是否做了縮放。這是頭號(hào)原因占比超過一半。特征量綱差異大而沒縮放KNN基本等于只在一個(gè)特征上做判斷。第二檢查縮放是不是做在了劃分之前。如果先對(duì)全量數(shù)據(jù)fit_transform再切分雖然準(zhǔn)確率會(huì)虛高而不是變低但另一個(gè)常見錯(cuò)誤是把fit_transform用在了測(cè)試集上這會(huì)導(dǎo)致訓(xùn)練和測(cè)試的變換標(biāo)準(zhǔn)不一致結(jié)果可能變得很奇怪。第三檢查有沒有把ID、時(shí)間戳之類的列喂進(jìn)去。這類特征的距離沒有語義會(huì)嚴(yán)重稀釋真實(shí)特征的作用。特征列一定要手動(dòng)點(diǎn)一遍名。第四檢查類別是否極度不平衡。不平衡時(shí)KNN會(huì)傾向于預(yù)測(cè)多數(shù)類整體準(zhǔn)確率看起來還行但少數(shù)類的召回率慘不忍睹。這時(shí)候要看每類的指標(biāo)并考慮用weightsdistance或者對(duì)多數(shù)類下采樣。第五檢查K值是不是太大。K接近樣本數(shù)時(shí)模型退化所有預(yù)測(cè)趨同。第六檢查是否存在重復(fù)樣本或近重復(fù)樣本導(dǎo)致的泄漏。如果同一條數(shù)據(jù)或幾乎相同的樣本同時(shí)出現(xiàn)在訓(xùn)練集和測(cè)試集KNN會(huì)直接背答案準(zhǔn)確率高得離譜。這在做過數(shù)據(jù)增強(qiáng)或者按時(shí)間切分不當(dāng)?shù)膱?chǎng)景里很常見。5.2 問題速查表現(xiàn)象最可能的原因處理方式準(zhǔn)確率明顯低于預(yù)期未做特征縮放加StandardScaler走Pipeline準(zhǔn)確率虛高接近100%數(shù)據(jù)泄漏或重復(fù)樣本檢查切分邏輯去重縮放只在訓(xùn)練集fit訓(xùn)練集100%、測(cè)試集很差K過小過擬合增大K用交叉驗(yàn)證選值所有樣本預(yù)測(cè)成同一類K過大或類別極不平衡減小K處理不平衡調(diào)參后提升很小超參不敏感或特征信息不足轉(zhuǎn)去做特征工程別死磕超參維度很高時(shí)效果崩塌維度災(zāi)難特征選擇或降維換ANN索引預(yù)測(cè)速度很慢暴力搜索大樣本KD樹/Ball樹或近似檢索概率輸出只有幾檔KNN概率粒度等于1/K增大K或用概率校準(zhǔn)5.3 幾個(gè)我踩過的坑寫下來省你時(shí)間坑一把predict和predict_proba的結(jié)果混用。前者返回類別標(biāo)簽后者返回各類占比。有次我在做閾值篩選直接把predict的結(jié)果當(dāng)概率去比較大小邏輯全錯(cuò)排查了半天才發(fā)現(xiàn)類型不對(duì)。坑二忽略特征的相關(guān)性做特征越多越好的判斷。加了一堆高度相關(guān)的特征KNN的距離計(jì)算里同一個(gè)信息被反復(fù)計(jì)權(quán)效果反而下降。加特征之前先看相關(guān)性矩陣??尤跁r(shí)間序列數(shù)據(jù)上隨機(jī)切分。KNN天然會(huì)把未來的信息帶到過去因?yàn)橄嗨频臉颖驹跁r(shí)間上往往相鄰。時(shí)間序列必須按時(shí)間切分否則評(píng)估結(jié)果毫無意義??铀囊詾閚_neighbors是唯一要調(diào)的。實(shí)際上metric的選擇在文本、embedding類任務(wù)上比K的影響大得多很多人壓根沒試過換度量??游逶谛颖旧线^度相信單次評(píng)估結(jié)果。150條數(shù)據(jù)測(cè)試集30條多對(duì)一個(gè)少對(duì)一個(gè)就是3.3個(gè)百分點(diǎn)。一定要用交叉驗(yàn)證并且報(bào)出均值±標(biāo)準(zhǔn)差而不是報(bào)一個(gè)孤零零的數(shù)字??恿浌潭S機(jī)種子。每次跑結(jié)果都不一樣調(diào)試的時(shí)候根本不知道變化是來自你的改動(dòng)還是來自切分。random_state是必備參數(shù)不是可選項(xiàng)。我在實(shí)際使用中的一個(gè)體會(huì)是KNN看起來是最簡(jiǎn)單的算法之一但它對(duì)數(shù)據(jù)質(zhì)量的敏感度在所有常見模型里排前列。線性模型還能靠權(quán)重把噪聲特征壓下去樹模型還能靠分裂點(diǎn)忽略無用維度KNN不行——你給它什么特征它就老老實(shí)實(shí)按這些特征算距離一維都不能糊弄。所以用好KNN的關(guān)鍵從來不在算法本身而在數(shù)據(jù)準(zhǔn)備那幾步特征選得準(zhǔn)不準(zhǔn)、量綱統(tǒng)一了沒有、有沒有混進(jìn)偽特征。把這幾步做扎實(shí)KNN在很多中小規(guī)模問題上依然是個(gè)又便宜又穩(wěn)的基線值得在項(xiàng)目早期就先跑一遍看看天花板在哪。