☰
kNN算法实战:sklearn鸢尾花分类、调参与避坑
2026/10/27 12:29:50 网站建设 项目流程

项目标题里的“kNN算法”和“sklearn鸢尾花数据集实战”,是我带新人时最常被点名要讲的一课。原因很实在:kNN(k-Nearest Neighbors,k近邻)几乎是把“机器学习”这四个字拆开摊在你面前的算法,没有损失函数要推、没有梯度下降要调、没有迭代轮次要等,理解成本极低,但真拿sklearn在鸢尾花数据集上跑起来,你会发现坑一个不少——标准化做不做、k取多少、交叉验证怎么切、准确率飙到1.0到底该高兴还是该警惕。这篇内容面向三类人:刚接触机器学习想找个能跑的入门项目、写过一阵子代码但对kNN只有“调包印象”、以及需要给团队或课程准备一份可复现实战材料的人。我下面会从思路拆解、核心参数、完整代码到踩坑排查,一条线走完,代码都能直接复制运行,参数选择过程和实测结果也一并交代清楚。

1. 先想明白:为什么第一个实战项目选kNN加鸢尾花

1.1 kNN本质是“物以类聚”的投票机制

我一般这么跟人解释kNN:你搬进一个新小区,想判断这栋楼里的人是早起型还是夜猫型,最直接的办法不是去统计整座城市,而是敲开离你最近的几户人家的门,看看他们几点拉窗帘。近邻的多数意见,就是你的标签。kNN干的事一模一样——一个新样本进来,算法计算它到训练集里每一个样本的距离,挑出距离最近的k个,然后让这k个邻居“投票”决定它属于哪一类。

这个类比里藏着三个关键量,正好对应后面要调的三个东西。第一是“距离怎么量”,决定了“离你近”到底是什么意思,是直线距离还是沿街区走的距离,这就是距离度量的选择。第二是“敲几户人家的门”,也就是k值,敲一户容易被噪音带偏,敲二十户又把整栋楼的共性糊上来了。第三是“投票是否等权”,离你三米的邻居和离你三十米的邻居,意见分量显然不该一样,这就是weights参数。

理解了这三点,你就会明白kNN的两个天然特征。它没有显式的训练过程,所谓“训练”只是把数据存下来,真正的计算全部发生在预测阶段,所以业界管它叫懒惰学习(lazy learning)。好处是模型更新成本几乎为零,坏处是预测时要跟全部训练样本算一遍距离,数据量一大就非常吃资源和时间。这个特性决定了它在实际业务里的位置:小数据、低维度、需要快速验证基线效果的场景,它是把好用的“尺子”;上了百万样本、几十维特征的场景,就得请它退场了。

1.2 鸢尾花数据集为什么是kNN的“天选陪练”

鸢尾花数据集(Iris)在sklearn里是内置的,一行代码就能加载,共150条样本、4个特征:花萼长度、花萼宽度、花瓣长度、花瓣宽度,单位都是厘米,标签是三个品种。这个数据集有几个特别适合入门的属性,我在实战中体会很深。

样本量小到能“看见”。150条数据,你甚至可以手动排序看看分布,画个散点图就能直观感受到类别之间的边界在哪里。不像动辄几十万行的业务数据,跑完了你也不知道模型到底学到了什么。特征维度低。4维意味着不用做降维、不用怕维度灾难,距离计算的意义也直白。类别可分性好但又不是完全线性可分。花瓣长度和花瓣宽度这两个特征几乎能靠一条直线把其中一个品种分出来,剩下两个品种有轻微重叠——这个“轻微重叠”太重要了,它让准确率不会永远停在1.0,你能真实看到k值变化带来的波动。数据集干净。没有缺失值、没有异常到离谱的记录、类别均衡(每类50条),你不用在数据清洗上耗费精力,可以专注在算法本身。

我实测过很多次,用鸢尾花做二分类划分时准确率常年稳定在1.0附近,做三分类时合理区间在0.93到1.0之间浮动。这个区间正好:既给你正反馈,又留出观察参数影响的窗口。

1.3 先用sklearn跑通,再手写一版对照

