☰
EEG情绪识别中的GCN+LSTM:从通道拓扑到时序建模的完整实践
2026/10/2 18:43:51 网站建设 项目流程

简介:基于GCN与LSTM的EEG情绪识别算法设计源码,是一份面向深度学习与生物信号处理研究者的完整项目。项目以图卷积网络(GCN)捕捉脑电信号的空间关联,以长短期记忆网络(LSTM)建模时间动态,覆盖数据预处理、模型定义、训练验证与结果输出全流程,适合对情绪识别、脑机接口或图神经网络应用感兴趣的开发者参考。压缩包共41个文件,以24个Python脚本为核心,辅以编译缓存、XML配置、Markdown/文本说明、NumPy特征矩阵、训练损失图与项目配置文件等,整体约94MB。目前已有227人学习,源码按功能拆分为数据处理、模型构建、训练评估等模块,便于对照论文复现实验或二次开发。除核心算法外,还能看到完整的DEAP数据集处理链路、预提取的feature.npy特征数据,以及训练过程的损失可视化图像,能帮助快速定位调参关键点,是一份难得的动手实践资料。

1. EEG情绪识别为什么绕不开GCN+LSTM:从脑电通道到情绪标签的完整链路

基于GCN和LSTM的深度学习EEG情绪识别,这几年在情感计算里一直是热点,但很多人第一次跑源码都会卡在同一个问题上:EEG数据明明是二维矩阵,为什么非要用GCN?因为电极按10-20系统贴在头皮上,通道间的空间关系是一张图而不是规则网格。CNN把电极当像素会丢掉真实邻接信息,GCN在这个拓扑上做邻居聚合,LSTM在时间维度捕捉情绪动态。空间交给GCN,时间交给LSTM,这就是标题里这套算法的设计核心。

这套方案解决的是从一段多通道脑电里判出情绪维度的落地问题,适合正在复现论文、准备毕设或要上线情绪识别模块的工程师。我会从图的构造讲起,给出一套基于PyTorch的GCN+LSTM最小可跑通源码,覆盖DEAP数据预处理、训练循环和评估方法,最后把踩过的坑按现象、原因、解决写清楚。建议先把二分类全流程跑通,再去加注意力或迁移学习的模块。

2. 把EEG通道建成图:邻接矩阵构造与GCN层的PyTorch实现

2.1 为什么是图不是矩阵:EEG通道的空间拓扑解析

EEG采集靠贴在头皮上的电极完成,电极按照国际10-20系统定位。32导联和64导联的差别不只是通道数量,更关键的是电极在头皮表面形成了不均匀分布:额区、中央区、枕区的电极间距各不相同,颞区通道还被拉得很开。把电极直接拍扁成二维网格,等于默认通道之间存在规则的八邻接关系,但左颞和右颞的通道在真实头皮上可能隔了好几厘米。图结构不存在这个问题,每条边只表达真实的邻接关系,GCN的聚合操作沿着边进行,空间拓扑信息就保住了。

我在结构选型时对比过CNN和GCN在DEAP上的表现:把32通道重排成8乘4的伪图像,用两层卷积的效果比自己设计的32节点GCN低2到4个点,而且CNN对通道排列顺序非常敏感,换个排法准确率就波动。GCN对通道顺序天然不变,只要邻接矩阵对,怎么打乱通道索引结果都一样,这个特性在跨设备迁移时很实用——不同采集设备的通道顺序可能不一致,但10-20系统的物理位置是固定的。

通道坐标不需要精确到毫米。10-20系统自带比例关系,我对比过实测坐标和按比例生成的简化坐标,最终准确率差异在1%以内。真正敏感的是连边阈值和权重衰减系数,这两个参数定下来,图的质量基本就定下来了。另外提醒一句:后期做eeg去噪时,坏导联的位置会影响图结构,如果某段数据有通道损坏,先插值补全再建图,别让坏道把邻接关系带偏。

2.2 邻接矩阵的三种构造法:从电极坐标到皮尔逊相关

第一种做法是用电极坐标算欧氏距离,距离小于阈值的通道连边,权重用高斯核衰减:

