☰
KNN手写数字识别实战:源码+数据集,半小时跑通
2026/10/3 3:23:43 网站建设 项目流程

简介: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 Neighbors,K 近邻算法。它的学习阶段没有梯度下降,没有权重更新,只是把训练样本原样存下来,等测试样本来了再现场算距离、投票出结果。这种「训练时几乎不做计算、预测时挨个比」的玩法叫惰性学习(lazy learning),而神经网络那种边训练边更新参数的方式叫急迫学习(eager learning)。这个区别解释了 KNN 在大规模数据集上不实用的核心原因:每次预测都要遍历全部训练样本。但在这份两千样本的入门数据集里,单次预测耗时在毫秒级,恰好把 KNN 的短板变成无感。

真正需要动手调的核心参数只有三个:距离度量、K 值和投票方式。距离度量最常见的是欧氏距离,把两个 1x1024 特征向量逐维相减、平方、求和、再开根号,得到一个数值代表两张图的整体差异。K 值决定取多少个近邻参与投票,一般取奇数,避免平票时没人说了算。投票方式默认是一人一票的多数表决,进阶做法是距离加权,让离得近的邻居话语权更大。这份源码用的是最朴素的「欧氏距离 + 等权投票」,对理解算法骨架来说刚刚好。

K 值的直觉,我一直这样跟新人解释:K 太小等于只听最近一个邻居的意见,样本一旦带噪声,预测立刻被带偏,过拟合;K 太大等于把距离很远的样本也拉进投票,模型变得过度平滑,欠拟合。在这个数据集上 K 取 3 到 5 通常表现最好,但具体数字必须跑一遍测试集才有说服力,这也是后面第四章要展开的坑。我自己第一次跑通时犯过 K=1 的错误,错误率比 K=3 差出一倍,这就是不扫参数吃到的亏。

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', encoding='utf-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*32+j 个位置。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(axis=1) 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(), key=operator.itemgetter(1), reverse=True) 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 按票数降序排列,取第一个键作为预测结果。

参数上要注意的点:axis=1 表示按行求和,也就是对每一行 1024 个差值平方求和,输出长度是 m。operator.itemgetter(1) 指定按字典的 value 排序而不是 key,reverse=True 是降序。k 没有默认值,必须由调用方显式传入;资源配套的习惯取值是 3,这是在这个数据集上试出来的,不是拍脑袋。

性能提示:np.tile 会临时生成一个 m x 1024 的复制矩阵,训练样本两万时,这个矩阵在内存里要占约 160MB(float64 算的话是 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 值先降后升,K=3 或 K=5 附近是最优点。这个「扫一遍再下结论」的习惯,能省掉大量关于 K 值选择的玄学争论——真实数据比拍脑袋靠谱。

提示:K 值扫完先看整体趋势再选点,错误率在最优值附近往往是平缓的,选 3 或 5 差异不大;如果只有一个 K 值表现特别好,回到第四章 4.5 检查是不是测试集和训练集发生了重叠。

压缩包里的资源文件对应关系,我整理成一张清单放在这里:

文件/目录作用备注
KNN_digits.py完整源码含 img2vector、classify0、主流程
trainingDigits/KNN 训练样本库约两千个 32x32 txt
testDigits/独立测试样本集约一千个 32x32 txt
Readme使用说明数据格式与运行说明
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 二值数据集上,K=3 或 K=5 通常落在最优区间,错误率在 1% 到 3% 之间。如果你的曲线整体偏高,先回 4.1 检查路径,再检查 file_name.split('_')[0] 是否真的拿到了数字标签。

4.3 现象三:4 和 9、3 和 8 经常互相误判

现象:错误集中在少数几类数字上,打印出误判样本一看,4 被认成 9,3 被认成 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, title='digit'): data = img2vector(filename).reshape(32, 32) plt.imshow(data, cmap='gray', interpolation='nearest') plt.title(title) plt.axis('off') plt.show()

先 img2vector 读出 1x1024 向量,再 reshape(32, 32) 还原成二维矩阵,交给 imshow 显示。cmap='gray' 让 0 显示为黑、1 显示为白;interpolation='nearest' 避免像素间被平滑插值,保证看到的和进模型的数据完全一致。在预测循环里把误判样本收集起来,逐个调用这个函数,就能回放模型每次「看走眼」的现场。我第一次看到 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), fill=255) img = img.resize((64, 64), Image.NEAREST) img = img.filter(ImageFilter.GaussianBlur(radius=1)) 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 值曲线 → 单独拆分训练与测试 → 外部样本做最终验证」三步走。顺序不能乱,一步跳过去,结果就要打问号。手写数字识别虽然简单,但它是我理解「数据决定上限,算法逼近上限」这句话的第一块实验田,希望这份源码和踩坑记录能帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询