有人会觉得,既然要学算法,就该从零手写。我不反对,但顺序要讲究。我的建议永远是先用sklearn把完整流程跑通——数据加载、划分、标准化、训练、预测、评估,一口气走完,先建立“一个机器学习项目长什么样”的肌肉记忆,再回头手写核心部分去理解内部机制。

理由很实际。手写kNN的代码量其实不大,核心就是一个距离矩阵加一个排序取top-k,但它会把你卡在实现的细枝末节上,反而看不清整个流程的骨架。而sklearn提供的不只是一个KNeighborsClassifier类,它背后是一整套设计范式:统一的fit/predict/predict_proba接口、可组合的Pipeline、与交叉验证和网格搜索无缝衔接。这套范式你以后换成逻辑回归、随机森林、支持向量机,用法完全一致,迁移成本为零。先把范式吃透,收益远大于先啃实现。

后面的第5节我会给出手写版,用向量化方式实现,跑完你会发现两者结果能对上,那时候你对kNN的理解才算闭环。

2. 参数与距离度量:把“近”这个字定义清楚

2.1 距离度量的选择逻辑与计算过程

sklearn的KNeighborsClassifier默认用闵可夫斯基距离(minkowski),而它的默认p值为2,也就等价于欧氏距离。这个默认值对鸢尾花这种连续型、量纲统一(都是厘米)的特征是合适的,但你需要知道背后发生了什么。

欧氏距离的计算是各维度差值的平方和再开根号。拿两条鸢尾花样本举例,样本A的花瓣长度为1.4、花瓣宽度为0.2,样本B为4.5、1.5,那么这两个特征维度上的距离贡献是根号下(4.5-1.4)² + (1.5-0.2)² = 根号下(9.61 + 1.69) = 根号下11.3 ≈ 3.36。四个特征都这么算完再汇总。这个公式的几何含义是空间中两点的直线距离,直观、可导、计算简单,是绝大多数场景的默认答案。

但有些场景要换。曼哈顿距离(p=1)是各维度差值绝对值之和,走的是“街区路线”,对异常值的敏感度低于欧氏距离——因为平方会放大大的差值。如果你的数据里有少量离群点,曼哈顿距离往往更稳。切比雪夫距离(p=无穷大)取各维度差值的最大值,适合“最差维度决定一切”的场景。

这里有个容易被忽略的点:闵可夫斯基距离公式里,各维度是直接相加的。如果两个特征量纲差得远——比如一个特征是年收入(几万到几十万),另一个是年龄(十几到几十),那收入维度的差值会彻底压过年龄维度,距离计算等于只看了收入。这就是为什么在算距离之前必须做标准化,第3.2节会详细展开。

距离类型metric参数p值计算方式适用场景
欧氏距离minkowski2差值平方和开根号连续特征、量纲统一、默认首选
曼哈顿距离minkowski1差值绝对值求和存在离群点、高维稀疏数据
切比雪夫距离chebyshev—各维度差值取最大关注单维度最坏情况
余弦距离cosine—向量夹角余弦文本、方向性特征、不关心绝对大小

2.2 n_neighbors的取值:k值的甜点区在哪

k值是这个算法名字里唯一带参数的字母,它的影响非常直观。k=1时,新样本的标签完全由最近那一个样本决定,决策边界极度曲折,训练集上准确率必然是1.0——因为每个训练样本的最近邻就是它自己(用留一法评估时略有不同)。这种模型对噪声毫无抵抗力,一个标错的样本就能在它周围划出一块错误的领地。

k取很大时,比如k=100,在150条样本的训练集里,每个预测都要看三分之二的样本,投票结果趋近于全局类别分布,模型退化成“多数类预测器”,边界被抹平成一条线,欠拟合。

那怎么挑?两条经验。第一条,k取奇数,在二分类场景下避免平票。三分类场景下平票的概率低一些,但仍建议优先奇数。第二条,k的经验初值取训练样本数的平方根。150条样本,训练集按8:2划分后约120条,根号120约等于11,那就从k=11附近开始试。这不是铁律,但是个很合理的起点。

