Python KNN手写数字识别课程设计:源码解析与调参避坑指南
2026/9/23 20:02:37 网站建设 项目流程

简介:这是一份面向高校学生与Python初学者的KNN手写数字识别实战项目,可直接用于课程设计、期末大作业或算法入门练习。项目以Python实现KNN分类算法,配套完整手写数字数据集,代码含详细注释,新手也能看懂并快速部署运行。压缩包共2000个文件,以1998个txt样本数据为主,另含1个py主程序与1个md说明文档,整体约785KB,体积轻便,便于本地调试与二次修改。目前已有202人学习下载,适合需要提交高质量作业或想动手理解KNN原理的读者。拿到资源后,可参考说明文档理清目录结构,直接运行主程序完成训练与识别,并结合注释逐行理解距离计算、K值选取与投票分类等关键环节,同时利用自带数据集反复实验,观察不同K值对识别效果的影响,为后续机器学习课程打下基础。

1. 一份能跑通的 KNN 手写数字识别作业,到底长什么样

课程设计周最怕的不是不会写代码,而是打开老师给的参考包发现只有一堆散装 txt,连个能跑的入口都找不到。这份基于 Python 的 KNN 手写数字识别源码包,结构简单到有点朴素:一个KNN.py主程序、一份README.md、外加一批形如5_71.txt0_24.txt的样本数据文件。文件名里的数字就是标签,下划线后面是样本编号,这种命名方式在课程设计里很常见,好处是解析标签不用额外查表。它解决的核心问题很明确——用最原始的 KNN 算法,把 32x32 的文本矩阵还原成手写数字并完成分类,适合正在做期末大作业、想找一个能讲清楚原理又能当场演示的 Python 入门项目的人。下面我按实际拆包顺序,把这份资源从数据格式到调参避坑完整走一遍。

2. 拆开数据包:32x32 文本矩阵怎么变成 KNN 能吃的向量

2.1 样本文件的真实结构

先别急着跑KNN.py,把任意一个5_71.txt用文本编辑器打开,你会看到 32 行、每行 32 个字符,字符只有01两种。这就是经典的图像二值化文本表示:1代表笔画经过的像素,0代表背景。文件名5_71拆开看,5是这张图对应的真实数字标签,71是样本序号,用来区分同一个数字的不同写法。

这种格式的好处是零依赖——不需要 PIL、不需要 numpy 就能读,纯 Python 的open().readlines()就能处理。坏处也很明显:32x32 一共 1024 个特征,如果直接用二维列表做距离计算,循环嵌套会写得很难看。所以常见做法是在读取阶段就把它拉平成一维向量,长度固定 1024。

import os import numpy as np def img2vector(filename): """把 32x32 的 txt 文件转成 1x1024 的 numpy 向量""" return_vect = np.zeros((1, 1024)) with open(filename) as f: for i in range(32): line_str = f.readline() for j in range(32): return_vect[0, 32 * i + j] = int(line_str[j]) return return_vect

这段代码的逻辑很直白:外层循环走 32 行,内层循环走每行 32 个字符,用32 * i + j把二维坐标映射到一维索引。参数上唯一需要注意的是int(line_str[j]),因为读进来是字符串'0''1',不转 int 的话后面算欧氏距离会变成字符串拼接。我一般会在这里加一个strip(),防止某些编辑器在行尾留下\r导致索引越界。

2.2 标签提取与数据集组织

标签直接从文件名拿,这是这份资源最省事的地方。写一个get_label(filename)函数,用filename.split('_')[0]就能拿到数字字符串,再int()一下即可。遍历整个数据目录时,把所有向量堆成一个(N, 1024)的矩阵,标签堆成一个长度 N 的列表,KNN 的训练集就准备好了。

def load_dataset(data_dir): """遍历目录,返回特征矩阵和标签列表""" features, labels = [], [] for fname in os.listdir(data_dir): if not fname.endswith('.txt'): continue label = int(fname.split('_')[0]) vect = img2vector(os.path.join(data_dir, fname)) features.append(vect[0]) labels.append(label) return np.array(features), np.array(labels)

