简介:本资源是一份面向深度学习初学者与时间序列预测实践者的CNN-LSTM混合模型完整实现方案,聚焦多输入单输出回归任务,适用于电力负荷预测、股价趋势拟合、传感器时序建模等典型场景。资源共9个文件,含4张模型结构与结果可视化图(.png)、2个核心MATLAB脚本(CNN_LSTM.m主模型+calulateE.m评价函数)、1个Excel数据集(data.xlsx)、1份图文运行指南(运行.docx)及1个说明文本(.txt),压缩包仅1.23MB,轻量易部署。已有367人下载学习,适合希望掌握CNN特征提取与LSTM时序建模协同机制的学习者。读者可直接复现端到端训练流程,获取R2、MAE、MSE、RMSE及MAPE五维评估结果,并通过可视化图表直观理解模型收敛性与预测偏差分布,是理解深度学习融合架构的优质入门级工程范例。
1. 为什么用 CNN-LSTM 做多输入单输出回归预测?——不是堆模型,是让空间特征和时序依赖真正咬合
你手头有一组带时间戳的工业传感器数据:温度、压力、振动频谱图、电流谐波分量,共4类输入,目标是预测未来1小时的轴承剩余寿命(RUL),单位是小时,连续值。直接扔进LSTM?你会发现频谱图这种2D结构被强行展平成向量,空间局部性全丢;只用CNN?又抓不住跨时间步的退化趋势。CNN-LSTM混合架构不是炫技,而是把“图像级局部模式识别”和“长程时序状态演化”拆开再缝合——CNN先对每个时间点的多维输入做空间压缩,提取出带物理意义的特征向量;LSTM再把这些向量按时间轴串起来,建模退化轨迹。这种分工明确的流水线,在小样本(<5000条带标签序列)、高噪声(信噪比<10dB)、多源异构(数值+图像+频谱)场景下,R²常比纯LSTM高0.15以上,RMSE下降22%。它适合设备健康评估、能源负荷预测、化工过程软测量这类需要同时理解“当前状态快照”和“历史演变路径”的任务,尤其当你只有几十个设备的历史数据、又不敢随便增广时——这正是标题里“多输入单输出回归预测”的真实战场。
2. 搭建CNN-LSTM回归模型:从数据预处理到模型定义的最小可行闭环
2.1 多输入数据的时空对齐与标准化:别让CNN看到“错位”的频谱图
多输入单输出的核心陷阱在于:不同传感器采样频率不同(如温度每秒1次,振动频谱每5秒1张图),原始数据时间戳不齐。常见做法是先统一重采样到最低采样率,再用滑动窗口切片。以轴承RUL预测为例:
- 温度/压力/电流:1Hz采样 → 保留原始时间序列
- 振动频谱图:每5秒1张64×64灰度图 → 用
scipy.ndimage.zoom插值到每秒1张(避免信息丢失) - 所有通道按时间戳对齐 → 用
pandas.merge_asof按时间索引左连接
import pandas as pd import numpy as np from scipy.ndimage import zoom # 假设df_temp_pressure_current为1Hz数据,df_spectrogram为5Hz频谱图数据 df_temp_pressure_current = df_temp_pressure_current.set_index('timestamp') df_spectrogram = df_spectrogram.set_index('timestamp') # 将频谱图上采样到1Hz:对每个频谱图做双线性插值(保持64x64尺寸) def upsample_spectrogram(spectrograms, target_freq=1): # spectrograms: list of (64,64) arrays, len=N # 插值倍数 = 原始采样率 / 目标采样率 = 5 / 1 = 5 upsampled = [] for spec in spectrograms: # zoom参数:(1,1)表示不缩放,但实际需按时间维度插值 # 正确做法:在时间轴上重复+插值,此处简化为每张图复制5次(保守策略) upsampled.extend([spec] * 5) return np.array(upsampled) # 实际工程中更推荐:用scipy.interpolate.interp1d对频谱图特征向量(如MFCC)插值 # 但若必须处理图像,建议用OpenCV的cv2.resize(..., interpolation=cv2.INTER_LINEAR)提示:频谱图上采样不能简单复制帧!会导致LSTM看到完全相同的“伪时间序列”。血泪经验:对频谱图提取时频特征(如短时傅里叶变换STFT的幅度谱均值、方差、峰度),再对这些标量特征插值,比直接插值图像更鲁棒。本例中我们用
cv2.resize对每张频谱图做双线性插值到64×64(保持分辨率),再按时间戳对齐。
标准化必须分通道独立进行:
- 数值型(温度/压力/电流):用
StandardScaler(均值为0,标准差为1) - 图像型(频谱图):用
MinMaxScaler归一化到[0,1](避免负值破坏CNN激活函数)
from sklearn.preprocessing import StandardScaler, MinMaxScaler # 数值型输入标准化 num_scaler = StandardScaler() df_num_scaled = num_scaler.fit_transform(df_temp_pressure_current[['temp','pressure','current']]) # 图像型输入标准化:对每张图单独归一化(避免全局归一化压垮局部对比度) spectrograms_normalized = [] for i in range(len(df_spectrogram)): spec = df_spectrogram.iloc[i]['spectrogram'] # shape (64,64) spec_norm = (spec - spec.min()) / (spec.max() - spec.min() + 1e-8) spectrograms_normalized.append(spec_norm) spectrograms_normalized = np.array(spectrograms_normalized) # shape (N,64,64)2.2 构建CNN-LSTM混合模型:Keras实现中的层衔接关键点
模型结构必须满足:CNN分支处理每个时间步的多维输入(含图像),输出固定长度特征向量;LSTM接收该向量序列,输出最终回归值。关键不是堆层,而是确保CNN输出维度与LSTM输入维度严格匹配。以下代码基于TensorFlow 2.15,使用Functional API:
import tensorflow as tf from tensorflow.keras.layers import Input, Conv2D, MaxPooling2D, Flatten, Dense, LSTM, Concatenate, Dropout from tensorflow.keras.models import Model # 定义输入:数值型输入(3维)和图像型输入(64x64) input_num = Input(shape=(3,), name='numerical_input') # 每个时间步的温度/压力/电流 input_img = Input(shape=(64, 64, 1), name='spectrogram_input') # 单通道频谱图 # CNN分支:处理单张频谱图 cnn_branch = Conv2D(32, (3,3), activation='relu', padding='same')(input_img) cnn_branch = MaxPooling2D((2,2))(cnn_branch) # 32x32 cnn_branch = Conv2D(64, (3,3), activation='relu', padding='same')(cnn_branch) cnn_branch = MaxPooling2D((2,2))(cnn_branch) # 16x16 cnn_branch = Conv2D(128, (3,3), activation='relu', padding='same')(cnn_branch) cnn_branch = MaxPooling2D((2,2))(cnn_branch) # 8x8 cnn_branch = Flatten()(cnn_branch) # 输出:128*8*8 = 8192维 cnn_branch = Dense(128, activation='relu', name='cnn_feature')(cnn_branch) # 压缩到128维 # 数值分支:简单映射(可加Dropout防过拟合) num_branch = Dense(64, activation='relu')(input_num) num_branch = Dropout(0.3)(num_branch) # 合并两个分支特征(每个时间步) merged = Concatenate()([cnn_branch, num_branch]) # shape: (128+64)=192 # 注意:此时merged是单个时间步的特征,需包装成TimeDistributed才能喂给LSTM # 但Keras中更自然的做法是:先构建CNN+Num分支,再用TimeDistributed包装整个分支 # 因此,上面的Input应改为TimeSeriesInput,见下方修正版修正版(正确的时间序列处理):
# 正确做法:定义时间维度T,所有输入带时间轴 T = 50 # 滑动窗口长度,即用过去50秒数据预测未来1小时RUL # 数值输入:(T, 3) input_num_ts = Input(shape=(T, 3), name='numerical_ts_input') # 图像输入:(T, 64, 64, 1) input_img_ts = Input(shape=(T, 64, 64, 1), name='spectrogram_ts_input') # CNN分支:用TimeDistributed包装,对每个时间步的图像单独卷积 cnn_ts = TimeDistributed(Conv2D(32, (3,3), activation='relu', padding='same'))(input_img_ts) cnn_ts = TimeDistributed(MaxPooling2D((2,2)))(cnn_ts) cnn_ts = TimeDistributed(Conv2D(64, (3,3), activation='relu', padding='same'))(cnn_ts) cnn_ts = TimeDistributed(MaxPooling2D((2,2)))(cnn_ts) cnn_ts = TimeDistributed(Conv2D(128, (3,3), activation='relu', padding='same'))(cnn_ts) cnn_ts = TimeDistributed(MaxPooling2D((2,2)))(cnn_ts) cnn_ts = TimeDistributed(Flatten())(cnn_ts) # (T, 8192) cnn_ts = TimeDistributed(Dense(128, activation='relu'))(cnn_ts) # (T, 128) # 数值分支:直接通过Dense(已含时间维度) num_ts = TimeDistributed(Dense(64, activation='relu'))(input_num_ts) # (T, 64) num_ts = TimeDistributed(Dropout(0.3))(num_ts) # 合并:(T, 128+64=192) merged_ts = Concatenate(axis=-1)([cnn_ts, num_ts]) # (T, 192) # LSTM分支:接收合并后的时序特征 lstm_out = LSTM(64, return_sequences=False, dropout=0.2, recurrent_dropout=0.2)(merged_ts) # (64,) output = Dense(1, activation='linear', name='rul_prediction')(lstm_out) # 单输出 model = Model(inputs=[input_num_ts, input_img_ts], outputs=output) model.compile(optimizer='adam', loss='mse', metrics=['mae'])参数说明:
T=50:窗口长度需根据物理过程确定(轴承退化周期约300秒,取50秒覆盖2个振动周期)LSTM(64):隐藏单元数64是经验值,小样本下不宜过大(易过拟合),大于128需配合更强正则化dropout=0.2:LSTM层输入门/输出门的随机失活,防止记忆过载recurrent_dropout=0.2:循环连接上的失活,缓解梯度爆炸activation='linear':回归任务必须用线性激活,否则输出被压缩到[0,1]或[-1,1]
2.3 数据生成器:用tf.data避免内存爆炸的滑动窗口切片
当数据量大(>10万时间步)时,一次性加载所有(T,64,64,1)图像会爆内存。我一般会用tf.data.Dataset.from_generator动态切片:
def windowed_dataset_generator(numerical_data, spectrogram_data, labels, window_size=50, step=1): """ 生成器:每次yield一个窗口的数值+图像+标签 numerical_data: (N, 3) N为总时间步数 spectrogram_data: (N, 64, 64) labels: (N,) RUL值,注意label对应窗口最后时刻的RUL """ for start in range(0, len(numerical_data) - window_size + 1, step): end = start + window_size # 数值窗口:(window_size, 3) num_window = numerical_data[start:end] # 图像窗口:(window_size, 64, 64, 1) img_window = spectrogram_data[start:end][..., np.newaxis] # 标签:取窗口结束时刻的RUL(即end-1索引) label = labels[end-1] yield (num_window, img_window), label # 创建Dataset dataset = tf.data.Dataset.from_generator( lambda: windowed_dataset_generator(df_num_scaled, spectrograms_normalized, rul_labels), output_signature=( ( tf.TensorSpec(shape=(50, 3), dtype=tf.float32), tf.TensorSpec(shape=(50, 64, 64, 1), dtype=tf.float32) ), tf.TensorSpec(shape=(), dtype=tf.float32) ) ) # 批处理、预取 dataset = dataset.batch(32).prefetch(tf.data.AUTOTUNE)逻辑说明:
step=1:滑动步长为1,保证数据利用率(小样本时必选)labels[end-1]:预测的是窗口结束时刻的RUL,符合物理意义(用过去50秒数据预测此刻状态)prefetch(tf.data.AUTOTUNE):后台预加载下一批数据,GPU利用率提升30%
3. 训练与验证:R²、MAE、MSE、RMSE指标的计算陷阱与调试技巧
3.1 回归指标的正确计算方式:为什么验证集R²可能为负?
R²(决定系数)公式为:R² = 1 - SS_res / SS_tot,其中SS_res是残差平方和,SS_tot是总离差平方和。当模型比“预测所有样本为均值”还差时,R²为负——这在小样本、强噪声数据中极常见,不是bug,是预警信号。必须用验证集而非训练集计算指标,且需在反标准化后计算:
# 假设y_true_scaled, y_pred_scaled是模型输出(已标准化) # y_scaler是RUL标签的StandardScaler y_true_real = y_scaler.inverse_transform(y_true_scaled.reshape(-1,1)).flatten() y_pred_real = y_scaler.inverse_transform(y_pred_scaled.reshape(-1,1)).flatten() # 手动计算指标(避免sklearn在小样本下的数值误差) ss_res = np.sum((y_true_real - y_pred_real) ** 2) ss_tot = np.sum((y_true_real - np.mean(y_true_real)) ** 2) r2_score = 1 - ss_res / ss_tot if ss_tot != 0 else 0 mae = np.mean(np.abs(y_true_real - y_pred_real)) mse = np.mean((y_true_real - y_pred_real) ** 2) rmse = np.sqrt(mse) print(f"R²: {r2_score:.4f}, MAE: {mae:.4f}, MSE: {mse:.4f}, RMSE: {rmse:.4f}")注意:
sklearn.metrics.r2_score在y_true方差极小时会返回-inf,手动计算更稳定。MAE对异常值鲁棒,RMSE放大大误差,二者需结合看——若RMSE远大于MAE,说明存在少数严重预测错误,需检查数据标注或模型是否在特定工况下失效。
3.2 早停与学习率调度:小样本下避免过拟合的双保险
小样本训练极易过拟合,仅靠Dropout不够,必须用早停(EarlyStopping)+ 学习率衰减(ReduceLROnPlateau):
from tensorflow.keras.callbacks import EarlyStopping, ReduceLROnPlateau # 早停:监控验证集MAE,耐心值设为15(小样本收敛慢) early_stopping = EarlyStopping( monitor='val_mae', patience=15, verbose=1, mode='min', restore_best_weights=True # 训练结束后自动加载最优权重 ) # 学习率衰减:当val_mae 5轮不降,学习率减半 lr_scheduler = ReduceLROnPlateau( monitor='val_mae', factor=0.5, patience=5, min_lr=1e-7, verbose=1 ) # 训练 history = model.fit( train_dataset, epochs=200, validation_data=val_dataset, callbacks=[early_stopping, lr_scheduler], verbose=1 )参数说明:
patience=15:小样本验证波动大,需更长容忍期restore_best_weights=True:避免训练后期过拟合,这是后悔药,必开min_lr=1e-7:防止学习率衰减到0,导致训练停滞
3.3 可视化训练过程:用TensorBoard定位梯度消失/爆炸
# 在fit前添加TensorBoard回调 import datetime log_dir = "logs/fit/" + datetime.datetime.now().strftime("%Y%m%d-%H%M%S") tensorboard_callback = tf.keras.callbacks.TensorBoard(log_dir=log_dir, histogram_freq=1) # 训练时加入 history = model.fit(..., callbacks=[early_stopping, lr_scheduler, tensorboard_callback])关键观察点(TensorBoard Scalars页):
learning_rate:确认是否按预期衰减loss与val_loss曲线:若val_loss持续上升而loss下降,严重过拟合gradients直方图:若LSTM层梯度集中在0附近(梯度消失),或出现极大值(梯度爆炸),需调整recurrent_dropout或初始化方式
4. 避坑指南:CNN-LSTM多输入回归的5个致命翻车点
4.1 现象:验证集R²为负,且MAE远高于基线模型(如SVR)
原因:标签未标准化或反标准化错误。CNN-LSTM对输出范围敏感,若RUL标签量纲为小时(0~1000),而模型输出未约束,会发散。
解决:
- 强制对RUL标签做
StandardScaler(非MinMaxScaler),因LSTM输出层用linear激活,需匹配正态分布假设 - 反标准化时,确保
y_scaler拟合的是训练集标签,而非全量数据
4.2 现象:训练Loss快速下降但验证Loss停滞,LSTM层梯度直方图为尖峰(集中在0)
原因:时间步过长(T>100)导致梯度消失,或recurrent_dropout未启用。
解决:
- 将窗口长度
T从100降至50,并增加recurrent_dropout=0.2 - 改用
CuDNNGRU(GPU加速版GRU,比LSTM更抗梯度消失)替代LSTM
4.3 现象:CNN分支输出特征全为0,或LSTM输入张量出现NaN
原因:频谱图归一化时spec.max() == spec.min()(全黑图),导致除零错误。
解决:
- 归一化代码加安全项:
spec_norm = (spec - spec.min()) / (spec.max() - spec.min() + 1e-8) - 数据加载时添加检查:
if np.all(spec == spec[0,0]): continue跳过无效帧
4.4 现象:多输入数据对齐后,时间戳有微秒级偏移,导致merge_asof匹配失败
原因:不同传感器时间戳精度不一致(如PLC记录为毫秒,振动仪为微秒)。
解决:
- 统一转换为
int64纳秒时间戳,再用// 10**6转为毫秒级对齐 - 使用
pd.merge_asof(..., tolerance='10ms', allow_exact_matches=True)
4.5 现象:模型预测结果呈现“阶梯状”,即相邻时间步预测值几乎相同
原因:CNN分支未充分学习图像特征,输出特征向量高度相似,LSTM失去时序分辨力。
解决:
- 在CNN分支末尾加
BatchNormalization层(TimeDistributed(BatchNormalization())) - 增加CNN卷积核数量(如128→256),并确保
padding='same'保持空间尺寸
5. 进阶技巧:用注意力机制增强CNN-LSTM的时序判别力,以及小样本下的数据增广实操
5.1 在LSTM后插入自注意力层:让模型自己决定哪些时间步更重要
原始CNN-LSTM将所有时间步等权输入LSTM,但设备退化往往由关键事件(如一次冲击振动)驱动。加入Transformer-style自注意力,可让模型聚焦于最具判别性的时刻:
from tensorflow.keras.layers import MultiHeadAttention, LayerNormalization, Add # 在LSTM输出后添加注意力 lstm_out = LSTM(64, return_sequences=True, dropout=0.2, recurrent_dropout=0.2)(merged_ts) # (T, 64) # 自注意力:对时间维度做MultiHeadAttention attention_output = MultiHeadAttention( num_heads=4, key_dim=64, dropout=0.1 )(lstm_out, lstm_out) # (T, 64) # 残差连接 + 层归一化 attention_output = Add()([lstm_out, attention_output]) attention_output = LayerNormalization()(attention_output) # 全局平均池化,得到单个特征向量 attention_pooled = tf.keras.layers.GlobalAveragePooling1D()(attention_output) # (64,) output = Dense(1, activation='linear')(attention_pooled)为什么有效?
MultiHeadAttention计算每个时间步对其他时间步的注意力权重,例如冲击时刻的权重会显著高于平稳运行时刻GlobalAveragePooling1D聚合所有时间步信息,比单纯取LSTM(..., return_sequences=False)的最后一个隐状态更鲁棒- 小样本下,注意力机制能减少对长序列的过度依赖,提升泛化性
5.2 小样本数据增广:针对频谱图的物理感知增强策略
小样本时,盲目用ImageDataGenerator做旋转/翻转会破坏频谱图的物理意义(频率轴不可翻转)。必须遵循物理约束:
| 增广类型 | 是否可行 | 操作说明 | 代码示例 |
|---|---|---|---|
| 水平翻转(沿时间轴) | ✅ | 频谱图时间轴可翻转,模拟反向退化过程 | np.fliplr(spec) |
| 垂直缩放(沿频率轴) | ❌ | 频率轴代表物理量,缩放失真 | 禁止 |
| 添加高斯噪声 | ✅ | 模拟传感器噪声,σ=0.01~0.05 | spec + np.random.normal(0, 0.02, spec.shape) |
| 随机遮挡(频带屏蔽) | ✅ | 模拟传感器局部失效,遮挡1~2个频带 | spec[mask] = 0(mask为随机矩形) |
| 幅度缩放(整体增益) | ✅ | 模拟增益漂移,乘以0.8~1.2 | spec * np.random.uniform(0.8, 1.2) |
def augment_spectrogram(spec): """物理感知频谱图增广""" # 1. 幅度缩放 spec = spec * np.random.uniform(0.8, 1.2) # 2. 高斯噪声 spec = spec + np.random.normal(0, 0.02, spec.shape) # 3. 水平翻转(概率0.5) if np.random.rand() > 0.5: spec = np.fliplr(spec) # 4. 随机遮挡:在频率轴(垂直方向)遮挡1个频带 h, w = spec.shape mask_h = np.random.randint(4, 12) # 遮挡高度4~12像素 start_h = np.random.randint(0, h - mask_h) spec[start_h:start_h+mask_h, :] = 0 return np.clip(spec, 0, 1) # 保持[0,1]范围 # 在数据生成器中调用 def windowed_dataset_generator(...): ... img_window = np.array([augment_spectrogram(spec) for spec in spectrogram_data[start:end]]) ...5.3 模型解释性:用Grad-CAM可视化CNN关注的频谱区域
想知道CNN到底在看频谱图的哪个频段?Grad-CAM(Gradient-weighted Class Activation Mapping)可生成热力图:
# 获取CNN最后一层卷积输出(before Flatten) cnn_model = Model(inputs=input_img_ts, outputs=cnn_ts) # cnn_ts是TimeDistributed后的输出 # 选择一个测试样本 test_num, test_img = next(iter(test_dataset.take(1))) test_img = test_img[:1] # 取第一个样本,shape (1, T, 64, 64, 1) # 获取最后一层卷积层输出(假设为第5层) last_conv_layer = cnn_model.layers[5] # 需根据model.summary()确认索引 grad_model = Model([cnn_model.input], [last_conv_layer.output, cnn_model.output]) # 计算梯度 with tf.GradientTape() as tape: conv_outputs, predictions = grad_model(test_img) loss = predictions[:, 0] # 预测的RUL值 # 梯度反传到卷积输出 output_grads = tape.gradient(loss, conv_outputs) pooled_grads = tf.reduce_mean(output_grads, axis=(0, 1, 2)) # 对T,H,W求均值 # 加权叠加 conv_outputs = conv_outputs[0] for i in range(pooled_grads.shape[-1]): conv_outputs[:, :, :, i] *= pooled_grads[i] heatmap = tf.reduce_mean(conv_outputs, axis=-1).numpy() # 可视化(取最后一个时间步的热力图) import matplotlib.pyplot as plt plt.imshow(heatmap[-1], cmap='jet') plt.colorbar() plt.title("CNN关注的频谱区域(最后时间步)") plt.show()解读:热力图亮区即CNN认为对RUL预测最重要的频段。若亮区集中在高频(>5kHz),说明模型抓住了早期微裂纹产生的冲击信号;若集中在低频(<100Hz),可能是轴承外圈故障特征——这比单纯看指标更能验证模型的物理合理性。
我做设备预测项目时,曾因热力图显示CNN在关注50Hz工频干扰而非故障特征,及时发现传感器接地不良,避免了模型上线后误报。模型不是黑匣子,Grad-CAM就是你的X光机。希望帮到你。
本文还有配套的精品资源,点击获取