news 2026/9/17 3:07:21

MATLAB手写CNN底层:从卷积到反向传播的全流程解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
MATLAB手写CNN底层:从卷积到反向传播的全流程解析

简介:这是一份面向MATLAB初学者的卷积神经网络模拟程序包,配套完整的网络训练与测试流程,帮助理解卷积层、池化层、全连接层以及反向传播等核心机制。压缩包内共30个M文件,大小仅16KB,涵盖网络初始化、前向传播、反向传播、梯度检查、训练与测试等核心程序模块,并配有可直接运行的示例脚本,结构清晰、便于逐模块调试与二次开发。已有216人学习下载,该程序基于MATLAB实现简化的卷积神经网络模拟,包含数据预处理、网络结构搭建、损失计算、优化器设置与训练评估等完整步骤,适合作为课程设计或自学深度学习原理的入门素材。运行示例即可清晰掌握从数据到特征的映射过程,观察参数更新对分类结果的影响,梯度检查模块则有助于验证反向传播实现是否正确,进一步为搭建更复杂的深度学习模型打下坚实基础。

1. 为什么这份MATLAB程序比工具箱更能看清CNN底层

用MATLAB做卷积神经网络(CNN),很多人第一反应是调 Deep Learning Toolbox 里的 layer 对象,但一遇到想改卷积核旋转、pooling 上采样这种底层操作就无从下手。cnn.zip 里的这套程序走的是另一条路:不用自动微分、不用内置训练函数,所有卷积层、池化层、全连接层都用普通矩阵运算搭出来,每一步的中间变量都能在 workspace 里看到。压缩包里的 cnnsetup.m、cnnff.m、cnnbp.m、cnntrain.m、cnnnumgradcheck.m 等脚本,正好覆盖了 CNN 从初始化、前向传播、反向传播到数值梯度检查的完整流程。适合正在啃 CNN 结构图、需要做 MATLAB 图像处理课程设计,或者想搞清楚反向传播里为什么要把卷积核旋转 180 度的人。

2. cnnsetup 结构定义与 cnnff 前向传播:在MATLAB里搭建CNN网络

2.1 先看文件清单,分清每一份脚本的作用

这套资源里没有把训练过程封成一个黑盒,而是拆成了一个个可以单独断点的.m文件。要理解它在干什么,第一步不是看代码,而是把文件功能对号入座。

文件名承担的角色
cnnsetup.m初始化网络层结构、卷积核、全连接层权重和偏置
cnnff.m前向传播,完成卷积、池化、展平、全连接输出
cnnbp.m反向传播,计算每一层的误差
cnnapplygrads.m根据梯度更新权重和偏置
cnntrain.m训练主循环,组织 batch、调用前向与反向传播
cnntest.m在测试集上跑前向传播并统计正确率
run_cnn_example.m入口脚本,加载数据、设置超参数、执行训练测试
cnnnumgradcheck.m用数值梯度对照解析梯度,检查反向传播是否写错

这套结构和早期开源深度学习工具箱非常接近,不依赖trainingOptions这类高层封装。每层网络用结构体数组net.layers描述,里面只存typekernelsizeoutputmapsscale这些最原始的字段。读这种代码需要的不是工具箱文档,而是对数组维度和索引的敏感度。

2.2 cnnsetup 关键逻辑:卷积核初始化与全连接层维度计算

run_cnn_example.m里会先定义net.layers,然后调用cnnsetup(net, x, y)。这里x是训练样本,y是标签,但函数真正用到的是它们的尺寸信息。简化后的初始化代码如下。

function net = cnnsetup(net, x, y) inputmaps = 1; mapsize = [size(x, 1), size(x, 2)]; for l = 1 : numel(net.layers) if strcmp(net.layers{l}.type, 'c') % 卷积层:特征图尺寸减小 kernelsize - 1 mapsize = mapsize - net.layers{l}.kernelsize + 1; fan_out = net.layers{l}.outputmaps * net.layers{l}.kernelsize ^ 2; for j = 1 : net.layers{l}.outputmaps for i = 1 : inputmaps fan_in = inputmaps * net.layers{l}.kernelsize ^ 2; % Xavier 风格初始化,避免激活值过早饱和 net.layers{l}.k{i}{j} = (rand(net.layers{l}.kernelsize) - 0.5) * 2 * sqrt(6 / (fan_in + fan_out)); net.layers{l}.b{i}{j} = 0; end end inputmaps = net.layers{l}.outputmaps; elseif strcmp(net.layers{l}.type, 's') % 池化层:特征图尺寸除以缩放因子 mapsize = mapsize / net.layers{l}.scale; end end fvnum = prod(mapsize) * inputmaps; outdim = numel(y); % 对 MNIST 是一个 10 维 one-hot 列向量 net.fvW = (rand(fvnum, outdim) - 0.5) * 2 * sqrt(6 / (fvnum + outdim)); net.fvb = zeros(outdim, 1); end

