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

文章详情

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

CNN-LSTM-SE注意力机制:原理与Matlab实现

CNN-LSTM-SE注意力机制:原理与Matlab实现 简介一份基于CNN-LSTM-SE注意力机制的多输入单输出分类预测Matlab实现面向需要进行时间序列分析、文本分类或语音识别等任务的研究者与工程人员。项目将卷积特征提取、长短期记忆建模与SE通道注意力相结合适用于特征间存在空间关联且带时序依赖的分类场景。压缩包共6个文件以1个可运行的主脚本、1个数据集xlsx及4张结果示意图结构图、损失与准确率曲线等构成整体仅185KB轻量易用。目前已收到1385人次学习下载。运行主程序脚本可复现从数据预处理、模型构建、训练验证到性能评估的完整流程并参照代码迁移至自身多输入分类任务配套图示能直观对照网络结构与训练动态理解SE模块对关键特征的强化效果适合作深度学习入门及课题复现参考。1. CNN-LSTM-SE Attention三个模块合起来到底强在哪做时序信号分类预测时最容易被问倒的一个问题是为什么CNN和LSTM拼在一起还不够我自己的血泪经验是纯CNN对局部突变很敏感但一碰到跨几百个时间步的依赖就抓瞎纯LSTM能记住长程关系却容易被无关通道的噪声带偏。CNN-LSTM-SE Attention的组合是在这两者之间塞进一个能学习的“通道开关”CNN负责把原始信号提成高维局部特征SE注意力机制按通道重要性重新加权LSTM再消化这些加权后的时序特征。对Matlab从业者来说这套方案有完整程序和数据可抄适合做故障诊断、行为识别、负荷分类这类序列分类预测任务。这篇文章从原理、完整Matlab代码到避坑参数一条线讲清楚。2. 先拆结构CNN抓局部、LSTM抓时序、SE Attention抓通道权重2.1 一个样本在三个模块里的流动路径先说清数据流这是后面所有代码的地基。在Matlab深度学习工具箱里一个序列分类样本的格式通常是“特征数×时间步数”的矩阵也就是 $C×T$。输入一个滚动轴承振动信号可能 12 个统计特征、800 个时间步那这个样本就是 $12×800$。CNN对它的处理是按时间轴做一维卷积输出变成 $F×T$其中 $F$ 是卷积核数比如 32这一步相当于在原始特征之上提取局部模式比如冲击脉冲、谐波片段。SE注意力层接在卷积之后输入输出尺寸不变仍是 $32×T$。它内部先做全局平均池化把每个通道压成一个标量再经过两个全连接层和Sigmoid激活得到 $32×1$ 的权重向量最后把这个权重向量乘回原来的 $32×T$ 特征图。这就是“通道重标定”有用的卷积核通道被放大没用的被压低。之后LSTM层接手LSTM的隐单元数设为 64输出模式选last也就是只取最后一个时间步的隐状态拼一个全连接层和Softmax做分类。下面是每一层的数据形状变化层输出尺寸作用序列输入层$12×T$Z-score 归一化稳定训练一维卷积层$32×T$提取局部时间模式ReLU 层$32×T$非线性激活SE Attention 层$32×T$通道加权突出有效特征LSTM 层$64×1$建模长程时序依赖Dropout 层$64×1$随机失活抑制过拟合全连接层$K×1$映射到分类数 $K$Softmax 层$K×1$输出类别概率从这张表能直观看到SE层把卷积和LSTM连接起来前面对通道做筛选后面时序建模用的特征已经“过滤过一遍”。我一般把这种结构叫“先提特征、再选通道、最后记时序”顺序的先后比想象中重要。2.2 SE注意力为什么放在CNN和LSTM之间很多第一次接触这个结构的同学会问SE注意力直接放在网络最后、Softmax之前行不行行是行但效果通常打折扣。原因在于SE的本质是“对特征通道做选择”而通道数最多、最需要选择的时刻恰恰是卷积层输出的时候。如果放在LSTM之后LSTM已经把时序信息压缩成一个向量通道维度消失了SE那个“通道开关”没有了作用对象只剩下一堆全连接退化成普通的特征加权。另一个常见问题是SE的两个全连接层参数量会不会太大。SE内部先降维再升维中间隐藏维度是 $C/r$$r$ 叫缩减比reduction ratio。设卷积核数为 32$r16$中间维度只有 2两个全连接权重加起来也就约 70 个参数对整体参数量几乎无感。但这 70 个参数能学出“哪些卷积核方向重要”有点像给CNN的每个卷积核配了一个可学习的音量旋钮。这个旋钮放在LSTM之前等价于告诉LSTM后面 300 个时间步里你优先看这几个通道的记忆。落到Matlab里SE层一般用自定义层实现。Matlab不像PyTorch那样有现成的SELayer但只要把两个全连接权重写成层的可学习属性整个自定义层也就几十行。这也是Matlab做这个结构最让人劝退的一步很多人挂在自定义层的写法上我在第3节会把可运行的代码直接贴出来。2.3 为什么不用注意力机制替代SE而是“结合”注意力机制和SE注意力不是一回事。经典注意力比如Bahdanau Attention是在时间步维度上分配权重回答的是“哪个时间点重要”SE是在通道维度上分配权重回答的是“哪一类特征重要”。时序信号分类里这两件事都需要做LSTM本质上已经在时间步维度上隐式建模了重要性但通道维度的选择只能靠SE。所以标题里写“结合”而不是“替代”是准确表述——CNN负责形态、SE负责通道、LSTM负责顺序各管一段。3. 在Matlab里搭CNN-LSTM-SE Attention完整程序与数据准备3.1 数据怎么摆成Matlab认识的形状Matlab做序列分类训练输入X必须是1×N的 cell 数组N是样本数。每个 cell 里是一个 $C×T$ 的 double 矩阵$C$ 是特征维度$T$ 是该样本的时间步数。标签Y是N×1的categorical向量。这里有个反直觉点cell 里每个样本的时间步数 $T$ 可以不一样。LSTM天然支持变长序列CNN层在一维卷积时也按各样本自身的时间轴滑动所以不必补齐到相同长度。我做数据准备时通常直接按下面这段代码切训练集和测试集% 原始数据: dataMat 为 N×C×T 的三维数组labels 为 N×1 的数值标签 % 第一步: 随机打乱样本顺序避免类别聚集 rng(42); permIdx randperm(size(dataMat, 1)); dataMat dataMat(permIdx, :, :); labels labels(permIdx); % 第二步: 按 8:2 切分训练/测试 numSamples size(dataMat, 1); numTrain round(0.8 * numSamples); % 第三步: 把三维数组转成 cell 数组每个样本是 特征×时间步 的矩阵 XTrain cell(1, numTrain); for i 1:numTrain XTrain{i} squeeze(dataMat(i, :, :)); % C×T end XTest cell(1, numSamples - numTrain); for i 1:numSamples - numTrain XTest{i} squeeze(dataMat(i numTrain, :, :)); end % 第四步: 标签转 categorical YTrain categorical(labels(1:numTrain)); YTest categorical(labels(1 numTrain:end)); % 核对形状 disp(size(XTrain{1})); % 期望输出 [C, T] disp(countcats(YTrain)); % 期望输出每个类别的样本数这里squeeze很容易翻车如果某个样本恰好 $C1$squeeze会把 $1×T$ 压成向量 $T×1$形状就反了。保险做法是reshape(dataMat(i, :, :), [C, T])按已知维度显式整形而不是靠squeeze猜维度。训练输入必须是 cell 数组不能直接把三维数组丢给trainNetwork这是Matlab序列分类最容易报错的地方之一。3.2 手写SE注意力自定义层可学习权重怎么存Matlab里没有内置SE层需要写一个继承nnet.layer.Layer的自定义类。自定义层的难点不是前向计算而是把两个全连接的权重声明成Learnable属性。下面这段代码是完整可运行的seAttention层输入输出都是 $C×T$ 矩阵classdef seAttention nnet.layer.Layer % SE Attention 层 % 输入 [C, T]输出 [C, T]对通道维做重标定 properties (Learnable) W1 % 降维全连接权重, 维度 [C/r, C] b1 % 降维偏置, 维度 [C/r, 1] W2 % 升维全连接权重, 维度 [C, C/r] b2 % 升维偏置, 维度 [C, 1] end methods function layer seAttention(numChannels, reductionRatio, layerName) % 构造函数: 指定通道数、缩减比和层名 layer.Name layerName; layer.Description SE Attention, reduction reductionRatio; hiddenChannels max(1, floor(numChannels / reductionRatio)); % Glorot 风格初始化, 避免训练初期梯度消失 scale1 sqrt(2 / (numChannels hiddenChannels)); scale2 sqrt(2 / (hiddenChannels numChannels)); layer.W1 (rand(hiddenChannels, numChannels, single) * 2 - 1) * scale1; layer.b1 zeros(hiddenChannels, 1, single); layer.W2 (rand(numChannels, hiddenChannels, single) * 2 - 1) * scale2; layer.b2 zeros(numChannels, 1, single); end function y predict(layer, x) % 训练时也要走的前向计算 % x: [C, T] 单样本特征图 z mean(x, 2); % 全局平均池化 - [C, 1] a max(layer.W1 * z layer.b1, 0); % 降维 ReLU s 1 ./ (1 exp(-(layer.W2 * a layer.b2))); % 升维 Sigmoid y x .* s; % 通道重标定, 广播乘法 end function [y, memory] forward(layer, x) % dlnetwork 需要 forward, 这里直接复用 predict y layer.predict(x); memory []; end end end这段代码里最关键的是mean(x, 2)。在 $C×T$ 矩阵上沿第 2 维求均值正好得到每个通道的全局平均响应x .* s里s是 $C×1$Matlab会自动把s广播到 $C×T$ 的每一列不需要repmat。初始化用rand(..., single) * 2 - 1把权重落在 $[-1, 1]$再乘 Glorot 尺度。不要用randn原始值当权重那样训练初期输出方差过大LSTM的梯度很容易炸掉。另一个要点是同时写了predict和forward。用trainNetwork训练时只调用predict如果你后面改走dlnetwork自定义训练循环就必须实现forward。我见过不少人在这个上面踩坑只写了predict一换dlnetwork就报“未定义方法 forward”。上面这段代码两个都写了两条路都能走。3.3 网络组装用 layerGraph 把SE层嵌进主线trainNetwork支持自定义层但需要先用layerGraph把层连起来再用connectLayers把支线接上。下面是完整组装代码% 超参数 inputSize 12; % 特征维度 C numFilters 32; % 卷积核数量 filterSize 5; % 卷积核长度 numHiddenUnits 64; % LSTM 隐单元数 numClasses 4; % 分类数, 按需修改 reductionRatio 16; % SE 缩减比 % 建立图层 lgraph layerGraph(); lgraph addLayers(lgraph, sequenceInputLayer(inputSize, ... Normalization, zscore, Name, input)); lgraph addLayers(lgraph, convolution1dLayer(filterSize, numFilters, ... Padding, same, Name, conv1)); lgraph addLayers(lgraph, reluLayer(Name, relu1)); lgraph addLayers(lgraph, seAttention(numFilters, reductionRatio, se1)); lgraph addLayers(lgraph, lstmLayer(numHiddenUnits, ... OutputMode, last, Name, lstm)); lgraph addLayers(lgraph, dropoutLayer(0.4, Name, drop)); lgraph addLayers(lgraph, fullyConnectedLayer(numClasses, Name, fc)); lgraph addLayers(lgraph, softmaxLayer(Name, softmax)); lgraph addLayers(lgraph, classificationLayer(Name, classoutput)); % 连接主线 lgraph connectLayers(lgraph, input, conv1); lgraph connectLayers(lgraph, conv1, relu1); lgraph connectLayers(lgraph, relu1, se1); lgraph connectLayers(lgraph, se1, lstm); lgraph connectLayers(lgraph, lstm, drop); lgraph connectLayers(lgraph, drop, fc); lgraph connectLayers(lgraph, fc, softmax); lgraph connectLayers(lgraph, softmax, classoutput); % 可视化检查: 确认 se1 正确嵌在 relu1 和 lstm 之间 analyzeNetwork(lgraph);sequenceInputLayer的Normalization设为zscore会在训练时按每个通道的均值和方差做标准化。这个选项对手工特征特别重要因为不同特征的量纲可能差几十倍比如均值是 0.01峰值是 500。如果原始数据已经单独归一化过这里可改成none。convolution1dLayer(filterSize, numFilters, Padding, same)保证卷积不缩短时间步长度。对于 $C×T$ 输入输出是 $32×T$不会因为卷积把时间轴截短——这对后面接LSTM很关键same填充避免了边界效应集中在序列两端。lstmLayer(..., OutputMode, last)表示只输出最后一个时间步的隐状态。做分类预测时一定要用last如果误用默认的sequence输出会是 $64×T$全连接层会对每个时间步都算一次分类训练速度和效果都会出问题。analyzeNetwork这一步建议每次都跑它会画出数据流图并逐个检查每个层的输入输出维度。SE自定义层如果有尺寸匹配错误在这里就会报红不用等训练到一半才翻车。3.4 训练选项validation patience 是后悔药训练选项里我特别看重三个参数ValidationPatience、OutputNetwork和LearnRateDropPeriod。ValidationPatience是验证集指标连续多少个周期不提升就提前停相当于内置的早停机制能在过拟合发生之前主动截断OutputNetwork设成best-validation表示训练结束后返回验证集上表现最好的那版权重而不是最后一轮权重。这两个搭配起来几乎等价于给训练上了保险。options trainingOptions(adam, ... InitialLearnRate, 0.005, ... LearnRateSchedule, piecewise, ... LearnRateDropPeriod, 10, ... LearnRateDropFactor, 0.5, ... MiniBatchSize, 32, ... MaxEpochs, 60, ... ValidationData, {XTest, YTest}, ... ValidationFrequency, 10, ... ValidationPatience, 8, ... Shuffle, every-epoch, ... Plots, training-progress, ... OutputNetwork, best-validation, ... Verbose, 1); net trainNetwork(XTrain, YTrain, lgraph, options);InitialLearnRate我一般从 0.005 起步。如果设 0.01SE层那两个全连接的权重更新幅度会偏大训练损失容易出现“掉下去又弹回来”的锯齿设 0.001 又太慢60 个 epoch 可能还没收敛到最佳。LearnRateDropPeriod10表示每 10 个 epoch 学习率乘以 0.5这种做法比固定学习率更容易在后期稳住。MiniBatchSize32在序列数据上不是越大越好。每个样本是一整条时间序列32个样本意味着一个 batch 里塞了 $32×800$ 个时间点显存和内存占用都不小。如果你的数据时间步特别长比如超过 2000 步可以把MiniBatchSize降到 16。训练结束后用classify和confusionchart做基础评估YPred classify(net, XTest, MiniBatchSize, 32); accuracy mean(YPred YTest); figure; confusionchart(YTest, YPred);4. 避坑排查这五个坑我几乎都踩过4.1 自定义层报错Layer se1 is not supported现象trainNetwork一开始就报错说网络包含不支持的层或者“未定义方法 forward”。原因Matlab 的trainNetwork和dlnetwork对自定义层的要求不一样。trainNetwork只要求实现predict方法而dlnetwork要求同时实现forward和predict。最常见的情况是用户从某个旧项目里复制了一个只写了predict的 SE 层塞进dlnetwork的训练循环里自然报错。解决把自定义层补上forward方法并在forward内部调用predict。前面的seAttention类里已经写了完整两个方法直接复制即可。如果用的是很老的 Matlab 版本R2018a 之前还需要在类定义里显式指定%#codegen或检查nnet.layer.Layer的基类语法兼容性。4.2 训练损失不降甚至直接变 NaN现象前几个 epoch 损失还在降第 5 个 epoch 左右突然变成 NaN训练曲线直接断掉。原因最常见的是学习率太大SE 层的全连接权重更新步长过大导致 Sigmoid 饱和区出现梯度消失或者权重矩阵中出现 Inf。第二个常见原因是初始化权重分布不当randn原始权重的方差约 1经过两层全连接后激活值极易饱和。解决把InitialLearnRate降到 0.005 以下SE 层的权重初始化换成 Glorot 风格。我在seAttention构造函数里已经做了缩放如果你是从别处复制的层务必检查W1、W2的初始化方差。还有一个调试技巧把Verbose, 1打开观察每个 epoch 的训练损失如果第二个 epoch 就出现NaN优先怀疑学习率而不是网络结构。4.3 训练集准确率 98%测试集只有 65%现象训练曲线一路上升到接近完美验证集曲线却停滞在低位典型过拟合。原因序列数据样本量通常不大而 LSTM 本身参数量不小64 个隐单元对应约 5000 个参数再加上 CNN 和 SE 层模型容量偏高。没有 dropout、没有早停过拟合几乎是必然。解决在 LSTM 后加dropoutLayer(0.4)把ValidationPatience设成 8 并配合OutputNetworkbest-validation将 LSTM 隐单元数从 128 降到 64 或 32。另外可以做数据增强一维时间序列的随机缩放、平移、加噪声在 Matlab 里用datastore自定义预处理实现成本不高但提升明显。4.4 维度对不上Incompatible input sizes 系列报错现象analyzeNetwork或训练时报维度不匹配常见于 SE 层的输出尺寸变成 $C×1$ 或 $1×T$。原因在predict里写了mean(x, 2)之后如果忘记把s广播回去而是直接返回s输出尺寸就成了 $C×1$LSTM 层无法接收这种二维特征。另一个常见错误是用squeeze处理中间变量把 $C×T$ 压成了 $T×C$ 或直接变成向量。解决SE 层predict的最终输出必须是x .* s其中x是原始输入 $C×T$s是 $C×1$。如果你做的是多输入融合结构比如两个分支分别经过 CNN 再拼接更要坚持用layerGraph的analyzeNetwork逐层检查别指望靠经验猜维度。4.5 分类结果“假好”准确率很高但某个类别完全没预测出来现象总准确率 90% 以上但混淆矩阵里有一整行都是零——某个少数类别一个都没分对。原因类别不平衡。故障诊断数据里正常样本占比高故障样本少数分类层采用交叉熵损失天然偏向多数类。SE 注意力层学到的通道权重也容易被多数类主导少数类特征被抑制。解决在classificationLayer里用ClassWeights属性给少数类加权权重按样本数的反比设置多数类权重 1少数类权重设为多数类样本数除以少数类样本数。这个属性在 Matlab 文档中有明确支持。加权之后看混淆矩阵每个类别的召回率而不是只看总准确率。5. 把分类预测做稳训练监控、指标选择与三组关键参数5.1 训练过程看什么损失曲线和梯度噪声打开Plots, training-progress后不要只盯着准确率。我更习惯看损失曲线是否平滑下降。如果损失曲线反复“锯齿”说明学习率偏高如果曲线在前 10 个 epoch 几乎水平说明学习率偏低或 SE 层初始化有问题。Matlab 训练进度图里还有“梯度”一栏如果梯度的范数剧烈跳动比如从 0.1 跳到 100需要调低学习率或检查输入数据里是否有极端离群值。还有一个容易被忽略的点zscore归一化是按通道做的但如果你把训练集和测试集分开归一化会引入数据泄露——测试集的统计信息在训练时不可见。正确做法是用summary函数先算训练集的均值和标准差然后直接应用到测试集。不过用了sequenceInputLayer的zscore选项后Matlab 默认按整个训练集统计这个坑会自动避掉。5.2 指标怎么选准确率之外看 F1 和加权召回多分类场景下准确率是最大的骗局。我做轴承故障诊断时习惯看三个数字总准确率、宏平均 F1、少数类召回率。F1 的计算在 Matlab 里不需要额外工具箱用混淆矩阵手算几行就行C confusionmat(YTest, YPred); precision diag(C) ./ sum(C, 1); recall diag(C) ./ sum(C, 2); F1 2 * precision .* recall ./ (precision recall); macroF1 mean(F1, omitnan); fprintf(总准确率: %.2f%%\n, accuracy * 100); fprintf(宏平均F1: %.3f\n, macroF1); fprintf(每类F1: %s\n, mat2str(round(F1, 3)));precision的算式里sum(C, 1)是预测为该类别的总数sum(C, 2)是该类别的真实总数两者做比时注意方向别反。omitnan用于处理某一类 precision 和 recall 都是 0 导致除零的情况。如果某个类别的 F1 比其他类别低 0.2 以上直接去查该类别的原始样本大概率是特征表达不足靠调参救不回来。5.3 三组关键参数怎么配SE缩减比、LSTM隐单元数、卷积核数SE 缩减比reductionRatio我默认用 16。这个值在 CIFAR 系实验里被验证过在一维时序上同样是稳妥起点。缩减比 8 表示更强的特征压缩参数量更少但信息丢失可能更多缩减比 32 更宽松但对过拟合更敏感。如果你发现训练集 F1 高、测试集 F1 一直上不去把缩减比从 16 调到 8 往往能缓解——更强的压缩相当于给 SE 层加了一层正则化。LSTM 隐单元数与序列长度有关。序列长度在 500 步以内64 个隐单元足够超过 1000 步可以加到 128但要注意过拟合风险。我一般把隐单元数当最后一个调节旋钮而不是第一个——先固定 64等 CNN 和 SE 的效果确认了再去动它。卷积核数同理16 起步、32 够用、64 偏大核数翻倍SE 层需要学习的通道权重也翻倍模型容量增长不是线性的。下面这张参数表是我在同类序列分类任务上的经验区间直接抄默认值通常不会差参数推荐区间翻车倾向reductionRatio8 ~ 32过小欠拟合过大过拟合LSTM 隐单元数32 ~ 128过大必过拟合卷积核数16 ~ 64过大训练慢且易过拟合卷积核长度3 ~ 9过长抓不到局部细节初始学习率0.001 ~ 0.010.01 以上常出 NaN6. 一个低成本迁移技巧把SE Attention当作通用通道筛选器CNN-LSTM-SE Attention 的价值不限于这个固定结构。当我在另一个项目里做多传感器融合分类时发现SE 层可以作为通用模块接到任意“特征维×时间步”的中间表示上。比如把原始振动信号和短时傅里叶变换后的频谱特征拼接成 $C_1C_2$ 维输入CNN 提取后通道数变成 48此时接一个seAttention(48, 16, se_fusion)模型会自动给两类来源分配重要度比手动加权融合稳定得多。一个我常用的小变体是把 SE 的全局平均池化改成“平均池化最大池化并联”。对尖峰型故障信号最大池化对突发冲击更敏感对平稳型磨损信号平均池化更合理。实现起来就是在predict里多加一行zMean mean(x, 2); zMax max(x, [], 2); a max(layer.W1 * zMean layer.b1, 0) max(layer.W1 * zMax layer.b1, 0);注意这里两个分支共享同一组W1和b1最终经过W2和 Sigmoid 得到通道权重。这个变体在突发故障数据集上比原始 SE 高出 3~5 个百分点的 F1代价只是推理时多一次最大池化计算。最后提醒一句任何技巧都先用小验证集跑通别直接把全部数据砸进去。我现在的习惯是先设MaxEpochs20快速确认代码能跑通再改成 60 训正式模型——这条省了我大量等待时间希望帮到你。本文还有配套的精品资源点击获取
返回列表