这里有个容易翻车的点:os.listdir返回的顺序在不同操作系统上不一致,如果你后面要做训练集/测试集切分,千万别依赖默认顺序,要么先sorted(),要么用random.seed()固定打乱。我见过有人因为没排序,在 Windows 上跑得好好的,换到 Linux 提交就报标签对不上,血泪经验。

提示:样本文件数量不多时,全部用来做测试也可以,但课程设计答辩时老师通常会问“你的训练集和测试集怎么划分的”,提前想好说法。

3. 手写 KNN 分类器:距离公式、k 值选取与投票逻辑

3.1 欧氏距离的向量化写法

KNN 的核心就一句话:找一个新样本在特征空间里最近的 k 个邻居,看它们多数是什么标签。距离度量默认用欧氏距离,公式是sqrt(sum((x1 - x2)^2))。如果按这个公式写双重循环,1024 维乘上几百个样本,Python 纯循环会慢到让你怀疑人生。正确做法是用 numpy 的广播机制一次性算完。

def classify(in_x, dataset, labels, k): """KNN 分类主函数""" # 1. 计算欧氏距离(向量化) diff = dataset - in_x # 广播:(N,1024) - (1,1024) sq_diff = diff ** 2 distances = np.sqrt(sq_diff.sum(axis=1)) # 按行求和再开方 # 2. 按距离升序取前 k 个索引 sorted_idx = distances.argsort() top_k_idx = sorted_idx[:k] # 3. 投票统计 vote_count = {} for idx in top_k_idx: vote_label = labels[idx] vote_count[vote_label] = vote_count.get(vote_label, 0) + 1 # 4. 返回票数最多的标签 sorted_votes = sorted(vote_count.items(), key=lambda x: x[1], reverse=True) return sorted_votes[0][0]

逻辑说明:dataset - in_x利用了 numpy 的广播,把(N,1024)的矩阵和(1,1024)的向量逐元素相减,得到 N 个样本各自的差值向量。sq_diff.sum(axis=1)沿特征维度求和,得到 N 个平方距离,再开方就是欧氏距离。argsort()返回的是索引而不是距离值,这点很关键,因为后面要用索引去labels里取标签。

参数说明:k是唯一需要调的参数,常见取值 3、5、7。k 太小对噪声敏感,k 太大又会把远处不相关的样本拉进来投票。这份资源里样本量不大,我一般先用 3 跑通流程,再试 5 看准确率变化。

3.2 k 值怎么选:一个可复现的对比实验

不要凭感觉定 k,写个循环把 k 从 1 到 10 都跑一遍,看测试准确率曲线。下面这段代码假设你已经把数据切成了训练集和测试集。

def evaluate_k(train_x, train_y, test_x, test_y, k_list): """遍历不同 k 值,输出准确率""" for k in k_list: correct = 0 for i in range(len(test_x)): pred = classify(test_x[i], train_x, train_y, k) if pred == test_y[i]: correct += 1 acc = correct / len(test_x) print(f"k={k}, accuracy={acc:.4f}")

跑完之后你会看到一条先升后降的曲线,峰值通常落在 3 到 5 之间。如果 k=1 准确率反而最高,别高兴太早,那说明测试集和训练集太像了,泛化能力存疑。答辩时被问到“为什么选这个 k”,你可以直接把这个对比表拿出来,比空口说“经验值”有说服力得多。

注意:每次跑classify都会重新计算全部距离,如果测试集有几百个样本,整体耗时会明显上升。课程设计演示时建议只跑 20 到 30 个测试样本,或者提前把距离矩阵缓存下来。

4. 避坑与排查:从文件读取到准确率异常的五个真实翻车点

4.1 现象:程序报IndexError: string index out of range

原因:某个 txt 文件的行长度不足 32,或者行尾有换行符导致实际字符数不对。常见于手动编辑过的样本文件,或者从 Windows 复制到 Linux 时换行符变成\r\n

