ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

EEG情绪识别实战:GCN+LSTM建模与预处理全解析

EEG情绪识别实战:GCN+LSTM建模与预处理全解析 简介这是一份基于GCN与LSTM的深度学习EEG情绪识别算法设计源码面向深度学习、生物信号处理与情感计算方向的研究者和工程师解决从脑电图信号中自动识别情绪状态的问题。压缩包共41个文件、约94MB以24个Python源文件为核心完整覆盖GCN与LSTM模型定义、DEAP数据集加载与预处理、对抗网络模块、训练与评估流程另含5个编译文件、4个XML配置、feature.npy特征数据、train_losses.png损失可视化等便于直接复现训练过程。文件按功能拆分GNNLSTM系列、DEAPDataset系列、train系列等脚本其中多个版本模块体现了模型调参与对比思路目录结构清晰适合作为情绪识别论文复现、课程设计或入门实践的参考资料。目前已有227人学习是一份可运行、可扩展的深度学习综合案例。1. EEG情绪识别为什么最终要落到GCNLSTM做EEG情绪识别实验的人都有过这种经历单通道特征加SVM精度卡在60%上下调参调到怀疑人生换成二维CNN把脑电当图像处理又总觉得哪里不对劲——脑电信号本质是分布在头皮空间上的多通道时序数据通道之间不是像素那样规整的网格关系。这份源码给我的第一印象就是它把两件事做对了用GCN显式建模通道间的空间拓扑再用LSTM吃时间维度。公开数据集上这套组合能比纯CNN高出十多个点前提是预处理和图构建没翻车。适合手里有脑电数据、想复现高精度情绪识别基线、又不想从零手写全套管线的人。2. 数据预处理与差分熵特征从原始EEG到模型能吃的张量2.1 原始数据读进来先干什么源码的预处理管线第一步是读取公开的EEG情绪数据集。常见做法是用DEAP系标准数据集它给出的是32导联、128Hz采样率的原始记录每个被试有40段实验。读入之后有四件事必须按顺序做坏导联检测、带通滤波、基线校正、按被试归一化。坏导联检测这一步很容易被跳过但实际数据里总有一两个通道因为接触不良出现大幅漂移。常见做法是用标准差阈值或者看频谱能量分布如果一个导联的方差超过其他导联均值的三倍基本可以判定异常。滤波方面我一般会用0.5到45Hz的带通保留theta到gamma的主要频带同时把工频干扰压掉。基线校正针对的是DEAP这类有3秒静息态的数据把每个trial前3秒的均值作为基线减掉消掉直流漂移。import numpy as np from scipy import signal def preprocess_eeg(raw, fs128, l_freq0.5, h_freq45.0): raw: shape (n_channels, n_samples) 返回滤波基线校正后的数据 # 1. 带通滤波抑制工频与高频噪声 sos signal.butter(4, [l_freq, h_freq], btypebandpass, fsfs) filtered signal.sosfilt(sos, raw, axis-1) # 2. 基线校正取前3秒均值作为参考 baseline_len fs * 3 baseline_mean filtered[:, :baseline_len].mean(axis-1, keepdimsTrue) corrected filtered - baseline_mean return corrected这段代码里sosfilt用四阶Butterworth滤波器做带通比filtfilt慢一点但能保留因果性基线校正用的是前3秒均值这个假设成立的前提是静息态的脑电统计平稳。参数上l_freq0.5和h_freq45是情绪识别任务里最常用的频段选择既保住了theta波4—8Hz和gamma波30—45Hz又避开了50Hz工频。如果你的数据采样率不是128Hz记得把fs改掉否则滤波器系数全错。2.2 差分熵而不是原始幅值特征侧选型原始脑电幅值直接送进网络效果通常不好因为幅值受个体差异和电极阻抗影响很大。源码在特征侧用的是差分熵Differential Entropy这是EEG情绪识别领域用得比较广的特征它本质上是某个频带内信号能量密度的对数表示。差分熵的物理含义很直观脑电在清醒、放松、紧张状态下各频带的能量分布差异明显。对一段信号做短时傅里叶变换算出theta、alpha、beta、gamma四个频带的能量差分熵就是对数化后的能量值。这样每个通道在每个时间窗内得到4个特征32个通道就是128维特征向量比原始采样点的维度压缩了一个量级。def differential_entropy(x, fs128, freq_bands[(4,8),(8,13),(13,30),(30,45)]): x: 单通道信号, shape (n_samples,) 返回该通道在四个频带的DE特征 # 短时傅里叶变换窗长256重叠128 f, t, Zxx signal.stft(x, fsfs, nperseg256, noverlap128) # 功率谱密度估计 psd np.abs(Zxx) ** 2 de_feat [] for lo, hi in freq_bands: idx np.where((f lo) (f hi))[0] # 频带能量取均值后取对数就是差分熵 energy np.mean(psd[idx, :], axis0) de np.log(energy) de_feat.append(de.mean()) return np.array(de_feat)注意这里窗长256对应2秒重叠128对应1秒等效窗移是1秒。np.log(energy)这一步是差分熵公式的核心——如果能量接近0对数之后会变成很大的负数实际数据里应该对energy加一个极小值epsilon防止取零。频段划分参数可以根据任务微调比如做唤醒度识别时把beta频段拆成beta1和beta2更有效。2.3 滑窗与tensor组装把特征拼成模型输入特征提取之后需要把单个窗口的128维特征组织成模型能吃的序列结构。这一步的目标是构造形状为(batch, time_steps, n_channels, n_features)的张量时间步是滑窗数量通道维度是32个导联特征维度是4个频带的DE值。滑窗参数直接影响时序建模的长度。我一般用窗口长度1秒、步长0.5秒这样一段60秒的trial能产生119个时间步序列足够长LSTM能学到情绪随时间演化的规律。步长太短会让相邻窗口高度相关训练时信息冗余太长又会丢掉时序细节。还有一个容易忽略的点是把样本轨迹打散也就是在训练集上按trial维度shuffle而不是按窗口打散否则同一trial的窗口会同时出现在训练集和测试集里造成数据泄漏这是后文避坑章要展开的重点。组装好的张量按7:1.5:1.5的比例切成训练、验证、测试集。取值标准是先按被试切分不做跨被试的混合随机切分情绪识别的泛化性验证才有意义。3. GCNLSTM模型构建图卷积建模空间、LSTM建模时序3.1 脑电通道为什么要建图EEG信号天然是图结构数据——32个导联按照10-20系统分布在头皮表面相邻导联之间存在空间关联比如前额叶的F3和F4、顶叶的C3和C4。传统CNN在处理这类数据时直接把通道排成网格强行套用卷积核的局部连接假设。问题在于头皮不是一个平面网格通道之间的「邻居关系」由物理距离决定而非排列顺序CNN的卷积核无法表达这一点。GCN的思路是把每个导联当成图上的一个节点通道间的关系用邻接矩阵的边权重表示。图卷积做的就是「对邻居节点的特征做加权聚合再更新自己」这个操作和脑电信号在头皮上传播的物理过程天然匹配。同样的思路在水文观测网络、气象站网这类空间采样场景里也在用本质都是把不规则空间结构建模成图。数据量小的时候GCN比CNN更容易收敛因为它不需要通过大量数据来学习空间位置的先验。3.2 邻接矩阵的三种构建方式图建得好不好直接决定GCN的上限。源码里给出了三种邻接矩阵的构建方式按推荐优先级排序第一是基于导联物理距离的阈值法。计算32个导联在球面上的三维坐标距离小于某个阈值比如60mm就算相邻构建出稀疏的二值邻接矩阵。这个方法的优点是先验性强完全由物理位置决定不需要从数据学习。第二是基于皮尔逊相关系数的方法在训练集上计算通道间的信号相关性取绝对值后作为边权重。这个方案的边是学出来的能捕捉功能性连接但计算量大且容易过拟合。第三是融合方案物理距离决定图的稀疏结构相关系数决定边的权重。import torch from scipy.spatial import distance # electrode_pos: shape (32, 3)32个导联的三维坐标 def build_adj(electrode_pos, threshold60.0): # 计算两两欧氏距离矩阵 dist_mat distance.cdist(electrode_pos, electrode_pos) adj (dist_mat threshold).astype(float) # 加自环GCN聚合时需要包含自身信息 adj adj np.eye(32) # 对称归一化D^{-1/2} A D^{-1/2} deg adj.sum(axis1) deg_inv_sqrt np.diag(np.power(deg, -0.5)) norm_adj deg_inv_sqrt adj deg_inv_sqrt return torch.tensor(norm_adj, dtypetorch.float32)threshold是图中最敏感的超参数设太小图会碎成不连通的小块设太大每个节点都变成全连接GCN退化成MLP。源码里的经验值是60mm32导联的头皮布局下刚好让每个节点平均有4到6个邻居。np.eye(32)加自环是GCN实现里的习惯做法否则节点在聚合时丢失自身信息。对称归一化的作用是让邻居多的节点不会被淹没数值上更稳定。3.3 GCN层与LSTM层的衔接问题模型结构的核心难点在GCN和LSTM怎么接。一种做法是先把每个时间窗的特征矩阵32节点×4特征经过几层GCN做空间聚合再把聚合后的节点特征按时间顺序排列喂给LSTM做时序建模。这里的维度变化是输入(window_steps, 32, 4)→ GCN聚合后(window_steps, 32, 64)→ 展平成(window_steps, 32*64)→ LSTM输出(window_steps, 128)→ 最后取最后一个时间步或做全局池化接全连接层到2分类正负效价或4分类效价×唤醒度。class GCNLayer(torch.nn.Module): def __init__(self, in_dim, out_dim, adj): super().__init__() self.adj adj # 归一化邻接矩阵32x32 self.W torch.nn.Parameter(torch.randn(in_dim, out_dim)) self.b torch.nn.Parameter(torch.zeros(out_dim)) def forward(self, x): # x: (batch, n_nodes, in_dim) h torch.matmul(x, self.W) # 特征线性变换 agg torch.matmul(self.adj, h) # 邻接矩阵聚合 return torch.relu(agg self.b) class EmotionNet(torch.nn.Module): def __init__(self, adj, n_features4, hidden_dim64): super().__init__() self.gcn1 GCNLayer(n_features, hidden_dim, adj) self.gcn2 GCNLayer(hidden_dim, hidden_dim, adj) self.lstm torch.nn.LSTM(hidden_dim * 32, 128, num_layers2, batch_firstTrue) self.fc torch.nn.Linear(128, 4) def forward(self, x): # x: (batch, time_steps, n_nodes, n_features) b, t, n, f x.shape x x.reshape(b * t, n, f) # 把时间步合并到batch维 x self.gcn1(x) x self.gcn2(x) x x.reshape(b, t, -1) # 恢复序列结构 out, _ self.lstm(x) out out[:, -1, :] # 取最后一个时间步 return self.fc(out)这段代码里有几个值得注意的设计选择。x.reshape(b*t, n, f)把时间维合并进batch维让GCN能并行处理所有时间窗的空间聚合这是效率考虑两层GCN都用了Relu第二层输出的hidden_dim*32就是把每个节点的64维特征拼成2048维全连接向量LSTM吃的是这个空间聚合后的序列。取out[:, -1, :]是情绪识别任务里的常见做法因为情绪状态往往在trial末端趋于稳定而LSTM最后一个时间步聚合了全部历史信息。如果你做的是实时情绪识别这里改成取最后一个窗口的输出会更合理。3.4 训练策略与超参数训练阶段的要点是LSTM对梯度爆炸敏感必须加梯度裁剪。我见过太多人LSTM不收敛看loss曲线一飞冲天——基本都是梯度爆炸。源码里用的是Adam优化器初始学习率1e-3weight decay设5e-4做L2正则每个epoch对GCN层和LSTM层的梯度统一裁剪到1.0。dropout建议加在GCN层的输出上而不是输入特征上0.5是比较合适的值太大欠拟合太小过拟合。optimizer torch.optim.Adam(model.parameters(), lr1e-3, weight_decay5e-4) scheduler torch.optim.lr_scheduler.ReduceLROnPlateau( optimizer, modemin, factor0.5, patience10 ) criterion torch.nn.CrossEntropyLoss() for epoch in range(100): model.train() for x_batch, y_batch in train_loader: optimizer.zero_grad() logits model(x_batch) loss criterion(logits, y_batch) loss.backward() # 梯度裁剪是LSTM训练的必要条件 torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm1.0) optimizer.step() # 验证loss不降时学习率减半 val_loss evaluate(model, val_loader) scheduler.step(val_loss)ReduceLROnPlateau比固定间隔衰减更实用验证集loss连续10个epoch不降才减半能有效避免学习率过早变小。训练epoch设100够用测试集上过拟合一般会出现在70—80个epoch之间用验证集选最优模型而不是最后一轮的权重保存模型。4. 训练与复现的四个高频坑现象、原因、解决4.1 标准化位置错了精度掉了十几个点现象训练集上loss正常下降但测试集精度一直上不去比基线还差。原因源码初始版本把z-score标准化放在了滑窗之后每个窗口单独计算均值和方差破坏了脑电信号的相对幅度关系。差分熵特征对幅值的尺度不敏感但对同一次标准化内部的分布一致性敏感窗口间独立的标准化等于给模型注入了随机噪声。更严重的是如果标准化用的是全样本统计量等于把测试集信息暴露给了训练集精度虚高但换数据就崩。解决标准化只在对数熵计算之前做一次用训练集的均值和方差去标准化验证集和测试集。具体就是保存训练集的mean和std推理时用同样的值不要在模型管线里反复计算。4.2 数据泄漏同一被试的窗口同时出现在训练和测试集现象验证集精度比测试集高很多测试集上换一个随机种子结果波动巨大。原因按窗口随机切分时同一个trial的相邻窗口被切到了不同数据集中。情绪识别任务里相邻窗口几乎复制了两个窗的重叠区域信息高度重合模型等于先看过答案再做测试。这是EEG序列任务里最典型的数据泄漏路径。解决按被试或按trial切分同一trial下的所有窗口必须完整进入同一个集合。交叉验证时用Leave-One-Subject-Out更严谨但计算量大实践中最少也要保证trial级别的隔离。4.3 GCN堆太深反而变笨现象GCN从两层加到五层精度不升反降验证集loss持续走平。原因GCN的过平滑问题——层数加深后节点特征不断聚合邻居信息所有节点的表征趋于一致区分度消失。这个现象和图卷积的感受野有关每一层聚合跳一次五层之后每个节点都收到了整个图的信息等于图结构被摊平了。解决源码里两层GCN是足够的一层学局部空间关系一层扩到次邻域。如果多分类任务确实需要更深的感受野正确做法是加残差连接把输入特征和GCN输出相加让深层网络至少能保持浅层水平。4.4 随机种子没固定复现全靠运气现象同一个脚本跑两遍精度差2到3个点说不清是模型方差还是代码问题。原因PyTorch默认的随机初始化、CUDA的非确定性操作、DataLoader的shuffle随机性三者叠加造成的。EEG数据本身的信噪比低随机波动本来就大再叠加网络初始化差异结果不可控。解决固定三处种子——Python内置random.seed、np.random.seed、torch.manual_seed同时给DataLoader设generator参数控制shuffle顺序。这样至少保证同环境同数据下结果是确定的你才能把精力放在真正影响性能的环节。5. 验证与进阶通道贡献度分析与消融实验模型训完不是终点你还需要两件事一是确认精度不是运气二是搞清楚模型到底在利用哪些通道。第二件事对撰写论文和部署落地都关键。通道贡献度分析的做法很简单用mask遮住单个通道的特征对比遮住前后的测试集精度下降幅度下降越大说明该通道贡献越高。这个方法的变体是把某个通道的全部特征置零后跑一遍模型记录logits变化量或者更严格地做GradCAM类方法。源码里我一般先用简单的置零法因为计算成本低结果也足够解释。def channel_importance(model, x, y, channel_idx): 遮住指定通道后比较准确率下降幅度 返回非负的贡献度值 base_acc evaluate(model, x, y) x_masked x.clone() # 把目标通道的所有特征置零破坏该通道信息 x_masked[:, :, channel_idx, :] 0.0 masked_acc evaluate(model, x_masked, y) return base_acc - masked_acc通常测试结果是颞叶和顶叶通道的贡献最大额叶通道对效价识别更敏感这跟情绪神经科学的文献结论一致。如果你的结果明显违背这个模式大概率是特征提取环节出了问题值得回头检查。消融实验则从模型结构入手关掉GCN只用LSTM或者关掉LSTM只用GCN加池化三条基线放一起对比能确认每一层都在起作用——很多论文审稿人会要看这个。从那以后我每次拿到新的EEG数据都会强制走一遍同样的流程先做坏导联检测和trial级隔离再跑一次基线消融最后才敢上完整模型。这三步虽然不起眼但能挡住大部分深坑省下的调试时间远超这几步的耗时。希望帮到你。本文还有配套的精品资源点击获取
返回列表