import numpy as np def build_adj_by_distance(channel_positions, threshold=6.0, sigma=2.0): # channel_positions: [num_channels, 2],二维平面投影坐标 num = len(channel_positions) adj = np.zeros((num, num)) for i in range(num): for j in range(num): dist = np.linalg.norm(channel_positions[i] - channel_positions[j]) if 0 < dist < threshold: # 高斯核加权:距离越近,权重越大 adj[i, j] = np.exp(-dist ** 2 / (2 * sigma ** 2)) return adj

threshold的经验范围在4到8之间。以32导联投影到头皮平面后的典型间距来说,取6.0时每个电极大约有5到8个邻居,图连通且不过密。sigma控制权重衰减速度,一般与threshold保持同一量级;sigma太小,稍远一点的邻居权重趋近于0,等于没连;sigma太大,远近权重拉不开差距,图卷积的局部性就弱了。这个矩阵构造完不要直接喂给模型,记得补自环,否则节点自身特征在聚合时会被稀释。

第二种做法是从数据本身学通道关系:统计训练集所有样本的皮尔逊相关系数,把相关系数的绝对值当作边权。这种做法在情绪识别里很常见,因为情绪状态会改变通道间的功能连接,比纯坐标更贴近任务:

def build_adj_by_correlation(eeg_data, topk=5): # eeg_data: [samples, num_channels, time_len] num_channels = eeg_data.shape[1] flat = eeg_data.reshape(-1, num_channels).T # [channels, N] corr = np.abs(np.corrcoef(flat)) np.fill_diagonal(corr, 0.0) # 稀疏化:每个通道只保留相关性最强的topk个邻居 for i in range(num_channels): idx = np.argsort(corr[i])[:-topk] corr[i, idx] = 0.0 return corr

用皮尔逊相关建图有一个必须注意的边界:相关性是在训练集上统计的,千万不要把测试样本混进来算,否则图结构里就带进了测试集信息,后面评估出的准确率全部虚高。topk一般取3到6,保留太多会引入噪声边,太少则图的连通性变差。另外,相关矩阵取绝对值会让负相关和正相关同权处理,但负相关在脑区功能连接里同样有生理意义,这一点比纯坐标法信息量更大。

第三种做法是把邻接矩阵本身设为可学习参数,跟模型一起训练。效果不一定比前两种高,但省去了手工调阈值的环节,适合当对比基线:

import torch import torch.nn as nn class LearnableAdj(nn.Module): def __init__(self, num_nodes): super().__init__() self.adj_param = nn.Parameter(torch.randn(num_nodes, num_nodes)) def forward(self): # 对称化:保证图是无向的 sym = (self.adj_param + self.adj_param.T) / 2 # 按行softmax归一化,让聚合权重落在0到1之间 adj = torch.softmax(sym, dim=-1) return adj

这个模块放在模型最前面,forward时先算出邻接矩阵再喂给GCN层。可学习邻接矩阵容易学成全连接图,GCN的邻居聚合就失去局部性了,所以一般要加稀疏正则,或者直接初始化成坐标法算好的矩阵再微调。我建议有训练时间就试第三种,赶时间就老老实实用坐标法。

三种方法没有绝对优劣,取决于任务约束。下面这个表是我常用的判断依据:

构造方法需要的数据可解释性主要风险
电极坐标距离通道位置高,能画出图阈值需要手调
皮尔逊相关训练集EEG中,边有生理含义容易数据泄漏
可学习参数无需先验低,黑匣子过拟合到全连接

2.3 GCN层的PyTorch实现:聚合公式与三个必调参数

图卷积层的实现很短,核心就两步:邻居聚合加线性变换。

import torch.nn.functional as F class GCNLayer(nn.Module): def __init__(self, in_dim, out_dim, dropout=0.3): super().__init__() self.fc = nn.Linear(in_dim, out_dim) self.dropout = nn.Dropout(dropout) self.bn = nn.BatchNorm1d(out_dim) def forward(self, x, adj): # x: [batch, num_nodes, in_dim] # adj: [num_nodes, num_nodes],已含自环并做归一化 h = torch.matmul(adj, x) # 邻居特征聚合 h = self.fc(h) # 特征线性变换 h = self.bn(h.transpose(1, 2)).transpose(1, 2) return F.relu(self.dropout(h))