解决:在img2vector里加一行line_str = line_str.strip(),并且在读取前用assert len(line_str) >= 32做校验。如果某个文件确实坏了,直接跳过并打印文件名,不要让它中断整个流程。

4.2 现象:准确率只有 10% 左右,跟随机猜差不多

原因:标签和特征对不上。要么是load_datasetfeatureslabels的追加顺序不一致,要么是文件名解析时split('_')拿到的不是第一位。比如文件名是5_71.txtsplit('_')[0]5,但如果文件名写成sample_5_71.txt,拿到的就是sample

解决:打印前 5 个样本的文件名和解析出的标签,肉眼核对。另外确认classify返回的是int而不是字符串,字符串比较'5' == 5永远是 False。

4.3 现象:np.array(features)之后形状变成(N,)而不是(N,1024)

原因:img2vector返回的是(1,1024)的二维数组,vect[0]取出来是(1024,)的一维数组,追加到列表再转 numpy 时,如果某个样本读取失败返回了空数组,整体形状就会塌掉。

解决:在load_dataset里加assert vect.shape == (1, 1024),读取失败直接continue。转 numpy 之后打印features.shape确认是二维。

4.4 现象:k 值调大后准确率反而下降得厉害

原因:样本类别分布不均衡。如果数字1的样本特别多,k 增大后邻居里1的票数天然占优,其他数字被淹没。

解决:要么对每个类别做欠采样,要么在投票时按距离加权——距离越近的邻居票数权重越大。加权投票改起来不难,把vote_count[vote_label] += 1改成+= 1 / (dist + 1e-5)即可,但要注意dist需要从distances里按索引取出来。

4.5 现象:在 PyCharm 里跑正常,命令行python KNN.py报找不到文件

原因:代码里用了相对路径'trainingDigits',而命令行的工作目录和 PyCharm 的项目根目录不一致。

解决:统一用os.path.dirname(os.path.abspath(__file__))拼绝对路径,或者把数据目录做成脚本参数传入。这是新手最容易忽略的环境问题,跟算法本身无关,但卡住的人最多。

5. 把准确率再往上推一点:距离加权与数据归一化的取舍

跑通基础版之后,如果你想让答辩时的数字好看一些,有两个方向可以试。第一个是距离加权投票,前面提过,把投票权重从 1 改成距离的倒数,让近邻说话更有分量。改完之后 k 可以适当取大一点,比如 7 或 9,因为远邻的权重已经被压得很低了。第二个是特征归一化,不过对于 0/1 二值矩阵来说,每个特征本身就在 [0,1] 区间,归一化收益不大,反而增加代码复杂度,课程设计里不推荐为了“显得高级”硬加。

def classify_weighted(in_x, dataset, labels, k): """距离加权版 KNN""" diff = dataset - in_x distances = np.sqrt((diff ** 2).sum(axis=1)) sorted_idx = distances.argsort()[:k] vote_count = {} for idx in sorted_idx: label = labels[idx] weight = 1.0 / (distances[idx] + 1e-5) vote_count[label] = vote_count.get(label, 0) + weight return max(vote_count.items(), key=lambda x: x[1])[0]

这段代码和基础版的区别只在投票环节,1e-5是防止距离为 0 时除零。实际跑下来,加权版在 k=7 时的准确率通常比基础版 k=3 高 1 到 2 个百分点,提升不算大,但答辩时多一个对比维度就多一分主动权。

还有一个容易被忽略的验证方法:把同一个数字的不同样本轮流当测试集,做留一交叉验证。虽然代码量比简单切分多几行,但能避免“运气好切到简单样本”的质疑。我一般会在README.md里补一句“支持留一法验证”,老师看到会觉得你想得比较周全。

从那以后我每次交课程设计前,都会先把数据目录用绝对路径跑一遍,再换一台电脑用相对路径跑一遍,确认没有环境依赖才敢打包。希望帮到你。

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

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

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

立即咨询