KNN手写数字识别实战:源码+数据集,半小时跑通

发布时间:2026/10/3 3:23:46
KNN手写数字识别实战:源码+数据集,半小时跑通 简介K近邻算法实现手写数字识别的完整工程资料包面向机器学习初学者与需要快速上手分类项目的开发者用经典手写数字识别任务演示K近邻算法的训练与预测流程。压缩包内含2882个文件2880个txt格式的样本文件构成训练集与测试集1个Python源码文件用于运行核心分类逻辑1个readme说明文件辅助理解目录结构与使用方式整体体积约972KB。trainingDigits与testDigits两个子目录将训练样本和评估样本分开存放每个txt文件对应一张手写数字图片的像素化特征便于观察0到9类样本的数据表达。目前已有2807人学习/下载。读者可借助这套资料获得完整可复现的K近邻识别代码和数据集理解特征向量、距离度量、多数投票等关键概念也可基于源码调整特征表示或K值用于课程设计或入门实验。1. KNN 手写数字识别实战一个源码包加两套数据集半小时跑通如果你刚接触机器学习想找一个「代码短、依赖少、跑起来还看得见效果」的入门项目KNN 手写数字识别几乎是绕不开的那一个。这个资源里装的正是这套经典落地形态一份 KNN_digits.py 源码、一套训练集 trainingDigits、一套测试集 testDigits外加 Readme 和几个可以直接预览的 txt 样本。它不像 MNIST 那样需要下载几百 MB 的灰度图也不用搭 PyTorch 或 TensorFlow 环境解压后直接在命令行跑起来就能看到模型把 0 到 9 认对多少。适合三类人刚学完 Python 语法想接触机器学习的新手、需要为课程设计找可复现案例的学生、以及想快速验证 KNN 调参逻辑的从业者。2. KNN 算法原理与数据集结构为什么这类任务选 KNN 而不是神经网络今天聊到手写数字识别很多人第一反应是 CNN、PyTorch、MNIST但在这份资源对应的场景里KNN 是比神经网络更合理的选择。原因有三训练样本总量才两千个上下拿去喂深度学习连验证集都喂不饱输入是 32x32 的二值化矩阵特征维度就 1024信息量远不如灰度图KNN 的决策过程完全可解释——某个测试样本被判成 4是因为它在训练集里找到 K 个最近邻居其中大多数是 4。对入门阶段来说这种「看得见逻辑」的模型比黑匣子神经网络友好得多。搞清楚这几个参数为什么起作用你才算真正把 KNN 装进了自己的工具箱。2.1 惰性学习与距离投票KNN 的三个核心参数KNN 全称是 K-Nearest NeighborsK 近邻算法。它的学习阶段没有梯度下降没有权重更新只是把训练样本原样存下来等测试样本来了再现场算距离、投票出结果。这种「训练时几乎不做计算、预测时挨个比」的玩法叫惰性学习lazy learning而神经网络那种边训练边更新参数的方式叫急迫学习eager learning。这个区别解释了 KNN 在大规模数据集上不实用的核心原因每次预测都要遍历全部训练样本。但在这份两千样本的入门数据集里单次预测耗时在毫秒级恰好把 KNN 的短板变成无感。真正需要动手调的核心参数只有三个距离度量、K 值和投票方式。距离度量最常见的是欧氏距离把两个 1x1024 特征向量逐维相减、平方、求和、再开根号得到一个数值代表两张图的整体差异。K 值决定取多少个近邻参与投票一般取奇数避免平票时没人说了算。投票方式默认是一人一票的多数表决进阶做法是距离加权让离得近的邻居话语权更大。这份源码用的是最朴素的「欧氏距离 等权投票」对理解算法骨架来说刚刚好。K 值的直觉我一直这样跟新人解释K 太小等于只听最近一个邻居的意见样本一旦带噪声预测立刻被带偏过拟合K 太大等于把距离很远的样本也拉进投票模型变得过度平滑欠拟合。在这个数据集上 K 取 3 到 5 通常表现最好但具体数字必须跑一遍测试集才有说服力这也是后面第四章要展开的坑。我自己第一次跑通时犯过 K1 的错误错误率比 K3 差出一倍这就是不扫参数吃到的亏。2.2 trainingDigits 与 testDigits目录、命名规则和样本量如何影响结果这套数据集的物理组织方式很有年代感但也极其透明。每张数字图片不是 bmp 或 png而是一个 32 行 32 列的 txt 文件里面只有 0 和 1 两种字符1 表示笔迹覆盖的像素0 表示空白背景。压缩包解开后目录结构长这样trainingDigits/数字 0 到 9 各有一批 txt 文件作为 KNN 的「记忆库」testDigits/每个数字各有一批独立 txt 文件用于评估模型没见过的新样本Readme说明文件介绍目录和数据格式根目录几个 4_80.txt、0_45.txt 这样的散文件单样本演示方便你解开压缩包先看格式不用进目录翻。命名规则是「数字 _ 序号 . txt」。下面几个例子是从资源里直接能看到的名字文件名标签部分序号部分含义4_80.txt480数字 4 的第 80 个样本0_45.txt045数字 0 的第 45 个样本1_46.txt146数字 1 的第 46 个样本这个命名规则是整份代码能跑起来的前提。源码在读取样本时就是靠split(_)[0]取出下划线前的字符转成 int 当作真实标签。如果你改文件名或者把文件移到别的位置标签和内容就会错位后面的准确率数据全部失效。样本量方面完整版的 trainingDigits 大约包含两千个文件testDigits 大约包含一千个文件每个数字对应的训练样本约两百个、测试样本约一百个。规模小到 numpy 处理起来毫无压力却足以覆盖不同书写风格的差异让 KNN 具备可观察的泛化能力。如果你手里的压缩包只有根目录那十来个演示 txt没有两个子目录说明数据不完整需要补全后才能复现出文中的错误率。2.3 从 32x32 矩阵到特征向量txt 里那一堆 01 是怎么变成输入数据的一张 32x32 的二值图展平后就是 1024 个 0/1 值。数据集的作者没有把二维像素数组直接塞给算法而是选择更朴素的做法从第一行第一个字符开始逐行扫描把每行 32 个字符依次填进长度为 1024 的一维数组。这其实就是最基础的特征工程——把图像变成模型能计算距离的向量。用文本编辑器打开 0_45.txt前五行大概是这样的00000000000000000000000000000000 00000000000000000000000000000000 00000000000000000000000000000000 00000000000000000000111110000000 00000000000000000011111111000000每一行恰好 32 个字符总共 32 行。前三行全是 0说明数字 0 的顶部在图像上方留了空白第四行开始出现连续的 1笔迹正式进入画面。这里丢掉灰度信息只保留「有没有字」的判断对 KNN 来说完全够用。第三章的 img2vector 函数就是按这个格式把 txt 读成 numpy 行向量再交给距离计算。接触这份资源之前如果你只玩过 MNIST可能会问MNIST 是 28x28 灰度图像素值 0 到 255这里却是 32x32 的 0/1 文本两者什么关系答案是它们解决同一个问题但存储与预处理方式不同。MNIST 面向深度学习保留灰度供卷积层提取纹理这份 txt 数据集面向 KNN已经做过二值化省去了读图和转灰度的麻烦。对初学者后者的「纯文本」恰恰是优点——不用引入 OpenCV、PIL注意力可以完全放在算法本身。3. KNN_digits.py 源码拆解从读文件到输出识别准确率源码按功能分三段img2vector 负责读 txt 转向量classify0 负责 KNN 分类主流程负责批量扫描目录、统计错误率。我按脚本执行的自然顺序往下拆每一段都给出可以直接跑的代码和参数说明。3.1 img2vector把二维矩阵拉平成 1x1024 的特征向量import numpy as np def img2vector(filename): return_vect np.zeros((1, 1024)) with open(filename, r, encodingutf-8) as fr: for i in range(32): line_str fr.readline() for j in range(32): return_vect[0, i * 32 j] int(line_str[j]) return return_vect这段代码的逻辑很直白函数接收一个 txt 路径返回形状为 (1, 1024) 的 numpy 行向量。外层循环读 32 行文本内层循环取每行第 j 个字符转成 int放进一维向量的第 i*32j 个位置。with open管理文件句柄避免忘记 close() 的隐患。对于这个体积的数据集一次性读入内存没有任何压力。几个必须说清的细节。第一line_str[j] 取到的是字符 0 或 1要转成数值必须用 int()。第二fr.readline() 会把行尾的换行符 \n 也读进来但换行符位于索引 32 号位内层循环只访问 0 到 31所以不会混进向量。第三range(32) 是硬编码它要求输入文件严格 32 行、每行严格 32 个字符。如果样本在预处理阶段被压缩了空行line_str[j] 会直接抛 IndexError这就是数据格式协议的一部分修改前要想清楚。有朋友问为什么不用 np.fromfile 或 np.loadtxt 一次性读入。loadtxt 确实能读但返回的是二维数组还要多做一次 reshape而且遇到行末换行、空白字符时行为不一致。我自己更喜欢手写循环因为它是「数据格式即代码」的直观表达——32x32 这个形状就写在循环头上看代码的人不需要去查数据说明文档。这种代码风格在教学场景下比追求极致的向量化更有价值。3.2 classify0欧氏距离、排序与 K 值投票的实现逻辑KNN 分类核心代码是机器学习入门里很经典的一版写法短但值得逐行看import numpy as np import operator def classify0(in_x, data_set, labels, k): data_set_size data_set.shape[0] diff_mat np.tile(in_x, (data_set_size, 1)) - data_set sq_diff_mat diff_mat ** 2 sq_distances sq_diff_mat.sum(axis1) distances sq_distances ** 0.5 sorted_dist_index distances.argsort() class_count {} for i in range(k): vote_label labels[sorted_dist_index[i]] class_count[vote_label] class_count.get(vote_label, 0) 1 sorted_class_count sorted(class_count.items(), keyoperator.itemgetter(1), reverseTrue) return sorted_class_count[0][0]逻辑拆开看in_x 是待预测的 1x1024 测试向量data_set 是全部训练样本组成的 m x 1024 矩阵labels 是每个训练样本的真实标签k 是近邻个数。np.tile(in_x, (data_set_size, 1)) 把测试向量在行方向复制 data_set_size 次复制后的矩阵和训练矩阵形状一致相减得到差值矩阵。接着平方、按行求和、开根号得到每个训练样本到测试样本的欧氏距离。argsort() 返回距离从小到大排列的索引序号labels[sorted_dist_index[i]] 表示距离最近的第 i 个训练样本对应的标签。class_count 字典统计前 K 个近邻里每个标签出现了几次。最后用 sorted 按票数降序排列取第一个键作为预测结果。参数上要注意的点axis1 表示按行求和也就是对每一行 1024 个差值平方求和输出长度是 m。operator.itemgetter(1) 指定按字典的 value 排序而不是 keyreverseTrue 是降序。k 没有默认值必须由调用方显式传入资源配套的习惯取值是 3这是在这个数据集上试出来的不是拍脑袋。性能提示np.tile 会临时生成一个 m x 1024 的复制矩阵训练样本两万时这个矩阵在内存里要占约 160MBfloat64 算的话是 2000010248 字节对本资源两千样本量毫无影响但如果后续你把训练集换成大几十倍的数据这个写法会先碰到内存瓶颈。真到那个量级应该换 scipy.spatial.distance.cdist 或者直接用 KDTree而不是继续手写 tile。3.3 主流程串联目录扫描、批量预测与 error rate 统计import os def handwriting_class_test(): base_dir os.path.dirname(os.path.abspath(__file__)) training_dir os.path.join(base_dir, trainingDigits) test_dir os.path.join(base_dir, testDigits) train_file_list os.listdir(training_dir) train_count len(train_file_list) train_mat np.zeros((train_count, 1024)) train_labels [] for i in range(train_count): file_name train_file_list[i] file_label int(file_name.split(_)[0]) train_labels.append(file_label) train_mat[i, :] img2vector(os.path.join(training_dir, file_name)) test_file_list os.listdir(test_dir) test_count len(test_file_list) error_count 0 for file_name in test_file_list: true_label int(file_name.split(_)[0]) test_vector img2vector(os.path.join(test_dir, file_name)) predict_label classify0(test_vector, train_mat, train_labels, 3) if predict_label ! true_label: error_count 1 error_rate error_count / test_count print(预测错误数: %d, 错误率: %f % (error_count, error_rate))主流程分两步。第一步扫描 trainingDigits把所有训练样本读进 train_mat 矩阵每行一个样本同时用文件名下划线前的部分提取标签。第二步扫描 testDigits逐个读入测试样本调用 classify0 预测并与真实标签比较统计错误数。最后错误数除以测试样本总数得到错误率这个数值就是评估模型的核心指标。我在代码里加了 base_dir os.path.dirname(os.path.abspath(file))这是我踩过坑之后的习惯。原版常见写法直接 os.listdir(trainingDigits)依赖当前工作目录在 PyCharm 里一换运行目录就报 FileNotFoundError或者更糟——不报错但读到空列表。用file定位脚本所在目录再拼路径无论从哪儿启动都不会漂移。os.listdir 返回的文件名顺序不固定但这里不影响结果因为标签已经通过 split(_)[0] 和样本绑定。os.path.join 做跨平台路径拼接在 Windows 上尤其重要。如果你在 Linux 上写 training_dir / file_name 没问题换到 Windows 就立刻翻车这是跨平台开发的经典坑。关于 K 值验证我在原版基础上习惯加一个循环for k in [1, 3, 5, 7, 9]: error_count 0 for file_name in test_file_list: true_label int(file_name.split(_)[0]) test_vector img2vector(os.path.join(test_dir, file_name)) predict_label classify0(test_vector, train_mat, train_labels, k) if predict_label ! true_label: error_count 1 print(k%d, error rate%.3f % (k, error_count / test_count))跑完你大概率会看到错误率随 K 值先降后升K3 或 K5 附近是最优点。这个「扫一遍再下结论」的习惯能省掉大量关于 K 值选择的玄学争论——真实数据比拍脑袋靠谱。提示K 值扫完先看整体趋势再选点错误率在最优值附近往往是平缓的选 3 或 5 差异不大如果只有一个 K 值表现特别好回到第四章 4.5 检查是不是测试集和训练集发生了重叠。压缩包里的资源文件对应关系我整理成一张清单放在这里文件/目录作用备注KNN_digits.py完整源码含 img2vector、classify0、主流程trainingDigits/KNN 训练样本库约两千个 32x32 txttestDigits/独立测试样本集约一千个 32x32 txtReadme使用说明数据格式与运行说明4_80.txt、0_45.txt 等散文件单样本演示便于快速了解格式4. 避坑与排查手写数字识别最容易翻车的五个问题代码量不到一百行的 KNN按理说没有太多出错空间但我见过太多次跑完结果不可信的情况。这一章专门列出最容易翻车的五个点每一条都按「现象 → 原因 → 解决」给你讲清楚都是真实场景里的血泪经验。4.1 现象一工作目录不对所有测试样本集体预测失败现象代码原样照抄运行时报 FileNotFoundError或者不报错但错误率高达 90% 以上。原因os.listdir(trainingDigits) 用的是相对路径解释器是按当前工作目录找的。PyCharm 默认运行目录可能和脚本所在目录不一致Jupyter Notebook 的 kernel 工作目录更是经常漂在别处。解决运行前先打印现场import os print(os.getcwd()) print(os.path.exists(trainingDigits))exists 返回 False 就说明目录没对上。推荐改用脚本绝对路径定位base_dir os.path.dirname(os.path.abspath(__file__)) training_dir os.path.join(base_dir, trainingDigits)这样无论你把脚本放哪儿、从哪个目录启动路径都不会丢失。4.2 现象二K 值选错正确率直接崩到 70% 以下现象网上教程说 K 取 3 错误率 1.2%你照做却只有 80% 多。原因先排除数据问题比如训练集和测试集目录搞反了、标签提取写错。数据没问题的话再看 K 值本身。K 取 1 对孤立点过于敏感某个数字的奇异笔迹会直接带偏预测K 取 15 到 20 时把大量远距离样本拉进投票模型被多数类绑架错误率显著上升。解决用一个小循环把 K 从 1 扫到 15只取奇数观察错误率曲线。在这个 32x32 二值数据集上K3 或 K5 通常落在最优区间错误率在 1% 到 3% 之间。如果你的曲线整体偏高先回 4.1 检查路径再检查 file_name.split(_)[0] 是否真的拿到了数字标签。4.3 现象三4 和 9、3 和 8 经常互相误判现象错误集中在少数几类数字上打印出误判样本一看4 被认成 93 被认成 8。原因这是数据本身的分辨率瓶颈。32x32 的 0/1 矩阵丢掉了很多笔画细节二值化之后某些人写的 4 和 9 在像素分布上确实高度相似。KNN 只计算像素距离不理解「哪个笔画是竖勾、哪个开口朝上」这样的拓扑语义。解决这属于 KNN 的固有瓶颈不是 bug不要花太多力气去抠。可以尝试把投票改成距离加权让更近的邻居权重更大能小幅改善边缘案例也可以对图像做四宫格笔画密度统计把特征从 1024 维扩到 1028 维。但对入门项目我建议做到「记录并展示混淆情况」这一步就收手真要突破这个瓶颈那是 CNN 的课题。4.4 现象四样本量太少调 K 值像开盲盒现象训练集只有几十个文件换一个 K 错误率忽高忽低没有任何规律。原因KNN 是记忆型模型训练样本就是它的全部知识。每个数字只有十来个样本时测试样本的书写风格稍微偏离训练分布最近邻居排序就是随机的K 值微调带来的波动会被噪声完全掩盖。解决给 KNN 加样本比调参重要得多。完整版的 trainingDigits 约有近两千个文件测试集约千个如果你手里的资源只有根目录那几个 4_80.txt、0_45.txt 演示文件说明子目录数据缺失需要先补齐。样本量上去了错误率才会收敛到可复现的区间。4.5 现象五把训练集当测试集用准确率虚高现象测试错误率 0.0% 或接近 0%开心不到十分钟换一份新数据立刻打回原形。原因训练样本本身就在 KNN 的记忆库里。拿一个训练样本去查最近邻居距离为 0 的必定是它自己投票对象里包含自身结果当然正确。这不代表模型具备真实泛化能力只是自嗨。解决测试集必须独立于训练集。如果你想用自己生成的图片验证也必须确认这张图从未出现在训练目录中。这条原则我从本科课程设计一路用到工作以后见过太多「精确率百分百」的翻车现场根子都在评估方式上。5. 进阶玩法把误判样本可视化并接入自己的手写图片跑通错误率只是第一步。这个源码包的真正价值是让你还能继续做两件进阶实验把模型看走眼的样本画出来直观看到原因再拿自己手写的图片去试验证模型在真实笔迹下的表现。5.1 用 matplotlib 把误判样本画出来import matplotlib.pyplot as plt def show_digit_from_txt(filename, titledigit): data img2vector(filename).reshape(32, 32) plt.imshow(data, cmapgray, interpolationnearest) plt.title(title) plt.axis(off) plt.show()先 img2vector 读出 1x1024 向量再 reshape(32, 32) 还原成二维矩阵交给 imshow 显示。cmapgray 让 0 显示为黑、1 显示为白interpolationnearest 避免像素间被平滑插值保证看到的和进模型的数据完全一致。在预测循环里把误判样本收集起来逐个调用这个函数就能回放模型每次「看走眼」的现场。我第一次看到 4 被认成 9 的图时才真正理解像素距离和人类视觉相似度完全是两回事图像上两个数字确实像模型理论上没有犯规。5.2 用 PIL 生成自己的手写图片补进 testDigits 验证鲁棒性from PIL import Image, ImageDraw, ImageFilter def make_digit_image(digit): img Image.new(L, (32, 32), 0) draw ImageDraw.Draw(img) draw.text((8, 2), str(digit), fill255) img img.resize((64, 64), Image.NEAREST) img img.filter(ImageFilter.GaussianBlur(radius1)) img img.resize((32, 32), Image.BILINEAR) return img def to_01_txt(img, out_path): with open(out_path, w) as f: for i in range(32): row for j in range(32): pixel img.getpixel((j, i)) row 1 if pixel 128 else 0 f.write(row \n)这段代码用 PIL 内置字体在 32x32 画布上画出数字放大后再模糊再缩小用平滑过渡模拟真实笔迹的边缘最后按 128 阈值二值化成 01 文本。生成 7_self.txt 放进 testDigits 重新跑主流程就能看到模型对你亲手造的数字给什么预测。需要注意PIL 字体是印刷体和人的手写风格差异不小拿它验出来的错误率会偏离原始测试集数字。想要更近一步可以用手机截图或数位板画数字再转 txt这个转换过程本身就是一次图像预处理练习。从那以后我每次跑这类 KNN 识别项目都强制自己按「扫一遍 K 值曲线 → 单独拆分训练与测试 → 外部样本做最终验证」三步走。顺序不能乱一步跳过去结果就要打问号。手写数字识别虽然简单但它是我理解「数据决定上限算法逼近上限」这句话的第一块实验田希望这份源码和踩坑记录能帮到你。本文还有配套的精品资源点击获取