这里有一个实现细节:邻接矩阵的归一化在进入模型之前做,不要在每一层重复算。常见做法是对称归一化,也就是用带自环的度矩阵D做D^{-1/2} A D^{-1/2}。32个节点的矩阵直接用torch.matmul做稠密乘法就行,开销可以忽略;以后换到128导联,建议转成torch.sparse的稀疏表示。

三个必调参数按优先级排:dropout影响过拟合,hidden_dim决定特征容量,层数决定感受野。我一般先固定hidden_dim为64,跑通后试128和32,观察验证集F1而不是训练准确率。dropout在0.3到0.5之间调,过拟合明显就往上加。层数尽量控制在2层以内,GCN超过3层会出现严重的过平滑,这个坑在第5章单独讲。BatchNorm放在线性变换之后,对EEG这种个体差异大的数据很有效,不加的话训练收敛会明显变慢。

3. LSTM吃时间步:时序建模与两种主流结构怎么拼

3.1 LSTM在EEG里的角色:从图级特征到情绪动态演化

GCN处理完后,每个时间步的每个通道都变成了一个特征向量。但情绪不是瞬时状态,一个60秒试次里前2秒和后2秒的脑电模式可能完全不同。LSTM的作用就是把时间维串起来,让模型看到情绪状态怎么演化。这里需要明确时间步的定义:DEAP一个试次原始有7680个采样点,切窗后每个样本内部再按窗口滑动得到多个时间步,LSTM就在这些时间步之间建模。

一个常见的误解是直接把原始采样点送进LSTM。128Hz下1秒就是128个点,序列太长,LSTM即使有门控也扛不住长程依赖,训练还慢。我习惯的做法是先把每个时间步内的EEG段计算成差分熵或功率谱密度特征,再把特征序列交给LSTM。这样时间步长从几百个采样点压缩到几个统计量,LSTM学的是情绪状态的演化趋势,而不是逐点的波形。调LSTM段的梯度问题时,我一般会对照PyTorch的LSTM源码确认参数顺序和hidden state的返回逻辑,这个模块的内部状态容易让人看晕,别看文档看到一半就上手改。

3.2 串行与并行:GCN和LSTM的拼法直接影响F1

两种主流拼法:串行结构是GCN在前、LSTM在后,输入特征先过图卷积提取空间特征,再按时间步过LSTM,最后全连接分类。并行结构是双分支,GCN分支只做空间聚合,LSTM分支直接吃原始特征,最后把两边输出拼接。用DEAP做valence二分类实验时,串行的F1通常比并行高2到3个百分点,原因是LSTM能学到GCN聚合后的高阶空间特征的时间变化,而不是原始通道的时序模式。

串行结构代码:

class GCNLSTM(nn.Module): def __init__(self, num_nodes, in_dim, hidden_dim, lstm_hidden, num_classes=2, lstm_layers=2, dropout=0.4): super().__init__() self.gcn1 = GCNLayer(in_dim, hidden_dim, dropout) self.gcn2 = GCNLayer(hidden_dim, hidden_dim, dropout) self.lstm = nn.LSTM(hidden_dim, lstm_hidden, lstm_layers, batch_first=True, dropout=dropout) self.classifier = nn.Sequential( nn.Linear(lstm_hidden, 64), nn.ReLU(), nn.Dropout(0.3), nn.Linear(64, num_classes) ) def forward(self, x, adj): # x: [batch, time_steps, num_nodes, in_dim] batch, steps, nodes, _ = x.shape x = x.reshape(batch * steps, nodes, -1) x = self.gcn1(x, adj) x = self.gcn2(x, adj) x = x.mean(dim=1) # 节点池化,得到图级特征 x = x.reshape(batch, steps, -1) out, _ = self.lstm(x) # [batch, steps, lstm_hidden] out = out[:, -1, :] # 取最后一个时间步 return self.classifier(out)

这段代码里的GCNLayer就是上一章定义的那个层。关键在节点池化:GCN输出的是每个通道的特征,但LSTM需要每个时间步一个特征向量,所以用mean把所有通道压缩成一个图级特征。也有人用attention池化,但数据量小的时候mean更不容易出问题。这里有个容易错的地方:LSTM的num_layers必须和dropout搭配,PyTorch规定只有层数大于1时层间dropout才生效,单层LSTM加dropout参数会被静默忽略,别踩这个哑巴坑。