我实测下来,鸢尾花三分类任务里,k从1到20扫一遍,准确率通常在k=3到k=9之间达到最高,往两边都会掉。掉得不剧烈,因为数据本身干净,但趋势能看出来。这个“扫一遍看趋势”的动作,比背任何推荐值都有用,代码我在3.4节会给出。

还有一个细节:样本量小时,k增大会明显改变预测结果的稳定性。你可以用predict_proba看概率值,k=3时很多样本的类别概率是0.67(两票对一票),说明决策很勉强;k=9时概率会变得更平滑,多数样本在0.8以上,模型的“信心”更足。概率分布比单一标签包含的信息多得多,实际业务里我会优先看这个。

2.3 weights、algorithm、metric的实用取舍

weights这个参数决定邻居的投票权重。默认是uniform,所有邻居等权。改成distance时,权重是距离的倒数,越近的邻居话语权越大。什么时候用distance?当你的数据分布密度不均、或者你相信“更近的样本更可信”这个假设成立时。鸢尾花上我试过,distance相比uniform提升有限,有时甚至因为过度相信最近那一个点而导致边界抖动。我的建议是:默认用uniform,只有在交叉验证结果明确显示distance更优时才切换,不要凭感觉换。

algorithm参数控制的是内部加速结构:ball_tree、kd_tree、brute、auto。默认auto会自己选。这里的取舍逻辑是维度和数据量。kd树和球树属于空间划分结构,通过在特征空间里建树来剪枝,减少距离计算次数,但高维情况下树的剪枝效率会急剧下降(维度灾难),一般维度超过20到30就不划算了,此时brute(暴力遍历全部样本)反而更快。鸢尾花只有4维、150条样本,任何算法都是毫秒级,你根本感觉不到差别。但我还是建议你把这个参数当作一个“规模信号”:如果你在真实项目里发现用brute比kd_tree快,那说明两个事,要么维度太高,要么数据量大到树结构维护成本压过了收益,这是一个很好的性能监控点。

n_jobs是用来并行的,预测阶段尤其有效,因为kNN的计算可以完全并行。设成-1用满所有核。数据量小的时候设了也没感觉,但如果你在做批量预测(比如一次预测十万条),n_jobs=-1的提速非常明显,我试过在八核机器上能接近线性加速。

3. 完整跑通:从加载数据到网格搜索调参

3.1 环境准备与数据加载的第一手观察

依赖就三样:scikit-learn、numpy、matplotlib。numpy本身是sklearn的依赖,装sklearn时会自动带上。matplotlib只用于画图,不画图可以不装。

pip install scikit-learn matplotlib

加载数据只有一行,但我要强调加载之后该做什么——别急着fit,先看数据。

from sklearn.datasets import load_iris import numpy as np iris = load_iris() X = iris.data # 形状 (150, 4) y = iris.target # 形状 (150,) print("特征名:", iris.feature_names) print("类别名:", iris.target_names) print("数据形状:", X.shape) print("各类别样本数:", np.bincount(y)) print("各特征最小值:", X.min(axis=0).round(2)) print("各特征最大值:", X.max(axis=0).round(2)) print("各特征标准差:", X.std(axis=0).round(3))

跑出来你会看到:特征名是四个花部尺寸,类别是三种鸢尾花,每类正好50条,四个特征的取值范围大致在0.1到7.9之间,但标准差差异不小——花瓣长度的标准差在1.76左右,花萼宽度只有0.43左右。这个差异就是标准化必要性的伏笔,花瓣长度这一维的波动幅度是花萼宽度的四倍,不处理的话距离计算会被前者主导。

这一步还有个隐藏价值:确认数据没有缺失值(sklearn内置数据集本来就没有),确认标签是整数编码而不是字符串(内置数据已经编码好了)。如果换成你自己的数据,这两件事必须手动检查,否则后面fit的时候报错信息会让你一头雾水。

3.2 标准化:为什么这一步不能省,以及怎么放对位置

