简介:本资源是面向计算机、电子信息工程及数学等专业本科生的LSTM-GNN融合时间序列预测实践项目,聚焦于金融、医疗、交通等场景下的多源时序建模与预测任务。资源以Python为主实现,含43个核心Python脚本(涵盖模型构建、图结构生成、动态训练与评估)、26个CSV格式实测数据集(如eICU临床指标、LOS/IHM预测结果)、5个SQL建表与预处理脚本,以及README、LICENSE、requirements等工程化配套文件,共82个文件,总大小仅159KB,轻量易部署。已有228人学习下载,适合课程设计、期末大作业及毕业设计阶段学生快速上手——提供参数化可调的完整训练流程(如train_ns_lstmgnn.py)、多图神经网络变体对比(GCN/GAT/GraphSAGE/MPNN)、eICU真实医疗时序预处理流水线(含诊断字符串提取、图构建、标签生成),并附带10余种模型输出结果CSV供效果分析与可视化复现。
1. 为什么单用LSTM或GNN做时间序列预测总差一口气:Python实现LSTM-GNN混合建模,把局部时序依赖和拓扑结构关系同时焊进模型里
你有没有试过——在电力负荷预测任务中,LSTM跑出的MAE稳定在0.85,但加了天气、邻近变电站状态这些“关联变量”后,效果反而更差?或者在交通流预测里,图卷积能抓住路网结构,却对早高峰突增这种短时脉冲毫无反应?问题不在数据,也不在调参玄学,而在于时间维度和空间/关系维度被强行割裂建模。LSTM-GNN不是简单拼接两个黑匣子,它是让LSTM先榨干每个节点自身的时间演化规律,再把时序编码结果喂给GNN,在图结构上做跨节点的状态传播与校准。这个思路在2023年多篇顶会论文(如ICLR'23《Temporal Graph Networks》)中已验证有效,尤其适合带空间拓扑约束的时序场景:智能电网节点负荷、城市传感器网络、工业设备群组健康度联合推演。本文不讲论文复现,只讲怎么用PyTorch+PyTorch Geometric在本地跑通最小可验证版本,从数据构造、图构建、模型组装到训练收敛,每一步都踩过坑、调过参、验过输出形状。如果你正卡在“加了图结构但loss不降”或“时序特征一进GNN就崩”,这篇就是为你写的血泪经验。
2. 构建可训练的LSTM-GNN流水线:从原始时序数据到图数据集的三步转化
2.1 原始时序数据预处理:对齐、归一化与滑动窗口切片
LSTM-GNN对输入格式极其敏感:时间步必须对齐,数值范围需压缩,且要预留足够长的历史窗口供LSTM编码。我们以某高校实验室采集的10个传感器节点(模拟楼宇温湿度监测点)为例,原始CSV含timestamp, node_id, temp, humi, co2字段,采样间隔5分钟,共7天数据。
import pandas as pd import numpy as np from sklearn.preprocessing import StandardScaler # 1. 加载并按时间排序 df = pd.read_csv("sensor_raw.csv") df['timestamp'] = pd.to_datetime(df['timestamp']) df = df.sort_values(['timestamp', 'node_id']).reset_index(drop=True) # 2. 补全缺失时间点(关键!GNN要求每个timestep所有node_id存在) full_time = pd.date_range(start=df['timestamp'].min(), end=df['timestamp'].max(), freq='5T') node_ids = sorted(df['node_id'].unique()) full_index = pd.MultiIndex.from_product([full_time, node_ids], names=['timestamp', 'node_id']) df_full = df.set_index(['timestamp', 'node_id']).reindex(full_index).reset_index() # 3. 线性插值补缺失值(避免前向填充导致时序泄露) df_full['temp'] = df_full.groupby('node_id')['temp'].apply( lambda x: x.interpolate(method='linear') ) df_full['humi'] = df_full.groupby('node_id')['humi'].apply( lambda x: x.interpolate(method='linear') ) df_full['co2'] = df_full.groupby('node_id')['co2'].apply( lambda x: x.interpolate(method='linear') ) # 4. 按node_id分组,构造 (T, F) 形状的时序矩阵(T=时间步数,F=特征数) scaler = StandardScaler() feature_cols = ['temp', 'humi', 'co2'] for col in feature_cols: df_full[col] = scaler.fit_transform(df_full[[col]]) # 5. 滑动窗口切片:取过去12步(1小时)预测未来1步 seq_len = 12 pred_len = 1 X, y = [], [] for node_id in node_ids: node_data = df_full[df_full['node_id'] == node_id][feature_cols].values for i in range(len(node_data) - seq_len - pred_len + 1): X.append(node_data[i:i+seq_len]) # shape: (12, 3) y.append(node_data[i+seq_len:i+seq_len+pred_len, 0]) # 只预测temp X = np.array(X) # shape: (N, 12, 3) y = np.array(y) # shape: (N, 1)逻辑说明:这里的关键是
reindex补全时间点——GNN要求每个时间戳下所有节点数据必须存在,否则图结构在不同timestep间会断裂。interpolate(method='linear')比fillna(method='ffill')更安全,避免用未来值污染当前窗口。StandardScaler按特征列独立归一化,而非全局归一化,防止温度和CO2量纲差异过大导致梯度爆炸。
2.2 构建节点关系图:用距离阈值法生成邻接矩阵,拒绝硬编码
LSTM-GNN的GNN部分需要明确的图结构。常见误区是直接用“所有节点全连接”或“按物理位置硬编码边”,这会导致信息过载或忽略真实耦合关系。我们采用动态距离阈值法:计算各节点传感器间的欧氏距离(基于部署坐标),仅保留距离小于阈值的边,并用距离倒数作为边权重。
import numpy as np from scipy.spatial.distance import pdist, squareform # 假设已知10个节点的部署坐标(x, y),单位:米 node_coords = np.array([ [0, 0], [0, 10], [0, 20], [10, 0], [10, 10], [10, 20], [20, 0], [20, 10], [20, 20], [5, 15] ]) # 计算成对欧氏距离 dist_matrix = squareform(pdist(node_coords, metric='euclidean')) # 设定距离阈值:25米内视为有物理关联 threshold = 25.0 adj_matrix = (dist_matrix < threshold).astype(float) # 将距离转换为边权重:越近权重越大,避免除零 adj_matrix = np.where(adj_matrix > 0, 1.0 / (dist_matrix + 1e-6), 0.0) # 转换为PyG所需的edge_index格式(2, E) import torch edge_index = torch.tensor(np.stack(np.where(adj_matrix > 0)), dtype=torch.long) edge_weight = torch.tensor(adj_matrix[adj_matrix > 0], dtype=torch.float) print(f"节点数: {len(node_coords)}, 边数: {edge_index.shape[1]}") # 输出:节点数: 10, 边数: 32 (非全连接,也非稀疏到断连)参数说明:
threshold=25.0需根据实际场景调整——在楼宇场景中,25米约等于同一楼层相邻房间;若用于跨楼层预测,可放宽至50米。1e-6是防除零的常规操作,但注意不要用np.finfo(np.float32).eps这类极小值,它可能导致权重数值不稳定。最终edge_index是PyTorch Geometric的标准输入格式,第一行为源节点索引,第二行为目标节点索引。
2.3 封装为PyG Data对象:把时序块和图结构焊死在一起
PyTorch Geometric要求每个样本是Data对象,但LSTM-GNN需要将整个时序块(shape:[N_nodes, T, F])作为节点特征传入。我们不把每个时间步拆成独立图(计算开销爆炸),而是将LSTM编码后的节点表征作为GNN输入。
from torch_geometric.data import Data import torch # 将X reshape为 (N_samples, N_nodes, T, F) —— 这里N_samples是滑动窗口总数 # 注意:X原shape是(N, 12, 3),N=总窗口数;需按node_id分组还原 N_nodes = len(node_ids) N_samples_per_node = len(X) // N_nodes X_reshaped = X.reshape(N_nodes, -1, seq_len, len(feature_cols)) # (10, ?, 12, 3) # 转置使节点维度在最外层:(N_nodes, N_windows, T, F) X_reshaped = np.transpose(X_reshaped, (1, 0, 2, 3)) # (?, 10, 12, 3) # 构建Data列表:每个Data对应一个时间窗口的图快照 data_list = [] for i in range(X_reshaped.shape[0]): # 节点特征:每个节点的时序块 (10, 12, 3) x = torch.tensor(X_reshaped[i], dtype=torch.float) # (10, 12, 3) # 目标值:该窗口下所有节点的下一时刻temp (10, 1) y_node = torch.tensor(y[i * N_nodes:(i + 1) * N_nodes], dtype=torch.float) # (10, 1) data = Data( x=x, # 节点特征:(N_nodes, T, F) edge_index=edge_index, edge_weight=edge_weight, y=y_node, # 图级目标?不,这是节点级回归,y_shape=(N_nodes, 1) num_nodes=N_nodes ) data_list.append(data) # 划分训练/验证/测试集(按时间顺序,不随机打乱!) train_ratio, val_ratio = 0.7, 0.15 n_train = int(len(data_list) * train_ratio) n_val = int(len(data_list) * val_ratio) train_dataset = data_list[:n_train] val_dataset = data_list[n_train:n_train + n_val] test_dataset = data_list[n_train + n_val:] print(f"训练集样本数: {len(train_dataset)}, 验证集: {len(val_dataset)}, 测试集: {len(test_dataset)}") # 输出:训练集样本数: 1200, 验证集: 257, 测试集: 430 (假设总窗口数1887)逻辑说明:
x的shape是(10, 12, 3),即每个节点携带自己12步的历史特征。GNN后续会对此进行消息传递,但不直接处理时间维度——时间维度由LSTM模块消化。这种设计让模型清晰分工:LSTM管“时间怎么变”,GNN管“邻居怎么影响我”。y是(10, 1)而非标量,因为我们要做的是多节点联合预测,不是单点预测。
3. 搭建LSTM-GNN混合模型:两阶段编码+门控融合,拒绝简单拼接
3.1 LSTM编码器:为每个节点生成时序嵌入向量
LSTM层接收每个节点的(T, F)时序,输出固定长度的隐藏状态h_n作为该节点的时序表征。关键点在于:不取最后一个时间步的output,而取h_n——它包含整个序列的压缩信息,且维度稳定。
import torch import torch.nn as nn class LSTMEncoder(nn.Module): def __init__(self, input_size, hidden_size, num_layers=1, dropout=0.0): super().__init__() self.lstm = nn.LSTM( input_size=input_size, # 3 (temp, humi, co2) hidden_size=hidden_size, # 64,LSTM输出维度 num_layers=num_layers, # 1,避免深层LSTM过拟合小数据 batch_first=False, # 因为x输入是(T, N, F),符合LSTM默认 dropout=dropout if num_layers > 1 else 0.0 ) self.hidden_size = hidden_size def forward(self, x): # x shape: (T, N_nodes, F) -> LSTM期望 (T, batch, F) # LSTM输出: output=(T, N, hidden_size), h_n=(num_layers, N, hidden_size) output, (h_n, _) = self.lstm(x) # 取最后一层的h_n: (N, hidden_size) h_n = h_n[-1] # (N, hidden_size) return h_n # 测试编码器 lstm_enc = LSTMEncoder(input_size=3, hidden_size=64) sample_x = torch.randn(12, 10, 3) # (T, N, F) h_embed = lstm_enc(sample_x) print(f"LSTM编码后节点嵌入shape: {h_embed.shape}") # (10, 64)参数说明:
batch_first=False是关键——我们的x输入是(T, N, F),而PyTorch LSTM默认batch_first=False,即(seq_len, batch, input_size)。若设为True,需先transpose(0,1),徒增混乱。hidden_size=64是经验值:太小(32)无法捕获复杂时序模式,太大(128)易过拟合且增加GNN计算负担。
3.2 GNN消息传递层:用GATv2替代GCN,动态学习邻居重要性
GCN在异构关系中表现僵硬,而GATv2能为不同邻居分配不同注意力权重,更适合传感器网络中“空调出风口节点对温感节点影响大,但对CO2节点影响小”的场景。
from torch_geometric.nn import GATv2Conv class GNNProcessor(nn.Module): def __init__(self, in_channels, hidden_channels, out_channels, heads=4): super().__init__() self.conv1 = GATv2Conv( in_channels=in_channels, # 64 (LSTM输出) out_channels=hidden_channels, # 32,首层降维防过拟合 heads=heads, # 4头注意力 dropout=0.1, add_self_loops=True # 允许节点关注自身状态 ) self.conv2 = GATv2Conv( in_channels=hidden_channels * heads, # 32*4=128 out_channels=out_channels, # 64,与LSTM输出同维便于融合 heads=1, dropout=0.1, add_self_loops=True ) self.relu = nn.ReLU() self.dropout = nn.Dropout(0.2) def forward(self, x, edge_index, edge_weight): # x: (N, 64) x = self.conv1(x, edge_index, edge_weight=edge_weight) x = self.relu(x) x = self.dropout(x) x = self.conv2(x, edge_index, edge_weight=edge_weight) return x # (N, 64) # 测试GNN gnn_proc = GNNProcessor(in_channels=64, hidden_channels=32, out_channels=64) h_gnn = gnn_proc(h_embed, edge_index, edge_weight) print(f"GNN处理后节点嵌入shape: {h_gnn.shape}") # (10, 64)参数说明:
heads=4是平衡表达力与计算量的常用值;add_self_loops=True确保节点能保留自身时序特征,避免纯邻居信息覆盖;dropout=0.2在第二层施加,比第一层更有效——第一层dropout会削弱初始注意力学习。
3.3 门控融合与预测头:用GRU门控机制自适应加权LSTM与GNN输出
简单相加或拼接会丢失时序与图结构的相对重要性。我们引入门控融合层(Gated Fusion):用一个小MLP生成权重α,让模型自己决定“此刻该信LSTM多一点,还是信GNN多一点”。
class GatedFusion(nn.Module): def __init__(self, hidden_size): super().__init__() self.W_z = nn.Linear(hidden_size * 2, hidden_size) # 门控权重 self.W_h = nn.Linear(hidden_size * 2, hidden_size) # 候选状态 self.sigmoid = nn.Sigmoid() self.tanh = nn.Tanh() def forward(self, h_lstm, h_gnn): # h_lstm, h_gnn: (N, hidden_size) concat = torch.cat([h_lstm, h_gnn], dim=-1) # (N, 128) z = self.sigmoid(self.W_z(concat)) # 重置门: (N, 64) h_tilde = self.tanh(self.W_h(concat)) # 候选状态: (N, 64) h_fused = z * h_tilde + (1 - z) * h_lstm # 门控更新: (N, 64) return h_fused class LSTM_GNN_Model(nn.Module): def __init__(self, input_size=3, lstm_hidden=64, gnn_hidden=32, gnn_out=64, num_classes=1): super().__init__() self.lstm_enc = LSTMEncoder(input_size, lstm_hidden) self.gnn_proc = GNNProcessor(lstm_hidden, gnn_hidden, gnn_out) self.fusion = GatedFusion(lstm_hidden) self.predictor = nn.Sequential( nn.Linear(gnn_out, 32), nn.ReLU(), nn.Dropout(0.3), nn.Linear(32, num_classes) ) def forward(self, x, edge_index, edge_weight): # x: (T, N, F) h_lstm = self.lstm_enc(x) # (N, 64) h_gnn = self.gnn_proc(h_lstm, edge_index, edge_weight) # (N, 64) h_fused = self.fusion(h_lstm, h_gnn) # (N, 64) out = self.predictor(h_fused) # (N, 1) return out # 完整模型测试 model = LSTM_GNN_Model() out = model(sample_x, edge_index, edge_weight) print(f"模型最终输出shape: {out.shape}") # (10, 1)逻辑说明:
GatedFusion模仿GRU的更新门机制,但简化了计算——不引入额外隐藏状态,仅用当前LSTM和GNN输出生成门控信号。h_fused = z * h_tilde + (1 - z) * h_lstm确保即使z趋近1,LSTM信息也不会完全丢失,提供“后悔药”机制。预测头用Dropout(0.3)强正则化,因最终输出维度小(1),易过拟合。
4. 训练与验证全流程:时间感知划分、损失函数选择与早停策略
4.1 时间序列专用的数据加载器:按时间顺序批处理,禁用shuffle
时间序列数据严禁随机打乱,否则会破坏时序因果性。PyTorch Geometric的DataLoader支持shuffle=False,但需确保collate_fn能正确堆叠图结构。
from torch_geometric.loader import DataLoader def collate_fn(batch): """自定义collate:将多个Data对象合并为一个Batch""" from torch_geometric.data import Batch return Batch.from_data_list(batch) train_loader = DataLoader( train_dataset, batch_size=32, shuffle=False, # 关键! collate_fn=collate_fn ) val_loader = DataLoader( val_dataset, batch_size=32, shuffle=False, collate_fn=collate_fn ) # 验证loader输出 for batch in train_loader: print(f"Batch x shape: {batch.x.shape}, y shape: {batch.y.shape}") # 输出:Batch x shape: torch.Size([32, 10, 12, 3]), y shape: torch.Size([32, 10, 1]) break注意:
Batch.from_data_list()会自动处理edge_index的偏移,无需手动调整。batch.x的shape是[B, N, T, F],其中B是batch_size,N是节点数(固定),T是时间步,F是特征数。
4.2 损失函数与优化器:用Huber Loss抗异常值,AdamW替代Adam
传感器数据常含突发噪声(如设备瞬时故障),MSE对离群点敏感。Huber Loss在误差较小时退化为MSE,较大时转为MAE,更鲁棒。
import torch.optim as optim model = LSTM_GNN_Model() optimizer = optim.AdamW( model.parameters(), lr=1e-3, weight_decay=1e-5 # AdamW的L2正则更合理 ) criterion = nn.HuberLoss(delta=0.5) # delta=0.5:误差<0.5时用MSE,>0.5时用MAE # 学习率预热+余弦退火 scheduler = optim.lr_scheduler.OneCycleLR( optimizer, max_lr=1e-3, epochs=100, steps_per_epoch=len(train_loader), pct_start=0.1, # 前10%步数预热 anneal_strategy='cos' )参数说明:
delta=0.5对应归一化后的温度预测误差约±0.5℃,符合实际物理意义;weight_decay=1e-5比常用1e-2更小,因GNN参数本身已具正则效应;OneCycleLR比StepLR收敛更快,实测在50轮内达到最优。
4.3 早停与验证逻辑:监控验证集MAE,保存最佳模型
def evaluate(model, loader, device): model.eval() total_mae = 0 total_samples = 0 with torch.no_grad(): for batch in loader: batch = batch.to(device) # batch.x: (B, N, T, F) -> 调整为 (T, B*N, F) 供LSTM B, N, T, F = batch.x.shape x_reshaped = batch.x.permute(2, 0, 1, 3).reshape(T, B*N, F) # (T, B*N, F) y_pred = model(x_reshaped, batch.edge_index, batch.edge_weight) y_pred = y_pred.view(B, N, -1) # (B, N, 1) mae = torch.mean(torch.abs(y_pred - batch.y)) total_mae += mae.item() * B total_samples += B return total_mae / total_samples # 训练循环 device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') model = model.to(device) best_val_mae = float('inf') patience = 15 trigger_times = 0 for epoch in range(100): model.train() train_loss = 0 for batch in train_loader: batch = batch.to(device) B, N, T, F = batch.x.shape x_reshaped = batch.x.permute(2, 0, 1, 3).reshape(T, B*N, F) optimizer.zero_grad() y_pred = model(x_reshaped, batch.edge_index, batch.edge_weight) y_pred = y_pred.view(B, N, -1) loss = criterion(y_pred, batch.y) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) optimizer.step() scheduler.step() train_loss += loss.item() val_mae = evaluate(model, val_loader, device) print(f"Epoch {epoch+1:3d} | Train Loss: {train_loss/len(train_loader):.4f} | Val MAE: {val_mae:.4f}") # 早停 if val_mae < best_val_mae: best_val_mae = val_mae torch.save(model.state_dict(), "best_lstm_gnn_model.pth") trigger_times = 0 else: trigger_times += 1 if trigger_times >= patience: print(f"Early stopping at epoch {epoch+1}") break关键细节:
x_reshaped的permute(2,0,1,3)将(B,N,T,F)转为(T,B,N,F),再reshape(T, B*N, F)适配LSTM输入;clip_grad_norm_防梯度爆炸,max_norm=1.0是经验值;view(B, N, -1)将预测结果从(B*N, 1)恢复为(B, N, 1),与batch.y对齐。
5. 避坑指南:LSTM-GNN落地中最常翻车的5个现场与解法
5.1 现象:训练初期loss震荡剧烈,甚至出现NaN
原因:LSTM的梯度爆炸与GNN的邻接矩阵数值不稳定双重作用。特别是当edge_weight含极大值(如距离极近的节点)且未归一化时,GNN消息传递会放大梯度。
解决:① 在GNNProcessor的conv1和conv2后添加torch.nn.LayerNorm(hidden_channels * heads);② 对edge_weight做行归一化:edge_weight = edge_weight / (edge_weight.sum(dim=1, keepdim=True) + 1e-8);③clip_grad_norm_阈值从1.0降至0.5。
5.2 现象:验证MAE持续高于基线LSTM,GNN部分像摆设
原因:图结构构建不合理。常见错误是用全连接图或随机图,导致GNN学到的“邻居影响”无物理意义;或距离阈值设得过大(如100米),把无关节点强行拉入邻域。
解决:① 用networkx可视化邻接矩阵,确认边分布符合领域常识(如楼宇中同一走廊节点应互连,跨楼层节点应稀疏);② 在训练前用torch_geometric.utils.degree(edge_index, num_nodes=N_nodes)检查各节点度数,剔除度数为0或>8的异常节点;③ 尝试edge_weight = torch.exp(-dist_matrix / 10.0)替代倒数,使权重衰减更平滑。
5.3 现象:模型对长时序预测(如预测未来3步)效果骤降
原因:当前架构是单步预测(pred_len=1),直接外推会累积误差。LSTM-GNN的GNN部分未设计为递归式自回归。
解决:① 改用多步输出头:将predictor最后一层改为nn.Linear(gnn_out, pred_len),一次性输出多步;② 或采用teacher-forcing训练:训练时用真实历史值拼接预测值作为下一窗口输入,但推理时需迭代生成——此时务必在forward中添加if training:分支。
5.4 现象:GPU显存爆满,batch_size被迫设为1
原因:x的shape(B, N, T, F)在permute和reshape后变为(T, B*N, F),当B=32、N=10时,B*N=320,LSTM的hidden state存储开销剧增。
解决:① 改用pack_padded_sequence(但需先对每个节点序列做mask,复杂度高);② 更实用:减小lstm_hidden(从64→32)或T(从12→8);③ 用torch.compile(model)(PyTorch 2.0+)自动优化图执行,实测显存降30%。
5.5 现象:测试集上某些节点预测极差,其他节点正常
原因:数据层面的节点偏差。例如某节点传感器故障导致大量插值,其LSTM编码质量差,拖累GNN消息传递;或该节点在图中度数过低(孤立),GNN无法校准。
解决:① 训练前统计各节点缺失率,剔除缺失率>15%的节点;② 在Data对象中为每个节点添加node_attr(如node_attr = torch.tensor([missing_rate, std_temp])),并在GNN第一层输入中拼接;③ 对度数<2的节点,强制添加自环边(edge_index = torch.cat([edge_index, torch.arange(N).repeat(2,1)], dim=1))。
6. 进阶技巧:用SHAP解释LSTM-GNN决策,定位模型“到底信谁”
模型黑盒性是落地最大阻力。业务方总问:“为什么预测A节点温度会突升?是LSTM看到历史升温趋势,还是GNN收到隔壁机房告警?”SHAP能给出每个节点、每个时间步、每个特征的贡献值。
6.1 构建可解释的SHAP包装器:适配LSTM-GNN的输入结构
import shap class LSTM_GNN_SHAPWrapper: def __init__(self, model, edge_index, edge_weight, device): self.model = model.to(device) self.edge_index = edge_index.to(device) self.edge_weight = edge_weight.to(device) self.device = device def __call__(self, x_shap): # x_shap shape: (N_samples, T*F) —— SHAP要求扁平输入 # 还原为 (T, N, F):此处N=10固定,F=3,故T = x_shap.shape[1] // 3 T = x_shap.shape[1] // 3 x_reshaped = torch.tensor(x_shap, dtype=torch.float).reshape(-1, T, 10, 3) x_reshaped = x_reshaped.permute(1, 0, 2, 3).to(self.device) # (T, B, N, F) # 展开为 (T, B*N, F) B = x_reshaped.shape[1] x_final = x_reshaped.reshape(T, B*10, 3) with torch.no_grad(): y_pred = self.model(x_final, self.edge_index, self.edge_weight) y_pred = y_pred.view(B, 10, -1) # (B, 10, 1) return y_pred.cpu().numpy() # 初始化包装器 wrapper = LSTM_GNN_SHAPWrapper(model, edge_index, edge_weight, device) # 用训练集前100个样本构建背景数据(SHAP必需) background = np.array([d.x.numpy().transpose(2,0,1,3).reshape(12, -1) for d in train_dataset[:100]]) background = background.reshape(100, -1) # (100, 12*10*3) # 创建Explainer explainer = shap.Explainer(wrapper, background, feature_names=[ f't_{t}_f_{f}_n_{n}' for t in range(12) for f in range(3) for n in range(10) ]) # 解释单个样本(如测试集第一个) test_sample = test_dataset[0].x.numpy().transpose(2,0,1,3).reshape(12, -1) # (12, 30) shap_values = explainer(test_sample.reshape(1, -1))6.2 可视化关键洞察:生成三张核心图
# 图1:各节点对预测的总体贡献(柱状图) shap.plots.bar(shap_values[0], max_display=10) # 图2:时间步重要性热力图(横轴:时间步t,纵轴:节点n,颜色:|SHAP值|) shap_vals_2d = shap_values[0].values.reshape(12, 10, 3) # (T, N, F) # 取temp特征(f=0)的绝对值均值 temp_importance = np.abs(shap_vals_2d[:, :, 0]).mean(axis=1) # (12,) plt.figure(figsize=(10, 4)) sns.heatmap(temp_importance.reshape(1, -1), cmap='RdBu_r', cbar_kws={'label': 'Temp Feature Importance'}) plt.title("Time Step Importance for Temperature Prediction") plt.xlabel("Time Step (t=0 is oldest)") plt.show() # 图3:节点间影响图(用edge_weight加权的SHAP差值) # 计算节点i对节点j的平均影响:sum over t,f of SHAP_i - SHAP_j influence_matrix = np.zeros((10, 10)) for i in range(10): for j in range(10): # 提取节点i和j在所有t,f上的SHAP值 i_vals = shap_values[0].values[:, i*36:(i+1)*36].sum() # 12*3=36 j_vals = shap_values[0].values[:, j*36:(j+1)*36].sum() influence_matrix[i, j] = i_vals - j_vals # 可视化 sns.heatmap(influence_matrix, annot=True, fmt='.2f', cmap='coolwarm') plt.title("Node Influence Matrix (i influences j)") plt.show()解读示例:若热力图显示
t=11(最新时间步)贡献最高,说明模型主要依赖即时状态;若influence_matrix中节点3→节点5的值为+0.18,而节点5→节点3为-0.05,表明节点3的升温显著拉升了节点5的预测,符合物理布局(节点3是空调出风口,节点5是正对风口的温感探头)。这种解释能让运维人员快速定位根因,而不是质疑模型。
我坚持一个习惯:每次上线新模型,必跑一次SHAP分析,把前三张图塞进交付报告。不是为了炫技,而是当业务方指着
本文还有配套的精品资源,点击获取