3.3 LSTM关键参数:hidden_size、层数与dropout怎么定

LSTM参数比GCN更靠经验,说玄学也不为过。hidden_size我通常在32到128之间试,DEAP这种体量用64就够了,再大只会增加过拟合风险。层数1到3层,2层是性价比最高的起点,LSTM层数一多训练时间涨得比准确率快。dropout放在LSTM层间和输出层之后,0.3到0.5之间,配合GCN的dropout一起调,不要两边都拉满,否则模型欠拟合。

梯度问题值得单独说。LSTM对梯度消失比RNN好得多,但时间步超过20时训练loss还是可能震荡。我的习惯是在优化器更新前加梯度裁剪:

loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=5.0) optimizer.step()

梯度范数超过5就截断。之前有一次不加裁剪,训练到第30轮loss突然变成nan,加了裁剪之后这种情况再没出现过。LSTM的初始细胞状态和隐藏状态默认是0,对短序列没问题,长序列建议改成随机初始化,但一定要固定种子,否则每次运行结果都飘。

4. 源码落地:DEAP数据预处理到训练循环的最小可跑通代码

4.1 DEAP数据加载与预处理管线:基线校正、切窗与标签二值化

DEAP是EEG情绪识别用得最多的公开数据集,32名被试、每人40个试次,每次记录60秒脑电,采样率128Hz,共32个EEG通道外加8个外周信号。标签是valence、arousal、dominance、liking四个维度,每项打分1到9。最常用的设置是把valence或arousal按5分阈值二值化,做二分类。

加载和预处理我习惯写成一个函数:

import scipy.io as sio import numpy as np def load_deap_subject(path, subject_id, segment_len=128, stride=64): mat = sio.loadmat(f'{path}/s{subject_id:02d}.mat') data = mat['data'] # [40 trials, 40 channels, 8064] labels = mat['labels'] # [40 trials, 4 ratings] eeg = data[:, :32, :] # 只取32个EEG通道 # 基线校正:减去每个试次前3秒的均值,384 = 128Hz * 3s baseline = eeg[:, :, :384].mean(axis=2, keepdims=True) eeg = eeg - baseline # 切窗:1秒一个窗,步长0.5秒 segments, seg_labels = [], [] for t in range(eeg.shape[0]): valence = labels[t, 0] label = 1 if valence >= 5 else 0 # 二值化 for start in range(0, eeg.shape[2] - segment_len, stride): segments.append(eeg[t, :, start:start + segment_len]) seg_labels.append(label) return np.stack(segments), np.array(seg_labels)

注意整个试次统一减基线均值,而不是每窗单独减,目的是保留情绪诱发成分。eeg去噪如果要做得更精细,可以加4到45Hz的带通滤波把工频干扰和低频漂移一起去掉,但滤波会引入相位偏移,要用零相位滤波。切窗参数直接影响后面的时间步数量,segment_len取128、stride取64时,一个60秒试次能切出约110个窗。

注意:stride小于segment_len时相邻窗口有重叠,重叠比例影响样本量和时间步长度,建议从0.5重叠起步,再按验证集表现调。

4.2 特征计算:差分熵DE的提取与模型输入维度

GCN的输入不能是原始采样点,通常把每个窗按频带算特征。DEAP上最常用的特征是差分熵,它在五个频段上分别计算,把1秒的128个点变成32通道乘5频段的特征矩阵:

def compute_de(segment, fs=128): from scipy.signal import welch freq_bands = [(1, 4), (4, 8), (8, 14), (14, 31), (31, 50)] de_features = [] for ch in segment: freqs, psd = welch(ch, fs=fs, nperseg=64) band_de = [] for low, high in freq_bands: mask = (freqs >= low) & (freqs <= high) # 差分熵 = PSD在频段内的对数均值 band_de.append(np.log(np.mean(psd[mask]) + 1e-6)) de_features.append(band_de) return np.array(de_features) # [32, 5]

