简介:一套基于图卷积网络与长短期记忆网络的脑电图情绪识别算法源码,面向深度学习、脑电信号处理与情感计算研究者,用于从脑电信号中自动识别情绪状态。包内共41个文件,压缩包约94.02MB,以24个Python源文件为主,涵盖模型定义、数据预处理、训练与评估等环节,另有编译文件、配置文档、文本文件、特征数组文件及损失图,目录结构层次清楚。目前已有227人学习下载。资源提供图卷积网络、长短期记忆网络、对抗网络等多种模型变体,配套脑电数据集系列数据加载脚本和多个独立训练脚本;特征文件保存预处理后的特征,损失图可直观查看训练过程,便于复现实验、对比模型效果或进一步改进,适合脑电情绪识别方向的入门与进阶研究者。
1. 为什么EEG情绪识别要把GCN和LSTM焊在一起
EEG情绪识别难在信号太弱、太乱、个体差异太大。单个通道的波形看不出情绪,真正有区分度的是通道之间的空间协同——比如额叶和颞叶的同步性、左右脑区的偏侧化——以及这种协同在时间上的演化。GCN(图卷积网络)负责把电极布局当作图结构,捕捉跨通道的空间依赖;LSTM(长短期记忆网络)负责建模时间维度,捕捉情绪状态随时间的动态变化。把两者串成一个端到端模型,是当前EEG情绪识别里最常见也最稳的落地方案。这套方案覆盖了从DEAP数据预处理、邻接矩阵构建、模型实现到训练调参的全链路,并给出可直接改用的源码,适合脑机接口、情感计算方向的入门者和想验证GCN+LSTM组合的从业者。
2. DEAP数据的预处理链路:从mat文件到图结构
2.1 为什么拿DEAP开刀:通道布局、标签和实验协议
EEG情绪识别公开数据集里,DEAP和SEED出镜率最高。SEED的诱发材料是电影片段,类别标签清晰,但只提供特征预提取版本,原始波形需要申请才能拿全;DEAP则把32通道原始波形和逐试次的情绪评分都打包在mat文件里,加载一个文件就能拿到一个被试的全部数据,做算法验证非常顺手,所以大多数复现工作都拿DEAP当基准。
DEAP的采集协议是:播放40段1分钟音乐视频,全程记录32通道EEG和8路外周生理信号,官方提供了降采样到128Hz的版本。每个视频结束后被试对valence(效价)、arousal(唤醒度)、dominance(支配感)、liking(喜欢程度)四项打分,范围0到9。情绪二分类的通行做法是取valence或arousal的5.0为阈值,把每个试次归为正/负两个类别。用DEAP还有一个隐形福利:32个电极严格遵循国际10-20系统的坐标,构建图结构时不需要自己操心电极位置表——这点到2.3节会用到。
2.2 预处理流程:滤波、去基线与微分熵特征
一段原始EEG波形不能直接喂给模型。一方面眨眼和肌电伪迹会把频带能量搅乱,另一方面GCN和LSTM吃的是"片段特征"而不是逐采样点的原始值。我一般按下面这条链路处理,每一步都有明确目的:
- 带通滤波:保留1-45Hz,去掉基线漂移和工频干扰
- 去基线:每试次前3秒是静息基线,情绪诱发从第4秒开始,基线段必须裁掉
- 分段:把剩余60秒切成4秒一个时间窗,窗与窗不重叠,得到15个片段
- 特征提取:对每个片段、每个通道,计算5个频带的微分熵(DE),作为该通道该时刻的特征
- z-score标准化:按每个被试逐个通道做标准化,抹平个体差异
微分熵(Differential Entropy)在脑机接口论文里几乎成了默认特征,它的近似计算很简单:对一段信号做频带滤波后,DE等于0.5乘上log(2πe倍的滤波后信号方差)。五个频带分别取delta(1-4Hz)、theta(4-8Hz)、alpha(8-14Hz)、beta(14-31Hz)、gamma(31-45Hz),每个通道输出5个DE值。这么一来,每个时间窗口的样本就是32通道×5频带共160维特征。
import scipy.io as sio import numpy as np from scipy import signal def load_deap_subject(mat_path): """加载一个DEAP被试的128Hz版本数据 返回: data: (40, 32, 8064) # 40个试次 x 32通道 x 63秒*128Hz labels: (40, 4) # valence, arousal, dominance, liking """ raw = sio.loadmat(mat_path) return raw['data'], raw['labels'] def preprocess_trial(trial, fs=128, window_sec=4, baseline_sec=3): """单个试次(32, 8064) -> 分窗后的特征(15, 32, 5)""" # 去前3秒基线 trial = trial[:, baseline_sec * fs:] n_samples = trial.shape[1] freq_bands = { 'delta': (1, 4), 'theta': (4, 8), 'alpha': (8, 14), 'beta': (14, 31), 'gamma': (31, 45) } windows = [] for start in range(0, n_samples - window_sec * fs + 1, window_sec * fs): win = trial[:, start:start + window_sec * fs] # (32, 512) de_features = [] for ch_idx in range(win.shape[0]): ch_de = [] for low, high in freq_bands.values(): # 带通滤波 b, a = signal.butter(4, [low, high], btype='bandpass', fs=fs) filtered = signal.filtfilt(b, a, win[ch_idx]) # 微分熵近似: 0.5 * log(2*pi*e * variance) de = 0.5 * np.log(2 * np.pi * np.e * np.var(filtered)) ch_de.append(de) de_features.append(ch_de) windows.append(np.array(de_features)) # (32, 5) return np.array(windows) # (15, 32, 5)预处理代码里的三个参数值得单独说。window_sec取4秒是经验值,小于2秒时单窗内频带能量估计不稳,LSTM能拿到的时间步又太少;大于8秒时一个片段内情绪状态可能已经变化,DE特征被平均掉了节奏信息。baseline_sec固定取3,这是DEAP协议里的基线长度,不同数据集的基线长度不一样,用之前先去翻文档。freq_bands的边界是神经信号处理里的惯用划分,gamma上限取45而不是更大的值,是为了避开肌肉伪迹的高频能量泄漏。
滤波用了butter(4)四阶巴特沃斯和filtfilt零相位滤波。filtfilt比lfilter多了一次反向过数据,能消除相位偏移,代价是延迟,离线预处理场景完全值得。如果你自己采数据,预处理链路还得加一步ICA伪迹去除,DEAP数据发布前已经做过初步处理,训练阶段直接跑以上流程基本够用。
2.3 三张邻接矩阵的构建方式:物理距离、相关性与功能连接
GCN的输入不只是节点特征,还需要一张邻接矩阵。EEG的32个通道天然有坐标,图结构不是额外工程,而是把电极之间的空间或功能关系显式建模。常见做法有三种,实际项目里可以先全试一遍,选验证集效果最好的:
- 物理距离构图:直接用10-20系统的电极坐标算欧氏距离,距离小于阈值就建边,边权取距离的倒数。做法直观,同一个脑区的电极天然连在一起,跨脑区弱连接被剪掉。阈值一般取0.5-0.8(按坐标归一化到[0,1]后)。
- 相关性构图:拿训练集所有片段算通道间皮尔逊相关系数,保留每个通道相关性最强的K个邻居。好处是不依赖坐标,能够捕捉个体特异的功能连接。
- 功能连接构图:用锁相值(PLV)或相干性(coherence)来建边,这俩度量的是通道间相位同步,对情绪诱发的gamma频带同步比较敏感,但计算成本高,数据量小时容易过拟合。
无论哪种构图,矩阵在进入GCN前都要做两件事:加自环、对称归一化。加自环是为了让节点在更新时保留自身特征;对称归一化就是计算D^(-1/2) A D^(-1/2),其中D是度矩阵,目的是把邻居聚合变成加权平均,避免度大的节点把特征稀释掉。
def adjacency_by_distance(channel_pos, radius=0.6): """基于物理距离的邻接矩阵 channel_pos: (32, 2) 归一化后的电极坐标 """ n = channel_pos.shape[0] dist = np.zeros((n, n)) for i in range(n): for j in range(i + 1, n): d = np.linalg.norm(channel_pos[i] - channel_pos[j]) dist[i, j] = dist[j, i] = d adj = (dist < radius).astype(float) np.fill_diagonal(adj, 1.0) # 加自环 return adj def normalize_adjacency(adj): """D^(-1/2) * A * D^(-1/2) 对称归一化""" deg = adj.sum(axis=1) deg_inv_sqrt = np.power(deg, -0.5) deg_inv_sqrt[np.isinf(deg_inv_sqrt)] = 0.0 return adj * deg_inv_sqrt[:, None] * deg_inv_sqrt[None, :]normalize_adjacency的实现比直接求逆矩阵更稳,绕开了np.linalg.inv对奇异矩阵的报错。DEAP的电极坐标从MNE库的standard_1020里直接取就行,对应Fp1、AF3、F7这些标准位置;如果你自采数据没有坐标,退而求其次用相关性构图,但注意相关性矩阵必须只用训练集算,否则又引入数据泄漏。
2.4 训练/验证/测试划分:按试次切,别按片段切
数据划分是EEG情绪识别里最容易被新手搞砸的一环。预处理后每个试次切成15个片段,总共40×15=600个样本。如果直接把这些片段随机划分,同一个试次的相邻片段会同时出现在训练集和验证集里。GCN和LSTM都是吃上下文特征的模型,模型记住了"这个片段的前后文"而不是"情绪特征",验证集虚高,换被试或换试次直接翻车。
正确的切法是按试次划分:把40个试次随机打乱,比如28个试次做训练、6个做验证、6个做测试,然后再把每个试次内的15个片段整体带过去。这样训练集和测试集之间没有任何时间重叠。做跨被试评估时,还要按被试划分,保证一个人的全部试次只出现在一个集合里,这个在6.2节再展开。
3. 图卷积+LSTM的模型实现:先空间后时间的双通道编码
3.1 模型整体数据流:输入张量每一步的shape变化
GCN和LSTM谁在前谁在后,这个顺序不是拍脑袋定的。EEG片段本身是"一个时间窗内、跨通道的切片",先让GCN把空间信息聚合到每个时间步上,再用LSTM把聚合后的空间特征沿着时间方向串起来,这是最顺的设计。反过来如果先过LSTM,每个通道的时间序列独立建模,通道间的关系就丢给后面的GCN去补,但那时序列已经被压成单向量,空间关系损失太多,效果明显差一截。
模型的输入输出数据流是:
- 输入张量
x形状为(B, T, C, F):B是batch内试次数,T是每个试次的时间窗口数(15),C是通道数(32),F是每个窗口每个通道的特征维数(5) - GCN编码器:把后两维
(C, F)当作图上的节点特征矩阵,做两层图卷积,每个时间窗口输出一个(C, hidden)的特征,再按通道做均值池化,得到(B, T, hidden) - LSTM:输入
(B, T, hidden),取最后一个时间步的隐状态(B, lstm_hidden) - 分类头:两层MLP,输出二分类logits
(B, 2)
T=15这个数字在后面所有代码里都出现,它由60秒/4秒直接决定。如果换数据集或换窗口长度,T要同步调整;LSTM对变长序列的处理比较复杂,新建项目时先把T固定,模型跑通后再考虑padding和mask。
3.2 GCN编码器:对称归一化邻接矩阵与两层图卷积
图卷积层的核心操作就一行:让每个通道的特征被自己和邻居的特征加权求和。数学上就是H' = ReLU(A_norm @ H @ W),其中A_norm是预处理好的对称归一化邻接矩阵,W是线性变换权重。实现时我不建议逐层调用PyG的GCNConv,EEG图只有32个节点,手动矩阵乘法开销极低而且shape一目了然,环境依赖还少一个。
import torch import torch.nn as nn import torch.nn.functional as F class GCNEncoder(nn.Module): def __init__(self, in_dim, hidden_dim, adj_norm): super().__init__() self.adj_norm = torch.tensor(adj_norm, dtype=torch.float32) self.layer1 = nn.Linear(in_dim, hidden_dim) self.layer2 = nn.Linear(hidden_dim, hidden_dim) def forward(self, x): # x: (B, T, C, F) B, T, C, F = x.shape x = x.reshape(B * T, C, F) # 合并batch和时间步,图卷积统一处理 adj = self.adj_norm.to(x.device) h = torch.bmm(adj.unsqueeze(0).expand(B * T, -1, -1), x) # 邻居聚合 (B*T, C, F) h = F.relu(self.layer1(h)) # (B*T, C, hidden) h = torch.bmm(adj.unsqueeze(0).expand(B * T, -1, -1), h) h = self.layer2(h) # (B*T, C, hidden) h = h.mean(dim=1) # 通道均值池化 (B*T, hidden) h = h.reshape(B, T, -1) return htorch.bmm是这一步的关键接口:adj扩成(B*T, C, C)后与特征(B*T, C, F)做批量矩阵乘,每个批次独立做图聚合。两个layer都是不带bias的nn.Linear,图卷积里的偏置可以直接合进下一层的偏置项,加上反而会引入与拓扑无关的平移。激活函数放在邻居聚合之后,这样"聚合+变换"的次序和标准GCN一致。
hidden_dim一般取32到64之间。取8以下特征表达能力不够,LSTM输入维度太窄;取128以上时图卷积那两层参数量翻倍,但32个节点的图上没有那么多信息容量,过拟合来得很快。DEAP这种600个片段的规模,hidden_dim=64是一个既稳又快的起点。层数只取2,这个后面5.2节还会专门说。
3.3 LSTM时序编码:把图特征按时间窗口串起来
GCN输出(B, T, hidden)之后,时间维度的建模交给LSTM。这里LSTM的每个时间步对应的输入就是"某个4秒窗口内、32个通道的空间聚合特征"。LSTM内部的门控结构——遗忘门、输入门、输出门——决定了它对"情绪在时间上的平滑演化"的建模能力比普通RNN强,梯度消失问题也轻得多。
我一般用两层LSTM加dropout。batch_first=True让输入输出都是(B, T, feature),PyTorch的LSTM源码里这种写法最直观。输出只取最后一个时间步的隐状态,因为情绪识别是序列级别的分类任务,不是要预测每个窗口的情绪变化。
class GCNLSTM(nn.Module): def __init__(self, in_dim=5, gcn_hidden=64, lstm_hidden=128, num_layers=2, num_classes=2, adj_norm=None): super().__init__() self.gcn = GCNEncoder(in_dim, gcn_hidden, adj_norm) self.lstm = nn.LSTM( input_size=gcn_hidden, hidden_size=lstm_hidden, num_layers=num_layers, batch_first=True, dropout=0.3 ) self.classifier = nn.Sequential( nn.Linear(lstm_hidden, 64), nn.ReLU(), nn.Dropout(0.5), nn.Linear(64, num_classes) ) def forward(self, x): # x: (B, T, C, F) h = self.gcn(x) # (B, T, gcn_hidden) lstm_out, _ = self.lstm(h) # (B, T, lstm_hidden) out = self.classifier(lstm_out[:, -1, :]) # 取最后时间步 return out两个参数值得讲清楚。lstm_hidden=128是LSTM隐状态的宽度,也是分类头输入维度;训练集更大时可以提到256,DEAP这种规模128就够。dropout=0.3只在两层LSTM之间生效,单层LSTM时这个参数会被忽略——不少新手看到单层LSTM里传了dropout就以为有正则化,其实没有,这是PyTorch源码实现里的一个坑。lstm_out[:, -1, :]取的是时间步维度的最后一个位置,如果T固定为15,这个位置固定对应最后那个4秒窗口。做完时序编码后,特征的"空间图结构"和"时间演化"都已经被压进这一个向量里,剩下就是分类头的工作。
3.4 分类头与损失:二分类任务的输出设计
分类头用一个两层MLP就够。第一层从lstm_hidden降到64,中间接ReLU和dropout,第二层输出到num_classes。这里不再加更深的网络,因为前面GCN和LSTM已经把特征抽象得足够高,分类头越浅,过拟合风险越小。
损失函数用交叉熵即可,PyTorch里对应nn.CrossEntropyLoss,它内部把模型最后一层的原始logits和标签做softmax加负对数似然,不需要自己在模型里额外加softmax。类别不平衡时要给CrossEntropyLoss传weight参数:先统计训练集两个类别的样本数,把权重设成max_count / class_count,否则模型会偏向多数类。DEAP按valence=5做划分时正负类通常比较均衡,但如果你换成arousal或四分类,一定要先看一眼分布再决定要不要加权。
4. 训练配置与参数调优:一组能稳定收敛的超参数
4.1 数据加载与批次组织:把试次和片段拆清楚
训练环境的配置是很多人在第一步就卡壳的地方。PyTorch的CUDA版本要和显卡驱动匹配,torch.cuda.is_available()返回False时先查驱动和CUDA版本,别急着重装环境。依赖只需要torch、numpy、scipy,图卷积部分是手动矩阵实现,不需要额外装PyG,这能让环境配置问题少一半。
数据加载器要用PyTorch的Dataset和DataLoader来组织。核心是把(trial, window)映射到(x, y),同时保存试次索引,方便后面做分组。一个关键细节:DataLoader默认shuffle=True打乱的是"片段级"顺序,但只要训练集和验证集已经把试次分干净了,片段级打乱不影响数据泄漏问题。
from torch.utils.data import Dataset, DataLoader class DEAPDataset(Dataset): def __init__(self, data, labels, trial_indices): """data: (40, 15, 32, 5) 预处理后的全部特征 labels: (40,) 每试次一个二分类标签 trial_indices: 分配给本集合的试次索引列表 """ self.x = [] self.y = [] for t in trial_indices: for win in range(data.shape[1]): self.x.append(data[t, win]) self.y.append(labels[t]) self.x = torch.tensor(np.array(self.x), dtype=torch.float32) self.y = torch.tensor(self.y, dtype=torch.long) def __len__(self): return len(self.y) def __getitem__(self, idx): return self.x[idx], self.y[idx] # 试次级划分:28/6/6 all_trials = np.arange(40) np.random.seed(42) np.random.shuffle(all_trials) train_idx, val_idx, test_idx = np.split(all_trials, [28, 34]) train_ds = DEAPDataset(features, labels, train_idx) val_ds = DEAPDataset(features, labels, val_idx) test_ds = DEAPDataset(features, labels, test_idx) train_loader = DataLoader(train_ds, batch_size=64, shuffle=True, drop_last=True) val_loader = DataLoader(val_ds, batch_size=128, shuffle=False)注意DataLoader里batch_size的单位是"片段"而不是"试次"。train_ds里600个片段按64打包,模型每次前向同时处理多个不同试次的片段。drop_last=True把最后一个不满64的批次丢弃,避免BN层(如果模型里有)均值和方差估计被小批次干扰。这里模型没有BN层,但这个习惯建议保持。
4.2 优化器、学习率与早停:Adam的默认值和衰减策略
优化器选Adam,lr=1e-3是图像分类任务迁移过来的经验值,直接能跑。weight_decay设1e-4做L2正则,对LSTM和MLP的权重都生效。训练中期用ReduceLROnPlateau做学习率衰减:连续8个epoch验证集准确率不上升,学习率乘0.5。这套组合在EEG这样的小数据任务上比余弦退火稳定,数据量小,cosine的周期性下降容易在局部最优里出不来。
epoch数设60,配合早停耐心值15。早停的判断标准是验证集准确率,保存best_val_acc对应的模型参数,训练结束后加载回来做测试集评估。这里有一个原则:测试集只能在全部训练和调参完成后碰一次,否则你就是在拿测试集调参。
4.3 一个完整的训练循环:梯度裁剪与最佳模型保存
import torch.optim as optim device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') model = GCNLSTM(in_dim=5, gcn_hidden=64, lstm_hidden=128, num_layers=2, num_classes=2, adj_norm=norm_adj).to(device) optimizer = optim.Adam(model.parameters(), lr=1e-3, weight_decay=1e-4) scheduler = optim.lr_scheduler.ReduceLROnPlateau(optimizer, mode='max', patience=8, factor=0.5) criterion = nn.CrossEntropyLoss() best_val_acc = 0.0 best_state = None patience_counter = 0 for epoch in range(60): model.train() total_loss = 0.0 for x_batch, y_batch in train_loader: x_batch, y_batch = x_batch.to(device), y_batch.to(device) optimizer.zero_grad() logits = model(x_batch) loss = criterion(logits, y_batch) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=5.0) optimizer.step() total_loss += loss.item() val_acc = evaluate(model, val_loader, device) scheduler.step(val_acc) if val_acc > best_val_acc: best_val_acc = val_acc best_state = {k: v.cpu().clone() for k, v in model.state_dict().items()} patience_counter = 0 else: patience_counter += 1 if patience_counter >= 15: break model.load_state_dict(best_state) test_acc = evaluate(model, test_loader, device)梯度裁剪clip_grad_norm_(..., max_norm=5.0)这行是LSTM必留的。EEG片段序列虽然只有15步,但LSTM的反向传播经过15个时间步,梯度范数仍然可能放大到数百量级,不裁剪的话loss会在某个epoch突然跳到NaN。剪裁阈值5.0的意思是:整个参数向量的L2范数超过5就把梯度等比例缩回5,方向和比例都保留。
best_state用cpu().clone()把模型参数拷回CPU保存,避免GPU显存被训练过程占着时再拷贝出问题。主程序里定义一个evaluate函数负责把模型切到eval()、torch.no_grad()下遍历加载器,返回准确率。注意model.train()和model.eval()切换的是LSTM和分类头里的dropout层——验证时不关dropout,结果会带随机性,每次跑的数字都不一样。
4.4 消融实验:证明GCN和LSTM各自有用
模型跑通后,下一步就是消融实验。这一步不只是为了丰富论文表格,它直接回答"这个组合到底值不值得用"。做法是把GCNLSTM的某个部件关掉再训练一次,比较验证集准确率:
- 去掉GCN:把
GCNEncoder替换成nn.Linear(C*F, gcn_hidden),输入从(B, T, C, F)展平成(B, T, C*F)再过线性层。这个对照组等价于假设通道间无拓扑结构。 - 去掉LSTM:把LSTM替换成对所有时间步做均值池化,再把池化结果送入分类头。这个对照组假设情绪状态是时不变的。
- 替换GCN为普通卷积:在时间步上做1D卷积,验证"图拓扑"比"邻域卷积"带来的增量。
以我跑DEAP的经验,完整模型验证集大约能到86-90%的准确率(按valence二分类、被试内划分),去掉GCN掉3-5个点,去掉LSTM掉5-8个点,两个都不留只剩MLP的话掉到70%左右。这几个数字本身就是模型可信度的证据——如果消融后指标不掉,说明GCN或LSTM没有真正学到东西,模型是在吃特征的"直白区分度",这时候回头检查邻接矩阵和序列建模,而不是继续调超参。
5. GCN+LSTM情绪识别的踩坑记录:现象、原因与解法
5.1 数据泄漏导致验证集虚高、测试集翻车
现象:训练时loss正常下降,验证集准确率一路爬到93%,满怀信心跑测试集,结果只有62%,比随机猜好不了多少。
原因:训练/验证/测试按"片段"随机划分,同一个试次的15个4秒窗口被拆散到不同集合。EEG信号在相邻窗口间高度相关,模型其实在记忆"这段波形接哪段波形",而不是在学情绪判别特征,换一段没见过的试次立刻失灵。
解决:按试次划分,且切分动作要在预处理之前完成。更保险的做法是划分函数里就返回试次索引集合,训练、验证、测试三个索引集合互不重叠,然后所有片段跟着试次走。跨被试实验同理:按被试划分,一个人只能出现在一个集合里。
5.2 GCN层数堆到第4层,准确率不升反降
现象:把GCNEncoder从2层加到4层后,验证集准确率不仅没涨,反而掉了3-4个点;观察t-SNE可视化,不同通道的特征挤成一团分不开。
原因:图卷积本质是图上的拉普拉斯平滑,每做一层,节点的特征就向邻居均值靠近一次。层数太多会陷入"过平滑":所有节点的特征最终趋同到几乎一样,GCN变成了全局平均池化,通道的空间差异性被抹掉了。
解决:EEG这种32节点的小图,2层是上限,3层是冗余。如果确实需要深层,要加残差连接(每层输出加输入)或跳连聚合(把每层的输出接起来再过分类层)。顺着这个思路,用GAT替换GCN也能缓解过平滑,因为注意力权重能让节点自己决定从哪些邻居取多少信息。
5.3 LSTM训练后期loss震荡,收敛不稳定
现象:前20个epoch loss稳步下降,第25个epoch开始loss突然跳到之前两倍高,然后降回来,再跳上去,验证集准确率跟着剧烈波动。
原因:LSTM经过15个时间步的反向传播,梯度范数在深路径上会指数级放大或消失。学习率1e-3在前期还行,后期参数接近局部最优时,一个稍大的梯度就能把参数推出稳定区域。另一个常见诱因是训练集和验证集的片段顺序在DataLoader里被打乱得不够充分,梯度更新方向有偏差。
解决:三管齐下。第一,训练循环里加clip_grad_norm_,这根救命的绳子在4.3节已经写进代码;第二,把ReduceLROnPlateau的patience从8调到12,给学习率衰减留更长的观察窗口;第三,检查DataLoader的shuffle=True,每个epoch都要真正乱序,不要为了省事一次性打乱后固定下来。
5.4 全连接邻接矩阵导致显存紧张、效果还差
现象:有人图省事把邻接矩阵全填1,结果训练速度慢、显存占用高,验证集准确率还比稀疏图低不少。
原因:全连接图让每个节点都要聚合其余31个通道的特征,图卷积变成了全局平均,通道间的选择性关系被噪声淹没。DEAP的32个电极里,距离近的电极信号高度相关(空间分辨率限制),距离远的本来就没多少同步性,硬加边只会引入无关信息。
解决:邻接矩阵构造时用物理距离阈值或KNN稀疏化,把每个节点的度数控制在5-10之间。另外矩阵在进模型前要检查稀疏度——如果非零元素超过一半,先怀疑构图参数是不是太松了。没有坐标数据时,用相关性构图比全连接稳,但按2.3节说的只用训练集计算相关性矩阵。
5.5 跨被试泛化差,模型只认一个人
现象:被试内划分(同一个人部分试次训练、部分测试)能到88%,一换被试直接掉到68%,模型像是记住了某个人的EEG基线。
原因:EEG信号个体差异很大,不同人的alpha节律频率基线、全脑能量分布都不同。GCN和LSTM学到的特征里混杂了"这个人长什么样"的信息,没有泛化到其他人身上。
解决:先用z-score按被试逐通道标准化,把个体基线抹平,这是投入产出比最高的一步。评估协议换成LOSO(留一被试交叉验证):拿N-1个被试的数据训练,在剩下1个被试上测试,重复N次取平均。这个指标才是情感计算论文里真正算数的泛化水平。如果LOSO后仍然不达标,考虑加域自适应模块,但那是进阶话题,基础方案里先把数据标准化做到位。
6. 验证与进阶:从模型跑通到结果可靠的关键动作
模型跑通不是终点,验证它"真的可靠"才是。我每次拿到一套新数据,会先做三件事:t-SNE可视化训练集和测试集的特征分布,看两类样本是否分群;算一次LOSO交叉验证,得到不带运气成分的泛化指标;最后画混淆矩阵,定位模型到底在哪种情绪上犯错——DEAP二分类里最容易混的是低唤醒正效价和高唤醒正效价,这两个类别的EEG模式本来就接近。
想继续提升,方向有三个。一是把GCN换成GAT:用nn.MultiheadAttention或PyG的GATConv实现,让每个通道自己学习邻居权重,过平滑问题轻得多。二是频带独立建模:把5个频带的DE特征拆成独立通道输入模型,给每个频带分配一组GCN参数,让图卷积自己决定在哪个频带上聚合——gamma频带的同步性和alpha频带的抑制模式,往往需要不同的空间传播路径。三是用SEED数据集做交叉验证:DEAP上调好的超参和邻接矩阵,直接搬到SEED的62通道配置上,如果准确率仍然稳定,模型才算有跨数据集的泛化能力。这三步做完,再回头审视时间窗口长度和邻接矩阵阈值——它们往往才是决定上限的变量,而不是模型结构本身。
最后说一句习惯:我在调参时会把每个实验的随机种子、数据划分、超参数和git commit记录写进csv表格,模型或数据一改动就能回溯。EEG实验的随机性比图像任务大,同样的代码不固定种子,两次跑可能差3个点——这不是玄学,是切换数据加载顺序和dropout掩码导致的正常波动,记录下来,别让它变成排查事故时的黑匣子。希望帮到你。
本文还有配套的精品资源,点击获取