前面提过量纲问题,这里展开说清楚。kNN是基于距离的算法,距离计算把所有维度平等相加。如果某一维的取值范围特别大,它在距离里就占绝对主导,其他维度形同虚设。鸢尾花四个特征单位一致,量级接近,问题不算严重,但标准化之后准确率仍会有可观测的提升,更重要的是——你把标准化的习惯养成了,以后遇到量纲差异大的数据才不会翻车。

标准化(StandardScaler)做的事是:对每一维减去均值、除以标准差,把数据变成均值0、方差1的分布。这样每一维对距离的贡献就处在同一量级上了。另一种常见做法是归一化(MinMaxScaler),把数据压到0到1区间。两者的区别在于对异常值的态度:标准化对异常值敏感(均值和标准差会被离群点拉偏),归一化更敏感(极值直接决定缩放比例)。kNN场景下我通常选标准化,因为它在特征近似正态分布时表现更稳定。

注意:标准化器只能在训练集上fit,然后用同一个标准化器去transform测试集。如果用全量数据fit标准化器再划分,测试集的统计信息就泄漏进了训练过程,评估结果会虚高。这个错误极其常见,我在很多人的代码里都见过。

正确的顺序是:先划分,再在训练集上fit_transform,在测试集上只transform。如果嫌手动管理麻烦,用Pipeline把标准化和模型打包,交叉验证时会自动按折处理,彻底杜绝泄漏。

from sklearn.model_selection import train_test_split from sklearn.preprocessing import StandardScaler from sklearn.pipeline import Pipeline from sklearn.neighbors import KNeighborsClassifier X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.2, random_state=42, stratify=y )

这里三个参数都值得说。test_size=0.2是八二开,150条数据里测试集30条,足够评估又不至于让训练集太小。random_state固定种子,保证你每次跑出来的划分一致,方便复现和对比。stratify=y最关键——它做分层抽样,保证训练集和测试集里三个类别的比例都跟原始数据一致(各占三分之一)。150条数据做八二开,如果不分层,完全有可能某个类别在测试集里只剩七八条,评估结果的方差会大得没法看。分层抽样在类别不均衡的数据上更是必须。

3.3 训练、预测与一份能看懂的评估报告

代码本身短,我习惯把Pipeline和分类器一起建好,后面调参直接复用。

pipe = Pipeline([ ("scaler", StandardScaler()), ("knn", KNeighborsClassifier(n_neighbors=5, weights="uniform")) ]) pipe.fit(X_train, y_train) y_pred = pipe.predict(X_test) from sklearn.metrics import classification_report, confusion_matrix, accuracy_score print("准确率:", accuracy_score(y_test, y_pred)) print(confusion_matrix(y_test, y_pred)) print(classification_report(y_test, y_pred, target_names=iris.target_names))

固定随机种子42、k=5的情况下,我跑出来的准确率是1.0,30条测试样本全对。看到这个数字先别高兴,第4.1节我会专门讲为什么kNN在鸢尾花上拿到1.0是常事,以及什么时候该警惕。

比准确率更有信息量的是混淆矩阵和分类报告。混淆矩阵是个3乘3的矩阵,行是真实类别、列是预测类别,对角线是分对的,非对角线是分错的。分类报告里的precision(精确率)、recall(召回率)、f1-score要分开看:精确率回答“我预测为A的样本里有多少真的是A”,召回率回答“真实的A样本里我找出了多少”。在鸢尾花上这两个指标通常都在0.95以上,但如果某个类别f1明显偏低,说明这个类别的边界跟别的类别有重叠,值得单独画图去看。

还有个我常用的动作:看predict_proba。

proba = pipe.predict_proba(X_test) print(proba[:5].round(3))

每行三个数,对应三个类别的概率,加起来是1。k=5时,概率只会是0、0.2、0.4、0.6、0.8、1.0这几种取值(因为5个邻居投票)。如果某条样本的概率是0.6,说明有2个邻居投了别的类,这个预测其实挺勉强的。实际业务里我会对这类“低置信度预测”单独打标,交给人工复核,而不是直接采信。这一步是把模型从“能跑”推到“能用”的关键,很多人调包调完就停了,其实浪费了模型最有价值的那部分输出。

3.4 交叉验证加网格搜索:把调参交给流程而不是手感