这里有几个地方值得说明。k{i}{j}是第 i 个输入特征图到第 j 个输出特征图的卷积核,二维矩阵的尺寸就是kernelsize。初始化公式来自 Xavier 论文的边界估计,用sqrt(6 / (fan_in + fan_out))控制初始权重幅度,比纯随机均匀分布收敛更稳。fvnum = prod(mapsize) * inputmaps是最后一个池化层展平后的向量长度,也就是全连接层的输入维数。numel(y)在这个经典实现里被用来表示输出类别数,所以喂给cnnsetupy必须是 one-hot 编码后的列向量,长度等于类别数。

如果换成自己的分类任务,比如要做 CNN 花卉图像分类,类别数变成 100,就需要把标签改成 100×1 的 one-hot 格式,否则net.fvW的行数会完全不同。

2.3 cnnff 前向传播:卷积、激活、平均池化与展平

前向传播是 CNN 结构图最直接的落地。cnnff.m的简化版本大致是这样。

function net = cnnff(net, x) % x 的格式是 h x w x channel x batch net.layers{1}.a{1} = x; for l = 1 : numel(net.layers) if strcmp(net.layers{l}.type, 'c') for j = 1 : net.layers{l}.outputmaps z = 0; for i = 1 : numel(net.layers{l-1}.a) z = z + convn(net.layers{l-1}.a{i}, net.layers{l}.k{i}{j}, 'valid'); end % sigmoid 激活,MATLAB 里写成 exp 更稳 net.layers{l}.a{j} = 1 ./ (1 + exp(-z - net.layers{l}.b{i}{j})); end elseif strcmp(net.layers{l}.type, 's') for j = 1 : numel(net.layers{l-1}.a) net.layers{l}.a{j} = meanpool(net.layers{l-1}.a{j}, net.layers{l}.scale); end end end fv = []; for j = 1 : numel(net.layers{end}.a) fv = [fv; reshape(net.layers{end}.a{j}, [], 1)]; end net.fv = fv; net.o = 1 ./ (1 + exp(-net.fvW * fv - net.fvb)); end

convn是 MATLAB 原生 N 维卷积函数,'valid'表示不做零填充,输出尺寸正好是输入尺寸减kernelsize加 1。循环里的z累加的是所有输入通道与对应卷积核的卷积结果,也就是真实卷积层做的事:多通道输入求和后加偏置。池化层用meanpool代表平均池化,它把特征图按scale划分成不重叠的块,对每个块求均值,所以特征图宽高同时整除scale,否则会报尺寸错误。

这段代码里最容易忽视的是激活函数选型。资源里用的是 sigmoid,原因不是 sigmoid 效果好,而是反向传播时它的导数求起来直观,a * (1 - a)一行就能表达,便于在课程设计中展示 CNN 结构图到代码的映射。实际工程中 ReLU 更常用,待会儿在第 5 章会说到怎么把这个位置替换成 ReLU。

3. cnnbp 反向传播与 cnnnumgradcheck 数值梯度校验:CNN训练背后

3.1 误差从全连接层回流到卷积层的方向

前向传播容易看懂,反向传播才是这套 MATLAB 程序最能教人的地方。cnnbp.m 里有两个关键的循环分支:一个是池化层的误差如何上采样回卷积层,一个是卷积层的误差如何通过翻转卷积核传到上一层。

池化层没有参数,反向传播要做的是把输出误差“放大”回输入尺寸。平均池化的前向操作是对scale × scale的窗口求均值,反传时就把当前误差复制scale²份,平摊到窗口的每个位置上。这个过程在经典实现里通常写成kron(d, ones(scale)),也就是用克罗内克积做上采样。

卷积层的参数是卷积核,它的反向传播不是简单地把误差乘以某个矩阵,而是把下一层的误差和旋转 180 度后的卷积核做'full'卷积。旋转的原因可以从线性代数角度理解:前向卷积在数学上等价于一个带权重的滑动线性运算,梯度反传时需要对权重矩阵做转置操作,转置后的相关运算恰好对应卷积核旋转后做卷积。很多资料只说“CNN 反传要把卷积核翻转”,你读完这段代码就能看到翻转到底发生在哪一行。

