CNN-LSTM联合分类:Keras串联与并联结构搭建 📅 发布时间:2026/9/18 22:39:25 👁 浏览次数: 简介面向深度学习开发者的Keras实践文档讲解如何将卷积神经网络与长短时记忆网络联合建模用于序列数据分类适合已掌握神经网络基础、希望理解复合模型搭建流程的读者。内容围绕40×80二维序列输入的六分类任务展开逐一呈现Input与Reshape的输入整形、ZeroPadding2D与Convolution2D堆叠的卷积特征提取、LeakyReLU激活、MaxPooling2D与Dropout的降维防过拟合以及LSTM对时序依赖的捕捉、全局最大池化与特征拼接、Dense与softmax输出层的完整结构并给出Adam优化器与学习率等编译配置便于比照修改网络深度、超参数与训练策略。资源为单个PDF文件压缩包约51KB篇幅精炼、代码集中可作为CNN-LSTM分类模型的实现参考与调参起点。目前已有6299人学习下载。1. 从 (40, 80) 输入说起CNN 与 LSTM 拼在一起的动机拿到一份 40×80 的样本矩阵、标签 6 类套个卷积网络就能跑但准确率常卡住。矩阵的行本身有先后顺序3×3 卷积核只关心邻域纹理行序在池化之后基本被抹平LSTM 把 40 行当 40 个时间步逐行递推正好补上这一段上下文。让模型同时看到局部纹理与全局先后就是这套 CNN 联合 LSTM 分类实例要解决的事。麻烦在拼接CNN 输出是 (batch, h, w, channels) 四维LSTM 只吃 (batch, timesteps, features) 三维并联路线里两条支路还必须同为 (batch, features) 才能 concatenate。Sequential 表达不了这种结构得换成函数式 API。适合已经跑通单路 CNN 或单路 LSTM、想搞清多分支模型怎么搭的人。2. 张量轴决定接法串联 CNN-LSTM 与并联 CNNLSTM 的分野2.1 空间特征与时间依赖两类归纳偏置为什么要互补卷积的强项是局部连接加权值共享一个 3×3 核在整张平面上滑动学到的是与位置无关的纹理模式池化再带来一点平移容忍度。它天然假设「相邻像素相关、远处像素无关」。如果输入矩阵的每一行代表某个时刻的观测、列代表该时刻的 80 个通道那行与行之间存在递进关系卷积核在垂直方向滑动时其实是把相邻两三行混在一起看长距离的行间依赖它抓不到。LSTM 的假设正相反输入是有序序列当前状态由前序状态和当前输入共同决定。它把 80 维的行向量当作一步输入靠输入门、遗忘门、输出门和细胞状态维护一条跨越 40 步的信息通道。两者组合的动机不是「效果更好」这种模糊说法而是让模型在同一个损失函数下同时优化两种假设——局部纹理靠卷积压出高维特征行间依赖靠门控记住。要注意的是CNN 支路输出的是空间特征LSTM 支路输出的是时序特征两者语义不同。融合后接全连接层本质是让分类头自己学习两路特征的权重配比。这也解释了为什么并联结构参数量更大却常常收敛更稳两条支路各自有独立的梯度通路不会因为一条支路早期梯度爆炸把另一条带偏。2.2 串联接法把卷积输出降维成 (timesteps, features)串联意味着 CNN 的输出直接作为 LSTM 的输入一条路走到底。这里最容易踩的坑是张量阶数卷积池化出来是 4DLSTM 要 3D中间必须手动 reshape。常见的写法如下。from tensorflow.keras.layers import (Input, Reshape, Conv2D, MaxPooling2D, Activation, LSTM, Dropout, Dense) from tensorflow.keras.models import Model def design_model(): inp Input(shape(11, 5)) # 11 步、每步 5 个特征 x Reshape((11, 5, 1))(inp) # 补一个通道轴 - 4D x Conv2D(32, (3, 3), paddingsame, kernel_initializerglorot_uniform)(x) x Activation(relu)(x) x Conv2D(64, (3, 3), paddingsame)(x) x Activation(relu)(x) x MaxPooling2D(pool_size(2, 2), paddingvalid)(x) # (5, 2, 64) x Reshape((5 * 2, 64))(x) # 把 h*w 当时间步 - (10, 64) x LSTM(30, activationtanh, return_sequencesFalse)(x) x Dropout(0.3)(x) out Dense(1, activationrelu)(x) model Model(inputsinp, outputsout) model.summary() return modelReshape((5 * 2, 64))这一行是整段代码的关键。池化后张量是 (5, 2, 64)元素总数 5×2×64640reshape 成 (10, 64) 同样 640 个元素所以能通过。写成Reshape((10, 64))也能跑但它是硬编码的一旦把池化核从 (2,2) 改成 (3,3) 或输入长度从 11 改成 13元素数就对不上直接抛ValueError: total size of new array must be unchanged。用h * w表达式让形状随上游自动推导是可维护性上更划算的写法。参数上paddingsame保证卷积不改变 h/wpaddingvalid的池化负责降尺寸池化窗口多大时序长度就缩多少倍。LSTM 的return_sequencesFalse表示只取最后一步的输出向量用于分类如果后面还要接一层 LSTM 或需要逐步输出就得置为 True。2.3 并联接法GlobalMaxPooling2D 与 LSTM 各走一路再 concatenate并联是原始实例采用的方案同一份输入同时喂给 CNN 支路和 LSTM 支路两条支路各自把结果压成二维向量末端用concatenate拼起来。CNN 支路末尾用GlobalMaxPooling2D把 (h, w, C) 在 h、w 两个轴上各取最大值输出 (C,)完全没有可训练参数LSTM 支路最后一步输出 (units,)也是二维。两个二维向量在axis-1上拼接得到 (C units,)再进全连接层做分类。这里的细节是axis。原始代码写的是concatenate([g, dl2], axis1)对形如 (batch, features) 的二维张量来说axis1和axis-1等价但写成axis-1更保险——如果哪天把融合点挪到三维特征图之后axis1会沿着批次维之外的第一维拼接语义就完全变了且不会报错只会静默产生错误的连接结构。2.4 两种接法的选择依据对比维度串联 CNN-LSTM并联 CNNLSTM数据流向卷积输出喂给 LSTM单通路同一输入分别过 CNN 与 LSTM末端融合张量衔接必须 reshape 成 3D元素数守恒两路各自降到 2D再 concat参数量较小LSTM 输入维度 通道数较大两条主干独立训练梯度通路单一通路LSTM 梯度要穿过卷积双通路互不干扰易收敛适用场景卷积特征本身构成序列如频谱图空间视角与时序视角需要并重选串联还是并联取决于卷积输出是否天然具备时序含义。如果 (h, w) 里的 h 本身就是时间步串联更自然参数也省如果 h、w 只是两个空间维度、和行序没关系那并联更合理别硬把 h×w 拉成时间步去喂 LSTM模型会去学一个不存在的顺序。3. Keras 函数式 API 落地把并联 CNN-LSTM 分类网络写对3.1 从老 API 迁到当前 Keras 的四处改名原始实例是早年间写的几个参数名在当前 Keras 里已经不存在。直接复制粘贴最常见的结果是TypeError: (Keyword argument not understood:, border_mode)。对照关系如下。老写法当前写法说明Convolution2DConv2D别名已移除border_modesamepaddingsame语义一致initglorot_uniformkernel_initializerglorot_uniform初始化参数拆分为 kernel/bias 两类LSTM(output_dim256)LSTM(units256)第一个位置参数即 unitsModel(inputinp, outputsout)Model(inputsinp, outputsout)复数形式LeakyReLU(alpha0.33)在新版里参数名是negative_slopealpha一般仍作为兼容别名保留但写negative_slope0.33更稳。0.33 这个斜率的含义是负半轴输出为输入的 1/3比 ReLU 的硬零更温和能避免神经元在负区永久失活。0.33 偏大实践中 0.10.3 更常见但既然原始结构用 0.33 也能收敛先照搬再调。3.2 Input 与 Reshape通道维度放在哪一边原始代码是Reshape((1, 40, 80))即把通道轴放在最前配合当时默认的通道优先数据格式。当前 Keras 默认是通道在后卷积层期望 (h, w, channels)所以正确写法是Reshape((40, 80, 1))。写错的表现很典型Conv2D会把 1 当成高度、40 当成宽度输出形状完全错位或者干脆报expected axis -1 of input shape to have value 1。判断方法很简单model.summary()里第一层卷积的输出形状如果不是 (40, 80, n)就是通道轴放错了。Input(shape(40, 80))里不含 batch 维度这一点新手容易混淆实际喂进去的数据形状是 (batch, 40, 80)而声明时写 (40, 80)。Reshape不改变元素总量40×803200reshape 成 (40, 80, 1) 仍是 3200只是多了一个长度为 1 的轴。3.3 卷积主干与 LSTM 支路的堆叠卷积主干按「两个卷积 一个池化」为一段堆叠通道数逐段翻倍。注意原始代码里ZeroPadding2D(padding(1, 1))和border_modesame是重复的——same模式下 Keras 已经自动补零让输出尺寸与输入一致再手动加一层ZeroPadding2D只会把尺寸撑大。留着不影响正确性但会让形状推导变复杂建议删掉。import tensorflow as tf from tensorflow.keras.layers import (Input, Reshape, Conv2D, MaxPooling2D, LeakyReLU, Dropout, LSTM, Dense, GlobalMaxPooling2D, concatenate) from tensorflow.keras.models import Model from tensorflow.keras.optimizers import Adam def get_model(n_classes6, n_rows40, n_cols80): inp Input(shape(n_rows, n_cols)) # (None, 40, 80) # ---------- CNN 支路把输入当二维平面看 ---------- c Reshape((n_rows, n_cols, 1))(inp) # (None, 40, 80, 1) for filters in (32, 64, 128): # 三段通道数翻倍 c Conv2D(filters, (3, 3), paddingsame, kernel_initializerglorot_uniform)(c) c LeakyReLU(negative_slope0.33)(c) c Conv2D(filters, (3, 3), paddingsame, kernel_initializerglorot_uniform)(c) c LeakyReLU(negative_slope0.33)(c) c MaxPooling2D((3, 3), strides(3, 3))(c) # 每个空间维缩 3 倍 c Dropout(0.25)(c) c Conv2D(256, (3, 3), paddingsame, kernel_initializerglorot_uniform)(c) # 末段只卷积不池化 c LeakyReLU(negative_slope0.33)(c) c Conv2D(256, (3, 3), paddingsame, kernel_initializerglorot_uniform)(c) c LeakyReLU(negative_slope0.33)(c) c GlobalMaxPooling2D()(c) # (None, 256) # ---------- LSTM 支路把 40 行当 40 个时间步 ---------- s LSTM(256, activationtanh, return_sequencesFalse)(inp) # (None, 256) s Dropout(0.3)(s) s Dense(200, activationrelu)(s) # (None, 200) s Dropout(0.3)(s) # ---------- 融合与分类头 ---------- z concatenate([c, s], axis-1) # (None, 456) z Dense(1024)(z) z LeakyReLU(negative_slope0.33)(z) z Dropout(0.5)(z) out Dense(n_classes, activationsoftmax)(z) # (None, 6) model Model(inputsinp, outputsout) model.compile( losscategorical_crossentropy, optimizerAdam(lr5e-4, beta_10.95, beta_20.999, epsilon1e-8), metrics[accuracy]) model.summary() return modelfor filters in (32, 64, 128)这个循环把原始代码里的四段手写卷积压缩成三段加一段收尾。原始结构是 (32,32)、(64,64)、(128,128) 三段带池化再加 (256,256) 一对卷积后接全局池化循环写法形状完全等价改通道数只需动元组。LSTM 支路把inp直接当输入Keras 会按 (batch, 40, 80) 解释成 40 个时间步、每步 80 维这正是「行是时间步」的假设。3.4 逐层形状与参数量核对搭完模型第一件事是核对summary()形状对不上后面全是白费功夫。层输出形状关键说明Input(None, 40, 80)不含 batchReshape(None, 40, 80, 1)元素数 3200 守恒Conv2D 32(None, 40, 80, 32)参数量 3×3×1×3232320MaxPooling2D 3×3(None, 13, 26, 32)40→13、80→26向下取整Conv2D 64(None, 13, 26, 64)—MaxPooling2D 3×3(None, 4, 8, 64)13→4、26→8Conv2D 128(None, 4, 8, 128)—MaxPooling2D 3×3(None, 1, 2, 128)4→1、8→2Conv2D 256 ×2(None, 1, 2, 256)末段不池化GlobalMaxPooling2D(None, 256)无参数LSTM(256)(None, 256)约 4×256×(802561)≈345kDense(200)(None, 200)256×200200concatenate(None, 456)256200无参数Dense(1024)(None, 1024)456×10241024Dense(6, softmax)(None, 6)1024×66池化尺寸的推导公式是floor((in - pool) / stride) 1。40 代入得floor(37/3)1 1380 代入得 26第二段 13 代入得 426 代入得 8第三段 4 代入得 18 代入得 2。这三段之后空间维已经压到 1×2再用 3×3 池化就会报负尺寸所以第四段改用全局池化这也是为什么原始结构在最后一段没有接MaxPooling2D——不是遗漏是尺寸不允许。LSTM 的参数量公式是4 × units × (input_dim units 1)四个门各一套权重加偏置。输入维度 80、单元数 256 时约 34.5 万是全模型里参数最集中的一层。如果显存吃紧先把 LSTM 单元数降到 128参数量直接砍掉一半以上准确率通常只掉一两个百分点。4. 编译与训练Adam 参数、one-hot 与回调组合4.1 损失函数与标签编码6 类互斥分类用categorical_crossentropy前提是标签已经 one-hot。如果手里是整数标签两种处理都行用to_categorical转成 6 维向量或者把损失换成sparse_categorical_crossentropy直接吃整数。混用的后果是静默出错——整数标签配categorical_crossentropy某些版本会报形状不匹配某些版本会算出完全无意义的损失值。import numpy as np from tensorflow.keras.utils import to_categorical X np.load(X.npy).astype(float32) # (N, 40, 80) y np.load(y.npy).astype(int32) # (N,)取值 0~5 X (X - X.mean()) / (X.std() 1e-8) # 全局标准化防止量纲差太大 y_cat to_categorical(y, num_classes6) # (N, 6)标准化用全局均值和标准差比逐样本标准化更简单也更稳。注意X.std()前加1e-8是防除零如果某批数据全为常数不加这一项会得到 NaN随后整个训练过程都是 NaN 损失看起来像模型不收敛其实是数值问题。4.2 Adam 的学习率与 beta_1 取值原始代码用Adam(lr0.0005, beta_10.95, beta_20.999, epsilon1e-08)。Adam 默认是lr0.001, beta_10.9这里把学习率减半、动量系数提到 0.95。学习率减半对并联结构是合理的——两条支路同时更新早期梯度方向差异大小步长能减少震荡。beta_1从 0.9 提到 0.95 表示一阶矩估计的记忆更长梯度方向变化缓慢时更平滑但遇到损失面陡峭变化时会滞后一拍。epsilon1e-08是防除零项一般不用动。如果训练中出现损失突然飙升到 NaN先把学习率再降一个数量级试试比调epsilon有效得多。ReduceLROnPlateau那行在原始代码里被注释掉了但它是这类多分支模型最该开的回调之一。4.3 fit 参数与三个回调from tensorflow.keras.callbacks import (ModelCheckpoint, ReduceLROnPlateau, EarlyStopping) cbs [ ModelCheckpoint(best_cnn_lstm.h5, monitorval_accuracy, save_best_onlyTrue, verbose1), ReduceLROnPlateau(monitorval_loss, factor0.1, patience2, min_lr1e-7, verbose1), EarlyStopping(monitorval_loss, patience6, restore_best_weightsTrue), ] model get_model() history model.fit( X, y_cat, epochs60, batch_size32, validation_split0.2, shuffleTrue, verbose2, callbackscbs)validation_split0.2从训练集尾部切 20% 做验证注意它在切分前不 shuffle所以务必先把数据整体打乱一次否则验证集可能全是同一类。shuffleTrue只在每个 epoch 开始时打乱训练批顺序不替代数据预打乱。ReduceLROnPlateau的patience2表示验证损失连续 2 个 epoch 不下降就把学习率乘以factor0.1min_lr1e-7是下限防止学习率降到零附近后完全停摆。EarlyStopping的patience6更宽松给学习率衰减后的模型留出恢复空间restore_best_weightsTrue会在停止时把权重回滚到验证损失最低那一轮省去手动重新加载。批大小 32 是个平衡点。并联结构显存占用比单路模型高批太小如 8会让 BatchNorm 类层统计不稳、梯度噪声大批太大如 256单 epoch 迭代次数骤减配合patience2的学习率衰减会过早触发。4.4 分类指标复核混淆矩阵与分类报告准确率在类别不均衡时会骗人。6 类里如果某一类占了一半样本模型全部猜这一类也能有 50% 准确率。训练完必须看每个类的精确率、召回率和 F1。from sklearn.metrics import confusion_matrix, classification_report y_prob model.predict(X_test, batch_size64) # (N, 6) 概率 y_pred y_prob.argmax(axis1) # 取概率最大的类别 y_true y_test.argmax(axis1) # one-hot 还原成整数 print(confusion_matrix(y_true, y_pred)) print(classification_report(y_true, y_pred, digits4))argmax(axis1)而不是axis0输出是 (N, 6)要沿类别维度取最大值写错轴会得到每列的最大值形状直接变成 (6,)后续所有指标都是错的。看混淆矩阵时重点盯两类召回率特别低的那一类通常说明 LSTM 支路没提供有效区分信息被误判成同一个类别的样本对往往意味着这两个类的空间纹理高度相似可以考虑给卷积支路再加一段或者在 LSTM 支路加深层数。5. 形状报错定位与并联结构进阶5.1 典型报错对照表并联模型的报错绝大多数出在维度上读懂报错能省掉大半天。报错信息触发原因处理方式expected ndim3, found ndim4把 4D 卷积输出直接喂 LSTM先GlobalMaxPooling2D或Reshape成 3Dtotal size of new array must be unchangedReshape前后元素数不等算清 h×w×C或用表达式自动推导expected axis -1 to have value 1Reshape((1,40,80))通道轴在前改成Reshape((40,80,1))A Concatenate layer requires inputs with matching shapes两路未压到同阶两路都降到 2D 再axis-1拼接Negative dimension size caused by subtracting池化核大于当前空间维末段改用GlobalMaxPooling2D5.2 用中间层输出做张量探针报错行号指向concatenate时真正的问题往往在上游好几层。最快的定位手段是把模型切成临时子模型直接打印每一路的输出形状。probe_cnn Model(inputsmodel.input, outputsmodel.get_layer(index15).output) probe_lstm Model(inputsmodel.input, outputsmodel.get_layer(index20).output) print(probe_cnn.predict(X[:2]).shape) print(probe_lstm.predict(X[:2]).shape)按索引取层容易数错更稳的做法是建模型时给关键层加name参数比如GlobalMaxPooling2D(namecnn_vec)之后用model.get_layer(cnn_vec).output。两路输出形状打印出来问题基本一眼可见哪一路还是 4D就是那一路上游漏了降维层。5.3 进阶TimeDistributed 包裹卷积 双向 LSTM前面的并联结构把 (40, 80) 整个当一张图看40 只是「行」。如果数据本来就是一段帧序列每个时间步都是一张完整的 40×80 图正确的做法是用TimeDistributed把卷积核共享地作用到每一帧再把每帧压成向量交给 LSTM。from tensorflow.keras.layers import TimeDistributed, Bidirectional inp Input(shape(T, 40, 80, 1)) # T 帧每帧 40x80 单通道 x TimeDistributed(Conv2D(32, (3, 3), paddingsame, activationrelu))(inp) x TimeDistributed(MaxPooling2D((3, 3)))(x) x TimeDistributed(GlobalMaxPooling2D())(x) # (None, T, 32) x Bidirectional(LSTM(64, return_sequencesFalse))(x) # (None, 128) out Dense(6, activationsoftmax)(x) model Model(inputsinp, outputsout)TimeDistributed会把同一个Conv2D实例沿时间轴复用参数量不随 T 增长这是它相对「手写 for 循环」的核心优势。GlobalMaxPooling2D同样被包裹后输出从 (T, h, w, 32) 变成 (T, 32)正好是 LSTM 要的三维。Bidirectional把前向和后向两个 LSTM 的输出在特征轴上拼起来输出维度是单元数的两倍——LSTM(64)配Bidirectional得到 128 维接全连接层时按 128 算输入维度。后向 LSTM 要求序列完整可见所以这个结构适合离线分类不适合逐帧实时推理的场景那种场合用单向 LSTM 加状态缓存更合适。本文还有配套的精品资源点击获取