2026/10/6 8:12:04

KNN鸢尾花分类实战:从交叉验证选K到混淆矩阵手算宏平均

KNN鸢尾花分类实战:从交叉验证选K到混淆矩阵手算宏平均 简介这份资源围绕鸢尾花数据集的KNN分类实验展开面向正在学习机器学习基础、需要完成课程作业或入门实战的读者帮助理清从数据观察到模型评估的完整流程。包内共1个文件为单个py脚本压缩包约5KB轻量易读可直接运行复现。实验内容涵盖借助箱式图对样本做总体了解按两种方式完成特征预处理并将数据随机打乱划分为80%训练集与20%测试集随后基于5折交叉验证在K3~9范围内选择近邻数以总体预测错误率为指标绘制K值与错误率的关系曲线最后在测试集上评价最终模型由预测结果生成混淆矩阵并据此估计各类别的查准率、查全率、F1分值以及宏查准率、宏查全率、宏F1分值。已有328人学习适合希望掌握KNN调参与分类评估指标计算细节的读者参考。1. 从一份 iris_KNN.rar 说起为什么 KNN 在鸢尾花上仍是绕不开的练手项目很多人第一次接触分类任务拿到的就是 iris 数据集跑的第一个算法就是 KNN。这份iris_KNN.rar里只有一个iris_KNN.py看起来简单得不像一个项目但它把 KNN 从数据观察、预处理、K 值选择到测试集评价的完整链路都走了一遍。我见过太多人直接fit完事结果被问到K 怎么定的混淆矩阵长什么样就答不上来。这份代码的价值不在于算法多复杂而在于它逼你把 k-fold 交叉验证选 K、混淆矩阵算查准查全、宏平均这些细节真正落地。适合刚入门机器学习、需要一份能跑通又能讲清楚每个环节的从业者也适合想拿它当模板改造成自己数据集的熟手。下面我按代码实际执行顺序拆开讲。2. 数据观察与两种特征预处理箱式图先看分布再决定怎么缩放2.1 先画箱式图别急着建模拿到 iris 数据后第一件事不是train_test_split而是看每个特征在三个类别上的分布。箱式图能直接暴露量纲差异和离群点。iris 的四个特征——花萼长度、花萼宽度、花瓣长度、花瓣宽度——单位都是厘米但数值范围不同花瓣长度跨度约 1~7花萼宽度只有 2~4.5。KNN 靠距离度量量纲大的特征会主导距离计算所以预处理不是可选项。import pandas as pd import matplotlib.pyplot as plt from sklearn.datasets import load_iris iris load_iris() df pd.DataFrame(iris.data, columnsiris.feature_names) df[target] iris.target # 按类别分组画箱式图看每个特征在三个类别上的分布差异 fig, axes plt.subplots(1, 4, figsize(16, 4)) for i, col in enumerate(iris.feature_names): df.boxplot(columncol, bytarget, axaxes[i]) axes[i].set_title(col) plt.suptitle() plt.tight_layout() plt.show()这段代码用boxplot按target分组每个特征一张子图。重点看两件事一是同一特征在不同类别上的中位数是否分开二是是否存在明显离群点。iris 里花瓣长度和花瓣宽度的类别区分度最高花萼宽度最差这直接决定了后面 KNN 在这两个特征上更容易分对。2.2 两种预处理路线标准化和归一化怎么选代码里做了两种特征预处理常见做法是StandardScaler和MinMaxScaler各跑一遍对比。标准化把每个特征变成均值 0、方差 1归一化把每个特征压到 [0,1]。KNN 对两者都敏感但选择取决于数据分布。from sklearn.preprocessing import StandardScaler, MinMaxScaler from sklearn.model_selection import train_test_split X iris.data y iris.target # 路线一标准化 scaler_std StandardScaler() X_std scaler_std.fit_transform(X) # 路线二归一化 scaler_mm MinMaxScaler() X_mm scaler_mm.fit_transform(X) # 按 80/20 随机打乱划分random_state 固定保证可复现 X_train, X_test, y_train, y_test train_test_split( X_std, y, test_size0.2, random_state42, shuffleTrue )参数说明test_size0.2对应 80/20 划分shuffleTrue是默认行为显式写出来提醒自己数据已经打乱random_state42固定随机种子否则每次跑出来的测试集不同K 值曲线会抖动。注意fit_transform只能在训练集上做测试集必须用训练集拟合好的 scaler 去transform否则信息泄露这是血泪经验里最常见的一条。3. 用 5-fold 交叉验证选 K错误率曲线怎么画才不误导3.1 k-fold 的原理和这里为什么用 m5k-fold 交叉验证把训练集切成 k 份每次拿 k-1 份训练、1 份验证循环 k 次取平均。这里 m5意味着训练集被分成 5 折每个 K 值都要跑 5 次训练和验证。相比单次划分k-fold 降低了评估结果的方差选出来的 K 更稳。KNN 的 K 是超参数K 太小对噪声敏感K 太大又把类别边界模糊掉所以要在 3~9 之间扫一遍。import numpy as np from sklearn.neighbors import KNeighborsClassifier from sklearn.model_selection import cross_val_score k_range range(3, 10) # 备选 K 3~9 cv_scores [] cv_errors [] for k in k_range: knn KNeighborsClassifier(n_neighborsk) # cross_val_score 默认做 stratified k-fold分类任务下每折类别比例一致 scores cross_val_score(knn, X_train, y_train, cv5, scoringaccuracy) cv_scores.append(scores.mean()) cv_errors.append(1 - scores.mean()) # 以 K 为横轴错误率为纵轴画曲线 plt.figure(figsize(8, 5)) plt.plot(list(k_range), cv_errors, markero, linestyle-, colorb) plt.xlabel(K value) plt.ylabel(Prediction error rate) plt.title(5-fold CV error vs K) plt.xticks(list(k_range)) plt.grid(True) plt.show() best_k list(k_range)[int(np.argmin(cv_errors))] print(fCV 选出的最优 K: {best_k})逻辑说明cross_val_score内部对分类任务默认用StratifiedKFold保证每折里三个类别的样本比例和整体一致iris 每类 50 个样本这点很重要否则某折可能缺一个类别。scoringaccuracy返回每折准确率取平均后用1 - mean得到错误率。np.argmin找错误率最低的 K。注意曲线可能有多个局部低点选最小错误率对应的 K如果并列就选较小的 K因为 K 小模型更简单。3.2 错误率曲线的读法和常见误判画出来的曲线通常先降后升中间有个谷底。但 iris 数据太干净K3 到 K9 的错误率可能都在 0.02~0.06 之间波动曲线不平滑。这时候不要死盯最小值要看整体趋势。如果 K3 和 K5 错误率一样选 K5因为奇数 K 在二分类里能避免投票平局多分类里也更稳。另一个坑是每次cross_val_score如果不固定random_state折的划分会变曲线会跳。虽然cross_val_score的cv5在 sklearn 新版本里对StratifiedKFold有默认随机性稳妥做法是显式传cvStratifiedKFold(n_splits5, shuffleTrue, random_state42)。4. 测试集最终评价混淆矩阵、查准查全和宏平均怎么算4.1 用选定的 K 训练并在测试集上预测交叉验证选完 K 后用全部训练集重新训练模型再在测试集上预测。这一步不能省因为交叉验证的模型只用了部分数据最终模型要用上所有训练样本。from sklearn.metrics import confusion_matrix, classification_report import seaborn as sns # 用 CV 选出的 best_k 在完整训练集上训练 final_knn KNeighborsClassifier(n_neighborsbest_k) final_knn.fit(X_train, y_train) # 测试集预测 y_pred final_knn.predict(X_test) # 混淆矩阵 cm confusion_matrix(y_test, y_pred) print(混淆矩阵) print(cm) # 每个类别的查准率、查全率、F1 print(\n分类报告) print(classification_report(y_test, y_pred, target_namesiris.target_names))confusion_matrix返回一个 3x3 矩阵行是真实类别列是预测类别对角线是分对的。classification_report直接给出每个类别的 precision、recall、f1-score 和 support。iris 测试集只有 30 个样本每类约 10 个所以 support 那一列会是 10 左右别看到数字小就以为代码错了。4.2 从混淆矩阵手算宏平均别只调库库函数方便但要知道宏平均怎么来的。宏查准率是各类查准率的算术平均宏查全率同理宏 F1 是宏查准和宏查全的调和平均。手算一遍能帮你理解为什么宏平均对稀有类别更敏感。# 手算宏平均验证 classification_report 的结果 n_classes cm.shape[0] precision_per_class [] recall_per_class [] f1_per_class [] for i in range(n_classes): tp cm[i, i] fp cm[:, i].sum() - tp fn cm[i, :].sum() - tp prec tp / (tp fp) if (tp fp) 0 else 0 rec tp / (tp fn) if (tp fn) 0 else 0 f1 2 * prec * rec / (prec rec) if (prec rec) 0 else 0 precision_per_class.append(prec) recall_per_class.append(rec) f1_per_class.append(f1) macro_precision np.mean(precision_per_class) macro_recall np.mean(recall_per_class) macro_f1 np.mean(f1_per_class) print(f宏查准率: {macro_precision:.4f}) print(f宏查全率: {macro_recall:.4f}) print(f宏 F1: {macro_f1:.4f})参数说明cm[:, i].sum()是第 i 列的和即所有被预测为第 i 类的样本数cm[i, :].sum()是第 i 行的和即真实第 i 类的样本数。tp fp是预测为正的样本数tp fn是真实为正的样本数。宏平均直接对三个类别的指标取平均不考虑类别样本数差异。如果各类样本数不均衡宏平均和加权平均会差很多iris 每类 50 个样本两者接近但换成不均衡数据集就要注意区分。5. 避坑与排查这份代码跑不通时先看这五条5.1 现象箱式图报错KeyError: target原因df.boxplot(columncol, bytarget)里by参数要求target是 DataFrame 的列如果前面df[target]没赋值成功或者用了iris.target直接传数组就会报错。解决确认df[target] iris.target这行在boxplot之前执行且df是 DataFrame 不是 numpy 数组。5.2 现象交叉验证准确率全是 1.0曲线一条直线原因预处理时对全量数据做了fit_transform再划分训练测试导致测试集信息泄露到训练过程。或者cross_val_score传的是已经划分好的X_test。解决fit_transform只在X_train上做X_test用transform。检查cross_val_score(knn, X_train, y_train, ...)里传的是训练集。5.3 现象混淆矩阵某一行全为 0原因测试集里某个类别的样本一个都没有通常是train_test_split没加stratifyy随机划分时把某一类全分到训练集了。iris 每类 50 个20% 测试集理论上每类 10 个但不分层时可能偏差很大。解决train_test_split(..., stratifyy)保证训练集和测试集类别比例一致。5.4 现象classification_report的 macro avg 和手算对不上原因classification_report默认输出两位小数手算用了完整精度四舍五入后看起来有差异。另一个可能是zero_division参数当某类查准率为 0 时 sklearn 默认给 0 并警告。解决手算时保留四位小数对比确认zero_division0行为一致。如果某类 support 为 0宏平均会把它算进去导致指标被拉低这时候要看weighted avg。5.5 现象K 值曲线每次跑都不一样原因cross_val_score的折划分没有固定随机种子每次运行折的边界不同。解决显式构造StratifiedKFold(n_splits5, shuffleTrue, random_state42)传给cv参数。另外train_test_split的random_state也要固定否则测试集本身就在变。6. 进阶技巧把 KNN 的决策边界画出来再谈 K 的物理意义跑完上面流程你得到了一个 K 和一组指标但 K 到底在做什么光看数字不够直观。我一般会拿 iris 的两个区分度最高的特征——花瓣长度和花瓣宽度——画决策边界把不同 K 下的分类面可视化。这样你能直接看到 K3 时边界贴着样本走K9 时边界被平滑掉理解K 越大模型越简单这句话到底长什么样。from matplotlib.colors import ListedColormap # 只取花瓣长度和花瓣宽度两个特征 X_2d iris.data[:, [2, 3]] y_2d iris.target # 标准化 scaler_2d StandardScaler() X_2d_std scaler_2d.fit_transform(X_2d) # 生成网格点 x_min, x_max X_2d_std[:, 0].min() - 0.5, X_2d_std[:, 0].max() 0.5 y_min, y_max X_2d_std[:, 1].min() - 0.5, X_2d_std[:, 1].max() 0.5 xx, yy np.meshgrid(np.arange(x_min, x_max, 0.02), np.arange(y_min, y_max, 0.02)) fig, axes plt.subplots(1, 3, figsize(15, 4)) for ax, k in zip(axes, [3, 5, 9]): knn_2d KNeighborsClassifier(n_neighborsk) knn_2d.fit(X_2d_std, y_2d) Z knn_2d.predict(np.c_[xx.ravel(), yy.ravel()]) Z Z.reshape(xx.shape) ax.contourf(xx, yy, Z, alpha0.3, cmapListedColormap([#FFAAAA, #AAFFAA, #AAAAFF])) ax.scatter(X_2d_std[:, 0], X_2d_std[:, 1], cy_2d, edgecolork, cmapListedColormap([#FF0000, #00FF00, #0000FF])) ax.set_title(fK {k}) plt.tight_layout() plt.show()这段代码用meshgrid生成密集网格点对每个点预测类别contourf填充颜色区域。K3 时三个类别的边界有锯齿K9 时边界更平滑但可能出现个别样本被划到错误区域。这就是 K 的物理意义K 是投票邻居数K 小则每个样本的局部噪声影响大K 大则决策面被全局平滑。iris 数据干净K3 到 K9 的边界差异不大但换成有噪声的数据集这个图能帮你直观判断 K 选得是否合理。一个具体技巧如果你要拿这份代码改造成自己的数据集先把iris.data和iris.target换成pd.read_csv读进来的特征和标签然后检查类别是否均衡。不均衡时把scoringaccuracy换成scoringf1_macro因为准确率在不均衡数据上会骗人。另外cross_val_score的cv参数在类别极少时比如某类只有 5 个样本要降低折数否则某折验证集里没有这个类别指标会报UndefinedMetricWarning。从那以后我每次跑 KNN 都强制走一遍箱式图 → 两种预处理对比 → 固定种子的 stratified k-fold → 混淆矩阵手算宏平均这个流程哪怕数据集再小也不跳步。希望帮到你。本文还有配套的精品资源点击获取