KNN算法手写数字识别实战:从零实现到调参优化

发布时间:2026/10/3 3:23:46
KNN算法手写数字识别实战:从零实现到调参优化 简介面向机器学习初学者的KNN算法实战资源以手写数字识别为核心案例完整演示从数据读取、距离计算到分类决策的流程适合高校学生与入门开发者用于课程设计或算法对比实验。压缩包共2882个文件其中2880个txt文件为手写数字样本按训练集与测试集分目录组织可直接用于模型训练与效果验证另含1个KNN_digits.py源码文件以及1个readme说明整体仅972KB轻量易用目录结构清晰。已有2807人学习。通过这份资源读者不仅能获得可运行的Python实现还能借助规范化的数据集复现KNN分类效果观察不同K值、距离度量方式对识别准确率的影响源码与注释有助于逐行理解算法原理可进一步扩展用于其他图像分类任务是机器学习入门与实验报告的优质参考。1. KNN算法做手写数字识别不调GPU也能到97%准确率的老项目KNN算法是机器学习里最朴素也最容易被低估的分类器它没有真正意义上的训练阶段把数据记下来、到预测时算距离投票就完成了分类。把KNN和手写数字识别放在一起做成项目是我带新人入门视觉分类时的固定路线MNIST数据集规模适中、标签干净不需要GPU就能在几分钟内跑完最终准确率能到97%上下和早期卷积网络相差不大。这个项目适合刚学完Python和numpy、想验证“分类到底是怎么回事”的工程师也适合想准备简历项目但暂时不打算上深度学习的同学。它不解决工业级识别问题却能把特征工程、距离度量、调参和模型评估这条路完整走一遍。2. KNN算法和MNIST数据集的耦合点距离计算与数据格式2.1 KNN的分类逻辑训练集是记忆库推理时才真正开始计算KNN是典型的懒学习模型。fit或者说训练阶段它只做一件事把训练样本原样存下来。真正的计算全部发生在predict阶段每次来一个未知样本它都要遍历一遍记忆库算出这个样本到每一条训练样本的距离把最近的k条找出来让k个邻居投票决定类别。这就带来一个反直觉的结论KNN的训练几乎瞬时完成预测却很慢和神经网络正好相反。MNIST手写数字识别之所以适合KNN是因为数字图片被展平成向量之后像素的灰度值天然具备可比性。同一个数字在不同人笔下的笔画位置大体接近所以两张“3”的图片对应的784维向量在欧氏空间里距离较近而“3”和“8”虽然形状相似但像素分布差异足够大到可以用距离区分。当然模糊、连笔、偏移这些情况会让距离失真这正是后面调参要处理的问题。KNN的完整分类过程可以拆成三个步骤先按选定的距离度量计算测试样本和所有训练样本的距离再对距离排序取出前k个最小值的下标最后让这k个邻居的标签投票票数多的类别作为预测结果。三个步骤里距离度量和k值是需要我们拍板的超参数投票方式相对固定少数服从多数平票时通常取类别索引更小的那个这一点在从零实现时要特别注意否则会和sklearn的结果对不上。2.2 MNIST数据集图片和标签是怎么配对的标题里的压缩包中除了源码最值钱的部分就是数据集。MNIST手写数字识别用的标准数据是Yann LeCun整理的MNIST库包含60000张训练图片和10000张测试图片每张图片是28×28的灰度图内容是一个0到9的手写数字。图片和标签分开存放图片文件记录像素值标签文件记录每个样本对应的数字两者按行号一一对应顺序完全一致。常见的数据集存储形态有两类。一类是原始的idx格式四个文件分别是训练图片、训练标签、测试图片、测试标签文件名通常是这种风格train-images-idx3-ubyte.gz train-labels-idx1-ubyte.gz t10k-images-idx3-ubyte.gz t10k-labels-idx1-ubyte.gz另一类是转好的CSV形态第一列是标签后面784列是像素值。两种形态我都遇到过idx格式更接近原始数据CSV格式更方便直接丢进pandas处理。拿到zip先判断数据集后缀再决定走哪条解析路径这比强行套代码更重要。idx文件是二进制的头部有固定字节数的元信息。图片文件头16字节记录magic number、图片数量、行数和列数标签文件头8字节记录magic number和标签数量。用Python的struct模块把头部解出来剩下的字节按uint8读取就是干净的图像矩阵。这里我固定用这段代码解析import numpy as np import struct def load_idx_images(filepath): # 解析idx格式的图像文件返回形状为 (样本数, 784) 的uint8数组 with open(filepath, rb) as f: magic, num, rows, cols struct.unpack(IIII, f.read(16)) print(fmagic{magic}, num{num}, rows{rows}, cols{cols}) data np.frombuffer(f.read(), dtypenp.uint8) return data.reshape(num, rows * cols) def load_idx_labels(filepath): # 解析idx格式的标签文件返回形状为 (样本数,) 的uint8数组 with open(filepath, rb) as f: magic, num struct.unpack(II, f.read(8)) print(fmagic{magic}, num{num}) return np.frombuffer(f.read(), dtypenp.uint8)这里的关键参数是struct.unpack里的它表示按大端字节序读取。MNIST的idx格式规定元信息以大端序存储如果漏掉、按本机默认的小端序解析读出来的num可能是25165824这样的天文数字后续reshape全部错乱。16字节对应四个无符号整型8字节对应两个无符号整型顺序不能换先magic再数量图像文件还要再读行数和列数。注意CSV形态的MNIST不需要这段代码直接用np.loadtxt或pd.read_csv读取即可但务必要确认第一列是不是标签有些二次处理过的CSV会把标签放在最后一列错列会让准确率直接崩到10%左右。2.3 从像素到向量归一化、展平和标签对齐图像文件解析出来之后每个样本是一行784维的向量取值范围是0到255。直接拿这个向量去算欧氏距离不是不行但高亮度像素的价值会被放大一个230和255的差异看起来不大投影到距离上却贡献了较大的平方差相当于把0到255的尺度强行当成了线性距离的权重。常见做法是除以255把像素压缩到0到1区间让距离计算更关注相对灰度差异而不是绝对亮度。归一化和标签对齐可以写成一个预处理函数顺便做数据类型转换。uint8直接做减法没问题但涉及平方累加时容易在边界产生精度损失先转成float32更稳妥。def preprocess(X_train, X_test): # 像素值从uint8归一化到[0,1]并转为float32避免距离累加时的精度损失 X_train X_train.astype(np.float32) / 255.0 X_test X_test.astype(np.float32) / 255.0 return X_train, X_test预处理阶段还有两个细节值得注意。第一归一化参数只需要训练集的最大值MNIST固定是255所以训练测试共用255是安全的换到其他数据集时应该用训练集的统计值去变换测试集不能拿测试集的最大值去归一化训练集那叫数据泄露。第二标签和图像的行号必须对齐加载后用np.bincount(y_train)看一眼每个类别的数量0到9各有6000上下就是正常的如果某个数字数量异常多半是文件配对时张冠李戴了。我还会顺手做一个随机抽样可视化抽25个样本画成5×5的网格视觉确认图片内容和标签文字一致。这一步花不了几秒钟却能避免后面所有评估结果建立在错位数据上。图像展平本身没什么技术含量reshape(-1, 784)一行搞定但理解“28×28的二维数组塌缩成一维向量”对后面理解距离计算至关重要。3. 项目实战在本地跑通KNN手写数字识别的最小代码3.1 项目文件怎么组织拿到zip后我习惯先把目录整理成src、data、output三个部分。data放数据集src放三个脚本output放可视化结果。这样后面换参数、跑对比实验时不会被一堆同名文件搞乱。mkdir -p knn_mnist/src mkdir -p knn_mnist/data mkdir -p knn_mnist/output cd knn_mnist touch src/knn_from_scratch.py src/knn_sklearn.py src/visualize_errors.py这段命令做的事情很简单建立目录结构并预留三个Python文件的位置。实际运行时先把数据文件放进data目录src下的脚本通过相对路径../data/xx引用输出图片统一写到output目录。如果你只是临时验证KNN效果不用这么多目录两个脚本加一个数据集就够但项目一旦涉及调参对比目录不规范迟早要吃亏。3.2 用numpy从零实现KNN核心逻辑从零实现的目的是把黑匣子拆开看一眼。这里我按KNN的三个步骤写广播算距离、argsort取近邻、bincount投票。import numpy as np def knn_predict(X_train, y_train, X_test, k5): 从零实现KNN分类 X_train: (n_train, n_features) float32 y_train: (n_train,) uint8 X_test: (n_test, n_features) k: 近邻数量 preds np.zeros(len(X_test), dtypenp.uint8) for i, x in enumerate(X_test): # 第一步广播计算当前样本到所有训练样本的欧氏距离 diff X_train - x.reshape(1, -1) dists np.sqrt(np.sum(diff * diff, axis1)) # 第二步距离从小到大排序取前k个下标 top_k_idx np.argsort(dists)[:k] # 第三步k个邻居的标签投票平票时返回类别编号较小的 top_k_labels y_train[top_k_idx] preds[i] np.argmax(np.bincount(top_k_labels)) return preds逻辑说明X_train - x.reshape(1, -1)利用numpy广播一次性算出当前测试样本到全部60000条训练样本的差值矩阵diffs * diff是逐元素平方sum(axis1)按特征维度求和开根号后就是欧氏距离向量。argsort拿到从小到大的索引切前k个就是最近邻的编号。最后用bincount统计0到9每个数字出现的次数argmax取出出现最多的那个。参数说明k的默认值设5是MNIST上的经验区间如果改成1准确率会略降且预测边界非常不平滑。这段代码的问题是慢500个测试样本在60000条训练数据上要循环500次每次算60000×784的矩阵减法普通笔记本跑完需要几分钟。验证逻辑时建议先用np.random.choice抽1000个测试样本或者把训练集临时截到10000条等确认逻辑无误再上全量。X_train, y_train, X_test, y_test ... # 前面预处理得到的数据 sample_idx np.random.choice(len(y_test), 1000, replaceFalse) preds knn_predict(X_train, y_train, X_test[sample_idx], k5) acc np.mean(preds y_test[sample_idx]) print(fknn from scratch accuracy: {acc * 100:.2f}%)准确率的计算方式是对比预测值和真实标签做布尔比较取均值就是正确率。这里踩过坑的人都知道测试集抽样后必须重新对齐索引曾经有人直接用y_test[:1000]结果样本和标签对不上准确率异常低。3.3 用scikit-learn快速对照从零实现验证了原理实际工程中没人手写KNN直接用sklearn的KNeighborsClassifier而且它支持多核并行和多距离度量比自己循环快几个数量级。from sklearn.neighbors import KNeighborsClassifier from sklearn.metrics import accuracy_score knn KNeighborsClassifier( n_neighbors5, weightsuniform, algorithmauto, p2, n_jobs-1 ) knn.fit(X_train, y_train) y_pred knn.predict(X_test) print(fsklearn knn accuracy: {accuracy_score(y_test, y_pred) * 100:.2f}%)参数说明n_neighbors对应KNN的k核心超参数weights有两个选择uniform是平等投票distance是按距离倒数加权距离越近的邻居话语权越大p2是欧氏距离改成1就是曼哈顿距离n_jobs-1使用所有CPU核心预测阶段能明显提速。一个反直觉的点是algorithmauto并非永远最优。sklearn会在kd-tree、ball-tree和brute三种算法里自动选但在784维的高维数据上kd-tree的剪枝效率很差auto经常会回退到暴力计算。数据量不大时可以直接指定algorithmbrute省去树构建的时间数据量大到内存紧张时再考虑ball-tree。3.4 可视化预测错误的样本只报一个准确率不能说明模型行为把预测错的样本打出来看是最直观的排查手段。错误样本往往集中在形状相近的数字对比如3和8、4和9、7和1。import matplotlib.pyplot as plt errors np.where(y_pred ! y_test)[0] print(ftotal errors: {len(errors)}) fig, axes plt.subplots(2, 5, figsize(12, 5)) for i, idx in enumerate(errors[:10]): ax axes[i // 5][i % 5] ax.imshow(X_test[idx].reshape(28, 28), cmapgray) ax.set_title(ftrue{y_test[idx]}, pred{y_pred[idx]}) ax.axis(off) plt.tight_layout() plt.savefig(output/knn_errors.png, dpi150)这段代码用np.where找出预测和标签不一致的下标取前10个画成2行5列的子图。i // 5和i % 5是子图定位的常见写法把一维循环序号映射到二维网格。画出来的图一旦发现某一对数字反复出错说明距离度量对这类形变不敏感接下来就要从调参上找补。4. 调参k值、距离度量、样本量怎么影响准确率和耗时4.1 扫描k值从1到15看准确率曲线k值是最直观的超参数我一般不拍脑袋定而是直接写个循环扫描。k太小模型被噪声样本带着跑k太大把远处的异类样本也拉进来投票边界被抹平。MNIST上常见的最优k落在3到7之间具体看训练样本量。import time from sklearn.neighbors import KNeighborsClassifier from sklearn.metrics import accuracy_score X_test_sub X_test[:2000] y_test_sub y_test[:2000] for k in [1, 3, 5, 7, 9, 11, 13, 15]: knn KNeighborsClassifier(n_neighborsk, n_jobs-1) t0 time.time() knn.fit(X_train, y_train) y_pred knn.predict(X_test_sub) acc accuracy_score(y_test_sub, y_pred) print(fk{k:2d} acc{acc:.4f} time{time.time() - t0:.1f}s)逻辑说明这里故意把测试集截到2000条因为调参过程要跑多组实验全量10000条会让等待时间线性放大。打印结果里主要看两件事准确率随k的走势以及时间随k的变化。在MNIST上k从1升到5时准确率明显往上走再往大就会开始缓慢下滑这是因为样本存在类间重叠区域过大的k会把重叠区另一侧的样本拉进来。注意扫描k值时固定其他参数一次只动一个变量。想同时调k和weights时用GridSearchCV或ParameterGrid不要手动排列组合容易漏组合而且看不出交互效应。4.2 距离度量欧氏距离、曼哈顿距离和distance权重距离度量决定了两个图片在向量空间里“怎么算接近”。欧氏距离对每个像素的差异一视同仁高亮像素的贡献会被平方放大曼哈顿距离是像素差绝对值之和对大噪声更鲁棒一点但在MNIST上通常差距不大。weightsdistance则是按距离倒数加权让近邻说话更响。参数配置距离含义MNIST上的常见表现p2, weightsuniform欧氏距离邻居平等投票默认组合准确率97%上下p1, weightsuniform曼哈顿距离准确率略低但对离群点不敏感p2, weightsdistance欧氏距离按距离倒数加权准确率可能高0.1%到0.3%但预测更慢p1, weightsdistance曼哈顿距离加权与第二行接近偶尔反超欧氏我的固定做法是先跑p2和uniform做基准再换distance加权看看提升幅度。如果提升不到0.2%就回到简单配置因为distance加权会让每个类别的决策边界更崎岖可视化时错误样本更不直观。距离度量的选择本质是给“相似”下一个定义在MNIST上没有标准答案只有针对实际预测目标的取舍。4.3 训练样本量从1万到6万的边际收益KNN是记忆型算法训练样本量直接决定预测阶段的计算量。样本翻倍准确率提升逐渐饱和但耗时线性上升。这是典型的规模与收益的权衡也是调参时最容易忽略的边界。train_subset_sizes [10000, 20000, 40000, 60000] for n in train_subset_sizes: idx np.random.choice(len(y_train), n, replaceFalse) knn KNeighborsClassifier(n_neighbors5, n_jobs-1) knn.fit(X_train[idx], y_train[idx]) y_pred knn.predict(X_test[:2000]) acc accuracy_score(y_test[:2000], y_pred) print(fn_train{n:5d} acc{acc:.4f})逻辑说明np.random.choice随机抽样索引抽样后训练集变小预测变快。这里不重置随机种子每次结果会有波动所以对比时要用同一组测试子集否则噪声会盖过样本量带来的差异。1万条训练样本时准确率大约在95%出头4万条能到96.5%以上再往上增速放缓这就是边际收益递减。如果你是做课程设计60000条全量能让你交出一张漂亮的准确率曲线如果模型要反复迭代调参长期驻留在25000条左右性价比最高准确率只比全量低0.5个百分点但每次实验快一倍。这一点在写报告时可以量化对比比只丢出一个最终准确率更有说服力。5. 避坑KNN手写数字识别最常见的四个翻车现场5.1 现象加载数据集时报struct.error或EOFError运行load_idx_images时抛异常提示需要更多字节才能解包或者解码出来的图片数量明显不对。原因通常是下载的文件不完整gz包只下了一半解压出来字节数偏少头部读出来的数量远超实际剩余字节。还有一种情况是下载工具没有跟随重定向把服务器返回的HTML页面存成了.gz文件。解决先检查文件大小是否和标准一致。MNIST的测试图片文件解压后是7840016字节左右如果差得远直接重新下载。用命令行下载时加上跟随重定向参数不要用浏览器右键另存为很多浏览器在这种静态文件上会做奇怪的处理。如果数据集来源不固定加载前加一个简单检查import os data_path data/t10k-images-idx3-ubyte # 校验文件大小7840016是10000张28x28图片加16字节头部的解压后大小 if os.path.exists(data_path) and os.path.getsize(data_path) ! 7840016: print(数据文件大小异常建议重新下载)5.2 现象准确率只有80%出头远低于97%的预期模型跑通了但准确率卡在80%到85%排除代码bug后最可能的原因是没归一化。直接用0到255的像素值算距离高亮像素的平方差会主导整个距离值相当于把分类决策权交给几个最亮的点前景像素的位置信息被稀释。还有一个常见原因是k设成了1单近邻对噪声零容忍个别训练样本的标注错误直接带偏预测。解决在预处理阶段统一除以255转成float32再进模型k放到3到7区间重跑一次。如果准确率还是上不去检查是否有样本和标签错位用前面提到的网格可视化抽25张人工核对。跑完这两个修正准确率通常会直接跳到95%以上剩下的差距才是算法本身的边界。5.3 现象预测结果几乎全是同一个数字准确率掉到10%左右和随机猜测一样而且np.unique(y_pred)显示预测值集中在某一个类别。这种现象在CSV格式的数据集上特别常见标签列被放在了最后一列而代码默认第一列是标签于是标签向量变成了像素向量的一部分实际标签变成无意义的像素值。另一个原因是标签和图片文件配对错了比如训练图片配上了测试标签行号对不上。解决先检查数据形状。y_train.min()和y_train.max()如果不在0到9范围内立即回头检查读取逻辑np.bincount(y_train)输出每个数字的计数正常应该约6000附近某个数字为零就要警惕。最后用图像网格可视化确认图片内容与标签文字一致。这一套检查下来90%的错位问题都能暴露。5.4 现象从零实现的KNN内存占用激增程序卡死自己写的循环版本每次计算X_train - x时生成一个60000×784的float32矩阵约188MB看起来还能接受但如果为了省事把测试样本也做全量广播一次生成(10000, 60000, 784)的浮点张量直接占掉180GB内存任何机器都会卡死。还有人在循环里没有及时释放中间变量Python的引用计数没把上一轮矩阵回收内存峰值叠加到爆缸。解决分段计算一次只处理一个或一小批测试样本用float32而不是float64内存减半循环末尾可写del diff, dists也可以依赖Python自动回收。如果数据量实在太大改用sklearn的KNeighborsClassifier底层实现本身做了分块优化比自己手写的循环省得多。我自己的习惯是手写版只用来验证逻辑验证完立刻切到sklearn跑全量。6. 进阶用PCA降维后再跑KNN看784维里的冗余被去掉多少784维像素向量里存在大量冗余数字图片的背景大片都是黑色真实有效的信息集中在前景笔画附近。KNN对维度很敏感高维空间里距离会趋于集中这也是“维度诅咒”的一种体现。用PCA先降维再跑KNN是验证这个现象最直接的方式。from sklearn.decomposition import PCA # 只用训练集拟合PCA再用同一组变换处理测试集避免数据泄露 pca PCA(n_components0.95) X_train_pca pca.fit_transform(X_train) X_test_pca pca.transform(X_test) print(f保留95%方差需要降到: {pca.n_components_} 维) knn_pca KNeighborsClassifier(n_neighbors5, n_jobs-1) knn_pca.fit(X_train_pca, y_train) y_pred_pca knn_pca.predict(X_test_pca) print(fPCA降维后准确率: {accuracy_score(y_test, y_pred_pca) * 100:.2f}%)逻辑说明n_components0.95告诉PCA保留95%的方差降到多少维由算法自动决定MNIST通常落在100维到150维之间。关键点在于fit和transform必须分离fit_transform(X_train)先学习训练集的主成分方向再用transform(X_test)把测试集投影到同一个坐标系如果拿全量数据一起fit测试信息就泄漏进了降维过程评估结果会虚高。MNIST上跑出来的结果是784维降到约110维准确率从97%掉到95.5%上下预测时间明显缩短。这就是我遇到维度诅咒时的固定实验路径——先全量跑基准再做一次降维对比找到速度和精度的拐点。我的习惯是把这个对比写进项目报告比单纯刷高分更能体现对模型边界的理解。希望帮到你。本文还有配套的精品资源点击获取