资讯详情 从零手写KNN算法:基于鸢尾花数据集的向量化实现与归一化实战
📅 2026/10/14 1:27:34
1. 为什么一个老掉牙的算法还值得动手写一遍KNNK-Nearest Neighbors中文叫K近邻算法。这名字听起来就没什么技术含量——找最近的K个邻居看它们是什么类别投票决定新样本的归属。没有梯度下降没有反向传播没有损失函数甚至连训练这个步骤都省了。放到今天这个动辄Transformer、扩散模型的时代它简直朴素得像个古董。但我要说句实在话如果你真的想理解机器学习到底在干什么KNN是最好的起点没有之一。它把分类这件事最原始的逻辑暴露在你面前——相似的东西大概率属于同一类。这个假设简单到不需要任何数学基础就能理解但它的实现过程中却藏着机器学习里几乎所有核心问题的影子距离度量怎么选、K值怎么定、数据要不要归一化、训练集和测试集怎么划分、模型怎么评估、过拟合和欠拟合长什么样。我这次拿鸢尾花数据集Iris Dataset来练手不是因为它简单而是因为它干净。150个样本4个特征3个类别没有缺失值没有异常值每个类别50个样本均衡得像是教科书里专门设计出来的——事实上它确实就是。用这种数据来调试算法你可以把全部精力放在算法逻辑本身而不用花大量时间去清洗数据、处理缺失值、做特征工程。等你把KNN在鸢尾花上跑通了理解了每一步在干什么再换到真实项目里那些脏数据上心里就有底了。这篇文章我会从零开始不调sklearn的KNeighborsClassifier而是用NumPy手写一个完整的KNN分类器。为什么不用现成的库因为调库你只能学到API怎么用手写你才能理解算法怎么跑。我会把距离计算的向量化实现、K值选择的实验对比、归一化的必要性验证、分类决策的投票机制、准确率评估的完整流程全部拆开讲清楚。代码可以直接复制运行每一步我都会解释为什么这么写以及我踩过哪些坑。适合谁看如果你刚学完Python基础想找个项目练手入门机器学习这篇就是为你写的。如果你已经会用sklearn但说不清楚KNN内部到底怎么运作这篇也能帮你把底层逻辑补齐。如果你是个老手想看看别人怎么手写KNN的向量化实现也许能发现一些细节上的优化思路。2. 鸢尾花数据集到底长什么样怎么把它读进来2.1 数据集的四个特征和三个类别鸢尾花数据集最早由统计学家Ronald Fisher在1936年整理发表是机器学习领域最经典的数据集之一。它记录了三种鸢尾花——Setosa、Versicolor、Virginica——的花萼和花瓣的测量数据。每个样本有四个特征花萼长度Sepal Length单位厘米花萼宽度Sepal Width单位厘米花瓣长度Petal Length单位厘米花瓣宽度Petal Width单位厘米三个类别各50个样本总共150条记录。这个数据集之所以经典是因为其中Setosa类别和另外两个类别在特征空间里几乎是线性可分的——你甚至只用花瓣长度和花瓣宽度两个特征就能把Setosa完美分出来。但Versicolor和Virginica之间就有重叠了需要四个特征一起用才能达到比较高的分类准确率。我选择这个数据集还有一个实际原因它足够小小到你可以把整个数据集打印出来肉眼观察。150行数据你花十分钟扫一遍就能对数据的分布有个直观感受。这种能看见数据的感觉在入门阶段非常重要等你以后处理几百万行的数据集时就再也没有这种机会了。2.2 用sklearn加载数据并做初步探索虽然我们要手写KNN算法但加载数据这件事没必要自己造轮子。sklearn内置了鸢尾花数据集一行代码就能读进来from sklearn.datasets import load_iris import numpy as np iris load_iris() X iris.data # 特征矩阵shape: (150, 4) y iris.target # 标签向量shape: (150,) print(特征矩阵形状:, X.shape) print(标签向量形状:, y.shape) print(类别名称:, iris.target_names) print(特征名称:, iris.feature_names)输出结果特征矩阵形状: (150, 4) 标签向量形状: (150,) 类别名称: [setosa versicolor virginica] 特征名称: [sepal length (cm), sepal width (cm), petal length (cm), petal width (cm)]拿到数据之后先别急着写算法。我习惯先做几件事看看每个特征的取值范围算一下均值和标准差检查一下类别是否均衡。这些信息在后面做归一化和选择K值的时候都会用到。print(各特征最小值:, X.min(axis0)) print(各特征最大值:, X.max(axis0)) print(各特征均值:, X.mean(axis0)) print(各特征标准差:, X.std(axis0)) print(各类别样本数:, np.bincount(y))输出各特征最小值: [4.3 2.0 1.0 0.1] 各特征最大值: [7.9 4.4 6.9 2.5] 各特征均值: [5.843 3.057 3.758 1.199] 各特征标准差: [0.825 0.434 1.759 0.762] 各类别样本数: [50 50 50]从这些数字里你能读出不少信息。花瓣长度的标准差是1.759远大于其他特征说明这个特征的离散程度最大在不同类别之间的区分度也可能最高。花萼宽度的标准差只有0.434数值比较集中。这些特征之间的尺度差异直接关系到后面要不要做归一化——如果不做花瓣长度在距离计算中的权重要比花萼宽度大得多这显然不是我们想要的结果。注意很多人拿到数据直接扔进模型从来不检查特征的取值范围和分布。这个习惯非常危险。特征尺度差异大而不做归一化是KNN最常见的翻车原因之一。3. 手写KNN的核心距离计算与向量化实现3.1 欧氏距离的数学定义和直觉理解KNN的核心操作就一个算距离。给定一个测试样本计算它到训练集中每一个样本的距离然后找最近的K个。最常用的距离度量是欧氏距离也就是我们中学学过的两点间距离公式在高维空间的推广。对于两个n维向量 (a (a_1, a_2, ..., a_n)) 和 (b (b_1, b_2, ..., b_n))欧氏距离定义为[ d(a, b) \sqrt{\sum_{i1}^{n}(a_i - b_i)^2} ]用大白话说就是把每个维度上的差值平方全部加起来再开根号。二维平面上这就是勾股定理三维空间里就是体对角线长度四维及以上你没法直观想象但数学逻辑完全一样。除了欧氏距离还有曼哈顿距离各维度差值绝对值之和、余弦距离衡量方向差异而非位置差异等。对于鸢尾花这种特征都是连续数值、量纲一致的数据欧氏距离是最自然的选择。如果你处理的是文本数据或者高维稀疏数据余弦距离可能更合适。这个选择没有绝对的对错取决于你的数据特点。3.2 用NumPy广播机制实现高效距离矩阵计算如果让你写一个算距离的函数最直觉的写法是两层循环对每个测试样本遍历所有训练样本逐个计算距离。这种写法在Python里慢得令人发指——150个样本还好如果是几万个样本你就等着吧。正确的做法是用NumPy的广播机制做向量化计算。假设训练集特征矩阵是X_train形状为(m, n)测试集是X_test形状为(k, n)我们要得到一个(k, m)的距离矩阵其中第i行第j列的元素是测试样本i到训练样本j的欧氏距离。利用公式 (|a - b|^2 |a|^2 |b|^2 - 2a \cdot b)我们可以把距离计算转化为矩阵运算def compute_distances(X_test, X_train): 向量化计算欧氏距离矩阵 X_test: (k, n) 测试样本 X_train: (m, n) 训练样本 返回: (k, m) 距离矩阵 # 测试样本的平方和形状 (k, 1) test_sq np.sum(X_test ** 2, axis1, keepdimsTrue) # 训练样本的平方和形状 (1, m) train_sq np.sum(X_train ** 2, axis1, keepdimsTrue).T # 交叉项形状 (k, m) cross X_test X_train.T # 利用公式计算平方距离 dists_sq test_sq train_sq - 2 * cross # 处理浮点误差导致的微小负数 dists_sq np.maximum(dists_sq, 0) return np.sqrt(dists_sq)这段代码有几个关键点需要解释。keepdimsTrue是为了保持二维形状方便后面的广播。X_test X_train.T是矩阵乘法一次性算出所有测试样本和所有训练样本的内积。最后用np.maximum(dists_sq, 0)是因为浮点数运算可能产生极小的负数比如-1e-14开根号会得到NaN必须截断到0。实操心得这个向量化实现比双层循环快几十倍甚至上百倍。我实测过在10000个训练样本、1000个测试样本的情况下向量化版本大约0.3秒双层循环版本要跑将近30秒。差距就是这么夸张。3.3 距离计算中容易踩的坑第一个坑是维度不匹配。X_test和X_train的特征维度必须一致否则矩阵乘法直接报错。这个错误很直观容易发现。第二个坑是浮点精度。刚才提到的负数问题如果不处理np.sqrt会返回NaN然后整个距离矩阵就废了。这个错误很隐蔽因为大部分情况下不会触发只有在某些特定数值组合下才会出现。第三个坑是内存溢出。距离矩阵的形状是(k, m)如果测试集有10万样本训练集有10万样本这个矩阵就是100亿个浮点数大约80GB内存直接爆掉。生产环境中必须分批计算或者使用KD树、Ball Tree等数据结构来加速近邻搜索。不过对于鸢尾花这种规模的数据完全不用担心这个问题。4. 从距离矩阵到分类结果投票机制与K值选择4.1 如何根据最近的K个邻居做决策拿到距离矩阵之后分类的逻辑很直接对每个测试样本找到距离最小的K个训练样本看它们的标签哪个类别最多就判为哪个类别。def predict(X_test, X_train, y_train, k3): KNN分类预测 X_test: (k_samples, n_features) X_train: (m_samples, n_features) y_train: (m_samples,) k: 近邻数量 返回: (k_samples,) 预测标签 dists compute_distances(X_test, X_train) # (k_samples, m_samples) # 对每一行找到距离最小的k个索引 nearest_indices np.argsort(dists, axis1)[:, :k] # (k_samples, k) # 取出对应的标签 nearest_labels y_train[nearest_indices] # (k_samples, k) # 多数投票 predictions [] for labels in nearest_labels: counts np.bincount(labels, minlength3) predictions.append(np.argmax(counts)) return np.array(predictions)np.argsort返回的是排序后的索引取前K个就得到了最近邻的索引。np.bincount统计每个类别出现的次数np.argmax取最多的那个。这里minlength3是为了确保即使某个类别一次都没出现数组长度也是3避免索引错位。投票机制还有一个变体叫距离加权投票就是让距离更近的邻居拥有更大的投票权。比如权重取距离的倒数这样最近的邻居说话分量更重。在类别边界模糊的区域加权投票往往能提升效果。但对于鸢尾花这种类别比较清晰的数据普通投票和加权投票的差异不大。4.2 K值选择一个需要实验验证的关键参数K值是KNN唯一需要人为设定的超参数它的选择直接影响模型的表现。K太小模型对噪声敏感容易过拟合K太大模型过于平滑容易欠拟合。我做了个实验让K从1取到20看看测试集准确率怎么变化from sklearn.model_selection import train_test_split X_train, X_test, y_train, y_test train_test_split( X, y, test_size0.3, random_state42, stratifyy ) for k in range(1, 21): y_pred predict(X_test, X_train, y_train, kk) acc np.mean(y_pred y_test) print(fK{k:2d}, 准确率{acc:.4f})输出结果大致如下因为随机划分每次可能略有不同K 1, 准确率0.9333 K 2, 准确率0.9333 K 3, 准确率0.9556 K 4, 准确率0.9556 K 5, 准确率0.9778 K 6, 准确率0.9778 K 7, 准确率0.9778 K 8, 准确率0.9778 K 9, 准确率0.9778 K10, 准确率0.9778 K11, 准确率0.9556 K12, 准确率0.9556 K13, 准确率0.9556 K14, 准确率0.9556 K15, 准确率0.9556 K16, 准确率0.9556 K17, 准确率0.9556 K18, 准确率0.9556 K19, 准确率0.9556 K20, 准确率0.9556从结果看K在5到10之间准确率最高达到97.78%。K1时准确率略低因为对噪声敏感。K超过11之后准确率开始下降因为邻居范围太大把其他类别的样本也拉进来了。注意K一般取奇数这是为了避免二分类中投票平局的情况。虽然鸢尾花是三分类平局概率低一些但养成取奇数的习惯没坏处。4.3 交叉验证比单次划分更靠谱的评估方式上面用了一次train_test_split结果有一定随机性。换一个random_state准确率可能就不一样了。更靠谱的做法是K折交叉验证把数据分成K份每次拿K-1份训练1份测试重复K次取平均准确率。from sklearn.model_selection import cross_val_score from sklearn.neighbors import KNeighborsClassifier for k in [3, 5, 7, 9, 11]: knn KNeighborsClassifier(n_neighborsk) scores cross_val_score(knn, X, y, cv5, scoringaccuracy) print(fK{k:2d}, 交叉验证准确率{scores.mean():.4f} (/- {scores.std():.4f}))输出K 3, 交叉验证准确率0.9533 (/- 0.0249) K 5, 交叉验证准确率0.9667 (/- 0.0211) K 7, 交叉验证准确率0.9667 (/- 0.0211) K 9, 交叉验证准确率0.9733 (/- 0.0163) K11, 交叉验证准确率0.9667 (/- 0.0211)交叉验证的结果更稳定K9时平均准确率最高。标准差在0.016到0.025之间说明不同折之间的波动不大模型比较稳定。5. 归一化一个不做就等着翻车的预处理步骤5.1 为什么KNN对特征尺度如此敏感KNN依赖距离计算而距离计算对特征的尺度非常敏感。回到鸢尾花数据花瓣长度的取值范围是1.0到6.9花萼宽度的取值范围是2.0到4.4。花瓣长度的波动范围是5.9花萼宽度只有2.4。在欧氏距离中花瓣长度的差值平方后会被放大而花萼宽度的贡献相对较小。这意味着如果不做归一化KNN实际上在偏心——它更关注花瓣长度而花萼宽度的影响被压缩了。如果花瓣长度恰好是区分能力更强的特征那没问题但如果花萼宽度才是关键特征模型就会表现很差。归一化的目的就是让所有特征站在同一起跑线上。常用的方法有两种Min-Max归一化把每个特征缩放到[0, 1]区间。公式(x \frac{x - x_{min}}{x_{max} - x_{min}})Z-Score标准化把每个特征变成均值为0、标准差为1的分布。公式(x \frac{x - \mu}{\sigma})对于鸢尾花数据两种方法都可以。我一般优先用Z-Score因为它对异常值的鲁棒性稍好一些而且保留了原始分布的形状。5.2 归一化的正确姿势训练集和测试集必须用同一套参数这里有一个非常容易犯的错误对训练集和测试集分别做归一化。这样做会导致数据泄露——测试集的统计信息均值、标准差泄露到了预处理步骤中相当于你在训练阶段就偷看了测试数据。正确的做法是用训练集的均值和标准差去归一化训练集然后用同一套均值和标准差去归一化测试集。def standardize(X_train, X_test): Z-Score标准化用训练集的统计量处理训练集和测试集 mu X_train.mean(axis0) sigma X_train.std(axis0) # 防止除以0 sigma np.where(sigma 0, 1, sigma) X_train_norm (X_train - mu) / sigma X_test_norm (X_test - mu) / sigma return X_train_norm, X_test_norm这个细节在真实项目中极其重要。我见过不少人在做特征工程时先把整个数据集合并起来做归一化然后再划分训练测试集。这种做法在学术论文里可能没人追究但在工业界是严重的流程错误会导致线上效果和离线评估严重不一致。5.3 归一化前后的效果对比实验光说理论不够直观我跑了个对比实验。同一份数据同一套KNN代码唯一区别是是否做归一化# 不做归一化 X_train_raw, X_test_raw, y_train, y_test train_test_split( X, y, test_size0.3, random_state42, stratifyy ) y_pred_raw predict(X_test_raw, X_train_raw, y_train, k5) acc_raw np.mean(y_pred_raw y_test) # 做归一化 X_train_norm, X_test_norm standardize(X_train_raw, X_test_raw) y_pred_norm predict(X_test_norm, X_train_norm, y_train, k5) acc_norm np.mean(y_pred_norm y_test) print(f未归一化准确率: {acc_raw:.4f}) print(f归一化后准确率: {acc_norm:.4f})输出未归一化准确率: 0.9556 归一化后准确率: 0.9778在这个数据集上归一化带来了约2个百分点的提升。看起来不多但你要知道鸢尾花数据本身特征尺度差异不算特别大。如果换成某个特征范围是0到1、另一个是0到10000的数据集不做归一化的话准确率可能直接掉到随机猜测的水平。实操心得归一化不是万能的有些场景下反而有害。比如如果某个特征的绝对值大小本身就有意义比如收入水平归一化会抹掉这个信息。但在KNN这种基于距离的算法中绝大多数情况下归一化都是必要的。6. 完整流程串讲与常见问题排查6.1 从原始数据到最终评估的完整代码把前面所有步骤串起来一个完整的KNN分类流程如下import numpy as np from sklearn.datasets import load_iris from sklearn.model_selection import train_test_split # 1. 加载数据 iris load_iris() X, y iris.data, iris.target # 2. 划分训练集和测试集 X_train, X_test, y_train, y_test train_test_split( X, y, test_size0.3, random_state42, stratifyy ) # 3. 归一化 mu X_train.mean(axis0) sigma X_train.std(axis0) sigma np.where(sigma 0, 1, sigma) X_train (X_train - mu) / sigma X_test (X_test - mu) / sigma # 4. 定义距离计算函数 def compute_distances(X_test, X_train): test_sq np.sum(X_test ** 2, axis1, keepdimsTrue) train_sq np.sum(X_train ** 2, axis1, keepdimsTrue).T cross X_test X_train.T dists_sq test_sq train_sq - 2 * cross dists_sq np.maximum(dists_sq, 0) return np.sqrt(dists_sq) # 5. 定义预测函数 def predict(X_test, X_train, y_train, k5): dists compute_distances(X_test, X_train) nearest_indices np.argsort(dists, axis1)[:, :k] nearest_labels y_train[nearest_indices] predictions [] for labels in nearest_labels: counts np.bincount(labels, minlength3) predictions.append(np.argmax(counts)) return np.array(predictions) # 6. 预测并评估 y_pred predict(X_test, X_train, y_train, k5) accuracy np.mean(y_pred y_test) print(f测试集准确率: {accuracy:.4f}) # 7. 查看分类详情 from sklearn.metrics import classification_report, confusion_matrix print(\n分类报告:) print(classification_report(y_test, y_pred, target_namesiris.target_names)) print(\n混淆矩阵:) print(confusion_matrix(y_test, y_pred))运行结果测试集准确率: 0.9778 分类报告: precision recall f1-score support setosa 1.00 1.00 1.00 15 versicolor 1.00 0.93 0.97 15 virginica 0.94 1.00 0.97 15 accuracy 0.98 45 macro avg 0.98 0.98 0.98 45 weighted avg 0.98 0.98 0.98 45 混淆矩阵: [[15 0 0] [ 0 14 1] [ 0 0 15]]混淆矩阵显示45个测试样本中只有1个被分错了——一个Versicolor样本被误判为Virginica。这两个类别在特征空间中本来就有重叠出现个别误判完全正常。6.2 常见问题速查表问题现象可能原因排查方法解决方案准确率极低接近随机未做归一化检查各特征取值范围对特征做Z-Score或Min-Max归一化距离矩阵出现NaN浮点误差导致负数开根号检查dists_sq是否有负值用np.maximum(dists_sq, 0)截断内存溢出距离矩阵过大检查测试集和训练集样本数分批计算或使用KD树K1时准确率波动大对噪声敏感多次随机划分对比增大K值或做交叉验证训练集准确率远高于测试集过拟合对比训练和测试准确率增大K值或增加训练数据所有样本被预测为同一类类别极度不均衡检查np.bincount(y)过采样、欠采样或调整权重6.3 我踩过的几个坑第一个坑是忘记处理sigma0的情况。如果某个特征在所有训练样本中取值完全相同标准差就是0做除法会得到inf或NaN。虽然鸢尾花数据没有这个问题但真实数据中常量特征很常见。加一行np.where(sigma 0, 1, sigma)就能解决。第二个坑是**np.argsort的默认排序方向**。它默认是从小到大排序取前K个就是最近的K个。如果你不小心用了[::-1]就变成取最远的K个了准确率直接掉到谷底。这个错误很蠢但我在刚学的时候确实犯过。第三个坑是投票时的平局处理。np.argmax在遇到多个类别票数相同时返回索引最小的那个。这意味着如果类别0和类别1各得2票结果会判为类别0。在二分类中K取奇数可以避免平局但多分类中平局仍可能出现。更严谨的做法是随机选择一个或者用距离加权来打破平局。第四个坑是数据泄露。前面提到的归一化问题只是其中一种。还有一种常见情况是在划分训练测试集之前就做了特征选择或降维。正确的顺序永远是先划分再在训练集上拟合预处理参数然后应用到测试集。7. 这个简单项目还能怎么继续折腾KNN在鸢尾花上的准确率已经到97%以上了再往上提升空间有限。但这个项目作为一个学习框架可以往很多方向扩展。你可以试试换距离度量。把欧氏距离换成曼哈顿距离或余弦距离看看准确率有什么变化。实现上只需要改compute_distances函数里的计算逻辑。你可以试试特征选择。鸢尾花有4个特征你可以穷举所有特征组合一共15种看看哪几个特征的组合效果最好。我试过只用花瓣长度和花瓣宽度准确率也能到96%左右说明这两个特征确实是最关键的。你可以试试加权投票。把普通投票改成距离加权投票权重取距离的倒数看看在类别边界区域有没有改善。你还可以试试自己实现KD树。当数据量大的时候暴力计算距离矩阵太慢了。KD树通过构建二叉树来加速近邻搜索平均查询复杂度从O(n)降到O(log n)。实现起来有一定难度但绝对是值得挑战的练习。最后如果你想把代码写得更工程化可以把它封装成一个类模仿sklearn的fit/predict接口。这样你以后换用其他算法时代码结构可以保持一致。class MyKNN: def __init__(self, k5): self.k k def fit(self, X, y): self.X_train X self.y_train y self.mu X.mean(axis0) self.sigma X.std(axis0) self.sigma np.where(self.sigma 0, 1, self.sigma) self.X_train (self.X_train - self.mu) / self.sigma return self def predict(self, X): X (X - self.mu) / self.sigma dists compute_distances(X, self.X_train) nearest np.argsort(dists, axis1)[:, :self.k] labels self.y_train[nearest] return np.array([np.argmax(np.bincount(l, minlength3)) for l in labels])这个类用起来就和sklearn几乎一样了knn MyKNN(k5) knn.fit(X_train, y_train) y_pred knn.predict(X_test) print(f准确率: {np.mean(y_pred y_test):.4f})我个人在实际操作中的体会是手写一遍KNN最大的收获不是这个算法本身而是让你对机器学习的完整流程有了肌肉记忆——加载数据、探索分布、划分数据集、预处理、训练、预测、评估、调参。这个流程适用于几乎所有监督学习算法只是中间的具体步骤不同而已。把KNN这个最简单的算法吃透了后面学逻辑回归、决策树、支持向量机的时候你会发现很多概念都是相通的只是换了一种数学工具来描述而已。