
简介基于SVM实现手写数字识别的Python课程设计资源面向计算机相关专业学生、教师及机器学习入门者提供完整源码、训练数据与测试数据可直接用于课程设计、毕业设计或项目练手。压缩包共2000个文件主体为1998个txt格式的手写数字特征样本配合viewdigits.py主程序与README.md说明文档整体仅828KB轻量易部署。项目代码已运行测试通过答辩评审平均96分功能完整文档说明覆盖环境配置与代码结构便于快速复现和二次开发。每个txt按“数字_编号”命名标签一目了然配合源码可观摩完整的SVM建模流程包括特征向量构建、模型训练与预测评估等关键环节。已有105人学习下载适合希望深入理解支持向量机在手写识别任务中应用的学习者参考。1. 手写数字识别用 SVM这门高分课设到底做对了什么手写数字识别很多人第一反应是上 CNN但这门课设偏偏用支持向量机SVM拿到了高分。SVM 在几十到几百张小样本上CPU 几秒就能训完模型是白盒答辩时能把决策边界、支持向量、核函数讲得明明白白这恰恰是深度学习给不了的。这份资源是一个完整的课程设计包Python 源码、README 说明、按数字类别拆好的 txt 训练/测试数据外加一个 viewdigits.py 可视化脚本。适合计科、人工智能、自动化等专业做课设或者毕设前期想快速验证 SVM 思路的同学。它解决的核心问题很简单拿到一堆散装的手写数字样本怎么批量读入、可视化、训练 SVM最后得到一个能跑通、能解释、能答辩的完整流程。2. 把 txt 数据吃透命名规则、像素结构与训练/测试划分2.1 从文件名反推数据集结构拿到资源先别急着跑代码第一件事是打开数据目录看文件命名。这份课设里的数据是5_71.txt、9_78.txt、0_24.txt这种格式规律非常直白下划线前的数字是类别标签下划线后是该类别下的样本编号。比如5_71.txt就是数字 5 的第 71 个样本9_78.txt就是数字 9 的第 78 个样本。从命名能推断出每类样本在几十到上百的量级整体数据规模在千级以内这正是 SVM 最舒服的样本区间。每个 txt 文件内部是 MNIST 风格的灰度像素数据。标准 MNIST 是 28×28 的灰度图像素值范围 0 到 255这份课设沿用了同样思路只是把批量格式拆成了单个文件。打开一个 txt常见的存储方式有两种一行 784 个数字或者 28 行每行 28 个数字。我在拆这种散装数据时会先打印前 50 个值看量级判断是 0-255 灰度值还是已经被归一化到 0-1 的小数。这个判断直接决定后面要不要做归一化先看一眼能省很多调试时间。读取的核心代码是 numpy 的 loadtxt但文件布局不同写法不同。我一般会写一个防御性的加载函数自动判断文件是单行还是多行import numpy as np def load_digit_file(filepath): # 先尝试直接读成一行失败再按矩阵读 data np.loadtxt(filepath) # 如果 data 是二维的说明是 28 行 x 28 列布局展平成一维 if data.ndim 2: data data.flatten() # 如果读出来是 784 或 785 个值784 是标准长度 if data.shape[0] ! 784: raise ValueError(f{filepath} 像素数不是 784实际是 {data.shape[0]}检查文件是否混入空行) return data这里np.loadtxt的返回值取决于 txt 内部布局单行 784 个数字读出是一维数组28×28 矩阵读出是二维数组。flatten()负责把二维压平统一交给后续处理。判断data.shape[0] ! 784很重要因为 txt 末尾多一个空行、或者某行少了一个数字读出的长度就会变成 785 或 783这种错误不显式抛异常的话后面训练时维度报错会非常难查。loadtxt 默认把换行当行分隔符多个空格当列分隔符所有像素都是数值类型时可以直接用但如果文件里混入了逗号或者[这种符号就得指定delimiter,或者做字符串清理。2.2 标签如何与特征对齐文件名里的标签不能靠人工一个个读要用 glob 批量提取。写一个构建数据集的主函数把目录下所有 txt 的路径、标签、特征矩阵一次性组装好import glob import os def build_dataset(data_dir): file_list glob.glob(os.path.join(data_dir, *.txt)) features, labels [], [] for fp in file_list: # 从文件名里解析标签如 5_71.txt - label 5 fname os.path.basename(fp) label int(fname.split(_)[0]) features.append(load_digit_file(fp)) labels.append(label) X np.array(features) y np.array(labels) print(f共加载 {len(file_list)} 个样本特征矩阵形状 {X.shape}标签分布:) for i in range(10): print(f 数字 {i}: {(y i).sum()} 个样本) return X, y注意os.path.basename先去掉路径只留文件名再用split(_)[0]取前缀转成 int这是从文件名取标签最稳的方式。print 标签分布这步不要省它能让你一眼看出数据是否覆盖了 0 到 9 所有类别。有的课设数据只给了部分数字的样本如果直接拿去训练模型对没见过的数字类别会完全失控。2.3 训练集与测试集划分的常见做法训练/测试划分是整个流程里最容易翻车的一步。典型错误是直接对全部数据train_test_split后开训完全忘记了随机种子。同样的代码这次跑 92 分下次跑 88 分答辩时老师一问就露馅。更严重的是如果数据文件顺序本身有规律——比如前面全是 0、1、2后面全是 7、8、9——不做 shuffle 会导致训练集和测试集分布不一致。稳妥的方案是固定 random_state 做分层划分保证每个数字类别在训练集和测试集中的比例一致from sklearn.model_selection import train_test_split X, y build_dataset(data) # test_size0.3 表示测试集占三成stratifyy 保证每个数字按比例进入两边 X_train, X_test, y_train, y_test train_test_split( X, y, test_size0.3, random_state42, stratifyy ) print(f训练集 {X_train.shape[0]} 个样本测试集 {X_test.shape[0]} 个样本)stratifyy是分层抽样参数y 里每个类别的比例会被保留。这个参数对小数据集是救命的如果某个数字总共只有 15 个样本不做分层随机切分训练集可能只剩 8 个测试集 7 个评估结果方差会非常大。random_state42就是固定随机种子保证每次切分结果一样答辩时复现不出偏差。这里在切分前不做 shuffle因为 train_test_split 内部会先打乱但要注意的是如果你的数据已经按类别排好序切好训练集和测试集就不要再用这个函数混到一起重分而是直接按目录拆。2.4 归一化SVM 的硬性前提SVM 对特征尺度极其敏感。如果像素值是 0-255 的灰度而某个意外列混入了 0-1 的数据模型会把更大数值范围的特征当成更重要。手写数字像素全部在 0-255 之间不存在个别特征量纲差异但直接把大数值丢进 RBF 核的 SVMgamma 计算时会因为数值范围过大导致距离计算失真。常见做法是除以 255.0 缩放到 [0, 1]这是手写数字场景的默认操作效果稳定且语义清晰——保留像素的相对亮度关系。# 如果像素范围是 0-255统一缩放到 0-1 X_train X_train / 255.0 X_test X_test / 255.0 # 验证一下范围 print(f归一化后像素范围: [{X_train.min():.2f}, {X_train.max():.2f}])注意测试集的缩放要用和训练集相同的除数不要在测试集上单独搞一套逻辑。有的同学在训练集上减均值除标准差测试集忘记做同样的变换导致推理时特征分布漂移准确率直接崩掉。这个资源的数据如果你打开发现已经是 0-1 的小数跳过这步就行但打印 min/max 确认一下是推荐的防御习惯。3. viewdigits.py 可视化把 txt 变成能看的图片3.1 这个脚本解决什么viewdigits.py 的名字就是 view digits职责是把散装的 txt 像素数组重新渲染成 28×28 的灰度图。它的价值比想象中大课设数据是从某个来源拆出来的你无法保证每个 txt 文件内容都完好。有的文件像素值全是 0有的图像旋转了 90 度有的边框噪声严重——这些通通只有可视化才能发现。训练前把增强集的样本打印成图片墙既能验证数据加载正确性也能在答辩 PPT 里当素材用。核心可视化代码并不复杂用 matplotlib 的imshow配合灰度 colormap 就能实现import matplotlib.pyplot as plt def view_single_digit(filepath, titleNone): data load_digit_file(filepath) # 784 维向量还原成 28x28 矩阵 image data.reshape(28, 28) plt.figure(figsize(4, 4)) # cmapgray 显示灰度vmin/vmax 锁定像素范围避免自动拉伸造成误判 plt.imshow(image, cmapgray, vmin0, vmax255) if title: plt.title(title) plt.axis(off) plt.show()reshape(28, 28)是把一维数组按行优先还原成矩阵顺序不能反否则图像会旋转 90 度。vmin0, vmax255这两个参数我每次都会显式写因为 imshow 默认会根据数据 min/max 自动拉伸对比度如果某个 txt 文件整体像素值偏低自动拉伸后图像会显得对比度异常让你误判为噪声数据。固定范围后所有文件的显示标准一致才能横向比较。3.2 多样本网格预览的写法单张预览适合排查单个文件但课设阶段你更需要的是一张图里同时看几十个样本的分布检查每个数字类别的书写风格差异和异常样本。用 subplots 铺网格def view_digit_grid(filepaths, cols5): rows int(np.ceil(len(filepaths) / cols)) fig, axes plt.subplots(rows, cols, figsize(cols * 2, rows * 2)) axes axes.flatten() for i, fp in enumerate(filepaths): data load_digit_file(fp) axes[i].imshow(data.reshape(28, 28), cmapgray, vmin0, vmax255) fname os.path.basename(fp) axes[i].set_title(fname.split(_)[0], fontsize10) axes[i].axis(off) # 多余的子图直接隐藏掉避免显示空白框 for j in range(len(filepaths), len(axes)): axes[j].axis(off) plt.tight_layout() plt.show()np.ceil向上取整算出需要的行数避免最后一个子图悬空。axes.flatten()把二维子图数组压平方便用单一索引循环。两个细节值得注意多余的子图要显式axis(off)不然会在网格末尾出现空白坐标框set_title只显示文件名里的类别数字不显示完整文件名否则图又密又乱。实际查看时建议每个数字类别随机抽 5-10 个样本混排重点看不同人书写风格下的相似度以及有没有方向倾斜特别离谱的样本。3.3 可视化暴露出来的数据问题我在拆解手写数字数据时被可视化坑过不止一次。最常见的问题是像素值范围异常有的 txt 文件里混入了 0.5 这种归一化后的中间值而其他文件还是 0-255 的整数。打印文件路径加 min/max 就能快速定位如果某个文件的最大值小于等于 1而其他文件最大值是 255说明这个文件经过另一套预处理标准训练前必须统一。这类问题不靠可视化光看数字很难发现因为 numpy 数组不会报错只会默默把错误数据喂给模型。另一个值得留意的现象是文件末尾的空行和异常字符。np.loadtxt遇到纯空行通常能跳过但我遇到过某份数据在一行像素中间插入了逗号分号loadtxt 直接抛出解析错误。报错未必是坏事最怕的是文件末尾多了一个和像素值同量级的数字比如 784 个像素后面跟了一个标签号 5导致样本维度变成 785。虽然加载不报错但 SVM 训练和预测时特征维度全部对不上。每次 load 完打印data.shape是成本最低的体检手段。4. SVM 训练与调参RBF 核、C/gamma 搜索和 96 分从哪来4.1 为什么是 RBF 核而不是线性核手写数字的类别边界在 784 维像素空间里是非线性的——同一个数字有不同的倾斜角度、笔画粗细、位置偏移线性超平面根本划不开。scikit-learn 里SVC默认就用 RBF 核这也是这个场景下最稳的选择。RBF 核把样本映射到无穷维空间在高维空间里找线性超平面本质是在原空间学一个非线性决策边界。这里有一个容易忽略的点784 维已经很高了很多人会直觉认为高维空间线性可分但实际上原始像素特征里充满了冗余和噪声直接线性分类效果远不如 RBF。RBF 核的两个核心参数是C和gammaC 控制误分类惩罚力度gamma 控制单个样本的影响半径。gamma 越大决策边界越弯曲越容易过拟合gamma 越小边界越平滑越容易欠拟合。from sklearn.svm import SVC # 先跑一个基线模型C1.0, gammascale 是 scikit-learn 的默认配置 model SVC(kernelrbf, C1.0, gammascale, random_state42) model.fit(X_train, y_train) train_acc model.score(X_train, y_train) test_acc model.score(X_test, y_test) print(f默认参数 - 训练集准确率 {train_acc:.4f}, 测试集准确率 {test_acc:.4f})gammascale是 sklearn 的自适应模式它会根据特征数量自动计算初始值公式是1 / (n_features * X.var())。对像素数据这种高维且方差分布不均衡的情况scale 模式比写死一个经验值更安全。random_state42让模型训练过程可复现。4.2 小数据量下 C 和 gamma 的手动搜索默认参数往往不够用尤其是这份课设数据量不大时默认的 C1.0 可能偏保守。我在调这种小样本 SVM 时会先手动跑几组组合感受一下数据对参数的敏感度再上网格搜索。手动搜索的好处是你能直观体会到 gamma 从 0.001 变到 0.01 时准确率的变化幅度这个手感对答辩时有帮助for gamma in [0.001, 0.01, 0.1]: for C in [1, 5, 10]: model SVC(kernelrbf, CC, gammagamma, random_state42) model.fit(X_train, y_train) acc model.score(X_test, y_test) print(fC{C:2d}, gamma{gamma:.3f} - 测试集准确率 {acc:.4f})如果某个 gamma 下训练集准确率接近 100% 而测试集明显低说明过拟合了gamma 要调小。如果两边都低说明模型欠拟合需要增大 C 或 gamma。手写数字场景的数据量不大这套循环跑完只需要几秒到十几秒完全来得及。4.3 GridSearchCV 网格搜索的正确姿势手动搜索能建立直觉但最终提交课设还是要有一个「我调过参」的证据。用GridSearchCV做交叉验证网格搜索比手动瞎试更有说服力from sklearn.model_selection import GridSearchCV param_grid { C: [0.5, 1, 5, 10], gamma: [0.001, 0.005, 0.01, 0.05], kernel: [rbf] } # cv5 表示五折交叉验证用交叉验证分数选参而不是单次划分 grid GridSearchCV( SVC(random_state42), param_grid, cv5, scoringaccuracy, n_jobs-1, verbose1 ) grid.fit(X_train, y_train) print(f最优参数: {grid.best_params_}) print(f最优交叉验证分数: {grid.best_score_:.4f}) print(f测试集分数: {grid.best_estimator_.score(X_test, y_test):.4f})cv5把训练数据切成 5 份轮流拿 4 份训练 1 份验证最终分数是 5 次验证的平均值。这个分数比单次 train_test_split 的分数稳定得多也更抗过拟合。n_jobs-1让网格搜索用满所有 CPU 核心在这个规模下几乎瞬间完成。注意参数网格不要贪大总共 4×4 个组合已经足够把 grid 设成 10 个值 × 10 个值的 100 组搜索小数据上很容易选出在验证集上碰巧好的参数这就是过拟合到了验证集上。4.4 评估指标准确率之外还要看混淆矩阵课设答辩中90% 的人只报一个准确率但你如果能在 PPT 里放一张混淆矩阵说明你能看到模型在哪类数字上容易混淆这属于加分项。手写数字里最常见的混淆是 4 和 9、3 和 8、7 和 9因为书写风格确实相近。用 confusion_matrix 和 classification_report 做细粒度评估from sklearn.metrics import classification_report, confusion_matrix y_pred grid.best_estimator_.predict(X_test) # classification_report 输出每个类别的精确率、召回率、F1 print(classification_report(y_test, y_pred, digits3)) cm confusion_matrix(y_test, y_pred) # 找出混淆最严重的类别对非对角线上的较大值 for i in range(10): for j in range(10): if i ! j and cm[i, j] cm.max() * 0.1: print(f数字 {i} 被误判为 {j}: {cm[i, j]} 个样本)classification_report里每个类别的 support 列是样本数如果某个类别的样本特别少它的精确率会很不稳定。如果发现测试集上某个数字类别准确率特别低先别急着调参看下这个类别的样本数——如果只有 5 个测试样本错 1 个就掉了 20 个百分点这是统计数据太少不代表模型有问题。混淆矩阵能帮你区分「模型真的分不清」和「样本太少导致方差大」这两种情况。4.5 96 分的课设分数到底怎么解读资源摘要里提到答辩平均分 96 分这里要说清楚这是课程设计的评审分不是模型在测试集上的准确率。模型能到 95% 以上的准确率当然很重要——毕竟这是硬指标——但课设高分的关键在于整体完整性数据加载是否健壮、可视化是否清晰、训练流程是否规范、调参是否有依据、答辩时能不能讲清楚 SVM 的原理和参数影响。把前面这些步骤全部走完哪怕最终准确率 93%也比你只给一个 97% 的准确率但没有过程解释拿到的分数高。5. 课设实战避坑常见问题与排查记录5.1 训练集准确率 100%测试集却很低现象SVM 在训练集上准确率达到 99% 甚至 100%但测试集只有 70% 出头差距巨大。原因过拟合通常是 gamma 设置过大导致决策边界过度弯曲模型把训练样本的噪声也学进去了。另一个常见诱因是数据没做归一化特征尺度范围不同导致 RBF 核距离计算失真。也有可能 train_test_split 没设 random_state偶然的划分产生分布偏差。解决先回去打印像素 min/max 确认归一化。再把 gamma 从 0.1 往 0.001 方向调看测试集准确率是否回升。最后固定 random_state用交叉验证分数代替单次划分分数。5.2 加载 txt 时维度报错或者 shape 是 785现象load_digit_file读出的数组长度不是 784训练时 X_train 和 y_train 的长度对不上或者 SVM 报维度不一致错误。原因txt 文件尾行混入了空行导致 loadtxt 多读出一行或者某行用逗号分隔而代码默认空格分隔导致把一行内容拆成了多行也可能某份文件末尾混入了标签之类的额外数字。解决在 load 函数里加 shape 校验不等于 784 直接抛异常。然后单独把报错文件名打印出来手动打开看最后几行。如果是空行问题指定 loadtxt 的ndmin1再配合行数检查如果是分隔符问题先读成字符串做替换。5.3 图像显示全黑或者全白现象imshow 出来的图像完全看不出数字形状要么全黑要么全白要么对比度异常。原因像素值范围搞错。如果数据已经是 0-1 归一化用 vmin0 vmax255 显示时所有值都接近 0看起来全黑。反过来0-255 的数据用 vmin0 vmax1 显示时所有像素都饱和看起来全白。也有可能是 reshape 顺序错了图像内容发生了转置看起来像噪声。解决先打印data.min()和data.max()判断真实范围再用对应的 vmin/vmax 显示。reshape 出错时尝试data.reshape(28, 28).T对比一下方向。我习惯在可视化函数里加一个像素范围探测器自动选择显示区间。5.4 网格搜索选出的参数在测试集上却不高现象GridSearchCV 找到的最优参数交叉验证分数很高但在固定测试集上准确率反而低于某个手动尝试的参数。原因网格搜索的交叉验证是在训练集内部划分的代表的是训练数据上的泛化能力而固定测试集是另一批数据。当数据量很小时交叉验证的方差会比较大选出的参数可能只是碰巧在验证折上表现好。另外网格粒度太粗也容易错过真正的最优区域。解决先加大网格密度在最优参数周围做二次小范围搜索。同时报告交叉验证分数和测试集分数两个指标如果两者差距过大考虑减少 C 值防止过拟合。答辩时如实说明这个差距反而能体现你对模型泛化能力的理解。5.5 答辩被问「为什么不用深度学习」答不上现象模型效果不错但老师一问选型理由就卡壳。原因只知道 SVM 能跑通没准备它和深度学习的对比论据。在小样本场景下硬上 CNN数据量根本不够反而容易过拟合SVM 有凸优化保证全局最优解释性强RBF 核自带非线性能力CNN 需要自己逐层设计结构。课设场景里 SVM 的工程成本远低于深度学习。解决准备一张小表列出样本量、训练时间、硬件要求、可解释性四列的对比。虽然这不算技术错误但对课设分数影响很大。我见过太多代码全对但讲不清原理的同学最后分数反而不如代码一般但讲得透彻的人。6. 把数据重切成自己的训练/测试集稳定性和可复现性的检验拿到这批 txt 数据后我建议你先别直接用原来的划分跑完就交。更值钱的一步是写一个通用的数据加载与切分流程再跑 3 次不同随机种子的评估看结果的方差。这能在答辩时回答「你这个模型的精度稳不稳定」这个灵魂问题也能暴露数据划分的偶然性问题。from sklearn.model_selection import cross_val_score from sklearn.svm import SVC def load_and_split(seed): X, y build_dataset(data) X X / 255.0 X_train, X_test, y_train, y_test train_test_split( X, y, test_size0.3, random_stateseed, stratifyy ) return X_train, X_test, y_train, y_test, X, y # 三次不同随机种子观察测试集准确率的波动范围 for seed in [0, 42, 2024]: X_train, X_test, y_train, y_test, X, y load_and_split(seed) model SVC(kernelrbf, C5, gamma0.01, random_state42) model.fit(X_train, y_train) acc model.score(X_test, y_test) print(fseed{seed:5d} - 测试集准确率 {acc:.4f})这段代码同时展示了两个稳定性的验证手段多个随机种子下的测试集分数以及用cross_val_score对全数据进行交叉验证。如果种子从 0 变到 42 时准确率从 92% 掉到 84%说明数据集太小模型性能方差很大你需要调低 test_size或者报告交叉验证分数而非单次准确率。我还习惯把加载和切分包成独立的函数这会让后续复现和改参数变得干净利落不会为了换一个随机种子把整个训练流程搬到新的文件里。有一次我图省事直接用交付方给的固定划分训练完就提交结果实现发现那个划分里测试集恰好集中了某几种书写风格极端的样本换一份划分准确率就差了近 10 个百分点。从那以后我拿到任何手写数字的散装数据集都会强制自己先写一个带随机种子的加载、切分、评估三步连通的脚本把多个种子下的准确率范围打印出来确认模型性能稳定再继续往下做。这个习惯帮我避开了很多隐蔽的偶然性问题。这份资源里的源码、txt 数据和文档足够你按照上面的流程完整走一遍希望帮到你。本文还有配套的精品资源点击获取