我入行那会儿,身边人一提卷积神经网络,第一反应都是“去装个深度学习框架”,要么走Python路线,要么折腾一堆依赖。直到有一次,我要在一个已经装好MATLAB的开发环境里快速验证一个图像分类原型,这时候我才认真研究起“用MATLAB写CNN”这件事。做完之后我发现,利用MATLAB的Deep Learning Toolbox,其实不用写多复杂的代码就能搭出一个可用的卷积神经网络,而且从数据处理到训练评估都是同一套工作流,相当顺手。所以这篇博文,我就把那个基于MATLAB的简单卷积神经网络实现代码整理出来,连同我在调参过程中踩过的坑、总结出的经验一并分享。适合的人群挺明确:有一定MATLAB基础,想快速上手深度学习,又不想为了一个Demo去折腾大框架的同学。
1. 为什么要在MATLAB里徒手写CNN:一次被迫上手的经历
1.1 不是所有深度学习任务都要先装一套大框架
我最早接触CNN是跟着教程用开源框架跑的,但那套环境配起来真的花了不少力气。后来换了一台电脑,装好MATLAB之后就不太想再折腾独立框架了,于是我就试着用MATLAB自带的功能实现CNN。
我知道不少同学一听到“MATLAB做深度学习”就皱眉头,觉得它只是用来做矩阵运算的。但实际上,Deep Learning Toolbox里面已经有了完整的网络层定义、训练器、数据预处理和可视化工具。很多经典结构,比如LeNet、AlexNet这种级别的网络,用MATLAB代码写出来非常直观。训练过程也不需要自己写反向传播,底层都封装好了,你只需要把数据格式摆对、把层结构定义对,剩下的让工具箱自己跑。
1.2 MATLAB做CNN的地基:Deep Learning Toolbox的职责划分
我先梳理一下,当我们说“在MATLAB里实现CNN”时,到底在用什么:
imageInputLayer定义输入尺寸,告诉网络“你接下来要吃的图像是多大、什么格式”;convolution2dLayer负责卷积核数量和尺寸设定,这是CNN最核心的算子;batchNormalizationLayer和reluLayer负责激活和防止梯度异常;maxPooling2dLayer用于下采样,逐步抽象特征;fullyConnectedLayer、softmaxLayer和classificationLayer负责最终分类输出。- 而
trainNetwork函数接收“层数组 + 数据 + 训练选项”三样东西,自动完成前向传播、损失计算、反向传播和参数更新。
这套链路和用其他框架训练网络没有本质区别,区别在于写起来更像是在搭积木,每一层就是一个对象,参数也都在构造的时候一次性给清楚。对于想弄清CNN层间关系的人来说,这种显式的写法反而比复杂的图定义更友好。
1.3 这篇实现代码的完整目标
我当时给自己定的目标是:不用任何预训练模型,不借助外部框架,纯靠MATLAB工具箱里最基础的函数,从零构建并训练一个能够对手写数字图像进行分类的CNN。这个目标麻雀虽小五脏俱全:数据读取、预处理、网络构建、训练配置、性能评估全部走一遍,之后再换到自己领域的数据集,只需要改改路径和类别数就行。这篇内容的代码我都按可复现的方式组织,文中出现的关键参数也是我实测跑过多次的,不是随手写的。
2. 数据准备与预处理:从原始像素到训练输入
2.1 数据集怎么加载:imageDatastore是第一步
写CNN之前,最容易被忽略的就是数据加载。很多人一上来就研究网络结构,结果数据格式不对,怎么都跑不通。在MATLAB里,最高效的做法是使用imageDatastore。
假设你的图片文件按类别分好了子文件夹,例如digitData/0、digitData/1……digitData/9,那加载代码就这么简单:
digitDataPath = fullfile(pwd, 'digitData'); imds = imageDatastore(digitDataPath, ... 'IncludeSubfolders', true, ... 'LabelSource', 'foldernames');这里有个好处:LabelSource设为'foldernames'之后,MATLAB会自动把每个子文件夹的名称变成标签,完全不用手工标注每一张图。如果用的是公开的手写数字数据集,文件夹结构通常也是现成的,这一步基本就是白给。加载完之后,我习惯先打印一下分类统计:
tbl = countEachLabel(imds); disp(tbl);这一步能快速发现数据是不是均衡的。比如某个类别的图片数量明显偏多,后续训练出来的模型就容易偏向该类。我做数字识别时各类数量基本均衡,但如果换到自己项目上,这里就必须留意,类别不均衡要先做处理,否则后面过拟合和偏向问题会一起来。
2.2 图像增强与尺寸归一:为什么建议统一到64x64
手写数字数据集里的原始图像尺寸各不一样,有的已经做了归一化,有的还是原始扫描效果。直接喂给CNN也不是不行,但不同尺寸会导致网络输入层无法统一。最稳妥的做法是使用augmentedImageDatastore把图像统一放缩到指定尺寸。
我当时纠结过到底用28x28还是64x64。28x28是传统数字数据集的经典尺寸,算起来快,内存占用小。但问题在于,简单的数字识别任务虽然不需要太高分辨率,但如果图像缩放得太狠,笔画细节容易糊掉,模型的鲁棒性会受影响。我自己实测下来,64x64是一个在速度和准确率之间比较舒服的平衡点。尺寸越大,卷积层能学到的边缘特征越精细,但相应地训练时间会上涨不少。
inputSize = [64 64 1]; % 高、宽、通道数 augimds = augmentedImageDatastore(inputSize, imds, ... 'DataAugmentation', imageDataAugmenter(... 'RandXTranslation', [-2 2], ... 'RandYTranslation', [-2 2]));这里我加了两个轻微平移的数据增强参数。别小看这两行,小幅平移等于在手动扩大训练样本的多样性。手写数字本身就有很大的笔画位置偏移,训练时稍微做点随机平移,能有效减少模型对“数字必须严格居中”的依赖。这也是我试了几次后留下的调整,加上之后验证集准确率有明显的提升。
2.3 训练/验证/测试三割:splitEachLabel的比例怎么定
数据划分我踩过一次坑。一开始我傻乎乎地把全部数据都拿去做训练,训练完一测,准确率贼高,结果拿到新图片上一试就露馅。后来才意识到,没有独立测试集做验证,模型到底有没有泛化能力根本看不出来。
[imdsTrain, imdsVal, imdsTest] = splitEachLabel(imds, 0.7, 0.15, 0.15);这个比例是我常用的基线:70%训练、15%验证、15%测试。对于数字识别这种相对简单的任务,70%的训练量已经足够;如果数据集本身很小,我建议把验证集和测试集比例降到10%,把更多数据让给训练。注意,splitEachLabel是按每个类别独立分配的,所以即便某一类数量偏少,也不会因为随机划分导致某类别在训练集或测试集中消失。这一步做完,数据准备阶段才算真正结束。
3. 网络结构搭建:每一层参数背后的理由
3.1 从LeNet老前辈借鉴骨架:卷积、池化、全连接的标准组合
CNN的结构设计不是拍脑袋决定的。我搭的这个简单网络,骨架借鉴了LeNet的经典思路:卷积层负责提取特征,池化层负责压缩特征图的尺寸,最后再接全连接层做分类。这个“卷积-激活-池化”的堆叠模式已经被验证过无数遍,用在数字识别这种任务上非常稳妥。
网络结构的代码如下:
layers = [ imageInputLayer(inputSize, 'Name', 'input') convolution2dLayer(3, 8, 'Padding', 'same', 'Name', 'conv1') batchNormalizationLayer('Name', 'bn1') reluLayer('Name', 'relu1') maxPooling2dLayer(2, 'Stride', 2, 'Name', 'pool1') convolution2dLayer(3, 16, 'Padding', 'same', 'Name', 'conv2') batchNormalizationLayer('Name', 'bn2') reluLayer('Name', 'relu2') maxPooling2dLayer(2, 'Stride', 2, 'Name', 'pool2') fullyConnectedLayer(64, 'Name', 'fc1') reluLayer('Name', 'relu3') fullyConnectedLayer(10, 'Name', 'fc2') softmaxLayer('Name', 'softmax') classificationLayer('Name', 'output') ];3.2 各层参数与代码实现:滤波器数量和卷积核尺寸怎么选
第一层卷积我把滤波器数量设为8,第二层设为16,卷积核尺寸统一用3x3。为什么是3x3?因为3x3是所有卷积核里性价比最高的尺寸之一,两个连续的3x3卷积在感受野上能接近一个5x5卷积,但参数量更少,非线性表达能力还更强。用奇数尺寸还能配合'Padding', 'same'保持输出尺寸不变,后续层数更容易推算。
滤波器数量从8涨到16,是CNN常用的“通道加深”策略:靠前的层提取边缘、纹理等底层特征,数量少一点没关系;靠后的层需要组合更复杂的高级特征,通道数应该逐步增多。如果一开始就用32个甚至64个滤波器,小数据集上很容易过拟合,而且训练速度会明显下降。以下是我在这个网络里用的关键参数汇总:
| 层名 | 类型 | 核心参数 | 输出尺寸(以64x64输入为例) |
|---|---|---|---|
| input | imageInputLayer | 64x64x1 | 64x64x1 |
| conv1 | convolution2dLayer | 8个3x3核,Padding same | 64x64x8 |
| bn1 | batchNormalizationLayer | - | 64x64x8 |
| relu1 | reluLayer | - | 64x64x8 |
| pool1 | maxPooling2dLayer | 2x2,Stride 2 | 32x32x8 |
| conv2 | convolution2dLayer | 16个3x3核,Padding same | 32x32x16 |
| pool2 | maxPooling2dLayer | 2x2,Stride 2 | 16x16x16 |
| fc1 | fullyConnectedLayer | 64个神经元 | 64 |
| fc2 | fullyConnectedLayer | 10个神经元 | 10 |
| output | classificationLayer | 10类 | 10 |
这个结构看起来简单,但信息量并不少。两轮卷积池化下来,特征图从64x64逐渐被浓缩到16x16,通道数从1涨到16。全连接层把特征图展开后映射到64维,再降到10维输出分类概率。中间所有卷积层后面都跟了批量归一化和ReLU,这个组合基本是现代CNN的标配,我当初不加BN层的时候,训练曲线震荡得厉害,加了之后收敛快了很多,后面细说。
3.3 为什么加批量归一化层:收敛速度与精度之间的平衡
不少初学者会觉得,LeNet那样“卷积+激活+池化”的经典结构就够了,为什么要多此一举加batchNormalizationLayer?
我用实际体验回答你:如果数据方差比较大,或者学习率设置稍高,不加BN的CNN很容易在训练中后期出现验证准确率反复横跳的问题。批量归一化的作用是在每一批数据经过卷积之后,把特征图的分布拉回到均值为0、方差为1附近。这样网络的每一层输入都比较稳定,梯度传播也就更平稳。等于说,BN给网络的每一层都配了一个自动调平器,让后面那些层不需要自己去适应前一层分布的变化。
在代码里插BN层非常容易,就是一行batchNormalizationLayer('Name','bn1')。如果你用的是比较深的网络,BN基本成了必需品;像我这种浅层网络,加了BN之后最明显的变化是——用同样的学习率,不加BN的时候训练到后期会出现不规律的波动,加上BN之后训练曲线平滑了很多,最后的准确率也稳了一些。代价仅仅是多了一点计算量,但对于这种规模的网络来说完全可以直接忽略。
4. 训练选项的调参经验:学习率、批大小与验证策略
4.1 trainingOptions里哪些参数是新手最容易踩雷的
网络结构定义好之后,最核心的就是trainingOptions了。不少初学者直接看别人代码里抄一段就开跑,结果要么训练不收敛,要么训练完了发现模型不行。我把自己常用的配置贴出来,逐个说清楚为什么这么设:
options = trainingOptions('sgdm', ... 'InitialLearnRate', 0.01, ... 'MaxEpochs', 10, ... 'MiniBatchSize', 128, ... 'ValidationData', augimdsVal, ... 'ValidationFrequency', 30, ... 'Shuffle', 'every-epoch', ... 'Verbose', true, ... 'Plots', 'training-progress', ... 'L2Regularization', 0.0001);这几个参数里,我最想强调的有四个:
学习率0.01:这是SGDM优化器在小网络上的安全起点。学习率太高,损失函数会在最优值附近震荡;学习率太低,训练半天没啥进展。如果你的数据相对简单、网络层数不多,0.01起步基本不会出大问题。万一损失曲线迟迟不下降,先降学习率,不要盲目加大迭代次数。
MiniBatchSize为128:批大小决定了一次参数更新用多少张图。128在我的数据规模下跑得稳,内存占用也不夸张。批大小如果设成512,训练一次迭代的间隔会变长,而且小数据集上大步长容易让梯度方向不够准确。反过来如果设成16,训练过程会非常敏感,学习率稍微不对就抖得厉害。
ValidationFrequency为30:这个值是“每隔多少次迭代跑一次验证集”。我通常按迭代数来设,而不是按epoch。因为一个epoch如果包含太多迭代,验证太频繁会拖慢训练;验证太少,又有可能错过了最佳模型都没发现。设成30之后,训练一开始就能通过绘图实时看到验证曲线的走势。
Shuffle设为every-epoch:每个epoch都重新打乱数据顺序,防止模型学到样本排序中的虚假规律。这个我一度觉得无所谓,后来对比了一次,发现不打乱数据时训练收敛得慢,而且验证曲线会出现奇怪的周期性波动。
4.2 训练动线与可视化:怎么判断网络真的在学
训练过程中的Plots选项一定要设成'training-progress',这样MATLAB会打开一个实时更新的训练进度图。我判断网络有没有在学,主要看两条曲线的关系:
- 训练损失曲线应该整体向下走,不能出现断崖式下跌,也不能长时间横盘。
- 验证准确率曲线应该稳步爬升,并且和训练准确率的差距不能太大。
如果训练曲线一路高歌但验证曲线纹丝不动,那是过拟合的前兆。如果训练损失在最初几千次迭代里就降到很低的水平,那大概率是学习率设太大,模型正在用激进的方式硬闯损失函数的沟壑,后面的验证结果会很不好看。我会在每个epoch结束之后瞄一眼训练图和命令行输出的数据,发现问题就先暂停,把学习率降一档再做一轮,而不是傻等10个epoch全部跑完。
4.3 整个训练流程的完整代码
数据准备好、层结构定义好、训练选项也设好之后,训练就只是一行调用的事:
net = trainNetwork(augimdsTrain, layers, options);trainNetwork会返回一个训练好的SeriesNetwork对象,里面包含了全部网络结构和学到的权重参数。训练过程中,如果验证准确率在某一轮后开始下降,你可以考虑用'OutputFcn'或提前停止的策略,但在我这个简单任务上不需要这些花招,10个epoch跑完基本就到稳定状态了。训练结束时命令行会打印一个最终的验证准确率,我那次跑出来的验证准确率大约在98%上下,对于这样一个小网络来说已经够用了。
5. 实战中会遇到的四个问题:我的排查过程与修复方法
5.1 症状一:准确率卡在某个固定百分比上原地不动
我第一次运行训练脚本时,损失函数在前几步急速下降,然后准确率就卡在了一个奇怪的位置上,怎么都不动。排查之后发现问题出在数据增强器上。我当时的增强参数里加了大量随机旋转和缩放,导致同一张图被增强之后可能已经看不出原本的类别了。数字识别不同于自然图像分类,过度的旋转会直接把6变成9、把7变成1。
这个问题的排查链路是:先看训练损失有没有正常下降——正常;再看验证准确率的收敛值——比预期低不少;最后检查了增强器参数,发现旋转范围设得过大。解决办法是把Rotate去掉,只保留轻微平移。替换之后,准确率马上回到了正常区间。
5.2 症状二:训练集准确率很高,验证集却上不去
这是典型的过拟合信号。我一开始为了“提高模型能力”,把第二个卷积层的滤波器数量从16加到了64,结果训练集准确率涨到99%以上,验证集却掉到90%左右。
排查思路很直观:模型容量太大,而训练数据不够多,网络开始“背答案”而不是学规律。我把滤波器数量从64降回16,同时引入L2正则化,验证集准确率反而上升了几个百分点。这件事让我彻底明白一个道理:在小数据集上,网络结构的“克制”比“堆参数”重要得多。
5.3 症状三:CPU训练也太慢了,每个epoch像一年
MATLAB的深度学习支持GPU加速,但不是每个人都有支持CUDA的显卡。我当时的电脑只有CPU,训练10个epoch大概要十几分钟,虽然数字识别任务还能忍,但如果你准备换到更大的数据集,就必须正视这个问题。
这里有几个实操思路:把下图尺寸从64x64降到32x32,网络输入的矩阵变小,计算量会明显下降;把MiniBatchSize适当调大,利用CPU的批量计算能力,每次处理更多图像能摊薄开销;或者换个更小的网络结构,去掉一个池化层,都能压时间。我最后是尺寸降到32x32,精度只掉了一点,但速度提高了一半以上,这对快速验证来说是非常划算的交换。
5.4 症状四:OOM内存不足与批量大小的连锁反应
训练到一半弹出内存不足的错误,这个问题在MATLAB里也常见。最大的嫌疑就是MiniBatchSize太大,导致单次迭代需要加载进内存的图片太多,尤其在图像尺寸被放大的时候。我遇到过两次,一次是因为图像尺寸从64x64改到128x128,另一次是因为augmentedImageDatastore里堆了太多随机增强变体到内存中。
处理方式也不复杂,把MiniBatchSize从128降到64,再把训练集和验证集的augmentedImageDatastore对象都显式创建,避免在循环内反复产生中间变量。MATLAB内存是连续的,大矩阵一旦开辟不好就容易崩,写脚本时养成随手清理不用的变量的习惯也很有帮助。
| 问题 | 常见症状 | 排查方向 | 解决手段 |
|---|---|---|---|
| 增强过度 | 损失正常下降、验证准确率偏低 | 检查数据增强参数范围 | 去掉旋转、仅保留轻微平移 |
| 过拟合 | 训练准确率远高于验证准确率 | 检查网络容量与数据量差距 | 减少滤波器数量、加L2正则 |
| 训练过慢 | 每个epoch耗时太久 | CPU/GPU占用与图像尺寸 | 降尺寸、调大批大小、精简网络 |
| 内存不足 | 训练中报OOM | 批大小与中间变量占用 | 调低批大小、清理变量 |
6. 预测、评估与特征图可视化:别只盯着准确率
6.1 用classify做批量预测
训练完模型,只会在训练集上自嗨可不行。测试集之前我们单独留出来了,现在正好派上用场:
predLabels = classify(net, augimdsTest); testAccuracy = mean(predLabels == imdsTest.Labels); fprintf('Test accuracy: %.2f%%\n', testAccuracy * 100);这段代码会把测试集里每一张图都跑一遍前向传播,得到一个预测标签,然后和真实标签对比算准确率。如果测试准确率和刚才训练时显示的验证准确率差太多,那就要回头检查数据划分有没有问题,或者模型是不是对验证集产生了一定程度的“记忆效应”。
6.2 混淆矩阵:只看准确率会骗人
准确率是一个综合性指标,但隐藏信息很多。比如某类别的识别率特别低,其他类别再高,平均下来看着也不差,实际使用却容易翻车。为了看得更清楚,我习惯在测试集上画混淆矩阵:
cm = confusionchart(imdsTest.Labels, predLabels);confusionchart会自动生成一个带颜色深浅的混淆矩阵图。对角线上的数值越大说明该类别识别得越准,非对角线上的色块则表示发生了混淆。我那次跑出来的结果里,大部分数字的识别准确率都很高,但7和1之间出现了少量混淆,原因也简单:手写体中7带横杠和不带横杠的差异巨大,网络在没有足够多样本的情况下很难总结出一套绝对规则。
拿到混淆矩阵之后,如果你发现某两个类特别容易被搞混,可以选择去收集更多这两个类的训练样本,也可以针对性地给这两个类增加数据增强概率。这是比盲目涨网络层数更加高效的优化路径。
6.3 特征图可视化:CNN内部到底看到了什么
最后做一步可视化,可以帮助我们从直觉上理解CNN每一层都在干什么。
img = readimage(imdsTest, 1); featureMap = activations(net, img, 'conv1');activations函数会返回指定层的输出。对于conv1层,输出是一个四维数组,分别对应样本序号、特征图编号、高度、宽度。把它按编号逐张画出来,你会看到第一层卷积提取的大多是边缘、方向、笔画轮廓之类的低层特征。再看conv2层,特征图明显变得更加抽象,有些图块对应着曲线组合,有些对应着局部纹理。到这里就理解了,CNN并不是直接“认出”一个数字,而是通过一层层组合最简单的基本特征,逐步得到更抽象的语义信息。如果做实际项目,这种可视化也可以帮助判断网络到底在靠什么做判断,而不是只当个黑盒用。
7. 沿途总结:一点实操体会
整个流程跑完之后,我的直接感受是:MATLAB做简单CNN确实是一条轻量、高效的路径,特别适合快速验证想法和处理中小规模图像分类任务。不过最后有几句真心话想写在这里:
第一,网络结构不要一上来就追求深和大。我的经验是先拿一个浅层网络跑通全流程,再根据训练曲线逐步加容量。这样排查问题时,变量少,能一次定位到病灶。
第二,数据准备的时间应该占到整个项目的一半以上。很多时候模型效果不好,真的不是网络结构不够好,而是数据划分、增强参数、样本均衡这些前置环节出了问题。
第三,调参不要靠玄学。每次改动只动一个变量,记录改动前后的验证指标和训练曲线,慢慢你就会形成自己的一套判断直觉。比如我现在看到训练曲线剧烈震荡,第一反应就是去调低学习率或者检查数据增强,而不是继续加层数。
这篇内容里给出的代码和参数,我后来又做了几次微调复现,整体表现都稳定。如果你正打算在MATLAB里实现CNN,可以把这份实现当作一个能跑的起点,然后往你自己的数据集上迁移,迁移过程中遇到的新问题会比这几节里提到的更有意思。