ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

手写数字识别Python课程设计:多元线性回归从训练到测试全流程

手写数字识别Python课程设计:多元线性回归从训练到测试全流程 简介手写数字识别是机器学习入门阶段的经典任务这份资源面向Python初学者及有课程设计需求的读者提供了一套涵盖模型训练、权重保存、图片测试与设计报告的完整方案。压缩包共16个文件、大小251KB核心包括两个Python脚本训练与识别测试、设计报告Word文档、多个手写数字BMP样例以及用于标签编码和模型权重的CSV文件结构清晰便于对照学习。资源以多元线性回归模型为基础将09数字识别建模为多分类问题可读取Windows画图绘制的28×28黑白数字图像由训练好的模型输出识别结果完整覆盖了从数据准备到模型应用的流程。当前已有4327人学习下载适合用于课程设计、实验复现或快速上手图像分类实践。配套的README与LICENSE文件也能帮助读者快速了解项目结构与使用许可。1. 手写数字识别一份能跑通训练与测试全流程的 Python 课程设计手写数字识别几乎是每个 Python 学习者绕不开的入门题输入一张手写的 0~9 数字图输出它到底是几。这份资源把课程设计需要的全套文件都备齐了训练脚本、测试脚本、10 张 28x28 的 bmp 样例、one-hot 标签 csv、训练好的权重 csv外加一份可以直接参考的设计报告。它选用的模型是多元线性回归把 784 个像素点当特征输出 10 类得分属于典型的多分类问题。对正在做 Python 课程设计、需要“理论能讲清、代码能跑通”的人来说这套资源的价值在于端到端完整从画图造数据到训练再到加载权重推理每一步都有对应文件。下面按实际拆箱流程过一遍把参数、格式和踩坑点都标出来。2. 数据准备与读取把 28x28 的 bmp 变成 784 维向量这一章先把“喂给模型的数据”这件事做扎实。手写数字识别的第一步不是建模型而是把图片文件转换为数值矩阵任何一步格式错了后面训练出来的权重全是废的。2.1 读取 bmp灰度、尺寸与归一化Windows 画图保存的 bmp 位图没有压缩28x28 的图在内存里就是 784 个灰度点。读取时最常用的工具是 Pillow配合 numpy 转成数组。样例图是黑底白字所以背景像素接近 0笔画像素接近 255。如果图片保存时带了颜色或透明通道直接 reshape 会产生 28x28x3 甚至 28x28x4 的维度线性模型根本接不住因此读取的第一步必须统一转灰度。from PIL import Image import numpy as np def load_image(path): # 转成 8 位灰度图去掉 RGB 通道 img Image.open(path).convert(L) # 统一缩放到 28x28防止画图时保存的尺寸有偏差 img img.resize((28, 28)) data np.array(img, dtypenp.float32) # 归一化到 0~1避免梯度计算时数值过大 data data / 255.0 # 拍平成 784 维行向量 return data.reshape(1, 784)这段代码有四个关键点。convert(L) 处理通道问题resize 处理尺寸偏差除以 255.0 做归一化reshape(1, 784) 是和后面权重矩阵做乘法的前提。这四个点里任何一个被漏掉后面都会以“维度对不上”或“loss 不下降”的形式报复你。注意不要直接用 cv2.imread 默认的 BGR 模式读图灰度图会读出三通道且数值含义容易搞混。课程设计场景下Pillow 一行 convert(L) 是最省事的做法。读进来之后我一般会先打印灰度分布再决定要不要继续。如果 data.min() 和 data.max() 分别是 0 和 255说明是标准的黑白图如果最大值不是 255说明画笔带了抗锯齿像素被压到了中间值这会影响后续训练。这个问题第 5 章会专门说。img Image.open(3.bmp).convert(L).resize((28, 28)) data np.array(img, dtypenp.float32) print(shape:, data.shape) print(灰度范围:, data.min(), data.max()) print(非零像素数:, np.sum(data 128))非零像素数能反映笔画粗细。手写数字“1”的非零像素可能只有几十个而“8”可能有一两百个。这个指标在排查“为什么 1 总被识别成 7”时很有用——如果 1 的笔画太斜模型学到的特征可能更接近 7。还要解释一下为什么必须固定 28x28。这个尺寸来自 MNIST 手写数字识别的标准也是线性模型输入维度的直接决定因素。784 维特征对线性模型来说不算小如果换成 56x56特征变成 3136 维权重矩阵变成 10x3136训练时间成倍增加而准确率并不会因为尺寸变大而明显提升。课程设计场景下28x28 是性价比最高的选择。2.2 标签的 one-hot 编码train_label_hotencoding.csv 的格式资源里的 train_label_hotencoding.csv 存的是 one-hot 编码的标签。什么是 one-hot简单说10 个类别就用 10 列表示当前样本属于第几类那一列就是 1其余列是 0。比如数字 3 的标签就是 [0,0,0,1,0,0,0,0,0,0]。为什么要用这种形式而不是直接存一个整数 3因为线性回归的输出是 10 个连续值我们希望这 10 个值分别去逼近“属于 0 的概率、属于 1 的概率……属于 9 的概率”。用整数当标签的话模型会错误地学习“3 比 2 大、9 比 8 大”这种顺序关系而 one-hot 完全避开了这个陷阱。import pandas as pd labels pd.read_csv(train_label_hotencoding.csv, headerNone) print(标签矩阵形状:, labels.shape) row labels.iloc[0].values print(第一行 one-hot 向量:, row) print(对应数字:, int(np.argmax(row)))读这个 csv 有两个易错点。第一headerNone 必须写因为文件里没有列名默认行为会把第一行当成表头标签直接少一行。第二读出来后逐行检查是否只有一个 1如果发现某行有两个 1说明数据本身有问题训练时模型会无所适从。提示训练脚本里通常会把标签矩阵转成 float32因为 numpy 默认读 csv 会得到整数或字符串类型而矩阵乘法和梯度更新都需要浮点数。我建议打开 csv 前先用文本编辑器看一眼前几行。如果第一行就是 0 1 0 0… 这样的编码说明没有表头如果第一行是 label0,label1,label2…那读取的时候就要用 header0。两种格式对应两种读取方式搞反了就是标签少一行或者多一列。2.3 批量组织训练集文件名、标签顺序与对齐把散落的 bmp 文件读进来再和标签一一对应这一步看似简单却是课程设计里最容易埋雷的地方。资源里的图片命名从 0.bmp 到 9.bmp刚好和标签的顺序一致直接按文件名循环即可。num_samples labels.shape[0] X np.zeros((num_samples, 784)) for i in range(num_samples): # 样例文件是 0.bmp ~ 9.bmp文件名即类别 X[i] load_image(f{i}.bmp) Y labels.values.astype(np.float32) print(X shape:, X.shape) # (10, 784) print(Y shape:, Y.shape) # (10, 10)这里有个容易被忽略的问题文件名顺序和标签顺序必须严格对齐。如果自己补了数据比如新增了 50 张图标签 csv 也得按同样的顺序排否则这张图标着“3”标签却是“5”模型会学到一套错误映射。我见过不止一个人在自己扩充数据后准确率反而暴跌最后发现是排序不一致。组织好 X 和 Y 之后训练集的规模问题也浮出水面。只有 10 张图模型能记住这 10 张图本身的特征但换了风格稍微不一样的手写体识别率会明显下降。课程设计里通常的做法是先用这 10 张保证流程跑通再自行补充几组不同笔画风格的数字图把样本量扩大到 50~100 张训练出来的模型才有实际用处。补充数据时不需要用复杂工具Windows 画图配合固定大小的画布就能完成关键是保持黑底白字和 28x28 这两个前提。还有一个小技巧目录里不要放其他无关的 bmp 文件。训练脚本如果按目录扫描图片多出来的图会被当成样本读进来导致 X 的行数和 Y 的行数不匹配报错信息还不直观。资源里图片直接放在根目录、按数字命名就是避免这个问题的典型做法。3. 多元线性回归做多分类训练脚本的核心逻辑与参数调整3.1 为什么用多元线性回归手写数字识别在业界已经有大量成熟方案CNN、PyTorch、TensorFlow 遍地都是。但这套资源选择的是最朴素的多元线性回归这个选型是有道理的它只涉及一次矩阵乘法和一次梯度更新所有公式都能在设计报告里手推出来答辩时不会卡壳。线性回归处理多分类的思路是给每个类别准备一组权重向量输入一张图的 784 个像素分别和 10 组权重做点积得到 10 个分数。分数最高的那个类别就是预测结果。数学上就是 y Wx bW 是 10x784 的矩阵x 是 784x1 的列向量y 是 10x1 的得分向量。网上很多教程提到 MNIST 手写数字识别都用 softmax 回归其实线性回归和 softmax 回归的骨架完全一样差别只在于损失函数和输出层是否做了指数归一化。用线性回归做课程设计的优势是损失曲线更直观梯度公式更简单调参时能看到“loss 降了、准确率升了”这个最朴素的因果关系。从训练流程上看这套资源也刻意保持了最小依赖。特征提取没有用卷积数据处理没有用深度学习框架代码里最核心的循环就是几行 numpy 矩阵运算。这对想搞懂原理的读者反而是优势每个变量的形状都能打印出来手动验算不存在“黑匣子”。3.2 初始化、学习率与迭代次数训练脚本中的权重初始化值得认真对待。权重 W 太大初始得分就大梯度容易爆炸W 全为零10 个类别输出相同梯度方向也相同模型学不出差异性。常见的做法是标准正态分布乘以 0.01。n_features 784 n_classes 10 W np.random.randn(n_classes, n_features) * 0.01 b np.zeros((n_classes, 1)) lr 0.001学习率 lr 是最需要调的超参数。lr0.001 稳定但偏慢适合 10 张图的演示lr0.01 收敛更快但在 loss 接近 0 时容易出现震荡lr0.1 基本告别收敛。特征经过归一化后输入范围是 0~1这时候 lr0.001 到 0.01 是合理的区间。参数推荐范围说明学习率 lr0.001~0.01偏大导致震荡偏小导致收敛慢迭代次数2000~10000样本少可适当减少主要看 loss 是否平稳权重初始化系数0.01系数过大初始得分和梯度都会偏大偏置 b0类别均衡时初始化为 0 即可迭代次数没有标准答案我判断收敛的方式是打印 loss如果连续几百次迭代 loss 变化小于 0.0001就说明模型已经学到了这个数据集上能学到的东西再加迭代只是浪费时间。3.3 训练循环前向、损失、反向训练循环是整套代码的心脏。每一步都在做同一件事拿当前权重去预测算出预测值和真实标签的差距再根据差距调整权重。n X.shape[0] for epoch in range(5000): # 前向计算所有样本的得分 # X 是 (10, 784)转置后与 W 相乘得到 (10, 10) y_pred W.dot(X.T) b loss np.mean((y_pred - Y.T) ** 2) # 反向梯度 2/n * 误差 * 输入 error y_pred - Y.T dW (2 / n) * error.dot(X) db (2 / n) * np.sum(error, axis1, keepdimsTrue) W - lr * dW b - lr * db if epoch % 500 0: print(fepoch {epoch}, loss {loss:.4f})这段代码的梯度推导可以这么理解均方误差对 W 的导数等于误差项乘以输入。error 是 (10, 10)X 是 (10, 784)两者做矩阵乘法后得到 (10, 784)正好和 W 同形能直接相减更新。db 则是把误差按行求和保留 10 个类别的维度。这里有个初学者容易绕晕的地方y_pred - Y.T 的形状是 (10, 10)代表 10 个样本在 10 个类别上的误差loss 是这些误差平方的均值一个标量而 dW 的每个元素都是 loss 对某个权重的偏导数。课程设计报告里写公式时把这三层关系交代清楚分数基本不会低。关键参数有两个。epoch 决定权重看过多少遍数据5000 次对 10 张图来说已经足够拟合跑出来的 loss 通常在 0.001 以下如果再想提升准确率优先考虑补数据而不是继续加迭代。打印频率 500 是为了观察收敛趋势如果 loss 在最后几百次迭代里不再变化说明已经收敛。注意如果 loss 打印出来是 nan先检查输入数据有没有除以 255.0。暗色背景下像素值直接当特征乘积很容易溢出。训练完成后还有一个必做动作在训练集上算一遍准确率。准确率不是 loss它表示预测对了多少张。如果训练集上准确率已经 100%但测试集上很差说明过拟合解决办法是加数据而不是改模型如果训练集上准确率就不高那问题出在特征或模型本身。y_pred_class np.argmax(W.dot(X.T) b, axis0) train_acc np.mean(y_pred_class np.argmax(Y.T, axis0)) print(ftrain accuracy: {train_acc:.2%})3.4 权重保存myweight1.csv 的写入与读取训练完的 W 必须落盘测试脚本才不用重新训练。资源里的 myweight1.csv 就是训练好的权重文件10 行 784 列。保存时最稳妥的方式是 pandas 的 to_csv但要严格控制参数。import pandas as pd pd.DataFrame(W).to_csv(myweight1.csv, indexFalse, headerNone) W pd.read_csv(myweight1.csv, headerNone).values print(加载后权重形状:, W.shape)indexFalse 是不写行号headerNone 是不写列名。这两个参数丢掉任何一个读出来的矩阵都会多一列或一行后面任何矩阵乘法都会报维度错误。这也是复现这套系统最高频的翻车点详细排查放在第 5 章。偏置 b 如果也想保存可以单独存一个文件或者追加到 csv 最后一行加载时再切分这里就不再单独演示了。读取后必须验证形状。如果是 (10, 784)说明格式正确如果是 (10, 785) 或 (11, 784)说明保存时带了索引或表头重新执行保存代码即可。这个验证动作只需要一行 print却能省掉后面所有的维度错误。4. 推理与测试加载权重、计算得分到输出预测4.1 加载权重和预处理输入图片训练是一个阶段识别是另一个阶段。测试脚本要做的事比训练少得多但每一步都依赖训练时留下的文件。先加载 myweight1.csv 的权重同时准备好偏置。import pandas as pd import numpy as np W pd.read_csv(myweight1.csv, headerNone).values b np.zeros((10, 1))这里有个现实问题资源里的训练脚本可能只保存了 W没有保存 b。如果训练时类别样本数均衡b 的作用确实不大近似为 0 也能接受但严谨的做法是把 b 一并持久化。我拆过不少类似资源几乎每个都有这个“只存权重不存偏置”的小瑕疵测试时自己补一个 b 就行不影响主流程。输入图片的预处理必须和训练时完全一致同样的灰度转换、同样的 resize、同样的归一化。测试脚本里如果漏了 resize用画图软件画一张 60x60 的图直接喂进来维度就炸了。所以我会在预处理函数末尾加一个形状断言。def load_input(path): img Image.open(path).convert(L).resize((28, 28)) data np.array(img, dtypenp.float32) / 255.0 data data.reshape(784, 1) # 注意这里转成列向量 assert data.shape (784, 1), f输入形状异常: {data.shape} return data训练时 load_image 返回的是 (1, 784) 的行向量这里 load_input 返回 (784, 1) 的列向量因为推理时要做 W.dot(x)W 是 (10, 784)x 必须是 (784, 1)结果才是一个 (10, 1) 的得分列向量。这个差别是初学者最常问的为什么训练和测试的向量方向不一样答案是矩阵乘法规则决定的reshape 一下就好。4.2 预测逻辑得分计算与 argmax推理就是一次前向传播。把图片列向量和权重矩阵相乘加上偏置得到 10 个分数取分数最高的下标作为预测类别。这一步没有反向传播没有梯度纯粹是矩阵乘法。def predict(path): x load_input(path) score W.dot(x) b pred int(np.argmax(score)) return pred, scoreargmax 的含义是“找到最大值的下标”。score 是 (10, 1)argmax 后得到 0~9 的整数正好对应数字类别。这里我特意把 score 也返回因为在调试时只看预测结果不够得分分布更能说明问题。如果最大得分和第二大得分只差 0.01说明模型在两类之间摇摆这个预测结果很可能换一张图就变了。判断模型是否可靠可以看得分差。写一个简单函数def predict_with_confidence(path): pred, score predict(path) sorted_scores np.sort(score[:, 0])[::-1] confidence_gap sorted_scores[0] - sorted_scores[1] return pred, confidence_gapconfidence_gap 越大模型对这次预测越有把握。如果 gap 小于 0.05我会认为这个结果“存疑”实际应用中应该给用户提示而不是直接当作正确答案展示。这也是真实产品里常说的置信度概念课程设计里能主动加上这一点属于明显的加分项。4.3 批量测试准确率统计与训练集测试资源里的 0.bmp 到 9.bmp 既是训练数据又是常用的测试数据。拿训练过的样本再测一遍得到的基本是 100% 准确率这只能证明模型“记住了”这些图不能证明它能识别没见过的手写体。for i in range(10): pred, score predict(f{i}.bmp) sorted_scores np.argsort(score[:, 0])[::-1] top2 sorted_scores[:2] print(f{i}.bmp 预测为 {pred}top2 类别{top2})这段代码把得分前两名也打印出来是为了观察类别间的竞争。4 和 9、3 和 8 是两对老冤家top2 里常出现它们的身影。如果发现某张图 top2 稳定出现错误的那个数字说明这类数字的笔画特征没有学好需要考虑补充该类别的训练样本。真正的可靠性测试应该用画图软件重新写几个数字另存为新的 bmp 文件再测。比如训练时用的是规整的 4手写时写得潦草一点看看模型还能不能认出来。这一步如果能过才说明模型学到了数字的结构特征而不是死记硬背了某张图的像素。提示画新测试图时保持和训练图一致的画布背景色。有的画图软件默认白色画布需要手动填充成黑色再写白色数字颜色不一致会直接触发第 5 章的 5.1 号坑。由于测试脚本只依赖权重文件和单张图片部署和使用都很轻量。我一般会把它改造成一个接收路径参数的函数方便在批量图片上跑准确率统计这也是第 6 章做混淆矩阵的基础。5. 避坑与常见问题复现时最容易翻车的五个点这一章是血泪经验。我拆课程设计资源时最怕的不是模型原理复杂而是文件格式和预处理上的小问题。下面这五个坑每条我都按“现象、原因、解决”记录下来照着排查能省下半天时间。5.1 预测结果全是同一个数字现象不管输入是 3.bmp 还是 7.bmp预测结果都固定输出 0 或 4几乎没有变化。原因最常见的是颜色反转。训练时用的是黑底白字测试时如果用画图默认的白底黑字像素分布整体翻转权重矩阵学到的“笔画处高亮”规律就失效了模型会偏向某一个类别。解决读取图片后检查灰度方向。如果是白底黑字做一次像素取反data 255.0 - data然后重新归一化到 0~1。判断依据很直接打印 data.min() 和 data.max()如果最大灰度值出现在背景区域就是反了。黑底白字图的最大值应该落在笔画区域白底黑字则相反。我习惯在读图后顺手打印一行说明能省掉后续所有“为什么预测全一样”的排查时间。5.2 训练 loss 不降或震荡现象打印出来的 loss 在 0.1 附近波动5000 次迭代后还是 0.09没有明显下降或者 loss 一开始就在不同值间乱跳。原因输入没有归一化像素值在 0~255 之间梯度更新步长被放大两个数量级权重在最优解附近反复横跳无法收敛。解决读图后立即除以 255.0把输入压到 0~1。如果已经归一化后 loss 依然震荡把学习率从 0.01 降到 0.001迭代次数增加到 5000。还有一种情况是权重初始化系数过大把 randn 的乘数从 0.01 改到 0.001 再试一次。提示判断收敛不只看 loss 绝对值要看趋势。最后 500 次迭代是否平稳比第一次 loss 多小更重要。5.3 权重 csv 加载后维度不对现象加载 myweight1.csv 后打印 W.shape 得到 (10, 785) 或 (11, 784)矩阵乘法和 load_image 的结果总报维度错误。原因保存权重时用了 pandas 默认参数写入了行号或列名读取时又没有对应处理。多出来的那列就是索引列。解决保存和读取的参数强制对称pd.DataFrame(W).to_csv(myweight1.csv, indexFalse, headerNone) W pd.read_csv(myweight1.csv, headerNone).values assert W.shape (10, 784), f权重形状异常: {W.shape}以后凡是看到权重 csv 加载后形状带小数或带偏移第一反应就是检查这两个参数。如果 csv 文件在公司电脑或者自己电脑之间来回拷贝还要注意 Excel 打开再保存会自作主张加上表头这种“被 Excel 污染”的 csv 也会触发同样的问题读取后同样用 assert 兜住。5.4 画图保存的图片是“灰”的现象用画图软件写数字保存的 bmp 看起来是黑白但读进来发现灰度值不是 0 和 255而是一堆 128、96 之类的中间值。原因Windows 画图的画笔有抗锯齿边缘笔画边缘的像素是半透明的灰度值被插值到了中间。模型训练时看到的是二值特征测试时却遇到连续灰度分布不一致导致识别率下降。解决读取后做一次二值化把 128 以上的像素置 1以下的置 0data np.where(data 128, 1.0, 0.0)阈值可以按图片实际灰度分布调我一般取 128对应 255 的一半。若图片偏暗就把阈值降到 100。二值化之后再除以 255 就没有意义了因为数据已经是 0 和 1。如果训练图也做了二值化测试图也必须做同样的操作保持分布一致。5.5 输入图片尺寸和通道对不上现象测试脚本报错 ValueError提示数组维度不匹配比如 expected 784 but got 900。原因画图时画布大小不是 28x28保存的图片是 30x30 或更大或者图片带透明通道PIL 读取后是四通道。解决在 load_image 里统一处理加上断言兜底img Image.open(path).convert(L).resize((28, 28)) data np.asarray(img, dtypenp.float32) / 255.0 data data.reshape(784) assert data.shape (784,), f图片 {path} 维度异常: {data.shape}这五个坑基本覆盖了课程设计复现中 80% 的问题。核心规律只有一条训练和测试的预处理必须完全一致。权重是训练时“惯出来”的测试时任何一点不同模型都会表现得很玄学。真遇到奇怪的结果不要急着改模型结构先回到数据读取这一段把灰度、尺寸、通道、标签形状四个变量全部打印一遍问题基本就显形了。6. 从能跑到跑好混淆矩阵验证与两个低成本改进6.1 用混淆矩阵判断模型瞎在哪手动看图测十次只能得到“对了几次”得不到“哪两个数字容易混”。混淆矩阵能直观显示第 i 行第 j 列表示真实类别 i 被预测成类别 j 的次数。对角线越大说明模型越可靠。from sklearn.metrics import confusion_matrix y_true list(range(10)) y_pred [predict(f{i}.bmp)[0] for i in range(10)] cm confusion_matrix(y_true, y_pred) print(cm)如果发现 4 和 9 互相混、3 和 8 互相混先别急着改模型大概率是这两类的训练样本太少或者笔画风格太接近。此时补几张风格差异大的图比调参更有效。6.2 把均方误差换成交叉熵多元线性回归配均方误差能跑通但梯度特性一般。一个低成本改进是把损失换成 softmax 交叉熵让输出变成真正的概率分布score W.dot(X.T) b exp_scores np.exp(score - np.max(score)) # 减去最大值防止溢出 probs exp_scores / np.sum(exp_scores, axis0, keepdimsTrue) loss -np.mean(np.sum(Y.T * np.log(probs 1e-8), axis0))随机减最大值是为了数值稳定这是 softmax 里最常见的防溢出做法。改完损失函数后预测逻辑完全不用动argmax 依然成立。loss 从均方误差变成交叉熵后收敛速度通常会更快因为梯度里多了一个概率误差的加权方向更准。6.3 用最笨的数据增强扩充训练集只有 10 张图时泛化能力有限。最简单的数据增强是把每张图的像素平移 1~2 个像素相当于生成更多偏移样本让模型见过更多“偏移”的手写体。shifted np.zeros_like(X) shifted[:, 1:] X[:, :-1] # 整体右移一个像素 X np.vstack([X, shifted]) Y np.vstack([Y, Y])平移后标签不变因为数字内容没有变。这样做出来的模型对写歪一点点的数字会鲁棒很多。从那以后我每次复现这类课程设计都强制先打印 W.shape、Y.shape 和每张图的灰度范围再进训练循环验证时也一定用混淆矩阵而不是凭感觉。这套习惯帮我避开了至少三次“训练集 100%、新图全错”的尴尬希望帮到你。本文还有配套的精品资源点击获取
返回列表