3.2 cnnbp 核心片段:翻转卷积核与激活导数

下面这段是 cnnbp.m 中反向传播主干的结构化写法,原始代码在此基础上加了维度调整和拼接。

rot180 = @(k) flipud(fliplr(k)); expand = @(d, scale) kron(d, ones(scale)); for l = numel(net.layers) : -1 : 2 if strcmp(net.layers{l}.type, 'c') % 卷积层的误差 = 池化层展开误差 * sigmoid 导数 for j = 1 : numel(net.layers{l}.a) net.layers{l}.d{j} = net.layers{l}.a{j} .* (1 - net.layers{l}.a{j}) ... .* expand(net.layers{l+1}.d{j}, net.layers{l+1}.scale); end elseif strcmp(net.layers{l}.type, 's') % 池化层的误差 = 下一层误差与旋转卷积核做 full 卷积 for i = 1 : numel(net.layers{l}.a) z = zeros(size(net.layers{l}.a{1})); for j = 1 : numel(net.layers{l+1}.a) z = z + convn(net.layers{l+1}.d{j}, rot180(net.layers{l+1}.k{i}{j}), 'full'); end net.layers{l}.d{i} = z; end end end

第一段循环处理卷积层。net.layers{l}.a{j} .* (1 - net.layers{l}.a{j})是 sigmoid 的导数,数值上等于前向输出乘以 1 减前向输出。这里的.是按元素乘法,不是矩阵乘法。如果替换成 ReLU,这一行就要改成net.layers{l}.a{j} > 0,也就是只有正区间梯度为 1。

第二段循环处理池化层。rot180把卷积核上下左右翻转,等价于顺时针旋转 180 度。'full'卷积保证误差图尺寸回滚到上一层输入大小,让下一轮循环能继续往前传。注意索引:k{i}{j}里的 i 是当前池化层第 i 个特征图,j 是下一层卷积层第 j 个输出特征图,所以外层循环固定 i,内层循环累加所有 j 的贡献。这就是“每个输入特征图会受到所有输出特征图误差影响”的矩阵化表达。

cnnapplygrads.m做的事情反而更简单,拿到cnnbp算出来的梯度后,按学习率更新权重:

% net.layers{l}.k{i}{j} 的偏导存于 net.layers{l}.dk{i}{j} for l = 1 : numel(net.layers) if strcmp(net.layers{l}.type, 'c') for j = 1 : numel(net.layers{l}.a) for i = 1 : numel(net.layers{l-1}.a) net.layers{l}.k{i}{j} = net.layers{l}.k{i}{j} - opts.alpha * net.layers{l}.dk{i}{j}; end end end end

权重更新的方向是梯度反方向,学习率opts.alpha决定每次迈多大步。这正是随机梯度下降最原始的形式,没有动量、没有 Adam,适合做数值梯度检查的基准。

3.3 数值梯度检查:用差分验证解析梯度

反向传播写错一个符号,训练可能仍然会下降一点,但梯度方向是错的。可靠的验证方式是cnnnumgradcheck.m:用有限差分近似计算梯度,再和反向传播给出的梯度比较。常规做法是在命令行里直接执行:

% 先用小训练集初始化一个极小的 CNN numgrad = cnnnumgradcheck(net, x, y); % 查看 numgrad 是否与 net 中各层梯度字段一致

实际脚本会把解析梯度和数值梯度拉平成两个长向量做归一化误差对比。常见的判定标准可以用下表。

归一化相对误差结论
< 1e-8反向传播实现非常精确,可以放心训练
< 1e-6基本可信,浮点精度影响可接受
< 1e-3可能存在索引错误或边界处理问题,需要逐层检查
> 1e-2反向传播逻辑大概率有错误,别急着训练

数值梯度检查只适合小网络,因为每个参数都要跑两次前向传播。用超大卷积核或大 batch 跑梯度检查会非常慢,通常会把kernelsize设为 3,把特征图尺寸控制在 16×16 以内。

4. cnntrain 训练循环与数据预处理:在run_cnn_example上改自己的图像

4.1 入口脚本里的超参数先读懂

run_cnn_example.m是整套程序的启动点,它先加载数据,再设置结构体和超参数。以经典 MNIST 手写数字二分类或十分类为例,训练参数的大致形式如下。

