说实话,最初接到这个任务时我并没有多想,无非是拿一个现成的分类网络微调一下。但真正动手之后发现,纯CNN在一批猫狗照片上跑出的结果总差口气——光照复杂、角度刁钻、局部遮挡多的图,模型往往只盯着纹理细节,忽略了不同区域之间的上下文关系。后来把LSTM接到CNN后面,让卷积层输出的特征图按空间位置展开成序列,LSTM像人扫视图片一样逐块读取这些特征,效果立刻不一样了。这篇文章就把这套CNN-LSTM图像分类模型在Matlab 2022版里的完整搭建过程写清楚,使用猫狗二分类数据集实战,程序结构和图像组织方案都会给出可直接参考的版本。
如果你正在做图像分类、准备课程设计,或者想把“CNN提特征、LSTM建模长程依赖”这套组合用在Matlab项目里,这篇内容应该能帮你省下不少自己摸索的时间。我会从原理讲到训练选项,再到常见报错的排查,尽量让大家能把代码真跑起来。
1. 从纯CNN到CNN-LSTM:我为什么在Matlab里搭这个网络
1.1 纯CNN在图像分类里的瓶颈在哪里
CNN擅长提取局部特征。卷积核在图像上滑动,捕捉边缘、纹理、形状片段,层层叠加后获得越来越抽象的表达。对于猫狗二分类这种任务,纯CNN的表现通常已经不错,但它有个天然短板:卷积操作看的是“局部感受野”,哪怕堆了很多层,最终对全局关系的建模仍然依赖深层大感受野的累积,中间的信息交互不够直接。
举个我实际遇到的例子。有一张侧趴着的猫,尾巴卷到身体前面,背景是类似颜色的地毯。纯CNN会花很多权重去学习猫毛纹理和地毯纹理的差异,但对“猫的头部大概在哪个区域、尾巴应该离头部多远”这种空间上下文关系捕捉得比较弱。结果就是分类边界容易受局部干扰,测试集上总有几个难样本反复出错。
LSTM不一样。它天然适合处理有顺序的数据,每一步都能看到前一步的状态,相当于记住并更新一条“记忆线”。如果把图像特征按空间位置展开成序列,LSTM就能在读取每个局部区域的同时,把之前看过的区域信息一起考虑进去。这种设计刚好补上CNN对全局上下文建模的短板。
1.2 CNN和LSTM的分工逻辑
整套网络的思路可以拆成两段。
第一段是CNN主干,负责把原始图像从像素级压缩成高层的空间特征图。这段的核心目标是提取出“哪里有猫的耳朵”“哪里像狗的鼻子”这一类信息,输出是一个多维特征图,每个位置对应原图某一块区域的语义抽象。
第二段是LSTM。CNN输出的特征图不是一个向量,而是有高度、宽度、通道数的立体结构。把它按空间位置展开,每个位置上的通道值就形成一个向量,整张特征图就变成了一个带顺序的向量序列。LSTM从左到右、从上到下逐个读取这些位置,每读一个位置都会更新内部状态,最后用最后一个时间步的隐藏状态作为整张图的全局表达,送到全连接层做分类。
这样分工的好处是:CNN负责“看”,LSTM负责“把看到的东西串起来”。原来CNN里需要靠很深网络才能勉强建模的长程空间依赖,现在由LSTM直接处理,整体结构更清晰,训练起来也更容易控制。
1.3 这套模型适合哪些场景
如果你手里是标准图像分类任务,数据量又很大,纯CNN甚至预训练模型微调可能就够用。但如果你想加深对深度学习模型组合的理解、课程设计需要体现“CNN-LSTM”这类混合结构、或者你的图像本身具有明显的空间布局规律(比如医学影像里的组织区域关系、文档图像里的版面结构),那这套模型就非常适合。
我用的猫狗数据集是一种典型验证场景:类别少、图像内容差异大、但难样本多。用这个数据集把整套流程跑通之后,换到其他图像分类任务只需要改数据路径和最后的分类层数量,迁移成本很低。
2. 数据准备:猫狗数据集的目录组织与预处理细节
2.1 数据集目录结构与标签映射
在Matlab里做图像分类,最顺手的数据组织方式是按类别建文件夹。这样用imageDatastore读取时,Matlab会自动把子文件夹名当作标签,省去手动标注的麻烦。我的目录结构是这样的:
cats_dogs/ ├── train/ │ ├── cat/ │ │ ├── cat_001.jpg │ │ └── cat_002.jpg │ └── dog/ │ ├── dog_001.jpg │ └── dog_002.jpg └── val/ ├── cat/ └── dog/实际项目中我建议直接从公开渠道下载一批猫狗图片,各类别数量控制在1000张左右。太少的话LSTM学不到稳定的空间依赖,太多的话训练时间会拉长。注意检查文件夹命名大小写要一致,否则LabelSource读取时可能出现标签不一致的问题。
用imageDatastore读取时,核心代码是:
imds = imageDatastore('cats_dogs', ... 'IncludeSubfolders', true, ... 'LabelSource', 'foldernames');这里有一个容易忽略的细节:如果所有图片都在train文件夹下而没有单独的val文件夹,建议先读取全部数据,再用splitEachLabel划分。我习惯先保证目录干净,train和val分开存放,后面做验证集的时候会更省事。
2.2 统一尺寸与数据增强的选择
CNN-LSTM网络的输入尺寸我选的是227×227×3。这个尺寸既不会太大导致显存压力,又不至于太小丢失细节。给模型投喂数据前,所有图片都要缩放到这个尺寸。用augmentedImageDatastore可以一边缩放一边做数据增强,非常方便。
数据增强这一环,我做项目时踩过不少坑。增强太弱,模型学不到泛化能力;增强太狠,比如把猫的尺寸缩放得过小、平移过多,会导致训练集和真实分布偏离。我最终采用的增强参数如下:
aug = imageDataAugmenter(... 'RandXTranslation', [-20 20], ... 'RandYTranslation', [-20 20], ... 'RandXReflection', true, ... 'RandScale', [0.9 1.1]);为什么选平移20像素和缩放10%?因为猫狗图像的主体通常集中在画面中央,小幅平移和缩放可以模拟拍摄角度和距离的轻微变化,又不会把主体移出画面。RandXReflection对猫狗这类对称性较强的物体非常有效,能直接让有效样本量翻倍。
2.3 训练集与验证集的合理划分
很多初学者喜欢把全部数据丢进去训练,最后才发现没有验证集。验证集的作用不仅是看准确率,更重要的是在训练过程中监视过拟合。我建议从原始数据中分出15%到20%作为验证集,并且保证猫狗两类在验证集中数量均衡。
使用splitEachLabel时要注意设置随机种子,否则每次运行的结果可能完全不同:
rng(2024); [imdsTrain, imdsVal] = splitEachLabel(imds, 0.8, 'randomized');这里0.8表示保留80%作为训练集,剩下20%自动成为验证集。加上'randomized'参数后,每次划分都是随机进行的。如果不设置rng,同一个脚本两次运行拿到的训练集都不一样,后续对比实验结果时就会很麻烦。
3. 网络结构设计:逐层拆解与参数推算
3.1 从输入到特征图:卷积基座的设计
网络的卷积部分我参考了经典结构的思路,但做了精简,主要为了控制参数量,让普通显卡也能跑得动。整体结构从输入到LSTM之前是这样设计的:
imgSize = [227 227 3]; baseLayers = [ imageInputLayer(imgSize, 'Name', 'input') convolution2dLayer([11 11], 96, 'Stride', 4, 'Padding', 'same', 'Name', 'conv1') batchNormalizationLayer('Name', 'bn1') reluLayer('Name', 'relu1') maxPooling2dLayer([3 3], 'Stride', 2, 'Name', 'pool1') convolution2dLayer([5 5], 256, 'Padding', 'same', 'Name', 'conv2') batchNormalizationLayer('Name', 'bn2') reluLayer('Name', 'relu2') maxPooling2dLayer([3 3], 'Stride', 2, 'Name', 'pool2') convolution2dLayer([3 3], 384, 'Padding', 'same', 'Name', 'conv3') batchNormalizationLayer('Name', 'bn3') reluLayer('Name', 'relu3') convolution2dLayer([3 3], 256, 'Padding', 'same', 'Name', 'conv4') batchNormalizationLayer('Name', 'bn4') reluLayer('Name', 'relu4') maxPooling2dLayer([3 3], 'Stride', 2, 'Name', 'pool3') ];算一下特征图尺寸:输入227×227,经conv1步长4,得到ceil(227/4)=57,即57×57×96;pool1后变成28×28×96;conv2保持28×28×256;pool2后14×14×256;conv3和conv4保持尺寸不变;pool3后最终得到7×7×256的特征图。
为什么第一层卷积核用11×11、步长4?因为输入尺寸偏大时,大步长可以快速缩小特征图,减少计算量。后面的3×3小卷积核用于在较小尺寸的空间上精细提取特征,同时把通道数提高到256,保证特征表达足够丰富。
3.2 sequenceFoldingLayer:把“图像”变成“空间序列”
这是整篇文章最关键的一层。CNN输出的特征是7×7×256,它本质是一张“空间特征图”,有高度、宽度、通道。LSTM要求输入是序列,所以必须把特征图转换成序列格式。
我用的办法是sequenceFoldingLayer。它的作用是把一个三维特征图按空间位置折叠成序列:7×7共有49个位置,每个位置有256个通道值,因此输出就是一个长度49、每步特征维度256的序列。
这种“空间序列”到底代表什么呢?我理解它相当于把图像分成49个区域,LSTM像视线一样依次扫过这些区域。第一个时间步读左上角区域,第二个时间步读右上角区域,依次类推。LSTM的隐藏状态会把已经看过的区域信息保留下来,所以当读到右下角时,模型已经记住了左上角的猫耳朵形态和中部区域的猫脸轮廓,最终形成完整的空间上下文判断。
代码里接入这一层非常直接:
foldLayer = sequenceFoldingLayer('Name', 'fold');需要注意,sequenceFoldingLayer在训练时会有一个MiniBatchSize属性需要与训练选项保持一致,这一点我后面专门讲坑的时候会详细说。
3.3 LSTM层与分类层:输出模式与过拟合控制
特征序列送到LSTM后,网络设计如下:
lstmLayers = [ lstmLayer(128, 'OutputMode', 'last', 'Name', 'lstm') dropoutLayer(0.5, 'Name', 'drop') fullyConnectedLayer(2, 'Name', 'fc') softmaxLayer('Name', 'softmax') classificationLayer('Name', 'classout') ];LSTM隐藏单元数选128,是我在64和256之间对比后的折中。64表达能力偏弱,256训练明显变慢且容易过拟合,128在猫狗二分类上表现最稳。OutputMode设为last,是因为分类任务只需要最后一个时间步汇总全部49个位置的最终状态,不需要输出每个位置的中间结果。
LSTM和全连接层之间我加了dropout,比例0.5。LSTM这种循环结构参数量不小,没有dropout时训练集准确率很容易冲到接近100%,验证集却停在92%上下。加上dropout后,过拟合现象明显缓解。
最后一层fullyConnectedLayer(2)对应猫、狗两个类别,softmax和classificationLayer负责输出概率和计算分类损失。这里唯一要注意的是输出节点数必须与标签类别数一致,如果改成了猫狗以外的三分类,记得把2改成3。
4. 训练选项与超参数:每一组数字背后的取舍
4.1 学习率与批大小怎么定
训练CNN-LSTM时,我反复试过几组学习率,最终选定1e-3的初始学习率。原因是网络里有BatchNormalization层,它对学习率不那么敏感,1e-3在这种中等规模网络上收敛速度合适,又不会像1e-2那样出现loss震荡。
批大小我选32。这个值和sequenceFoldingLayer的MiniBatchSize有直接关系,后面会详细解释。从效果看,批大小32配合学习率1e-3,在验证集上的准确率曲线比较平滑,没有出现剧烈波动。如果你显存不够,可以尝试16,但此时建议把学习率降到5e-4,否则梯度噪声太大。
训练选项的完整配置如下:
options = trainingOptions('adam', ... 'InitialLearnRate', 1e-3, ... 'MiniBatchSize', 32, ... 'MaxEpochs', 20, ... 'Shuffle', 'every-epoch', ... 'ValidationData', auimdsVal, ... 'ValidationFrequency', 20, ... 'Verbose', true, ... 'VerboseFrequency', 10, ... 'Plots', 'training-progress', ... 'ExecutionEnvironment', 'auto');有GPU时ExecutionEnvironment设为auto会自动调用GPU,没有GPU时会自动退回CPU。用CPU训练的话,相同数据量大概会慢3到5倍,建议把MaxEpochs降到10以内先跑通流程。
4.2 epoch、验证频率与数据打乱
MaxEpochs选20,是我在猫狗数据集上观察到的收敛拐点。前5个epoch训练准确率从80%迅速升到95%,之后增速变慢;15个epoch后验证准确率基本稳定在97%左右,再训练下去也没有明显提升。
ValidationFrequency设为20,意思是每20次迭代验证一次。为什么不是每个epoch都验证?因为一个epoch大约有50个batch,每个epoch结束时验证已经足够观察趋势。设得太频繁会拖慢训练,尤其是验证集比较大时,每次验证都要在整个验证集上跑一遍前向计算。
Shuffle设为every-epoch,每个epoch开始前都把训练数据重新打乱。这个设置对LSTM尤其重要。如果数据顺序固定,模型会逐渐记住批次之间的表面顺序关系,导致验证集表现不稳定。
4.3 训练进度图怎么读
Matlab训练过程中弹出的进度图包含两组曲线:上面是准确率,下面是损失。蓝色是训练集,黑色是验证集。
我习惯盯着验证集损失看。如果训练损失持续下降、验证损失却反弹上升,基本可以断定过拟合。这时候不是调低学习率,而是加强dropout或数据增强。如果训练损失和验证损失同时高位不动,说明学习率太大或网络结构有问题。如果验证损失在某个值附近反复横跳,多半是批大小偏小、梯度噪声过大。
一个我自己的经验:看到准确率到98%就急着停掉训练其实不可取,还是让训练把20个epoch跑完,然后对比验证集上的最终结果。有时候最后一个epoch会有意外惊喜,因为LSTM这类模型在后期的微调阶段会悄悄优化掉不少边缘样本。
5. 可直接运行的Matlab代码
5.1 主脚本:从数据读取到模型训练
把上面的模块串起来,就是一套完整可运行的训练脚本。命名我习惯用main_cnn_lstm.m,整个过程分四段:读取数据、构建网络、设置训练参数、启动训练。
% ===== 1. 读取与增强数据 ===== imds = imageDatastore('cats_dogs', ... 'IncludeSubfolders', true, ... 'LabelSource', 'foldernames'); rng(2024); [imdsTrain, imdsVal] = splitEachLabel(imds, 0.8, 'randomized'); aug = imageDataAugmenter(... 'RandXTranslation', [-20 20], ... 'RandYTranslation', [-20 20], ... 'RandXReflection', true, ... 'RandScale', [0.9 1.1]); auimdsTrain = augmentedImageDatastore([227 227], imdsTrain, 'DataAugmentation', aug); auimdsVal = augmentedImageDatastore([227 227], imdsVal); % ===== 2. 构建CNN-LSTM网络 ===== baseLayers = [ ... 上面写过的卷积层 ... ]; foldLayer = sequenceFoldingLayer('Name', 'fold'); lstmLayers = [ ... 上面写过的LSTM分类层 ... ]; layers = [baseLayers; foldLayer; lstmLayers]; % ===== 3. 设置训练选项 ===== options = trainingOptions('adam', ... 'InitialLearnRate', 1e-3, ... 'MiniBatchSize', 32, ... 'MaxEpochs', 20, ... 'Shuffle', 'every-epoch', ... 'ValidationData', auimdsVal, ... 'ValidationFrequency', 20, ... 'Plots', 'training-progress'); % ===== 4. 开始训练 ===== net = trainNetwork(auimdsTrain, layers, options);如果你在某一版本里遇到layer数组拼接时报维度不匹配的错误,可以用layerGraph手动连接:
lgraph = layerGraph(layers); lgraph = connectLayers(lgraph, 'pool3', 'fold');trainNetwork对层的连接顺序有严格检查,autoconnect偶尔会处理不好sequenceFoldingLayer这种特殊层,手动connect会更稳。
5.2 特征图可视化:确认CNN学到了什么
训练完之后,我建议抽一张验证集图片,可视化中间卷积层的特征图,确认模型没有把学习引向奇怪的方向。代码很简单:
testImg = readimage(imdsVal, 1); act = activations(net, testImg, 'relu4'); actMat = act(:,:,:,1); actMat = mat2gray(actMat); montage(actMat, 'Size', [8 8]);这里取relu4的输出,因为它是送入LSTM前的最后一层卷积特征,最能代表模型最终提取到的空间特征质量。如果特征图看起来是一堆均匀噪点,说明网络没有学到有效结构;如果能隐约看出猫或狗的轮廓,说明特征提取是正常的。
特征图可视化还有一个作用:帮你判断是否需要加深CNN。如果特征图过于稀疏,大部分位置都是黑色,说明当前任务对CNN来说太简单,可以适当减少卷积层;如果特征图过于模糊,则可以加一层卷积提升表达力。
5.3 评估:准确率与混淆矩阵
训练完成后,在验证集上做最终评估。这一步必须先reset数据存储,否则分类顺序会错位:
reset(auimdsVal); YPred = classify(net, auimdsVal); YVal = imdsVal.Labels; accuracy = sum(YPred == YVal) / numel(YVal); fprintf('验证集准确率: %.2f%%\n', accuracy * 100); figure; confusionchart(YVal, YPred);我跑通后验证集准确率稳定在96%到98%之间。如果猫狗图片本身差异较大,通过这套流程达到95%以上是比较正常的。
混淆矩阵要重点看“狗被误判成猫”和“猫被误判成狗”哪个方向更多。这个信息能帮你反推数据问题,比如某一类图片整体偏暗、角度特殊,就需要在增强时针对这类样本增加亮度扰动或旋转角度。
6. 实战中踩过的坑与备选方案
6.1 sequenceFoldingLayer与MiniBatchSize的烦恼
我第一次跑这个网络时,卡在了一个很隐蔽的报错上。训练刚开始就提示sequenceFoldingLayer的MiniBatchSize必须与训练选项里的MiniBatchSize一致。
问题在于,sequenceFoldingLayer在构造时没有显式暴露MiniBatchSize的设置接口,训练时它和trainingOptions里的MiniBatchSize必须严格相等。我当时的解决方法是把训练选项里的MiniBatchSize固定为32,同时在构造layerGraph后去读一下fold层的MiniBatchSize确认一致:
layers(ismember({layers.Name}, 'fold'))如果你的代码是在旧版本的Matlab运行时,还有可能碰到sequenceFoldingLayer不被trainNetwork支持的情况。这种情况不要死磕,直接跳到下面的两步法方案。
6.2 GPU显存不足与过拟合对策
LSTM层虽然是序列建模,但在Matlab里它同样需要大量矩阵运算,显存占用比纯CNN高不少。我用一块中端显卡跑的时候,batch32一度接近显存上限。遇到Out of Memory时,最直接的办法是把MiniBatchSize调到16,同时把LSTM隐藏单元从128降到64。
降batch后要注意同步调整学习率。我通常的做法是batch减半,学习率也减半,从1e-3降到5e-4,这样收敛稳定性不会受到太大影响。
过拟合的另一个对策是增强dropout强度。0.5的dropout在中等数据集上效果不错,如果你发现验证准确率明显低于训练准确率,可以尝试把dropout提高到0.6,同时增强RandScale的范围到[0.85 1.15]。
6.3 不依赖sequenceFoldingLayer的两步法
如果你的Matlab版本不支持sequenceFoldingLayer,或者你想彻底回避它带来的训练选项限制,可以用两步法实现同样的CNN-LSTM效果。
第一步,用预训练网络或自己训练的CNN提取每张图的特征图,并将特征图reshape成序列:
featureSize = 6 * 6 * 256; XTrain = cell(numel(imdsTrain.Files), 1); for i = 1:numel(imdsTrain.Files) img = readimage(imdsTrain, i); img = imresize(img, [227 227]); act = activations(cnnNet, img, 'relu4'); act = squeeze(act(:,:,:,1)); XTrain{i} = reshape(act, size(act,1)*size(act,2), size(act,3))'; end第二步,用LSTM网络对序列分类:
lstmLayers = [ sequenceInputLayer(256) lstmLayer(128, 'OutputMode', 'last') dropoutLayer(0.5) fullyConnectedLayer(2) softmaxLayer classificationLayer];这种方式的好处是每一步都可以单独调试,先确认CNN特征提取质量,再训练LSTM分类器。缺点是不能端到端联合调优,但作为学习目的和中等规模数据来说,效果已经足够。
我在实际项目里遇到特殊的层兼容性问题时,都是先用两步法把流程跑通,再回头优化端到端结构。这种做法在排错时特别高效。
最后再分享一个体会。这个CNN-LSTM结构对我来说最大的价值,并不是在准确率上比纯CNN高了多少,而是它逼着我把模型的每个中间环节都看得清清楚楚。CNN输出的特征图是模型真正“看到”的东西,LSTM则强迫模型按顺序去理解整张图。做图像分类项目时遇到瓶颈,不妨试试把模型拆开看看,往往比盲目堆更多卷积层有效得多。后续如果想进一步提升,可以在这个框架基础上加入注意力机制,或者把LSTM替换成双向结构,扩展方向非常多。