简介:面向深度学习开发者的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 与并联 CNN+LSTM 的分野
2.1 空间特征与时间依赖:两类归纳偏置为什么要互补
卷积的强项是局部连接加权值共享,一个 3×3 核在整张平面上滑动,学到的是与位置无关的纹理模式,池化再带来一点平移容忍度。它天然假设「相邻像素相关、远处像素无关」。如果输入矩阵的每一行代表某个时刻的观测、列代表该时刻的 80 个通道,那行与行之间存在递进关系,卷积核在垂直方向滑动时其实是把相邻两三行混在一起看,长距离的行间依赖它抓不到。
LSTM 的假设正相反:输入是有序序列,当前状态由前序状态和当前输入共同决定。它把 80 维的行向量当作一步输入,靠输入门、遗忘门、输出门和细胞状态维护一条跨越 40 步的信息通道。两者组合的动机不是「效果更好」这种模糊说法,而是让模型在同一个损失函数下同时优化两种假设——局部纹理靠卷积压出高维特征,行间依赖靠门控记住。
要注意的是,CNN 支路输出的是空间特征,LSTM 支路输出的是时序特征,两者语义不同。融合后接全连接层,本质是让分类头自己学习两路特征的权重配比。这也解释了为什么并联结构参数量更大却常常收敛更稳:两条支路各自有独立的梯度通路,不会因为一条支路早期梯度爆炸把另一条带偏。
2.2 串联接法:把卷积输出降维成 (timesteps, features)
串联意味着 CNN 的输出直接作为 LSTM 的输入,一条路走到底。这里最容易踩的坑是张量阶数:卷积池化出来是 4D,LSTM 要 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), padding='same', kernel_initializer='glorot_uniform')(x) x = Activation('relu')(x) x = Conv2D(64, (3, 3), padding='same')(x) x = Activation('relu')(x) x = MaxPooling2D(pool_size=(2, 2), padding='valid')(x) # (5, 2, 64) x = Reshape((5 * 2, 64))(x) # 把 h*w 当时间步 -> (10, 64) x = LSTM(30, activation='tanh', return_sequences=False)(x) x = Dropout(0.3)(x) out = Dense(1, activation='relu')(x) model = Model(inputs=inp, outputs=out) model.summary() return modelReshape((5 * 2, 64))这一行是整段代码的关键。池化后张量是 (5, 2, 64),元素总数 5×2×64=640,reshape 成 (10, 64) 同样 640 个元素,所以能通过。写成Reshape((10, 64))也能跑,但它是硬编码的:一旦把池化核从 (2,2) 改成 (3,3) 或输入长度从 11 改成 13,元素数就对不上,直接抛ValueError: total size of new array must be unchanged。用h * w表达式让形状随上游自动推导,是可维护性上更划算的写法。
参数上,padding='same'保证卷积不改变 h/w;padding='valid'的池化负责降尺寸,池化窗口多大,时序长度就缩多少倍。LSTM 的return_sequences=False表示只取最后一步的输出向量用于分类,如果后面还要接一层 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], axis=1),对形如 (batch, features) 的二维张量来说axis=1和axis=-1等价,但写成axis=-1更保险——如果哪天把融合点挪到三维特征图之后,axis=1会沿着批次维之外的第一维拼接,语义就完全变了,且不会报错,只会静默产生错误的连接结构。
2.4 两种接法的选择依据
| 对比维度 | 串联 CNN-LSTM | 并联 CNN+LSTM |
|---|---|---|
| 数据流向 | 卷积输出喂给 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')。对照关系如下。
| 老写法 | 当前写法 | 说明 |
|---|---|---|
Convolution2D | Conv2D | 别名已移除 |
border_mode='same' | padding='same' | 语义一致 |
init='glorot_uniform' | kernel_initializer='glorot_uniform' | 初始化参数拆分为 kernel/bias 两类 |
LSTM(output_dim=256) | LSTM(units=256) | 第一个位置参数即 units |
Model(input=inp, outputs=out) | Model(inputs=inp, outputs=out) | 复数形式 |
LeakyReLU(alpha=0.33)在新版里参数名是negative_slope,alpha一般仍作为兼容别名保留,但写negative_slope=0.33更稳。0.33 这个斜率的含义是:负半轴输出为输入的 1/3,比 ReLU 的硬零更温和,能避免神经元在负区永久失活。0.33 偏大,实践中 0.1~0.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×80=3200,reshape 成 (40, 80, 1) 仍是 3200,只是多了一个长度为 1 的轴。
3.3 卷积主干与 LSTM 支路的堆叠
卷积主干按「两个卷积 + 一个池化」为一段堆叠,通道数逐段翻倍。注意原始代码里ZeroPadding2D(padding=(1, 1))和border_mode='same'是重复的——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_classes=6, n_rows=40, n_cols=80): 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), padding='same', kernel_initializer='glorot_uniform')(c) c = LeakyReLU(negative_slope=0.33)(c) c = Conv2D(filters, (3, 3), padding='same', kernel_initializer='glorot_uniform')(c) c = LeakyReLU(negative_slope=0.33)(c) c = MaxPooling2D((3, 3), strides=(3, 3))(c) # 每个空间维缩 3 倍 c = Dropout(0.25)(c) c = Conv2D(256, (3, 3), padding='same', kernel_initializer='glorot_uniform')(c) # 末段只卷积不池化 c = LeakyReLU(negative_slope=0.33)(c) c = Conv2D(256, (3, 3), padding='same', kernel_initializer='glorot_uniform')(c) c = LeakyReLU(negative_slope=0.33)(c) c = GlobalMaxPooling2D()(c) # (None, 256) # ---------- LSTM 支路:把 40 行当 40 个时间步 ---------- s = LSTM(256, activation='tanh', return_sequences=False)(inp) # (None, 256) s = Dropout(0.3)(s) s = Dense(200, activation='relu')(s) # (None, 200) s = Dropout(0.3)(s) # ---------- 融合与分类头 ---------- z = concatenate([c, s], axis=-1) # (None, 456) z = Dense(1024)(z) z = LeakyReLU(negative_slope=0.33)(z) z = Dropout(0.5)(z) out = Dense(n_classes, activation='softmax')(z) # (None, 6) model = Model(inputs=inp, outputs=out) model.compile( loss='categorical_crossentropy', optimizer=Adam(lr=5e-4, beta_1=0.95, beta_2=0.999, epsilon=1e-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) | 不含 batch |
| Reshape | (None, 40, 80, 1) | 元素数 3200 守恒 |
| Conv2D 32 | (None, 40, 80, 32) | 参数量 3×3×1×32+32=320 |
| MaxPooling2D 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→8 |
| Conv2D 128 | (None, 4, 8, 128) | — |
| MaxPooling2D 3×3 | (None, 1, 2, 128) | 4→1、8→2 |
| Conv2D 256 ×2 | (None, 1, 2, 256) | 末段不池化 |
| GlobalMaxPooling2D | (None, 256) | 无参数 |
| LSTM(256) | (None, 256) | 约 4×256×(80+256+1)≈345k |
| Dense(200) | (None, 200) | 256×200+200 |
| concatenate | (None, 456) | 256+200,无参数 |
| Dense(1024) | (None, 1024) | 456×1024+1024 |
| Dense(6, softmax) | (None, 6) | 1024×6+6 |
池化尺寸的推导公式是floor((in - pool) / stride) + 1。40 代入得floor(37/3)+1 = 13,80 代入得 26;第二段 13 代入得 4,26 代入得 8;第三段 4 代入得 1,8 代入得 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_classes=6) # (N, 6)标准化用全局均值和标准差,比逐样本标准化更简单也更稳。注意X.std()前加1e-8是防除零,如果某批数据全为常数,不加这一项会得到 NaN,随后整个训练过程都是 NaN 损失,看起来像模型不收敛,其实是数值问题。
4.2 Adam 的学习率与 beta_1 取值
原始代码用Adam(lr=0.0005, beta_1=0.95, beta_2=0.999, epsilon=1e-08)。Adam 默认是lr=0.001, beta_1=0.9,这里把学习率减半、动量系数提到 0.95。学习率减半对并联结构是合理的——两条支路同时更新,早期梯度方向差异大,小步长能减少震荡。beta_1从 0.9 提到 0.95 表示一阶矩估计的记忆更长,梯度方向变化缓慢时更平滑,但遇到损失面陡峭变化时会滞后一拍。
epsilon=1e-08是防除零项,一般不用动。如果训练中出现损失突然飙升到 NaN,先把学习率再降一个数量级试试,比调epsilon有效得多。ReduceLROnPlateau那行在原始代码里被注释掉了,但它是这类多分支模型最该开的回调之一。
4.3 fit 参数与三个回调
from tensorflow.keras.callbacks import (ModelCheckpoint, ReduceLROnPlateau, EarlyStopping) cbs = [ ModelCheckpoint('best_cnn_lstm.h5', monitor='val_accuracy', save_best_only=True, verbose=1), ReduceLROnPlateau(monitor='val_loss', factor=0.1, patience=2, min_lr=1e-7, verbose=1), EarlyStopping(monitor='val_loss', patience=6, restore_best_weights=True), ] model = get_model() history = model.fit( X, y_cat, epochs=60, batch_size=32, validation_split=0.2, shuffle=True, verbose=2, callbacks=cbs)validation_split=0.2从训练集尾部切 20% 做验证,注意它在切分前不 shuffle,所以务必先把数据整体打乱一次,否则验证集可能全是同一类。shuffle=True只在每个 epoch 开始时打乱训练批顺序,不替代数据预打乱。
ReduceLROnPlateau的patience=2表示验证损失连续 2 个 epoch 不下降就把学习率乘以factor=0.1,min_lr=1e-7是下限,防止学习率降到零附近后完全停摆。EarlyStopping的patience=6更宽松,给学习率衰减后的模型留出恢复空间;restore_best_weights=True会在停止时把权重回滚到验证损失最低那一轮,省去手动重新加载。
批大小 32 是个平衡点。并联结构显存占用比单路模型高,批太小(如 8)会让 BatchNorm 类层统计不稳、梯度噪声大;批太大(如 256)单 epoch 迭代次数骤减,配合patience=2的学习率衰减会过早触发。
4.4 分类指标复核:混淆矩阵与分类报告
准确率在类别不均衡时会骗人。6 类里如果某一类占了一半样本,模型全部猜这一类也能有 50% 准确率。训练完必须看每个类的精确率、召回率和 F1。
from sklearn.metrics import confusion_matrix, classification_report y_prob = model.predict(X_test, batch_size=64) # (N, 6) 概率 y_pred = y_prob.argmax(axis=1) # 取概率最大的类别 y_true = y_test.argmax(axis=1) # one-hot 还原成整数 print(confusion_matrix(y_true, y_pred)) print(classification_report(y_true, y_pred, digits=4))argmax(axis=1)而不是axis=0:输出是 (N, 6),要沿类别维度取最大值,写错轴会得到每列的最大值,形状直接变成 (6,),后续所有指标都是错的。看混淆矩阵时重点盯两类:召回率特别低的那一类,通常说明 LSTM 支路没提供有效区分信息;被误判成同一个类别的样本对,往往意味着这两个类的空间纹理高度相似,可以考虑给卷积支路再加一段或者在 LSTM 支路加深层数。
5. 形状报错定位与并联结构进阶
5.1 典型报错对照表
并联模型的报错绝大多数出在维度上,读懂报错能省掉大半天。
| 报错信息 | 触发原因 | 处理方式 |
|---|---|---|
expected ndim=3, found ndim=4 | 把 4D 卷积输出直接喂 LSTM | 先GlobalMaxPooling2D或Reshape成 3D |
total size of new array must be unchanged | Reshape前后元素数不等 | 算清 h×w×C,或用表达式自动推导 |
expected axis -1 to have value 1 | Reshape((1,40,80))通道轴在前 | 改成Reshape((40,80,1)) |
A Concatenate layer requires inputs with matching shapes | 两路未压到同阶 | 两路都降到 2D 再axis=-1拼接 |
Negative dimension size caused by subtracting | 池化核大于当前空间维 | 末段改用GlobalMaxPooling2D |
5.2 用中间层输出做张量探针
报错行号指向concatenate时,真正的问题往往在上游好几层。最快的定位手段是把模型切成临时子模型,直接打印每一路的输出形状。
probe_cnn = Model(inputs=model.input, outputs=model.get_layer(index=15).output) probe_lstm = Model(inputs=model.input, outputs=model.get_layer(index=20).output) print(probe_cnn.predict(X[:2]).shape) print(probe_lstm.predict(X[:2]).shape)按索引取层容易数错,更稳的做法是建模型时给关键层加name参数,比如GlobalMaxPooling2D(name='cnn_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), padding='same', activation='relu'))(inp) x = TimeDistributed(MaxPooling2D((3, 3)))(x) x = TimeDistributed(GlobalMaxPooling2D())(x) # (None, T, 32) x = Bidirectional(LSTM(64, return_sequences=False))(x) # (None, 128) out = Dense(6, activation='softmax')(x) model = Model(inputs=inp, outputs=out)TimeDistributed会把同一个Conv2D实例沿时间轴复用,参数量不随 T 增长,这是它相对「手写 for 循环」的核心优势。GlobalMaxPooling2D同样被包裹后,输出从 (T, h, w, 32) 变成 (T, 32),正好是 LSTM 要的三维。Bidirectional把前向和后向两个 LSTM 的输出在特征轴上拼起来,输出维度是单元数的两倍——LSTM(64)配Bidirectional得到 128 维,接全连接层时按 128 算输入维度。后向 LSTM 要求序列完整可见,所以这个结构适合离线分类,不适合逐帧实时推理的场景,那种场合用单向 LSTM 加状态缓存更合适。
本文还有配套的精品资源,点击获取