简介:面向计算机视觉初学者与Matlab深度学习用户,这份压缩包提供完整的CNN手写数字识别实例。内容涵盖基于LeNet架构的Matlab实现,从卷积层、池化层、全连接层到ReLU激活函数的逐层配置,并完整演示MNIST数据集的导入、训练、评估与测试流程,可帮助理解特征提取与分类的完整原理。资源共2000个文件,其中1991个BMP格式0~9手写数字样本用于训练与测试,8个M脚本分别承担网络结构搭建、超参数设置、模型训练及准确率验证等功能,另有1个TXT记录训练损失变化与使用说明,整体约11.36MB。已有160人学习,可支撑课程设计、毕业设计或深度学习的动手入门,尤其适合希望快速掌握Matlab神经网络工具箱、以图像识别任务为起点的读者。
1. CNN卷积神经网络Matlab实现:先别急着解压,想清楚你要的是代码还是效果
“CNN卷积神经网络Matlab实现”这行字,大概是所有想用Matlab入门深度学习的人最熟悉的搜索词。网上下到的同类zip包,结构往往高度一致:一个train.m、一个forward.m、一个backward.m,外加若干.mat权重文件。可真正解压跑起来,一半人卡在维度对不上,另一半人卡在loss不下降。这篇文章不调deep learning toolbox里的trainNetwork,而是把手写CNN从卷积、汇聚、全连接一路拆到反向传播和调参,最后告诉你这类源码包的常见坑在哪、怎么改成自己的网络。适合两类人:要做课程设计或实验复现的学生,以及想把CNN用到自己的图像、信号任务里却不想被工具箱限制的工程师。
2. 从卷积核到类别输出:在Matlab里把卷积、汇聚、全连接一层层写出来
2.1 用im2col做卷积:把四层循环换成矩阵乘法
很多人一上来就写三层for循环,遍历通道、高、宽,再和卷积核逐元素相乘。Matlab里这样写小图能跑,图稍大一点就慢到怀疑人生。常见做法是用im2col把每个卷积窗口展开成列,把整个卷积操作变成一个矩阵乘法。这也是很多源码包里最核心的一步。
function col = im2col_forward(x, kh, kw, stride, pad) % x: [C, H, W] 单张图,按通道×高×宽存放 % 返回 col: [C*kh*kw, Ho*Wo],每列是一个卷积窗口展开 [C, H, W] = size(x); Hp = H + 2*pad; Wp = W + 2*pad; xp = zeros(C, Hp, Wp); xp(:, pad+1:pad+H, pad+1:pad+W) = x; Ho = floor((Hp - kh) / stride) + 1; Wo = floor((Wp - kw) / stride) + 1; col = zeros(C*kh*kw, Ho*Wo); n = 0; for i = 1:stride:Hp-kh+1 for j = 1:stride:Wp-kw+1 n = n + 1; patch = xp(:, i:i+kh-1, j:j+kw-1); col(:, n) = patch(:); end end end这里把输入按“通道优先”存放,即三维数组的第一维是通道。很多网上下载的代码包用HxWxC(和图像矩阵直接对应),这就会在后面做reshape时埋下隐患。im2col的列数等于输出特征图的像素数HoWo,行数等于一个卷积核覆盖的所有通道的元素数。stride和pad决定输出尺寸,公式就是 Ho = floor((H + 2pad - kh)/stride) + 1。pad只在四周补零,不会改变通道数;stride=1时,加了padding的输出尺寸通常等于输入尺寸。
卷积层前向调用:
function out = conv_forward(x, W, b, kh, kw, stride, pad) % x: [C, H, W]; W: [F, C*kh*kw]; b: [F,1] % 输出 out: [F, Ho, Wo] col = im2col_forward(x, kh, kw, stride, pad); out = W * col + b; % [F, Ho*Wo] Ho = floor((size(x,2) + 2*pad - kh) / stride) + 1; Wo = floor((size(x,3) + 2*pad - kw) / stride) + 1; out = reshape(out, F, Ho, Wo); end这里的W布局是[F, Ckhkw],矩阵乘后自然就是对每个位置的卷积结果。reshape顺序是Matlab列优先,所以要先保证col的列是按输出像素顺序排列的。理解这个顺序,是后续反向传播的关键。
2.2 汇聚层与激活函数:降采样不是只要取最大值
汇聚层在卷积神经网络里几乎是标配,很多人叫它池化,其实在学术翻译里更常称“汇聚层”。Max汇聚保存窗口内的最强响应,对边缘纹理更敏感;平均汇聚则梯度传递更平滑。手写实现时,max汇聚必须保存最大值的位置,否则反向传播根本无法定位梯度。
function [out, mask] = maxpool_forward(x, pool_size, stride) % x: [C, H, W]; pool_size: 汇聚窗口边长; stride通常等于pool_size [C, H, W] = size(x); Ho = floor((H - pool_size) / stride) + 1; Wo = floor((W - pool_size) / stride) + 1; out = zeros(C, Ho, Wo); mask = zeros(C, Ho, Wo, 2); % 记录每个窗口最大值的局部坐标(row, col) for i = 1:Ho for j = 1:Wo r0 = (i-1)*stride + 1; c0 = (j-1)*stride + 1; patch = x(:, r0:r0+pool_size-1, c0:c0+pool_size-1); [v, idx] = max(reshape(patch, C, pool_size*pool_size), [], 2); [rr, cc] = ind2sub([pool_size, pool_size], idx); out(:, i, j) = v; mask(:, i, j, 1) = rr; mask(:, i, j, 2) = cc; end end end这段代码里mask是反向传播的后悔药。注意当pool_size=2、stride=2时,特征图尺寸直接减半,这也是大多数LeNet-5风格网络的做法。
激活函数最常用的是ReLU,代码只有一行:
function out = relu_forward(x) out = max(x, 0); endReLU的作用不只是非线性,它还能缓解梯度消失。和CNN经常对比的RNN不同,CNN用局部连接和权值共享处理空间结构,ReLU在这里比sigmoid稳定得多。如果某天你看到代码里用sigmoid,除非是特意做二分类输出,否则大概率是旧代码或误用。
2.3 全连接层与Softmax:让得分变成可比的概率
卷积和汇聚提取特征后,最后通常压平(flatten),接上一层或两层全连接,然后过一个Softmax。Softmax本身没有参数,但它有两个容易翻车的地方:一是减去最大值防止exp溢出,二是交叉熵的数值稳定性。
function [prob, z] = fc_softmax_forward(x, W, b) % x: [D, N] 批量的特征向量,W: [K, D], b: [K,1] z = W * x + b; z = z - max(z, [], 1); % 每列减去最大值 expz = exp(z); prob = expz ./ sum(expz, 1); % [K, N] end function loss = cross_entropy_loss(prob, labels) % labels: 1xN,值为1~K N = size(prob, 2); idx = sub2ind(size(prob), labels, 1:N); loss = -mean(log(prob(idx) + 1e-12)); % 加一个小常数防止log(0) end很多人会问,为什么不直接用MSE当损失。答案是分类任务里的目标是概率分布,交叉熵的梯度形式更干净,收敛也更快。这里的W是从上一层特征维度D映射到类别数K的矩阵,初始化不能用全零,常见做法是随机初始化,下一章讲训练时会具体说。
3. 训练循环与反向传播:让手写CNN真正学会识别数字
3.1 反向传播的四行核心代码
手写CNN最劝退的就是反向传播。其实当前向用了im2col之后,反向也可以向量化。先记住结论:softmax层的误差项是概率与one-hot标签之差除以batch大小。
function dz = softmax_backward(prob, labels) N = size(prob, 2); dz = prob; idx = sub2ind(size(prob), labels, 1:N); dz(idx) = dz(idx) - 1; dz = dz / N; end % 全连接层反向 dW = dz * x'; db = sum(dz, 2); dx = W' * dz;这三行就是全连接层全部梯度逻辑。db要按行求和,因为b以广播方式作用到每个样本上;dx是传给前一层的梯度。对应ReLU反向则是:
dx = dout .* (x > 0);汇聚层的反向稍微绕一点。Max汇聚前向保存的mask在这里起作用:梯度被回填到最大值的位置,其他位置补零。
function dx = maxpool_backward(dout, x, pool_size, stride, mask) [C, H, W] = size(x); dx = zeros(size(x)); for i = 1:size(dout, 2) for j = 1:size(dout, 3) for c = 1:C r = mask(c, i, j, 1); cc = mask(c, i, j, 2); dx(c, (i-1)*stride+r, (j-1)*stride+cc) = ... dx(c, (i-1)*stride+r, (j-1)*stride+cc) + dout(c, i, j); end end end end卷积层反向的向量化做法是:先把dout reshape成[F, Ho*Wo],然后:
dW = dout_reshaped * col'; % col是前向保存的im2col矩阵 db = sum(dout_reshaped, 2); dcol = W' * dout_reshaped; % [C*kh*kw, Ho*Wo] dx = col2im_backward(dcol, size(x), kh, kw, stride, pad);其中col2im_backward是把展开的梯度加回原图:
function dx = col2im_backward(dcol, x_shape, kh, kw, stride, pad) [C, H, W] = x_shape; Hp = H + 2*pad; Wp = W + 2*pad; dx_pad = zeros(C, Hp, Wp); n = 0; for i = 1:stride:Hp-kh+1 for j = 1:stride:Wp-kw+1 n = n + 1; patch_grad = reshape(dcol(:, n), C, kh, kw); dx_pad(:, i:i+kh-1, j:j+kw-1) = ... dx_pad(:, i:i+kh-1, j:j+kw-1) + patch_grad; end end dx = dx_pad(:, pad+1:pad+H, pad+1:pad+W); end注意,多个patch的梯度要累加,因为重叠区域的一个输入像素会被多个卷积窗口覆盖。这就是为什么手写卷积反向比前向更容易出错——忘了累加,梯度就缺了一块。
3.2 数据加载与mini-batch训练
有了前向和反向,训练就是一个循环。常见的源码包会自带MNIST的.mat版本。如果你的Matlab版本刚好没有,也可以自己读csv,这里只讲如何组织数据。
load mnist.mat % 包含 train_x: 60000x784, train_y: 60000x1 train_x = reshape(double(train_x'), 28, 28, 1, []); train_x = permute(train_x, [3 1 2 4]); % 变成 [1, 28, 28, 60000] train_y = double(train_y(:)) + 1; % 标签从0~9变成1~10 % 分成mini-batch batch_size = 64; num_batches = floor(size(train_x, 4) / batch_size); order = randperm(size(train_x, 4));这里permute是个大坑:Matlab图像矩阵习惯是HxWxC,但手写CNN内部用CxHxW更快,所以必须用permute把通道维度换到第一维。注意train_x原始是784列,reshape成[28,28,1,N]后,四个维度的顺序是行、列、通道、样本;再permute([3 1 2 4])就是把通道放第一位。顺序错了,后面的reshape和卷积全部白算。
训练主循环:
for epoch = 1:max_epoch order = randperm(size(train_x, 4)); for i = 1:num_batches idx = order((i-1)*batch_size+1 : i*batch_size); x_batch = train_x(:, :, :, idx); y_batch = train_y(idx); % 前向 [prob, cache] = cnn_forward(x_batch, params); loss = cross_entropy_loss(prob, y_batch); % 反向 grads = cnn_backward(prob, y_batch, cache, params); % 更新参数 params = update_params(params, grads, lr, momentum); end % 每个epoch结束在验证集上算准确率 end这里的cnn_forward和cnn_backward是对前面各层函数的封装,顺序是conv -> relu -> pool -> conv -> relu -> pool -> flatten -> fc -> softmax。cache里要保存每一层的输入和中间结果,尤其是im2col展开后的col和max pooling的mask,不然反向没法算。
3.3 超参数设置:学习率、批次规模、卷积核数量
手写CNN调起来比工具箱麻烦,但也就是几个旋钮。下面是我自己在MNIST这种小基准上常用的起点:
| 超参数 | 常见范围 | 备注 |
|---|---|---|
| 学习率 | 0.001 ~ 0.1 | Adam可用0.001,SGD可先用0.01 |
| 批次大小 | 32 ~ 128 | 太大内存压不住 |
| 卷积核数量 | 16/32/64 | 按层加深递增 |
| 卷积核尺寸 | 3x3 或 5x5 | 3x3叠加可以替代大核 |
| 汇聚窗口 | 2x2 stride=2 | 最常用 |
| 权重初始化 | Xavier或He | 全零必死,正态小随机可以 |
学习率0.1在SGD上有时会训练不稳定,loss跳到NaN;第一次跑通建议从0.01开始。批次大小影响梯度噪声,小批次收敛更快但不稳。用这些默认值,类LeNet-5的网络在MNIST上跑到98%以上并不难,前提是数据归一化到[0,1]。权重初始化多用Xavier,Matlab里可以这样:
fan_in = C * kh * kw; fan_out = F * kh * kw; std = sqrt(2 / (fan_in + fan_out)); W = randn(F, C*kh*kw) * std;4. CNN卷积神经网络Matlab实现避坑指南:五个踩完就忘不掉的坑
4.1 中文注释乱码与脚本编码:打开别人的zip全是乱码
现象:从网上下载的.m文件,在Matlab编辑器里中文注释变成一串乱码,比如“鈥斺€”或“鎴愬姛”。
原因:Matlab 2023之前的某些版本在Windows上默认GBK编码,而文件可能是UTF-8,或者反过来。如果你的代码包是老版本Matlab R2014上写的,大概率是GBK。新版Matlab读旧文件或旧版软件读新文件,都会出现编码错乱。
解决:不要直接用Matlab编辑器硬看,用记事本或Notepad++打开,选择“转为UTF-8编码”再另存;或者在Matlab主页的“预设 -> 编辑器/调试器 -> 语言”里设置默认编码。我的习惯是代码注释一律用英文,避免换电脑、换版本就乱码。这也是为什么上面代码片段里的注释我用英文写。
4.2 维度隐式变形:squeeze、permute、reshape的顺序
现象:前向传播好好的,反向传播一到某个矩阵乘法就报维度不一致。
原因:Matlab的reshape是列优先填充,而squeeze会悄无声息删掉长度为1的维度。很多代码从工具箱或别人的脚本复制过来时,数据形状是[H,W,C,N];你改成[C,H,W,N]后,老代码里的squeeze(x,1)就可能把通道维度删掉。
解决:定义统一的形状规则,入口数据全部转成[C,H,W,N];不要依赖squeeze,显式用permute;每次改变形状后打印size(x)验证。我最开始调的一晚上,就是死在permute和reshape的顺序上。如果你也发现forward和backward的维度对不上,先把每个变量名后面的size打出来,逐层对比。
4.3 梯度NaN:数值稳定性的三个杀手
现象:loss前几步正常,几十步后突然变成NaN,然后就一直是NaN。
原因:一是softmax里exp(z)溢出,当z超过700多时exp直接爆成Inf;二是交叉熵里log(0),概率被算成精确的0;三是学习率太大,导致更新后的权重出现极端值,梯度在下一轮直接爆炸。
解决:softmax前先减去每列最大值;交叉熵里加1e-12;训练时做梯度截断,比如梯度范数超过5就缩放回5。前两个是代码问题,第三个是调参问题。如果做了这些还是NaN,去看是不是输入数据里有NaN或Inf,这一步经常被忽略。
4.4 内存爆炸:全连接层太大,Matlab直接无响应
现象:模型不大,但训练到一半Matlab标题栏变成“未响应”,或者提示Out of Memory。
原因:im2col展开的内存放大系数约等于kh*kw。28x28小图没事,但如果你改成256x256输入,第一个卷积层就会展开成几百MB。另一个原因是全连接层:从128个通道的特征图直接flatten到1000维全连接,中间变量巨大。
解决:调小batch size到16或8;用1x1卷积降通道;或者先把输入resize到64x64。如果用的是GPU,还要注意gpuArray的开销,小模型用CPU反而更快。
4.5 GPU与CPU切换:同样的代码换个机器结果不一样
现象:在自己电脑CPU上跑得好好的,拿到服务器上加gpuArray,结果每次训练出来的准确率都不一样,甚至报错。
原因:GPU并行加法不保证完全确定的运算顺序,浮点累积误差会不同;但更大的坑是数据类型,gpuArray默认single,CPU端double,导致训练曲线不同。
解决:训练时固定随机种子,用rng(0);数据统一转single;如果非要在GPU跑,就把计算图完整放在GPU,不要反复gather。手写CNN用GPU不一定比CPU快,尤其模型不太大的时候,CPU的反而是更可控的选择。
5. 把这份代码包变成自己的工具箱:梯度检查、特征图可视化与扩展方向
5.1 梯度检查:先证明反向传播没有写错
手写反向传播后第一件事不是训练,而是梯度检查。常见做法是在一小批数据上,用数值差分对比解析梯度:
% 对第k个参数做数值梯度 e = 1e-5; num_grad = zeros(size(W)); for i = 1:numel(W) Wp = W; Wm = W; Wp(i) = Wp(i) + e; Wm(i) = Wm(i) - e; loss_p = compute_loss(x_batch, y_batch, Wp); loss_m = compute_loss(x_batch, y_batch, Wm); num_grad(i) = (loss_p - loss_m) / (2*e); end diff = norm(num_grad - analytic_grad) / (norm(num_grad) + norm(analytic_grad));相对误差小于1e-7基本说明反向没问题,1e-4左右也还能接受。注意用双精度,别在梯度检查时用single。这样跑一次,能省下后面好几天debug时间。
5.2 特征图可视化:看卷积核到底在找什么
把某张测试图输入,取出第一个卷积层输出,画成网格:
figure; for c = 1:size(feat, 1) subplot(4, 8, c); imagesc(feat(c, :, :)); title(sprintf('F%d', c)); end colormap gray;前几层学习的是边缘、斜线、色块。这里画出来既是卷积神经网络结构图的可视化,也是判断网络有没有学到有效特征的直观手段。如果某几个卷积核的输出全是常数,那这几个核就废了,说明初始化或梯度流动有问题。
5.3 从分类到更多任务:改造这份代码包
常见的改造方向有两个:把全连接层输出改成n个回归值,损失从交叉熵换成MSE;或者把输入换成灰度图像、传感器一维信号,此时只需调整第一层的输入尺寸。CNN和RNN的数据流不同,RNN是时间步递推,CNN是空间卷积加下采样;如果你要处理时序信号,不要硬套卷积网络的维度。这些年Transformer也很火,但CNN在中小规模图像数据上依然是一种低门槛、可解释、可快速部署的选择。
我每次拿到这类以“副本”命名的zip包,第一件事不是训练整个网络,而是把数据维度打印一遍,再跑梯度检查。这个习惯帮我挡掉了至少五次翻车。希望帮到你。
本文还有配套的精品资源,点击获取