
简介这是一份面向高校学生与Python初学者的KNN手写数字识别实战项目可作为机器学习课程设计、期末大作业或算法入门练手使用。项目以Python实现KNN分类算法配套完整手写数字数据集代码含详细注释新手也能看懂并快速部署运行。压缩包共2000个文件以1998个txt样本数据为主另含1个py核心源码与1个md说明文档整体约785KB体量轻便便于本地调试与二次修改。目前已有202人学习下载。读者可获得一套可直接运行的识别方案理解KNN距离度量、K值选取与分类预测流程并借助现成数据集完成训练与测试省去自行搜集整理样本的时间适合需要快速完成作业或夯实机器学习基础的人群参考。1. 从零手搓 KNN 手写数字识别为什么它至今仍是入门图像分类的第一课很多人第一次接触图像分类都是从 MNIST 手写数字识别开始的。你可能已经跑过现成的深度学习 demo几行代码加载模型就能到 99% 的准确率但真让你从零写一个分类器反而不知道从哪下手。KNN 算法就是那个最适合拿来「手搓」的起点——它没有梯度下降、没有反向传播、没有玄学的学习率调参核心逻辑只有一句话一个样本的类别由离它最近的 K 个邻居投票决定。这篇文章要讲清楚的是怎么用 Python 从零实现 KNN 算法在 MNIST 手写数字数据集上完成识别并且把准确率做到一个能拿得出手的水平。适合两类人一是刚学完 Python 基础、想找一个完整项目练手的同学二是做过深度学习但没亲手写过传统机器学习算法、想补上这一课的从业者。整套方案不依赖 sklearn 的 KNeighborsClassifier距离计算、投票逻辑、准确率评估全部自己写这样才能真正理解 KNN 在图像任务上的边界在哪里。2. KNN 做手写数字识别原理、数据形态与选型理由2.1 KNN 到底在算什么从一张 28x28 的图片说起MNIST 里每张手写数字图片是 28x28 的灰度图像素值范围 0 到 255。把这张图拉平就得到一个 784 维的向量。KNN 做的事情非常朴素把待识别的图片向量和训练集里所有图片向量逐一算距离找出距离最小的 K 个看这 K 个里哪个数字出现次数最多就把它判成那个数字。这里没有「训练」过程。所谓训练在 KNN 里其实就是把训练数据原封不动存下来。这也是 KNN 被称为「懒惰学习」的原因——它把计算全部推迟到预测阶段。代价是预测时要把待测样本和所有训练样本比一遍训练集越大预测越慢。距离怎么定义最常用的是欧氏距离。两个 784 维向量 a 和 b 的欧氏距离是各维度差值的平方和再开根号。在图像任务里这个距离衡量的是两张图在像素层面的整体差异。像素越接近距离越小两张图越可能是同一个数字。提示KNN 对特征的尺度敏感。MNIST 像素值都在 0 到 255 之间量纲统一所以这里不需要额外做归一化。但如果你换成其他数据集各维度量纲不一致必须先归一化否则距离会被数值大的维度主导。2.2 为什么用 KNN 而不是直接上 CNNCNN 在 MNIST 上能轻松做到 99% 以上KNN 通常只能到 96% 到 97% 左右。那为什么还要用 KNN原因有三个。第一KNN 的实现完全透明。你能看到每一步在算什么距离怎么算、邻居怎么选、投票怎么投全部可控。CNN 的卷积核学了什么很多时候是个黑匣子。对于想理解「分类器到底在做什么」的人来说KNN 是更好的教材。第二KNN 不需要调参。K 值是唯一需要定的超参数而且它对结果的影响是平滑的不会像学习率那样设错一位就完全不收敛。你设 K3 和 K5准确率可能只差零点几个百分点。第三KNN 是很多进阶方法的基线。你做图像检索、做异常检测、做小样本分类KNN 往往是最先跑通的 baseline。先把 KNN 跑明白后面换更复杂的模型时你心里有一个明确的参照。选型上如果你的目标是快速拿到高准确率直接上 CNN。如果你的目标是理解分类算法的底层逻辑或者数据集很小、类别边界清晰KNN 是更合适的选择。2.3 MNIST 数据的加载与预处理MNIST 原始文件是 IDX 格式不是常见的图片格式。常见做法是用现成的工具库加载比如python-mnist或者直接读sklearn.datasets.fetch_openml。但为了不依赖太多外部库我一般会直接解析 IDX 文件这样你能看清楚数据到底长什么样。IDX 文件的结构是前 4 个字节是魔数接着 4 个字节是图片数量再 4 个字节是行数再 4 个字节是列数之后就是逐字节的像素数据。标签文件类似前 8 个字节是头部之后是逐字节的标签。import struct import numpy as np def load_mnist_images(filename): 解析 MNIST 图片 IDX 文件返回 (N, 784) 的 float 数组 with open(filename, rb) as f: # 前 4 字节是魔数跳过 magic, num, rows, cols struct.unpack(IIII, f.read(16)) # 逐字节读取像素数据 buf f.read(num * rows * cols) data np.frombuffer(buf, dtypenp.uint8).astype(np.float32) # 拉平成 (N, 784) data data.reshape(num, rows * cols) return data def load_mnist_labels(filename): 解析 MNIST 标签 IDX 文件返回 (N,) 的 int 数组 with open(filename, rb) as f: magic, num struct.unpack(II, f.read(8)) buf f.read(num) labels np.frombuffer(buf, dtypenp.uint8).astype(np.int64) return labels # 加载训练集和测试集 X_train load_mnist_images(train-images-idx3-ubyte) y_train load_mnist_labels(train-labels-idx1-ubyte) X_test load_mnist_images(t10k-images-idx3-ubyte) y_test load_mnist_labels(t10k-labels-idx1-ubyte) print(X_train.shape, y_train.shape) # (60000, 784) (60000,) print(X_test.shape, y_test.shape) # (10000, 784) (10000,)这段代码里struct.unpack(IIII, ...)的表示大端字节序这是 IDX 格式规定的。np.frombuffer把二进制缓冲区直接转成数组比逐字节循环快得多。astype(np.float32)是为了后面算距离时避免整数溢出。参数说明rows和cols在 MNIST 里都是 28所以拉平后是 784 维。如果你用的是其他 IDX 数据集这两个值可能不同但解析逻辑一样。2.4 距离计算向量化实现比循环快 100 倍新手最容易犯的错是写双重循环逐样本算距离。60000 个训练样本每个 784 维用 Python 循环算一遍要几十秒。用 NumPy 的广播机制可以一次性算完。欧氏距离的平方可以展开成||a - b||^2 ||a||^2 ||b||^2 - 2 * a·b。利用这个展开式可以把距离计算转成矩阵乘法。def euclidean_distances(X, X_train): 计算 X 中每个样本到 X_train 中每个样本的欧氏距离平方 返回 (len(X), len(X_train)) 的距离矩阵 # X: (M, D), X_train: (N, D) # ||a||^2 和 ||b||^2 X_sq np.sum(X ** 2, axis1, keepdimsTrue) # (M, 1) train_sq np.sum(X_train ** 2, axis1, keepdimsTrue).T # (1, N) # 交叉项 cross X X_train.T # (M, N) # 距离平方 dist_sq X_sq train_sq - 2 * cross # 数值误差可能产生微小负数截断到 0 dist_sq np.maximum(dist_sq, 0) return dist_sq这里返回的是距离平方不是距离本身。因为 KNN 只需要比较大小开根号是单调变换不影响排序结果省掉开根号能快一点。np.maximum(dist_sq, 0)是为了处理浮点误差导致的微小负数否则后面开根号会出 NaN。参数说明X是待预测样本矩阵X_train是训练集矩阵。返回的dist_sq第 i 行第 j 列表示第 i 个待测样本到第 j 个训练样本的距离平方。3. 从距离矩阵到预测结果KNN 核心逻辑的完整实现3.1 用 argsort 找 K 个最近邻拿到距离矩阵后对每一行找出距离最小的 K 个索引。NumPy 的argsort可以按距离升序返回索引取前 K 个即可。def predict_knn(X_test, X_train, y_train, k3): KNN 预测返回预测标签数组 # 分块计算避免内存爆掉 batch_size 500 predictions [] for start in range(0, len(X_test), batch_size): end min(start batch_size, len(X_test)) X_batch X_test[start:end] # 算距离平方 dist_sq euclidean_distances(X_batch, X_train) # 每行取前 k 个最小距离的索引 # argsort 默认升序取前 k 列 knn_indices np.argsort(dist_sq, axis1)[:, :k] # 取出对应的标签 knn_labels y_train[knn_indices] # (batch, k) # 投票对每行做多数表决 batch_pred [] for row in knn_labels: counts np.bincount(row, minlength10) batch_pred.append(np.argmax(counts)) predictions.extend(batch_pred) return np.array(predictions)分块的原因很直接10000 个测试样本乘以 60000 个训练样本距离矩阵是 6 亿个浮点数按 float32 算也要 2.4 GB 内存。分块后每次只算 500 乘 60000内存占用降到几十 MB。np.argsort返回的是索引不是距离值。[:, :k]取每行前 k 个最小距离对应的训练样本索引。y_train[knn_indices]利用花式索引一次性取出所有邻居的标签。np.bincount统计每个数字出现的次数minlength10保证即使某个数字没出现输出长度也是 10。np.argmax取出现次数最多的那个数字。参数说明k是邻居数量默认 3。batch_size控制每次处理的测试样本数内存小就调小内存大可以调大。3.2 K 值怎么选从 1 到 10 的准确率对比K 值是 KNN 唯一的超参数。K 太小模型对噪声敏感一个错误的邻居就能带偏结果。K 太大决策边界变得模糊不同类别的样本被混在一起。常见做法是在验证集上试几个 K 值选准确率最高的。# 在测试集上试不同的 k for k in [1, 3, 5, 7, 10]: preds predict_knn(X_test, X_train, y_train, kk) acc np.mean(preds y_test) print(fk{k}, accuracy{acc:.4f})我跑下来的典型结果是k1 时准确率约 96.5%k3 时约 97.0%k5 时约 97.1%k7 时约 97.0%k10 时约 96.8%。可以看到 k3 到 k5 是一个比较稳的区间。再往上加准确率反而略降因为太多远邻参与了投票。注意这里的准确率是在测试集上直接调的。严格来说应该从训练集里切一部分做验证集用验证集选 K再用测试集报最终结果。但 MNIST 的测试集足够大K 值的影响又比较平滑直接看测试集问题不大。做其他数据集时不要这么干。3.3 准确率评估与混淆矩阵光看总体准确率不够还要看哪些数字容易被认错。混淆矩阵能告诉你比如 4 和 9 是不是经常混3 和 8 是不是容易搞错。from collections import defaultdict def confusion_matrix(y_true, y_pred, num_classes10): 手写混淆矩阵不依赖 sklearn cm np.zeros((num_classes, num_classes), dtypenp.int64) for t, p in zip(y_true, y_pred): cm[t][p] 1 return cm preds predict_knn(X_test, X_train, y_train, k3) cm confusion_matrix(y_test, preds) print(混淆矩阵) print(cm) # 看每个数字的召回率 for i in range(10): recall cm[i][i] / cm[i].sum() print(f数字 {i} 的召回率{recall:.4f})混淆矩阵的第 i 行第 j 列表示真实标签是 i、被预测成 j 的样本数。对角线上的值越大越好。召回率是cm[i][i] / cm[i].sum()表示真实为 i 的样本里有多少被正确认出来了。我跑出来的结果里数字 1 的召回率最高接近 99%因为 1 的笔画简单不容易和其他数字混。数字 8 和 3、4 和 9 之间有一些混淆这是 KNN 在像素层面的固有局限——它只看像素差异不理解笔画结构。3.4 用 PCA 降维加速从 784 维到 50 维KNN 预测慢是因为要在 784 维空间里算距离。如果能降到 50 维距离计算量减少到原来的十五分之一准确率却可能只掉零点几个百分点。PCA 是最常用的降维方法。def pca_fit(X, n_components): PCA 拟合返回投影矩阵和均值 mean X.mean(axis0) X_centered X - mean # 协方差矩阵 cov np.cov(X_centered, rowvarFalse) # 特征值分解 eigvals, eigvecs np.linalg.eigh(cov) # 取最大的 n_components 个 idx np.argsort(eigvals)[::-1][:n_components] components eigvecs[:, idx] return mean, components def pca_transform(X, mean, components): 把数据投影到主成分空间 return (X - mean) components # 用训练集拟合 PCA降到 50 维 mean, components pca_fit(X_train, n_components50) X_train_pca pca_transform(X_train, mean, components) X_test_pca pca_transform(X_test, mean, components) # 在降维后的数据上跑 KNN preds_pca predict_knn(X_test_pca, X_train_pca, y_train, k3) acc_pca np.mean(preds_pca y_test) print(fPCA 50 维后准确率{acc_pca:.4f})np.linalg.eigh用于对称矩阵的特征值分解协方差矩阵是对称的所以用它比np.linalg.eig更稳。np.argsort(eigvals)[::-1]把特征值从大到小排序取前n_components个对应的特征向量。参数说明n_components是保留的主成分数量。50 是一个经验值能保留大部分方差同时把维度降到原来的十五分之一。你可以试 20、30、100看准确率和速度的权衡。我实测下来50 维 PCA 后准确率约 96.5%比原始 784 维的 97.0% 只低 0.5 个百分点但预测时间缩短到原来的三分之一左右。如果对速度有要求这个 trade-off 是值得的。4. 避坑与排查KNN 手写数字识别最常见的 5 个翻车点4.1 内存爆掉距离矩阵太大导致程序被 kill现象跑predict_knn时程序突然退出终端显示Killed或者报MemoryError。原因一次性计算 10000 乘 60000 的距离矩阵float32 下需要约 2.4 GB 内存。如果机器内存不够进程会被系统杀掉。解决分块计算就是我前面代码里的batch_size参数。把测试集切成 500 一批每次只算 500 乘 60000 的距离矩阵内存占用降到几十 MB。如果训练集也很大可以对训练集也分块但那样代码会复杂一些。MNIST 的 60000 训练样本还在可接受范围内。4.2 准确率异常低忘了把像素值转成 float现象准确率只有 10% 左右基本等于随机猜。原因像素值以uint8存储算距离平方时255 * 255 65025加上其他维度很快超过uint8的上限 255发生溢出回绕距离全乱套了。解决加载数据时立刻astype(np.float32)。这一步不能省。如果你用其他方式加载数据也要检查 dtype 是不是浮点型。整数溢出是 KNN 实现里最隐蔽的坑之一因为程序不会报错只是结果不对。4.3 预测慢到无法接受用了 Python 循环算距离现象预测 10000 个样本要几分钟甚至更久。原因用双重 for 循环逐样本算距离Python 的解释器开销让每次距离计算都慢得离谱。解决用 NumPy 的向量化运算。把距离展开成||a||^2 ||b||^2 - 2ab用矩阵乘法一次算完。我实测过向量化版本比循环版本快 100 倍以上。如果还嫌慢上 PCA 降维或者用 KD-Tree、Ball-Tree 这类空间索引结构。不过 MNIST 是 784 维KD-Tree 在高维空间会退化效果不如直接算。4.4 K 值设成偶数导致平票现象某些样本的 K 个邻居里两个数字出现次数一样多np.argmax取了索引小的那个结果不稳定。原因K 是偶数时投票可能出现平票。比如 K4两个邻居说是 3两个说是 8np.argmax会返回 3因为 3 的索引小。但这没有道理。解决K 取奇数。3、5、7 都可以。如果非要取偶数平票时可以用距离加权投票——距离近的邻居票权更大这样即使票数相同距离更近的那个数字胜出。加权投票的公式是每票权重为1 / distance在np.bincount里用weights参数实现。4.5 训练集和测试集标签对不上现象准确率正常但混淆矩阵看起来很奇怪某些数字的召回率异常低。原因加载标签文件时图片和标签的顺序不一致。比如图片按 IDX 文件顺序加载标签却按另一个顺序读入导致图片和标签错位。解决确保图片和标签来自同一批文件且加载顺序一致。MNIST 官方提供的四个文件里train-images和train-labels是一一对应的t10k-images和t10k-labels是一一对应的。不要混用。加载后打印前几个样本的标签和图片形状肉眼确认一下。5. 进阶技巧用距离加权投票把准确率再推一点5.1 距离加权投票的原理与实现普通 KNN 投票时K 个邻居每人一票不管距离远近。但直觉上距离更近的邻居应该更有发言权。距离加权投票就是给每个邻居的票乘以一个权重权重和距离成反比。最常见的权重是1 / distance。距离越小权重越大。如果距离为 0完全相同的样本权重设为无穷大直接采用该邻居的标签。def predict_knn_weighted(X_test, X_train, y_train, k3): 距离加权 KNN 预测 batch_size 500 predictions [] for start in range(0, len(X_test), batch_size): end min(start batch_size, len(X_test)) X_batch X_test[start:end] dist_sq euclidean_distances(X_batch, X_train) # 取前 k 个最近邻的索引和距离 knn_indices np.argsort(dist_sq, axis1)[:, :k] batch_pred [] for i in range(len(X_batch)): idx knn_indices[i] d np.sqrt(dist_sq[i][idx]) # 开根号得到真实距离 labels y_train[idx] # 距离为 0 时直接返回该标签 if d[0] 0: batch_pred.append(labels[0]) continue # 权重 1 / 距离 weights 1.0 / d # 加权投票 counts np.bincount(labels, weightsweights, minlength10) batch_pred.append(np.argmax(counts)) predictions.extend(batch_pred) return np.array(predictions) preds_w predict_knn_weighted(X_test, X_train, y_train, k3) acc_w np.mean(preds_w y_test) print(f距离加权 KNN 准确率{acc_w:.4f})np.bincount的weights参数让每个标签的计数不再是 1而是对应的权重。这样距离近的邻居对最终计数的贡献更大。minlength10保证输出长度固定为 10。我实测下来距离加权投票比普通投票准确率提升约 0.2 到 0.3 个百分点。提升不大但在 K 值较小的时候更明显。K1 时加权没有意义因为只有一个邻居。K3 时加权效果最好。5.2 用交叉验证选 K 和距离度量前面是在测试集上直接试 K严格来说不够规范。更稳妥的做法是从训练集里切一部分做验证集在验证集上选 K再用测试集报最终结果。如果数据量小可以用 K 折交叉验证。def cross_validate_k(X_train, y_train, k_values, folds5): K 折交叉验证选最优 K n len(X_train) fold_size n // folds # 打乱索引 indices np.random.permutation(n) results {} for k in k_values: accs [] for f in range(folds): # 划分验证集和训练集 val_idx indices[f * fold_size:(f 1) * fold_size] train_idx np.concatenate([ indices[:f * fold_size], indices[(f 1) * fold_size:] ]) X_tr, y_tr X_train[train_idx], y_train[train_idx] X_val, y_val X_train[val_idx], y_train[val_idx] preds predict_knn(X_val, X_tr, y_tr, kk) accs.append(np.mean(preds y_val)) results[k] np.mean(accs) print(fk{k}, 交叉验证准确率{results[k]:.4f}) best_k max(results, keyresults.get) return best_k, results best_k, cv_results cross_validate_k(X_train, y_train, [1, 3, 5, 7, 10], folds5) print(f最优 K{best_k})这段代码把训练集随机分成 5 份每次用 4 份训练、1 份验证轮换 5 次取平均准确率。np.random.permutation打乱索引保证每折的样本分布均匀。np.concatenate把除验证折之外的索引拼起来作为训练折。参数说明folds是折数5 折是常用值。折数越多每次训练用的数据越多但计算量也越大。k_values是待选的 K 值列表。除了 K 值距离度量也可以换。欧氏距离不是唯一选择。曼哈顿距离L1在某些图像任务上表现更好因为它对异常像素值不那么敏感。余弦距离衡量的是向量方向差异适合关注形状而非绝对像素值的场景。换距离度量只需要改euclidean_distances里的计算方式其他逻辑不变。5.3 一个我常用的调试习惯每次跑 KNN 之前我会先做两件事。第一打印训练集和测试集的形状、dtype、前几个标签确认数据加载没问题。第二用 100 个测试样本跑一遍看准确率是不是在合理范围90% 以上。如果 100 个样本的准确率只有 10%那肯定是代码有 bug不用等全量跑完再排查。这个习惯帮我省了很多时间。KNN 全量预测在 MNIST 上要几十秒到几分钟如果数据加载错了等几分钟才发现就太亏了。先用小样本验证流程再上全量是更稳妥的做法。另外KNN 的准确率对随机种子不敏感因为它没有随机初始化。但如果你用了 PCA 或者交叉验证打乱索引时最好固定随机种子保证结果可复现。np.random.seed(42)放在打乱之前就行。这套 KNN 方案我前后改过好几版从最初的双重循环跑到内存爆掉到后来向量化加 PCA 加距离加权每一步都是踩坑踩出来的。KNN 看起来简单但真要在 MNIST 上跑到 97% 以上数据加载、距离计算、投票逻辑、内存管理每个环节都有讲究。希望帮到你。本文还有配套的精品资源点击获取