单次划分的评估结果方差大,30条测试样本错了1条准确率就掉3.3个百分点,这种波动盖住了参数带来的真实差异。所以要用交叉验证:把训练集切成K折,轮流用其中一折做验证、其余做训练,最后取平均。5折在数据量不大时是常用选择,折数太小方差大,折数太多每折样本少、评估不稳。

网格搜索是在这个基础上对参数组合做穷举。我把k值和weights一起扫,同时用Pipeline保证每一折的标准化都只在训练折上fit。

from sklearn.model_selection import GridSearchCV, cross_val_score param_grid = { "knn__n_neighbors": list(range(1, 31)), "knn__weights": ["uniform", "distance"], "knn__p": [1, 2] } gs = GridSearchCV( estimator=pipe, param_grid=param_grid, cv=5, scoring="accuracy", n_jobs=-1, return_train_score=True ) gs.fit(X_train, y_train) print("最佳参数:", gs.best_params_) print("最佳交叉验证得分:", round(gs.best_score_, 4)) print("测试集得分:", round(gs.score(X_test, y_test), 4))

注意参数名前缀knn__,因为Pipeline里的分类器步骤命名是knn,两层下划线是固定写法。这个组合一共30乘2乘2等于120种配置,乘以5折等于600次训练加预测。鸢尾花数据小,全部跑完也就一两秒,n_jobs=-1下更快。

跑完之后我最关心的不是best_params本身,而是趋势。我会把结果抽出来画一条k值与得分的关系曲线:

import matplotlib.pyplot as plt results = gs.cv_results_ mask = (results["param_knn__weights"] == "uniform") & (results["param_knn__p"] == 2) ks = results["param_knn__n_neighbors"][mask].astype(int) scores = results["mean_test_score"][mask] train_scores = results["mean_train_score"][mask] plt.figure(figsize=(9, 5)) plt.plot(ks, train_scores, marker="o", label="训练集(交叉验证均分)") plt.plot(ks, scores, marker="s", label="验证集(交叉验证均分)") plt.xlabel("n_neighbors") plt.ylabel("accuracy") plt.legend() plt.grid(alpha=0.3) plt.show()

这条图能读出很多东西。k很小时训练分接近1.0而验证分略低,这是过拟合的典型形态;k变大后两条线逐渐靠拢并一起下降,这是欠拟合的信号。两条线的间距就是泛化差距。我实测的结果是,k在3到9之间验证分基本贴着1.0,k超过15之后开始缓慢下滑,到k=29时降到0.95左右。这个形态很符合直觉——邻居越多,少数派的意见越容易被淹没。

提示:如果你扫出来的曲线在k=1时验证分也很高,先别认定模型完美,可能是数据太简单或者存在数据泄漏,回到3.2节自查标准化的位置。

顺便说一句scoring参数的选择。多分类任务上accuracy能用,但如果类别不均衡,我更倾向换成f1_macro,它把每个类别的f1等权平均,不会让多数类的好处掩盖少数类的失败。评估指标选错,后面所有调参都是在优化一个错误的目标。

4. 踩过的坑:典型问题与排查思路实录

4.1 准确率1.0该不该信:三个自查点

第一次在鸢尾花上跑到1.0,我的第一反应是“代码写错了”。后来跑多了才明白,这个数据集上三分类做到1.0是完全可能的,尤其是k取3到7、做了标准化、测试集只有30条的时候。但“可能”不等于“可信”,我形成了三个固定的自查动作。

第一个,看训练集和测试集的分数差距。如果训练集交叉验证分也是1.0,测试集也是1.0,两者一致,那大概率是真本事。如果训练分1.0、测试分只有0.8,那才是过拟合,或者划分有问题。

第二个,换种子重跑。random_state从42换成0、1、7、2024,各跑一遍。如果五次里有四次是1.0、一次是0.97,说明模型确实强,那0.97是划分带来的正常波动。如果五次结果在0.85到1.0之间大幅跳动,说明模型不稳,得回头看k值和标准化。