差分熵对情绪状态区分度好、计算简单,五个频段的对数功率均值拼在一起就是特征。这里的in_dim对应5,如果换成PSD特征,维度会高很多,GCN第一层参数也跟着涨。另一个细节:不同被试的信号幅值差异大,特征算完要做z-score归一化,归一化统计量只从训练集算,测试集用同一组参数变换,否则又踩数据泄漏。

构造x_all时要注意时间步的组装:每个样本的多个时间步窗要从同一个试次按时间顺序取。比如切窗函数切出110个窗,可以每5个相邻窗合成一个样本,时间步就是5,样本量变成原窗数的五分之一。别把不同试次的窗随机拼在一起,那样LSTM学到的是假的跨试次时序。

4.3 训练循环:损失函数、优化器与模型装配

训练循环部分直接照标准PyTorch写法就行,重点是模型输入维度要对齐。

import torch from torch.utils.data import TensorDataset, DataLoader # x_all: [N, time_steps, 32, 5],y_all: [N] dataset = TensorDataset(torch.FloatTensor(x_all), torch.LongTensor(y_all)) loader = DataLoader(dataset, batch_size=64, shuffle=True) model = GCNLSTM(num_nodes=32, in_dim=5, hidden_dim=64, lstm_hidden=64, num_classes=2) optimizer = torch.optim.Adam(model.parameters(), lr=1e-3, weight_decay=1e-4) criterion = torch.nn.CrossEntropyLoss() for epoch in range(50): model.train() total_loss, correct, total = 0, 0, 0 for xb, yb in loader: optimizer.zero_grad() pred = model(xb, adj_tensor) loss = criterion(pred, yb) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), 5.0) optimizer.step() total_loss += loss.item() * xb.size(0) correct += (pred.argmax(1) == yb).sum().item() total += xb.size(0) print(f'epoch {epoch:02d} loss {total_loss/total:.4f} acc {correct/total:.4f}')

这里的坑在时间步维度。GCNLSTM的forward里x被reshape成(batch*steps, nodes, in_dim),也就是说x_all在送入模型前必须保证有时间步维度。如果每个窗独立当样本、时间步写成1,LSTM等于退化成全连接,完全失去时序建模能力。切窗时让窗与窗之间有重叠,再把相邻窗按顺序叠成时间步,这样LSTM才有序列可学。

学习率1e-3配合Adam是起步值,loss在头10轮不降就降到5e-4重跑,这种情况在EEG数据上比计算机视觉数据更常见。50轮训练里每轮都打印loss和acc,观察验证曲线大概在第20轮左右不涨,就该用早停,别硬跑满50轮。

5. EEG情绪识别避坑:数据泄漏、过平滑与样本不均衡的排查记录

5.1 数据泄漏:归一化放错位置,测试集信息提前见光

现象:训练集准确率正常,验证集准确率也高得离谱,第一次跑到87%以上。换一组被试跑,掉到60%左右,来回横跳。

原因:直接拿全部数据的均值和标准差做z-score归一化,再划分训练集和测试集。归一化统计量来自全部样本,测试集的分布信息通过均值泄漏进了训练过程,模型等于开卷考试。EEG数据的个体差异大,这种泄漏带来的虚高比图像分类更严重。

解决:划分后再计算归一化参数,只fit训练集,用同一组参数transform验证集和测试集。scikit-learn的StandardScaler按这个流程用就行,关键是fit和transform的时机严格分离。检查时留意一个细节:如果验证准确率和训练准确率差距小于1%,先怀疑泄漏,再怀疑模型太好。这个坑在EEG任务里几乎人手一个,我自己的方案跑出92%准确率时兴奋了半天,查完泄漏原因后真实成绩是71%。

5.2 GCN过平滑:图卷积堆到第三层,准确率反而跳水

现象:GCN从2层加到3层,验证准确率掉3到5个点,加到4层掉得更多。一开始以为是过拟合,加了dropout和weight_decay都没用。

原因:这是图卷积的典型问题——过平滑。每做一次邻居聚合,节点特征就向邻居均值靠拢一次,层数多了,所有节点的特征趋同,LSTM拿到的输入基本是一条均值线,时序信息全没了。

