简介:这份资源围绕鸢尾花数据集的KNN分类实验展开,面向正在学习机器学习基础、需要完成课程作业或入门实战的读者,帮助理清从数据观察到模型评估的完整流程。包内共1个文件,为单个py脚本,压缩包约5KB,轻量易读,可直接运行复现。实验内容涵盖:借助箱式图对样本做总体了解,按两种方式完成特征预处理,并将数据随机打乱划分为80%训练集与20%测试集;随后基于5折交叉验证在K=3~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, columns=iris.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(column=col, by='target', ax=axes[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_size=0.2, random_state=42, shuffle=True )参数说明:test_size=0.2对应 80/20 划分;shuffle=True是默认行为,显式写出来提醒自己数据已经打乱;random_state=42固定随机种子,否则每次跑出来的测试集不同,K 值曲线会抖动。注意fit_transform只能在训练集上做,测试集必须用训练集拟合好的 scaler 去transform,否则信息泄露,这是血泪经验里最常见的一条。
3. 用 5-fold 交叉验证选 K:错误率曲线怎么画才不误导
3.1 k-fold 的原理和这里为什么用 m=5
k-fold 交叉验证把训练集切成 k 份,每次拿 k-1 份训练、1 份验证,循环 k 次取平均。这里 m=5,意味着训练集被分成 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_neighbors=k) # cross_val_score 默认做 stratified k-fold,分类任务下每折类别比例一致 scores = cross_val_score(knn, X_train, y_train, cv=5, scoring='accuracy') cv_scores.append(scores.mean()) cv_errors.append(1 - scores.mean()) # 以 K 为横轴,错误率为纵轴画曲线 plt.figure(figsize=(8, 5)) plt.plot(list(k_range), cv_errors, marker='o', linestyle='-', color='b') 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(f"CV 选出的最优 K: {best_k}")逻辑说明:cross_val_score内部对分类任务默认用StratifiedKFold,保证每折里三个类别的样本比例和整体一致,iris 每类 50 个样本,这点很重要,否则某折可能缺一个类别。scoring='accuracy'返回每折准确率,取平均后用1 - mean得到错误率。np.argmin找错误率最低的 K。注意曲线可能有多个局部低点,选最小错误率对应的 K,如果并列就选较小的 K,因为 K 小模型更简单。
3.2 错误率曲线的读法和常见误判
画出来的曲线通常先降后升,中间有个谷底。但 iris 数据太干净,K=3 到 K=9 的错误率可能都在 0.02~0.06 之间波动,曲线不平滑。这时候不要死盯最小值,要看整体趋势。如果 K=3 和 K=5 错误率一样,选 K=5,因为奇数 K 在二分类里能避免投票平局,多分类里也更稳。另一个坑是每次cross_val_score如果不固定random_state,折的划分会变,曲线会跳。虽然cross_val_score的cv=5在 sklearn 新版本里对StratifiedKFold有默认随机性,稳妥做法是显式传cv=StratifiedKFold(n_splits=5, shuffle=True, random_state=42)。
4. 测试集最终评价:混淆矩阵、查准查全和宏平均怎么算
4.1 用选定的 K 训练并在测试集上预测
交叉验证选完 K 后,用全部训练集重新训练模型,再在测试集上预测。这一步不能省,因为交叉验证的模型只用了部分数据,最终模型要用上所有训练样本。
from sklearn.metrics import confusion_matrix, classification_report import seaborn as sns # 用 CV 选出的 best_k 在完整训练集上训练 final_knn = KNeighborsClassifier(n_neighbors=best_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_names=iris.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(column=col, by='target')里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没加stratify=y,随机划分时把某一类全分到训练集了。iris 每类 50 个,20% 测试集理论上每类 10 个,但不分层时可能偏差很大。解决:train_test_split(..., stratify=y),保证训练集和测试集类别比例一致。
5.4 现象:classification_report的 macro avg 和手算对不上
原因:classification_report默认输出两位小数,手算用了完整精度,四舍五入后看起来有差异。另一个可能是zero_division参数,当某类查准率为 0 时 sklearn 默认给 0 并警告。解决:手算时保留四位小数对比,确认zero_division=0行为一致。如果某类 support 为 0,宏平均会把它算进去,导致指标被拉低,这时候要看weighted avg。
5.5 现象:K 值曲线每次跑都不一样
原因:cross_val_score的折划分没有固定随机种子,每次运行折的边界不同。解决:显式构造StratifiedKFold(n_splits=5, shuffle=True, random_state=42)传给cv参数。另外train_test_split的random_state也要固定,否则测试集本身就在变。
6. 进阶技巧:把 KNN 的决策边界画出来,再谈 K 的物理意义
跑完上面流程,你得到了一个 K 和一组指标,但 K 到底在做什么,光看数字不够直观。我一般会拿 iris 的两个区分度最高的特征——花瓣长度和花瓣宽度——画决策边界,把不同 K 下的分类面可视化。这样你能直接看到 K=3 时边界贴着样本走,K=9 时边界被平滑掉,理解"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_neighbors=k) 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, alpha=0.3, cmap=ListedColormap(['#FFAAAA', '#AAFFAA', '#AAAAFF'])) ax.scatter(X_2d_std[:, 0], X_2d_std[:, 1], c=y_2d, edgecolor='k', cmap=ListedColormap(['#FF0000', '#00FF00', '#0000FF'])) ax.set_title(f'K = {k}') plt.tight_layout() plt.show()这段代码用meshgrid生成密集网格点,对每个点预测类别,contourf填充颜色区域。K=3 时三个类别的边界有锯齿,K=9 时边界更平滑,但可能出现个别样本被划到错误区域。这就是 K 的物理意义:K 是投票邻居数,K 小则每个样本的局部噪声影响大,K 大则决策面被全局平滑。iris 数据干净,K=3 到 K=9 的边界差异不大,但换成有噪声的数据集,这个图能帮你直观判断 K 选得是否合理。
一个具体技巧:如果你要拿这份代码改造成自己的数据集,先把iris.data和iris.target换成pd.read_csv读进来的特征和标签,然后检查类别是否均衡。不均衡时把scoring='accuracy'换成scoring='f1_macro',因为准确率在不均衡数据上会骗人。另外cross_val_score的cv参数在类别极少时(比如某类只有 5 个样本)要降低折数,否则某折验证集里没有这个类别,指标会报UndefinedMetricWarning。
从那以后我每次跑 KNN 都强制走一遍"箱式图 → 两种预处理对比 → 固定种子的 stratified k-fold → 混淆矩阵手算宏平均"这个流程,哪怕数据集再小也不跳步。希望帮到你。
本文还有配套的精品资源,点击获取