第三个,检查有没有泄漏。最常见的就是3.2节说的“先标准化全量再划分”。第二种隐蔽泄漏是用测试集参与了调参——比如你反复用测试集分数去挑k值,挑到最后那个1.0其实是测试集被“看过”太多次的结果。正确做法是调参只看交叉验证分,测试集只在最后用一次。

我个人的判断标准是:鸢尾花上准确率落在0.95到1.0之间都算正常,低于0.93就要查问题。这个数字换个数据集完全不适用,别把它当成通用标尺。

4.2 预测慢、内存涨:kNN的规模天花板在哪

kNN的预测复杂度是O(n×d),n是训练样本数,d是特征维度。150条4维数据毫无压力,但你得知道这条线画在哪。我做过粗略测量:训练集十万条、20维特征,单条预测在毫秒级,批量预测一万条就要几秒;训练集涨到百万级,单条预测开始上百毫秒,批量预测基本不可接受。

这种场景下的优化方向有几个。第一,降低n,也就是减少训练样本,但kNN对样本量敏感,砍样本会直接影响精度,得用原型选择之类的办法挑代表性样本。第二,降低d,做特征选择或降维,这对kNN收益特别大,因为维度灾难会让“最近邻”这个概念本身失效——高维空间里所有点之间的距离趋于相等,近邻不再“近”。第三,换算法结构,用近似最近邻检索的思路(比如基于树或图的方法),牺牲一点精度换速度。第四,如果只是要做批量预测,n_jobs=-1加上把数据分批喂进去,能缓解但不解决根本问题。

还有一个纯内存的坑:kNN的fit阶段只是把训练数据存起来,看起来“训练零成本”,但如果你训练集有几十GB,模型对象本身就占几十GB内存,保存成文件也是这个体积。这跟线性模型动辄几MB的模型文件完全不是一个量级。部署时忘了这点,很容易在服务端内存上翻车。

4.3 报错与异常结果速查

下面这张表是我这些年被问到最多、以及自己踩过的kNN相关问题,按现象倒推原因,比从头读文档快得多。

现象最可能的原因处理办法
准确率明显偏低(低于0.9)未做标准化,某维度主导距离加StandardScaler,且只在训练集fit
每次运行结果都不一样未固定random_state划分、模型、交叉验证都设种子
测试集某类别样本极少甚至没有划分时未分层train_test_split加stratify=y
k值增大到很大时准确率骤降邻居太多,退化为多数类预测k控制在训练样本数的平方根附近
训练分很高、验证分明显低过拟合,k过小或存在噪声增大k,检查标签是否有误标
预测耗时随数据量爆炸式增长kNN预测复杂度与训练集规模成正比降维、减样本、或改用其他算法
kNN(k=1)时测试集准确率总是1.0用训练集自身评估(未划分)必须划分测试集,或用交叉验证
特征量纲相差悬殊时结果异常大尺度特征主导距离计算标准化或归一化
出现平票导致预测不稳定k为偶数二分类场景优先取奇数k
模型文件体积异常大kNN存储全部训练数据属正常现象,部署时预留内存

表格里最后一条特别值得强调,因为它不是“错误”而是“特性”。很多人第一次把kNN模型存盘,看到文件大小接近训练数据本身,会以为保存方式有问题。其实这就是懒惰学习的代价,模型的“参数”就是数据本身。

4.4 一些不那么显眼但很影响结果的经验

说几个文档里不会写、但我实际撞过的细节。

第一个,特征的尺度统一不等于特征的信息量统一。标准化只解决量纲问题,不解决“某个特征其实是噪声”的问题。鸢尾花的四个特征里,花萼宽度对分类的贡献明显弱于花瓣长度。如果你有一堆特征,可以做一个简单的消融测试:每次去掉一个特征重跑交叉验证,看分数掉多少,掉得少的说明贡献小,可以考虑剔掉。对kNN来说,去掉噪声特征往往比调k值带来的提升更大,因为噪声维度会污染距离计算。

第二个,predict和predict_proba在大样本下的耗时不同。predict_proba要多算一步归一化,还要返回完整的类别概率矩阵,批量预测时内存占用会涨得快。如果只需要标签,别顺手写predict_proba。