解决:GCN控制在2层。如果确实需要更大的感受野,不要加层,改用残差连接把输入特征跳过中间层拼到输出。还有一种做法是把邻接矩阵的幂级数展开,一步覆盖多跳邻居,但DEAP这种小数据集上收益有限。验证方法很简单:打印GCN输出的特征标准差,如果趋近于0,就是过平滑实锤。我养成的习惯是每层GCN后都打印一次输出分布,这个习惯帮我提前发现过平滑,不用等到训练结束才看结果。

5.3 样本不均衡:valence阈值一划,正负样本直接失衡

现象:valence按5分二值化后,正样本是负样本的两倍多。训练出来的模型把几乎所有样本都预测成多数类,准确率看着有60%多,F1却只有0.4几。

原因:DEAP的标签本身有偏,而且切窗后同一个试次切出的上百个窗共享同一个标签,样本不均衡被成倍放大。准确率被多数类带跑,困惑矩阵一出来就露馅。

解决:先用困惑矩阵定位,别只看accuracy。loss上给少数类加权,CrossEntropyLoss的weight参数按样本数倒数设置,或者用Focal Loss。评估指标换成F1和ROC-AUC,跟准确率一起看。如果做下采样,要用试次级别的下采样,不能随机丢窗,因为同一试次的窗共享标签,随机丢窗等于随机删信息。

5.4 跨被试泛化差:别人的模型换个人就翻车

现象:模型在自己切分的数据上测试一切正常,换一个没见过的被试,准确率直接掉到随机水平。

原因:跨被试泛化是EEG情绪识别的天然困境。不同人的脑电幅值和基线水平差异很大,同一个情绪在不同个体上的脑电模式不一定对齐。如果训练集和测试集来自同一个被试的不同试次,这种同源数据会让分数虚高,但换人就露馅。

解决:评估时用留一被试交叉验证,把32个被试轮流当测试集,剩下31个训练。尽管要跑32轮很费时间,但它才能反映真实泛化能力。预处理阶段可以做被试级归一化,把每个被试的数据按自己的均值和标准差标准化,减弱个体幅值差异的影响。这个操作对LOSO分数的提升比调模型参数更明显。

6. 从跑通到可交付:LOSO验证、消融实验与一个训练习惯

模型在随机划分的测试集上跑出70%以上的准确率后,离可交付还有两条路要走:LOSO验证和消融实验。LOSO把每个被试当作独立的测试集,DEAP上LOSO的准确率通常比随机划分低8到15个百分点,如果LOSO结果还在60%以上,说明模型学到的是模式而不是记忆。跑LOSO时把32次结果汇总,计算平均准确率和标准差,标准差超过5说明模型对某些被试完全不可靠,需要回到预处理阶段排查。这一步是论文和工程的分水岭,也是评审和验收最看重的数字。

消融实验回答的是GCN和LSTM到底谁在起作用。我的做法是准备三个变体:去掉GCN只留LSTM、去掉LSTM只留GCN、把GCN换成同参数CNN。四个模型在相同的LOSO协议下对比F1,如果去掉GCN掉点最多,说明空间图结构是主线;如果去掉LSTM掉点最多,说明时序演化才是主线。这个结论直接决定后面加模块的方向:空间主导就研究更精细的邻接矩阵,时序主导就尝试注意力机制替代LSTM。这套思路同样可以平移去做EEG时间序列预测类任务,评估框架不用改。

最后分享一个训练习惯:每次跑实验前固定随机种子,训练循环里保存验证集最优的checkpoint而不是最后一轮。这是踩了多次忘保存、重训一晚上的坑之后养成的血泪经验。

import random torch.manual_seed(42) np.random.seed(42) random.seed(42) best_f1 = 0.0 for epoch in range(50): # ... 训练和验证 ... if val_f1 > best_f1: best_f1 = val_f1 torch.save(model.state_dict(), 'best_model.pt')

种子固定保证同一个脚本两次运行结果一致,checkpoint保证你拿到的是验证集最优而不是过拟合后的最后一轮。换数据集、调参、改结构的时候,这两个习惯能帮你省下大量重复劳动;反之,不固定种子的话,每次结果都差一点,你根本分不清是改动了模型还是随机波动。这套GCN和LSTM的调试流程我用了快两年,最深的体会是EEG模型的分数波动大,所有结论都要建立在固定协议和固定种子之上,否则换个人复现就对不上。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询