load mnist_uint8; train_x = double(reshape(train_x', 28, 28, 1, size(train_x, 1))) / 255; train_y = double(train_y'); net.layers = { ... struct('type', 'c', 'outputmaps', 6, 'kernelsize', 5), ... struct('type', 's', 'scale', 2), ... struct('type', 'c', 'outputmaps', 12, 'kernelsize', 5), ... struct('type', 's', 'scale', 2)}; opts.alpha = 0.05; opts.batchsize = 50; opts.numepochs = 3; net = cnnsetup(net, train_x, train_y); net = cnntrain(net, train_x, train_y, opts); net = cnntest(net, test_x, test_y);

train_x原本是样本数 × 784的矩阵,reshape 成28 × 28 × 1 × 样本数后成为 CNN 需要的四维数组。除以 255 是把像素从 0-255 归一化到 0-1,这一步直接影响 sigmoid 的输入范围。如果原始图像是灰度图但尺寸不是 28×28,需要先用imresize统一尺寸,否则cnnsetup计算出来的mapsize会对不上后续卷积层的输出。

opts.batchsize是每次前向-反向传播使用的样本数,cnntrain内部会按这个值把训练集切块。这个参数比较关键:batch 太小时梯度噪声大,batch 太大时同样 epoch 的情况下参数更新次数变少,需要更多轮次才能收敛。经典实现的cnntrain.m里没有动量项,所以学习率alpha也不能给太大。

超参数参考区间典型异常表现
alpha0.01 ~ 0.1过大时 loss 变成 NaN 或剧烈震荡;过小时 loss 下降几乎看不见
batchsize16 ~ 256太小时训练曲线毛刺多;太大时收敛速度明显变慢
numepochs1 ~ 20太小时欠拟合;过大时在简单任务上可能过拟合

4.2 把程序改成自己的图像数据

资源默认跑 MNIST,但换成自己的图像分类任务时,最省事的做法是用dir遍历图像目录,自己写一个数据加载循环。以“以文件夹名作为类别名”的常见做法为例,代码如下。

files = dir(fullfile('train', '*.jpg')); for i = 1 : numel(files) img = imread(fullfile(files(i).folder, files(i).name)); if size(img, 3) == 3 img = rgb2gray(img); % 灰度化,颜色不是强特征时用 end img = imresize(img, [28 28]); train_x(:, :, 1, i) = double(img) / 255; label = str2double(files(i).name(1)); % 以文件名首字符作为标签 train_y(label + 1, i) = 1; % one-hot 编码 end

这段代码有几个容易踩的坑。第一,imresize默认使用双三次插值,小尺寸图像放大后边缘会有光晕,如果任务对细节敏感,建议改成'bilinear''nearest'。第二,train_y必须预先初始化成全零矩阵,大小是类别数 × 样本数,否则累加 one-hot 向量时会报矩阵维度不一致。第三,标签从 1 开始而不是 0,因为 MATLAB 索引不能是 0,所以 MNIST 原始标签 0-9 在转换为 one-hot 时通常要执行label + 1

如果做的是 CNN 花卉图像分类这类颜色信息很强的任务,把rgb2gray那一步注释掉,保留三通道,同时把cnnsetupinputmaps的初始值从 1 改成 3,卷积层的输入特征图数量也会随之变为 3。初始的net.layers{1}.a{1} = x这时就是一个h × w × 3 × batch的数组,convn会分别对三个通道做卷积然后累加,不需要额外改循环。

4.3 训练过程中看什么指标

经典实现没有打印精美曲线,但cnnff.m返回的net.o可以直接用来观察输出分布。在cnntrain.m的循环内部,通常会有类似下面这种代码来统计误差。

% 在每次迭代末尾,计算当前 batch 的分类误差 [~, pred] = max(net.o); [~, gt] = max(batch_y); err = mean(pred ~= gt); fprintf('epoch %d, batch %d, error %.4f\n', epoch, batch, err);

如果err一直稳定在 0.9 附近说明没有学起来,先检查归一化是否做过头,比如输入变成了全零。如果err从 0.5 慢慢掉到 0.1 但最终停在某个较大值,多半是网络容量不够,可以增加outputmaps或再加一层卷积。这套底层实现里没有学习率衰减,后期可以在cnntrain的 epoch 循环里手动把opts.alpha乘以 0.5,常见做法是每个 epoch 衰减一次。

5. 进阶验证:可视化特征图与核对每层输出尺寸

训练完成后,最有价值的验证不是只看正确率,而是直接看每一层到底学到了什么。对卷积神经网络来说,第一层卷积核往往能看出边缘纹理方向,最后一层特征图的高激活区域则对应分类依据。用 MATLAB 的imagesc可以轻松把这套程序跑出来的中间层画出来。

% 输入 test_x 中第一张图像 net = cnnff(net, test_x(:, :, :, 1)); % 画出第一个卷积层的 6 个特征图 for j = 1 : numel(net.layers{2}.a) subplot(2, 3, j); imagesc(net.layers{2}.a{j}(:, :, 1)); colormap gray; axis off; title(['feature map ', num2str(j)]); end

这段代码里net.layers{2}.a{j}是第一个卷积层的输出,因为net.layers{1}.a{1}是输入图像。(:, :, 1)取的是第一个 batch 样本的特征图,如果你给cnnff传入的是四维数组,这里需要保留批量维索引。如果特征图全黑或全白,说明激活值全部饱和,问题通常出在权重初始化幅度偏大或者输入没归一化。

另一个更偏调试的技巧是核对每一层的特征图尺寸。在修改网络结构时,经常出现池化层scale=2但输入尺寸是奇数,导致特征图出现非整数尺寸。原版cnnsetup里如果用整数除法,会直接丢像素,之后的反向传播就会出现维度对不齐。这时候可以用下面这段代码把所有层的尺寸打出来。

mapsize = [size(train_x, 1), size(train_x, 2)]; for l = 1 : numel(net.layers) if strcmp(net.layers{l}.type, 'c') mapsize = mapsize - net.layers{l}.kernelsize + 1; elseif strcmp(net.layers{l}.type, 's') mapsize = mapsize / net.layers{l}.scale; end fprintf('layer %d: %s, size %d x %d\n', l, net.layers{l}.type, mapsize(1), mapsize(2)); end

如果输出结果里出现小数,或者某层 size 变成 0,说明输入图像尺寸和kernelsizescale的组合有问题。比较稳妥的改法是把输入图像统一 resize 到能被 4 整除的边长,比如 28×28 经过了两次scale=2池化后仍是 7×7,如果再用一个kernelsize=5的卷积层,7-5+1=3,没问题,但继续加scale=2池化就会报错。这时要么把池化scale改成 1,要么先把图像 resize 成 32×32,32 一路减半到 8,再卷积到 4,完全不会碰见奇数维度。

如果要替换激活函数,只需要在cnnff.mcnnbp.m两处同步修改。把1 ./ (1 + exp(-z))换成max(0, z),把 sigmoid 导数a .* (1 - a)换成a > 0,然后重新跑一遍cnnnumgradcheck。只要数值梯度误差能回到 1e-6 以下,就说明你亲手把一个 sigmoid CNN 改成了 ReLU CNN,而且反向传播仍然正确。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/17 3:07:00

Star CCM+旋风分离器网格与湍流协同优化实战指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/17 3:06:55

CAPL实战8大硬核场景:从抖动控制到LIN切换的工程解法

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/17 3:06:06

MATLAB卫星定位解算:RINEX数据处理、最小二乘与EKF滤波

简介&#xff1a;面向GPS定位初学者的AMP MATLAB定位解算程序&#xff0c;围绕卫星导航中的几何定位问题&#xff0c;提供从数据读取到结果输出的完整示例代码&#xff0c;帮助用户理解伪距观测、卫星位置解算与最小二乘定位的基本逻辑。压缩包内共有五个文件&#xff0c;包括三…

作者头像 李华
网站建设 2026/9/17 3:05:55

基于Spark的亿级用户聚类分析实战:K-Means客户细分全流程

很多做数据分析和用户增长的朋友&#xff0c;一聊到客户细分&#xff0c;第一反应就是用SQL跑几个RFM指标&#xff0c;然后手动分一下层。这种做法在数据量小、维度少的时候还行&#xff0c;可一旦用户量到了千万级&#xff0c;特征维度扩展到十几个的时候&#xff0c;传统方式…

作者头像 李华
网站建设 2026/9/17 3:03:37

连续小波变换C语言实现:从cwt.m到嵌入式信号处理与优化

简介&#xff1a;这是一个用C语言实现连续小波变换&#xff08;CWT&#xff09;的源码包&#xff0c;适合信号处理初学者、嵌入式开发人员以及需要在C/C工程中集成时频分析功能的工程师。代码通过尺度向量与小波母函数参数&#xff0c;对输入信号进行多分辨率分解&#xff0c;在…

作者头像 李华