第三个,类别标签的编码方式会影响某些评估函数的行为。sklearn的多数指标默认按标签值排序处理,如果你的标签是从1开始而不是从0开始,或者标签是字符串,混淆矩阵的行列顺序可能跟你想的不一样。用target_names明确指定显示名称,能省掉一堆误读。

第四个,Pipeline里步骤命名不要用双下划线。网格搜索的参数名用双下划线连接步骤名和参数名,如果步骤名本身带下划线,你会在param_grid里数不清到底几个下划线,很容易写错然后报一个很难懂的KeyError。

5. 进阶对照:手写一版kNN,把黑盒拆开

5.1 向量化实现的核心思路

sklearn版跑顺之后,我建议花二十分钟手写一版,只做预测,不做训练(因为训练没什么可做的)。核心是两个矩阵运算,用numpy写出来只有几行,但能让你彻底看清kNN在算什么。

import numpy as np from collections import Counter class SimpleKNN: def __init__(self, k=5, p=2): self.k = k self.p = p def fit(self, X, y): self.X_train = np.asarray(X, dtype=float) self.y_train = np.asarray(y) return self def _distances(self, x): diff = self.X_train - x # 广播:(n_train, d) if self.p == 2: return np.sqrt((diff ** 2).sum(axis=1)) return np.abs(diff).sum(axis=1) def predict(self, X): X = np.asarray(X, dtype=float) preds = [] for x in X: d = self._distances(x) idx = np.argsort(d)[: self.k] labels = self.y_train[idx] preds.append(Counter(labels).most_common(1)[0][0]) return np.array(preds)

关键点有三个。第一,距离计算用了广播:self.X_train是(n,d),x是(d,),相减自动扩展成(n,d),然后沿axis=1求和,一次算出这个样本到所有训练样本的距离,避免了Python层的循环。第二,argsort取前k个索引,这是“找最近邻”的全部实现。第三,Counter投票,取出现次数最多的标签。

这个实现没有处理平票,如果两个标签票数相同,Counter的most_common会按插入顺序返回,结果不确定。工业级实现要引入距离加权或按距离排序打破平票,这也是我在2.2节建议用奇数k的原因之一。

5.2 两个版本结果对齐与性能对比

把两个版本放在同一个划分上跑,对比预测结果:

sk_model = Pipeline([ ("scaler", StandardScaler()), ("knn", KNeighborsClassifier(n_neighbors=5)) ]) sk_model.fit(X_train, y_train) pred_sk = sk_model.predict(X_test) scaler = StandardScaler().fit(X_train) X_train_s = scaler.transform(X_train) X_test_s = scaler.transform(X_test) my_model = SimpleKNN(k=5, p=2).fit(X_train_s, y_train) pred_my = my_model.predict(X_test_s) print("sklearn预测:", pred_sk) print("手写版预测:", pred_my) print("完全一致:", np.array_equal(pred_sk, pred_my))

注意手写版必须喂标准化后的数据,否则距离计算的量纲问题会让两个版本对不上,这也是个很好的反面教材——你只要试一次“手写版直接用原始数据”,就能直观看到准确率掉了多少,比任何解释都管用。

一致率通常在95%以上,偶尔个别样本不一致,多半是平票或者距离完全相等导致的顺序差异,这属于正常现象,说明两边的实现细节有细微差别。性能上sklearn版在150条数据上快一个数量级,因为它用到了优化的底层实现(Cython编译的距离计算)。但在小数据上这个差距毫无意义,手写版的价值在于可解释性。

跑完这一轮,我对kNN的完整认识才算建立起来:它简单到能用二十行写明白,但要在实际项目里用好,标准化的顺序、k值的选择、评估方式的设计,每一个环节都得想清楚。这也是我一直推荐拿它做第一个实战项目的原因——代码量小,能暴露的问题却一样不少。

最后分享一个我自己做题时的习惯:第一次跑通之后,故意把某一个环节改错,比如不做标准化、把k设成100、把stratify去掉,各跑一遍看看指标怎么变。错误是最好的老师,你亲手制造过的偏差,比读十遍文档记得都牢。这套方法我后来迁移到每个新算法上,屡试不爽。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询