多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

CNN-MNIST手写识别:一份能看透卷积神经网络内部结构的Matlab实现

CNN-MNIST手写识别:一份能看透卷积神经网络内部结构的Matlab实现 简介这是一套面向深度学习初学者的CNN-MNIST手写数字识别MATLAB实现与原理讲解资料以MNIST经典数据集为载体演示卷积、池化、全连接等核心操作如何完成图像分类适合图像分类、智能信号处理、模式识别等方向的课程实验与自学进阶。资源包共16个文件整体约12.42MB包含11个m脚本、1个mat模型、1个PDF文档和MNIST原始图像/标签文件idx格式。其中loadMNISTImages.m、loadMNISTLabels.m负责数据读取Conv.m、Pool.m、ReLU.m、Softmax.m分别实现卷积、池化、激活与分类TestMnistConv.m用于测试MnistConv.mat保存已训练好的模型PDF系统梳理CNN原理、网络结构与训练流程便于结合代码逐行理解。已有641人学习下载。读者可对照文档从数据预处理、模型构建、训练评估走到测试验证掌握端到端实现思路也可基于现成权重继续调参或二次开发用于课程设计、毕业设计或入门巩固。1. CNN-MNIST 手写识别程序一份能把 CNN 内部结构看透的 Matlab 实现手写数字识别这几年被讲得很多但真正把 CNN 每一层都摊开、能一行行跟代码走的 Matlab 实现反而少见。这套 CNN-MNIST 手写识别程序做的就是这件事它不依赖 Matlab 深度学习工具箱的封装而是从图像加载、卷积、池化、ReLU、Softmax 到反向传播全部用原生脚本手写实现附带一份讲 CNN 识别原理的 PDF。适合两类人一是刚接触卷积神经网络、想知道池化层反向传播到底怎么传的学生二是做智能信号处理或图像分类、需要一套能改参数、能完全复现的基准代码的从业者。我拿到包之后没有急着跑而是先按文件清单把每个 .m 文件对应到网络的一层确认它确实是手工实现而不是工具箱的包装这一步决定了后面所有排查思路的方向。2. 拆开资源包文件清单就是 CNN 的骨架解压之后你会看到 12 个 .m 文件、一个 .mat 参数文件、一份 PDF。命名方式明显是课程或自研项目的风格不是让人摸不着头脑的 toolbox 封装。把这套文件按职责归一下类网络结构就自己浮出来了。2.1 文件清单与职责文件类型在 CNN 里扮演的角色Conv.m卷积层二维卷积的前向传播Pool.m池化层2×2 最大池化记录最大值位置ReLU.m激活层非线性激活负数截断为 0MNIST Softmax.m输出层10 类概率输出MnistConv.m主网络前向与反向传播的串联入口loadMNISTImages.m数据加载读取图像二进制文件loadMNISTLabels.m数据加载读取标签二进制文件TestMnistConv.m测试入口加载训练好的参数并计算准确率display_network.m可视化把卷积核排成网格显示PlotFeatures.m可视化绘制样本在各层的特征图rng.m工具固定随机种子保证实验可复现MnistConv.mat参数文件存放训练完成的权重与偏置这种按层拆文件的写法有个很实在的好处每一层都能单独调出来喂一个假输入验证输出维度对不对。我拿到包时就是这么干的——先单独跑 Conv.m输入一个 28×28 的随机矩阵看输出是不是 24×24再去跑 Pool.m看是不是 12×12。单独验证每一层比整体跑通了之后再回头查维度要省事得多。2.2 经典的两层卷积结构这套程序采用的是 MNIST 任务上最经典的两层卷积配置参数如下层操作输入尺寸输出尺寸第 1 层卷积 5×520 个滤波器28×28×124×24×20第 2 层ReLU 2×2 最大池化24×24×2012×12×20第 3 层卷积 5×520 个滤波器12×12×208×8×20第 4 层ReLU 2×2 最大池化8×8×204×4×20第 5 层展平 全连接4×4×2032010输出Softmax1010 类概率为什么用两层卷积而不是一层MNIST 是 28×28 的小灰度图第一层卷积只能提取边缘和角点这类低级特征第二层卷积才能把低级特征组合成笔画和局部形状。单层卷积的准确率一般卡在 98% 出头加上第二层能推到 99% 以上。滤波器数量用 20 是课程代码里的常见保守选择想提升精度可以改成 32 或 64但训练时间会相应增加。注意一个细节这个网络没有在卷积层做填充所以每经过一次 5×5 卷积尺寸就减 4。28 → 24 → 12 → 8 → 4 的链路后半段全靠 2×2 池化把尺寸压下来。如果你改成 3×3 卷积核尺寸变化会不一样反向传播里的梯度维度也要跟着核对。2.3 MnistConv.mat 与 PDF 的定位MnistConv.mat 是已经训练完成的模型参数包含各层卷积核、偏置和全连接权重。你可以直接用 TestMnistConv.m 加载它跑测试集跳过训练过程先看到结果再决定要不要自己从头训。rng.m 的存在很关键它在训练前固定随机种子——如果不固定每次跑出来的准确率都会因为初始化不同而漂移你很难判断改动是来自参数调整还是随机波动。PDF 文档讲的是 CNN 原理和 MNIST 实现的理论背景。它和代码不是孤立的两份东西PDF 里讲卷积核怎么扫描图像、池化怎么降采样代码里对应的就是 Conv.m 和 Pool.m。建议先花半小时过一遍 PDF再对照代码看比只看代码容易理解得多。3. 数据加载与预处理MNIST 的二进制格式是第一个坑MNIST 官方数据不是图片文件而是四个二进制文件。很多人在这一步就翻车了因为直接fopen之后用fread读出来的数据是乱的。这一章把加载逻辑拆开讲清楚。3.1 MNIST 原始格式与字节序MNIST 的四个文件分别是训练图像、训练标签、测试图像、测试标签。每个文件头部都是几个 32 位整数而且是大端字节序存储文件magic number头部之后的内容train-images-idx3-ubyte205160000 张 28×28 图像train-labels-idx1-ubyte204960000 个标签t10k-images-idx3-ubyte205110000 张 28×28 图像t10k-labels-idx1-ubyte204910000 个标签大端是第一个坑。Matlab 的fread默认按本机字节序读取在 x86 机器上是小端直接读会把头部四个整数读成完全不同的数值。所以加载时必须显式指定ieee-be。loadMNISTImages.m 里处理的就是这件事。3.2 loadMNISTImages.m图像读取与归一化function images loadMNISTImages(filename) fid fopen(filename, rb); % 二进制只读 magic fread(fid, 1, int32, 0, ieee-be); % 大端读 magic number nImages fread(fid, 1, int32, 0, ieee-be);% 图像总数 nRows fread(fid, 1, int32, 0, ieee-be); % 行数固定 28 nCols fread(fid, 1, int32, 0, ieee-be); % 列数固定 28 data fread(fid, inf, uint8); % 剩余全部像素 fclose(fid); images reshape(data, nCols, nRows, nImages); % 先按列填 images permute(images, [2 1 3]); % 转成正常图像方向 images images / 255; % 归一化到 [0,1] end这段代码里有三个必须说明的点。第一ieee-be指定了大端字节序这是读对头部的保证。第二MNIST 原始数据按行优先存储而 Matlab 的reshape是列优先所以先按[nCols, nRows]填充再permute交换前两维。如果漏掉这一步读出来的数字是横着躺的。第三像素值从 0 到 255 归一化到 0 到 1。CNN 对输入尺度敏感不归一化会导致梯度更新不稳定这也是训练不收敛的常见原因。3.3 loadMNISTLabels.m标签读取与输出层对齐function labels loadMNISTLabels(filename) fid fopen(filename, rb); magic fread(fid, 1, int32, 0, ieee-be); nLabels fread(fid, 1, int32, 0, ieee-be); labels fread(fid, inf, uint8); fclose(fid); end标签文件读出来是 0 到 9 的整数直接对应 Softmax 输出的 10 个维度。这里不需要做 one-hot 编码因为 MnistConv.m 里会用标签索引去取 Softmax 输出的对应维度来计算损失。有一点要注意MNIST 标签范围是 0 到 9不是 1 到 10。如果你在训练循环里用labels - 1或者labels 1对齐索引先确认数据范围否则损失函数会直接报索引越界。4. 核心实现从 Conv 到 MnistConv 的每一层这一章是资源包的重头戏。手写 CNN 的价值不在于跑多快而在于每一行代码都能和原理对上。下面按前向传播的顺序把每个核心文件拆开讲。4.1 Conv.m多通道卷积的前向写法function y Conv(x, W) % x: 输入图像或特征图, W: 卷积核 % 步长1, 无填充, 输出尺寸 输入尺寸 - 卷积核尺寸 1 [~, ~, c] size(x); % 输入通道数 [r, s, ~] size(W); % 卷积核的行列数 m size(x, 1) - r 1; % 输出行数 n size(x, 2) - s 1; % 输出列数 y zeros(m, n, c); % 预分配输出 for i 1:m for j 1:n % 取 r×s 窗口与卷积核逐元素相乘并累加 y(i, j, :) sum(sum(x(i:ir-1, j:js-1, :) .* W, 1), 2); end end end这段代码用双重循环实现了滑窗卷积。.*是逐元素乘法sum(..., 1)沿行方向求和sum(..., 2)沿列方向求和两次求和就把一个窗口内的所有乘积压成了一个数。循环次数是输出尺寸的乘积在 28×28 的小图上完全够用到了大图就会慢这是手写实现的正常代价。为什么不直接用 Matlab 的conv2因为conv2只支持单通道而第二层卷积的输入是 20 通道的特征图需要每个通道分别卷积再累加。手写循环加上第三维索引逻辑上更直观也方便后面反向传播时对齐梯度位置。4.2 Pool.m 与 ReLU.m最大池化必须记住位置function [y, idx] Pool(x) % 2×2 最大池化, 步长2, 不重叠 [m, n, c] size(x); y zeros(m/2, n/2, c); idx cell(m/2, n/2, c); % 记录每个池化窗口最大值的位置 for i 1:2:m for j 1:2:n for k 1:c block x(i:i1, j:j1, k); % 取 2×2 块 [~, p] max(block(:)); % 最大值及线性索引 y((i1)/2, (j1)/2, k) block(p); % 把线性索引还原成行列坐标, 存给反向传播用 idx{(i1)/2, (j1)/2, k} [i mod(p-1,2), j floor((p-1)/2)]; end end end endPool.m 里最关键的是idx这个输出。最大池化在前向传播时只保留每个 2×2 块里的最大值但反向传播时梯度必须放回原来的最大值位置其他三个位置补零。如果不记录位置反向传播就只能把梯度平均分给四个位置梯度就被稀释了准确率会明显下降。这是手写 CNN 和工具箱实现最大的区别之一工具箱替你把这些记好了手写代码里必须自己管理。ReLU.m 更简单就是对输入逐元素做max(0, x)。它对卷积层输出的负值截断给网络引入非线性。没有 ReLU两层卷积叠加起来仍然是一个线性变换网络表达能力会大打折扣。4.3 MNIST Softmax.m数值稳定的概率输出function p Softmax(y) % y: 10×1 的得分向量 e exp(y - max(y)); % 先减最大值, 防止 exp 溢出 p e / sum(e); % 归一化成概率分布 endSoftmax 的作用是把全连接层输出的 10 个实数得分转换成 10 个和为 1 的概率。y - max(y)这一步在只有 10 类的时候作用不明显但如果你把网络改大到几百类得分很容易跑到几十甚至上百直接exp(y)会溢出成 Inf整个训练直接 NaN。养成先减最大值的习惯属于血泪经验。4.4 MnistConv.m前向与反向的串联MnistConv.m 把前面所有层串起来。前向流程如下function P MnistConvForward(W, B, X) % W{1}, B{1}: 第一层卷积权重与偏置 % W{2}, B{2}: 第二层卷积权重与偏置 % W{3}, B{3}: 全连接层权重与偏置 conv1 Conv(X, W{1}) B{1}; % 28×28 → 24×24 relu1 ReLU(conv1); [pool1, ~] Pool(relu1); % 24×24 → 12×12 conv2 Conv(pool1, W{2}) B{2}; % 12×12 → 8×8 relu2 ReLU(conv2); [pool2, ~] Pool(relu2); % 8×8 → 4×4 f pool2(:); % 展平成 320×1 z W{3} * f B{3}; % 全连接, 得到 10×1 P Softmax(z); % 输出概率 end每一步的尺寸变化都在注释里标明了跑之前先对着注释核一遍能避免大量维度报错。反向传播是这套代码里最需要耐心看的部分我这里只强调池化层反传function dx PoolBackward(dy, idx, x) % dy: 上游传来的梯度, 尺寸与池化输出相同 % idx: 前向时记录的最大值位置 [m, n, c] size(x); dx zeros(m, n, c); for i 1:2:m for j 1:2:n for k 1:c p idx{(i1)/2, (j1)/2, k}; dx(p(1), p(2), k) dy((i1)/2, (j1)/2, k); end end end end卷积层反向传播的核心是把卷积核旋转 180 度后与上游梯度做卷积这一步很多初学者会漏掉翻转导致梯度方向错误、训练不收敛。如果你拿到代码后想自己改结构先把这两个反向传播点对照原理图想清楚再动手改。4.5 训练循环与超参数训练入口的主循环大致如下% 超参数: 学习率 eta 0.05, mini-batch 大小 20, 训练轮数 3 eta 0.05; batchSize 20; epochs 3; rng(0); % 固定随机种子, 保证可复现 for epoch 1:epochs for step 1:60000/batchSize idx (step-1)*batchSize (1:batchSize); X images(:, :, :, idx); % 取一个 batch D labels(idx); % 对应标签 [P, W, B] MnistConv(W, B, X, D); % 前向 反向 权重更新 end % 每轮结束后在验证集上评估一次 end学习率 0.05 是这套代码比较稳妥的起步值。如果损失函数震荡降到 0.01如果收敛太慢可以试着提到 0.1但要注意观察有没有 NaN。mini-batch 取 20 是内存和时间之间的折中机器内存够可以调到 100训练会更快但收敛稳定性略降。训练轮数 3 轮在 MNIST 上已经能看到不错的效果想逼近 99% 需要更多轮次配合学习率衰减。提示训练前先固定随机种子。不固定的话每次跑出来的准确率都会因为初始化不同而波动你很难判断改动究竟来自参数还是运气。5. 训练与避坑Matlab 跑 CNN 常见的五个坑这一章把我在复现这类手写 CNN 时遇到过的实际问题整理出来。每一条都是现象、原因、解决三步讲清楚你照着排查能省很多时间。5.1 训练入口怎么组织资源包里的 MnistConv.m 同时承担前向、反向和权重更新TestMnistConv.m 负责加载 MnistConv.mat 或调用训练完毕后的权重做测试。我建议你第一次运行时先直接加载 MnistConv.mat 跑测试确认环境没问题再从头训练。从头训练前先确认数据加载的尺寸size(images)应该是 28×28×1×60000size(labels)应该是 60000×1。尺寸不对后面所有层的维度全部对不上。5.2 避坑记录现象 1loadMNISTImages 读出来的图像方向不对数字像是躺着的。原因MNIST 按行优先存储像素Matlab 的 reshape 按列优先填充直接 reshape 会把图像转置 90 度。 解决先按[nCols, nRows]reshape再permute(images, [2 1 3])交换前两维。这一步是固定写法所有 MNIST 加载代码都这么处理。现象 2跑 Conv.m 报错 “Matrix dimensions must agree” 或输出尺寸和预期不符。原因输入没有 reshape 成 28×28×1还是 784×1 的列向量或者卷积核的第三个维度与输入通道数不匹配。 解决在训练循环里显式把单张图reshape(28, 28, 1)。检查size(W{1})是否为 5×5×1第二层则为 5×5×20通道数必须等于上一层输出的通道数。现象 3准确率卡在 90% 上下上不去 99%。原因池化层反向传播写错了最典型的是把梯度平均分给 2×2 块的四个位置而不是只给最大值位置。 解决前向时用idx记录最大值坐标反向时按idx回填梯度。这也是我反复强调 Pool.m 里那几行idx代码的原因。你可以单独写个 4×4 的输入手动算一遍 Pool 和 PoolBackward验证梯度位置对不对。现象 4训练到一半 loss 变成 NaN或者一开始就是 NaN。原因Softmax 里exp溢出或者学习率太大导致梯度爆炸。 解决Softmax 先exp(y - max(y))。学习率从 0.05 降到 0.01 或 0.005。另外检查标签是否混入了 10MNIST 标签只有 0 到 9出现 10 会在取索引时报错或者算出错误损失。现象 560000 张全量训练太慢跑一轮要十几分钟。原因双层 for 循环的卷积实现在 Matlab 里本来就慢加上全量 60000 张逐个 mini-batch 循环时间自然可观。 解决先用前 1000 张图做冒烟测试确认代码逻辑没问题再跑全量。单张图卷积如果性能吃紧可以单独把 Conv.m 的循环用conv2替代——但要注意conv2需要先把卷积核旋转 180 度否则卷积方向就反了。注意改代码前先备份一份原始版本。手写 CNN 的每一层都是耦合的你动了 Conv.m 的输出尺寸Pool.m 和后面的全连接维度全要跟着改。6. 验证与可视化把训练结果落到数字和特征图上训练完成不是终点你得让模型在测试集上给出一个可信的准确率并且用可视化确认网络学到的不是噪声。6.1 用 TestMnistConv 算测试准确率TestMnistConv.m 的流程是加载 MnistConv.mat遍历 10000 张测试图像对每张图做一次前向传播取 Softmax 输出中概率最大的类别作为预测结果再和真实标签对比。% 加载训练好的参数 load(MnistConv.mat, W, B); % 对测试集逐张预测并统计准确率 correct 0; for i 1:10000 X testImages(:, :, :, i); P MnistConvForward(W, B, X); % 前向 [~, pred] max(P); % 概率最大的类别 if pred - 1 testLabels(i); % 注意标签从 0 开始 correct correct 1; end end accuracy correct / 10000;注意pred - 1这个细节。Matlab 索引从 1 开始Softmax 输出的第 1 维对应数字 0第 2 维对应数字 1以此类推。直接拿pred和标签比较会全部错位。这类索引偏移错误很隐蔽但会直接把准确率打到 10% 左右——因为相当于全部预测结果偏移了一位。6.2 用 display_network 和 PlotFeatures 看网络学到了什么准确率只是一个数字特征可视化能告诉你网络到底在提取什么。display_network.m 可以把第一层的 20 个 5×5 卷积核排成网格打印出来。训练正常的网络这 20 个小方块里能看到类似边缘、笔画、角点的明暗图案。如果看起来全是均匀噪声说明训练没收敛或者初始化出了问题。PlotFeatures.m 则是挑几张测试图像把图像经过每一层后的中间输出画出来。28×28 的输入经过第一层卷积变成 24×24×20你可以选几个通道看有的通道对竖线响应强有的对横线响应强有的对角点响应强。到第二层卷积特征开始组合成更复杂的局部形状。看到这个变化过程比单纯盯着准确率数字更能理解 CNN 为什么有效。6.3 我的验证习惯这次复现给我留下的一个习惯是拿到任何手写 CNN 代码包强制走一遍固定流程——先单独验证每一层的前向输出维度再小批量训练做冒烟测试中间穿插可视化权重最后才跑全量训练。特别是可视化权重这一步它能在准确率还没出来之前就暴露训练异常。有一次我改了两层卷积之间的通道数准确率看起来正常但 PlotFeatures 画出来的特征图明显比之前稀疏一查果然是池化步长写错了梯度回传的位置整体偏移。从那以后我每次训练完都强制看一眼 display_network 的输出确认卷积核是结构化的图案而不是噪声再谈准确率。这套流程花不了五分钟但能省下反复全量训练的几小时。希望帮到你。本文还有配套的精品资源点击获取
返回列表