☰
CNN+RNN脑电情绪识别:SEED/DEAP/SEED-IV数据预处理与模型实现
2026/10/27 16:40:05 网站建设 项目流程

简介:将RNN与CNN结合的脑电情绪识别项目资料,内含论文全文与可复现源码。面向情感计算、脑机接口方向的研究人员和研究生,也适合想系统了解混合模型搭建的深度学习初学者。核心方法同时建模脑电频带功率的序列特征与图像化特征,通过层次RNN捕获通道间空间关系,再用CNN提取深层表征,基于显著性分析实现双分支联合训练;模型已在SEED、DEAP、SEED-IV、MPED等多个公开数据集上完成评估。压缩包共21个文件、约9.45MB,含7个Python脚本,分别对应显著图模型、损失函数、特征提取、模型定义、数据预处理等模块;8个npy文件保存电极位置及样例输入;另附PDF论文、README说明、环境依赖文件与许可证,整体目录清晰,便于对照论文逐步复现。目前已有2691人学习下载,适合快速上手脑电情绪识别、理解双分支协同训练思路的读者。

1. RNN和CNN结合的脑电情绪识别:这份源码能帮你跑通什么

脑电情绪识别在深度学习里有个很扎心的现状:SEED、DEAP这类公开数据集论文上准确率动辄90%以上,但自己复现时能跑到论文分数的极少。问题大多数时候不在模型多玄学,而在数据管线——采样率、窗口、特征、标签对齐任何一个环节松了,结果就崩。这份资源把SEED、DEAP、SEED-IV三个数据集的CNN+RNN论文和源码打包在一起,从数据加载、滑窗、差分熵特征到Conv1d+LSTM模型训练,是一条能直接照跑的链路。适合刚接手脑电项目的研究生,也适合已经在用传统机器学习特征、想换成深度模型的工程师。

2. 三个数据集先对齐到同一套预处理链路:采样率、滑窗与差分熵

2.1 数据集差异决定预处理起点

先别急着写模型。SEED、DEAP、SEED-IV这三个数据集官方格式、采样率、导联数、标签体系都不一样,直接把同一个加载脚本套上去大概率读错维度。

数据集脑电通道数被试数情绪类别标签形式常见分类任务
SEED62导153类(正/中/负)每段视频trials对应一个离散标签三分类
DEAP32导EEG + 8路外周32四维连续打分valence/arousal/dominance/likingvalence或arousal二分类
SEED-IV62导154类(happy/sad/fear/neutral)每个trial对应一个离散标签四分类

SEED和SEED-IV结构基本一致,区别是标签数量:SEED是三分类,SEED-IV是四分类且类间更接近,难度明显高一截。DEAP是受试者看音乐视频时采集的连续信号,每个trial末尾有1~9的打分,训练前要先定好阈值把它切成二分类。这三个数据集的预处理口径统一之后,模型代码几乎不用大改,只调n_channels和n_classes就能互相切换,这也是这套源码最实用的地方。

2.2 降采样与滑窗:先保证样本数量和质量

脑电原始信号不适合直接整段喂给网络,一是太长、二是样本数太少。常见做法是先降采样,再用滑窗把每段trial切成若干短时样本。

import numpy as np import scipy.io as sio from scipy import signal as scipy_signal def load_trial(path): # 读 SEED/SEED-IV 的单个 trial,DEAP 按同样逻辑读取后取前32通道即可 mat = sio.loadmat(path) data = mat['data'].astype(np.float32) return data # shape: [n_channels, n_samples] def resample_eeg(data, fs_orig, fs_target=200): # 先做低通滤波再重采样,避免高频混叠进目标频带 if fs_orig == fs_target: return data nyq = fs_target / 2.0 b, a = scipy_signal.butter(4, nyq / (fs_orig / 2.0), btype='low') data = scipy_signal.filtfilt(b, a, data, axis=-1) n_samples = int(data.shape[-1] * fs_target / fs_orig) return scipy_signal.resample(data, n_samples, axis=-1) def sliding_window(data, win_len, step): # 按窗口长度和步长切片段,返回 [n_windows, n_channels, win_len] windows = [] for start in range(0, data.shape[-1] - win_len + 1, step): windows.append(data[:, start:start + win_len]) return np.stack(windows, axis=0)

这段代码里最重要的是先滤波再重采样。如果你直接重采样,高于目标采样率一半的分量会混叠到低频段,脑电里60Hz附近的环境噪声可能会被折叠到有效频段,后面模型再强也救不回来。SEED原始采样率公开版本常见1000Hz,降到200Hz已经能覆盖到49Hz,脑电情绪识别有效信息基本都在这个范围内。

窗口长度我一般取200点到800点,也就是1~4秒。窗口越长单个样本包含的上下文越完整,但同样长度数据产出的样本越少,模型越容易过拟合;窗口太短则每个样本里的情绪信息太单薄。DEAP是512Hz采样,换过去时把win_len改成512或2048,其他逻辑不用动。

2.3 差分熵:一个让模型收敛更快的手工特征

模型虽然能直接吃raw信号,但脑电的个体差异很大。不同被试的头骨厚度、电极接触阻抗、基线幅值都不一样,raw信号直接进网络,模型很可能先学到“这是谁”而不是“这是什么情绪”。差分熵是SEED论文里反复用过的一个特征,能把每个窗口的复杂度压成一个标量。

def differential_entropy(windows): # windows: [n_windows, n_channels, win_len] # 对每个通道每个窗口按方差近似计算差分熵 var = np.var(windows, axis=-1) + 1e-6 de = 0.5 * np.log(2 * np.pi * np.e * var) return de.astype(np.float32)

脑电短时片段近似服从高斯分布时,差分熵只依赖方差,所以实现起来就是一行方差取对数。它的物理含义是这段脑电的复杂度/不确定性——情绪唤醒度高时,脑电节律能量分布会变化,差分熵随之改变。用这个特征而不是raw波形,模型要学的东西少很多,收敛快,跨被试稳定性也更好。1e-6是防零方差导致log(0),np.e是为了保持自然对数下的差分熵定义,你改成log10也不影响区分度,但特征尺度会变,后续学习率要重新调。

2.4 标签对齐:窗口永远不能跨trial

滑窗之后标签怎么绑定是个容易翻车的细节。正确做法是按trial独立滑窗,不能让窗口跨过两个trial的边界。

def build_samples(trials, labels, win_len, step): Xs, ys = [], [] for sig, lab in zip(trials, labels): ws = sliding_window(sig, win_len, step) Xs.append(ws) ys.append(np.full(len(ws), int(lab), dtype=np.int64)) X = np.concatenate(Xs, axis=0) y = np.concatenate(ys, axis=0) return X, y

核心约束是每个trial单独滑窗,再统一concat。如果你把整个被试的信号先拼成一条再滑窗,就会产出跨trial边界的窗口,那个窗口混了两段情绪信号,标签无论给哪个都不对,模型学出来的特征全是混叠的。写完这个函数我习惯加一行断言:assert y.shape[0] == sum(len(_) for _ in Xs),窗口数和标签数对不上立刻就能发现。

这类数据集的标签文件经常是独立提供的,我拿到新数据第一件事是打印所有trial的标签分布,确认类别均衡情况,顺便核对文件名和标签顺序没有被排序操作弄错位。

3. 让CNN和RNN各司其职:Conv1d提局部特征,LSTM接时序依赖

3.1 为什么单用CNN或单用RNN都差一口气

纯CNN把一段脑电当普通序列处理,卷积核在时间轴上滑动,能提取局部波形和通道间的短时协同模式,但它没有隐藏状态,无法显式建模“上一个窗口的情绪状态如何影响当前窗口”。纯RNN能记时序,但脑电原始信号每个时间点都是一个通道向量,里面掺杂大量噪声和个体差异,让LSTM直接在这个输入上工作,梯度很容易被局部抖动带偏,训练不稳定还容易过拟合。

常见做法是把CNN当特征抽取器,先用卷积在时间轴上把相邻波形压成紧凑特征,再用LSTM或GRU在这个特征序列上建模情绪随时间的变化。CNN把信号里的局部模式提纯,RNN负责把这些模式串成一个完整的情绪演变过程,两者互补。这也是这套源码里模型结构最核心的组合思路。

3.2 参考模型实现:Conv1d + Bi-LSTM

import torch import torch.nn as nn class CnnRnnEEG(nn.Module): def __init__(self, n_channels=62, n_classes=3, cnn_out=64, rnn_hidden=128, dropout=0.3): super().__init__() # 时间维上的卷积分支 self.conv1 = nn.Conv1d(n_channels, 64, kernel_size=3, padding=1) self.bn1 = nn.BatchNorm1d(64) self.conv2 = nn.Conv1d(64, cnn_out, kernel_size=3, padding=1) self.bn2 = nn.BatchNorm1d(cnn_out) self.pool = nn.AvgPool1d(kernel_size=2) self.drop = nn.Dropout(dropout) # 时序分支:双向 LSTM self.lstm = nn.LSTM(cnn_out, rnn_hidden, num_layers=1, batch_first=True, bidirectional=True) self.classifier = nn.Linear(rnn_hidden * 2, n_classes) def forward(self, x): # 输入 x: [batch, n_channels, seq_len] x = torch.relu(self.bn1(self.conv1(x))) x = self.pool(x) x = torch.relu(self.bn2(self.conv2(x))) x = self.pool(x) # 转成 [batch, seq_len, cnn_out],符合 LSTM 的 batch_first 输入格式 x = x.permute(0, 2, 1) out, _ = self.lstm(x) out = out[:, -1, :] # 取最后一个时间步的输出 out = self.drop(out) return self.classifier(out)

conv1的输入通道数是脑电导联数,kernel_size=3在时间轴滑动,相当于每次看前后共3个采样点。两个卷积加两个池化后,时间维度缩到原来的四分之一再进LSTM,序列长度变短,显存压力和训练时间都降下来了。permute(0, 2, 1)是必须的一步,因为PyTorch的LSTM在batch_first=True时要求输入是[batch, seq_len, input_size],而Conv1d的输出是[batch, channels, seq_len]。

out[:, -1, :]只取最后一个时间步的输出,原因是我们用双向LSTM,最终输出已经编码了整段窗口的正反两个方向信息。rnn_hidden乘2是因为bidirectional=True时输出维度翻倍。如果你把LSTM换成GRU,只改类名就行,参数调用方式一致,但GRU比LSTM少一组cell state,显存占用更小、训练略快,适合序列比较长的场景。

3.3 把输入组织成序列的两种思路

输入方案做法优点缺点
方案A:2D网格把62导按头皮位置排成8×8或10×10网格,用Conv2d提取空间拓扑特征利用电极间几何位置信息需要导联坐标,不同数据集布局不同,还要处理空缺位置,预处理量大
方案B:1D序列直接以通道为输入维、时间为序列维,Conv1d提特征后接RNN换数据集只需改n_channels,不依赖导联坐标没有显式利用电极空间位置

这套源码按方案B为主干,因为SEED、DEAP、SEED-IV三个数据集导联数都不一样,方案B改一个n_channels参数就能跑。方案A理论上空间信息利用更充分,但工程改动量大,而且62导重排成二维网格时,空缺电极位置怎么填也是个需要调参的坑。先跑通方案B,再考虑方案A做对比实验,是比较务实的路线。

3.4 这几个超参数决定模型能否收敛

n_channels对应脑电导联数,SEED和SEED-IV是62,DEAP取32。cnn_out是卷积输出维度,64到128之间都可以,太小特征不够,太大容易过拟合。rnn_hidden建议64到128,小数据集上128已经够用,再大收益不明显。num_layers一般设1,脑电窗口序列不算长,两层LSTM提升有限,训练成本和过拟合风险反而增加。dropout放在LSTM输出和分类器之间,0.3到0.5比较稳。

池化层有个容易忽略的点:脑电幅值带噪声,AvgPool1d比MaxPool1d更稳,MaxPool容易把某个尖峰噪声放大进后续特征,平均池化则会把噪声平滑掉一部分。如果你想压时间维度,优先用平均池化。

4. 把源码跑通:目录对应关系、训练脚本与评估口径

4.1 解压后的文件怎么对应到训练流程

资源打包的源码目录不一定是同一个命名,但功能块基本逃不出这几部分,按功能对应就能少走弯路。

常见文件对应流程需要改什么
data/原始.mat或预处理后的.npy按你的实际路径改data_dir
preprocess.py滑窗、差分熵、标签对齐不必大改,核对数据集名
models/cnn_rnn.py模型定义n_channels和n_classes按数据集改
config.py超参集中配置dataset、win_len、step大概率要改
train.py训练入口检查输出目录是否存在
eval.py评估脚本输出准确率和混淆矩阵

拿到代码先别跑,打开config.py看一眼dataset字段,再打开preprocess.py确认读取路径指向真实目录。最常翻车的不是模型代码,而是路径写错导致加载了空数据,训练loss不降还以为是模型写错了。

4.2 config与训练循环:先让样板参数跑通

config = { 'dataset': 'seed', # seed / deap / seed-iv 'data_dir': './data/seed', 'fs': 200, # 统一采样率 'win_len': 200, # 1s 窗口 @200Hz 'step': 100, # 步长 0.5s,窗口重叠一半 'batch_size': 64, 'epochs': 50, 'lr': 1e-3, 'rnn_hidden': 128, 'num_layers': 1, 'bidirectional': True, 'dropout': 0.3, 'random_seed': 42, }

win_len和step是数据层面的两个关键参数。窗口200点@200Hz是1秒,步长100点是0.5秒,也就是50%重叠。重叠滑窗能增加样本数量,对深度学习模型几乎是必须的,否则一个4分钟的trial只能产出240个样本,完全不够训练。样本数不够时优先减小step,而不是把win_len切更短——切太短会损失情绪信息。

model = CnnRnnEEG(n_channels=62, n_classes=3, rnn_hidden=config['rnn_hidden'], dropout=config['dropout']) criterion = nn.CrossEntropyLoss() optimizer = torch.optim.Adam(model.parameters(), lr=config['lr']) scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=config['epochs'])

训练循环加一个余弦退火学习率调度,脑电这类小数据集上比固定学习率更容易收敛到平稳点。

best_acc = 0.0 for epoch in range(config['epochs']): model.train() train_loss = 0.0 for xb, yb in train_loader: optimizer.zero_grad() logits = model(xb) loss = criterion(logits, yb) loss.backward() optimizer.step() train_loss += loss.item() scheduler.step() val_acc, _, _ = evaluate(model, val_loader) if val_acc > best_acc: best_acc = val_acc torch.save(model.state_dict(), './best_model.pth')

保存val_acc最优的权重而不是最后一个epoch的权重,能避免把过拟合尾巴带进最终结果。训练日志里我一般同时打印训练loss和验证acc,如果训练loss在降但验证acc一直不动,大概率是过拟合或数据泄漏,后面避坑章节会细说。

4.3 评估时把准确率和混淆矩阵一起看

def evaluate(model, loader): model.eval() preds_all, labels_all = [], [] with torch.no_grad(): for xb, yb in loader: logits = model(xb) preds_all.append(logits.argmax(dim=1).numpy()) labels_all.append(yb.numpy()) preds_all = np.concatenate(preds_all) labels_all = np.concatenate(labels_all) acc = (preds_all == labels_all).mean() return acc, preds_all, labels_all

评估时必须包torch.no_grad(),不然模型会保存中间张量,显存白白被占。这里有个口径问题要注意:同被试数据切训练/验证,和按被试留出是两种完全不同的实验设定。前者叫subject-dependent,后者叫subject-independent,两者准确率差距能到20个点以上。SEED上subject-dependent常见能到90%以上,subject-independent可能掉到60%~75%,这在脑电情绪识别里是正常现象,论文里都会注明用的是哪种协议。

5. 避坑记录:数据泄漏、标签错位、随机性这几个常见问题

5.1 验证集准确率虚高,换被试就崩

现象:训练准确率95%,验证准确率90%,模型加载到新被试数据上只剩60%,比随机高一点。

原因:预处理时对全量数据做了标准化,StandardScaler用所有样本的均值和方差做归一化,验证集的信息已经混进训练流程。这属于数据泄漏,模型看似泛化,实则是“偷看”了验证集的分布。

解决:交叉验证里按fold先fit训练集,再transform验证集和测试集。

from sklearn.preprocessing import StandardScaler # 错误示范:对全量X做fit_transform,验证集信息泄漏 # X_all = scaler.fit_transform(X_all) # 正确做法:只fit训练集 scaler = StandardScaler() X_train_flat = X_train.reshape(len(X_train), -1) scaler.fit(X_train_flat) X_train_norm = scaler.transform(X_train_flat).reshape(X_train.shape) X_val_flat = X_val.reshape(len(X_val), -1) X_val_norm = scaler.transform(X_val_flat).reshape(X_val.shape)

reshape回来后要还原成[n, channels, seq_len]的模型输入格式,不然模型维度会报错。如果数据量大,按trial或按被试fit也是可以的,核心原则是验证集和测试集永远只transform不fit。

5.2 滑窗跨了trial边界,准确率卡死在随机水平

现象:训练loss正常下降,验证准确率一直在33%上下跳动(三分类),模型完全没学到东西。

原因:把整个被试的信号拼接成一条连续数据后再滑窗,窗口跨越了两个trial的边界。边界前后可能是一个negative一个positive,窗口内混了两段信号,标签给哪一个都错。

解决:按trial独立滑窗再合并,代码见2.4节。加一条断言防止复发:

expected = sum(len(sliding_window(sig, win_len, step)) for sig in trials) assert X.shape[0] == expected, f"窗口数 {X.shape[0]} != 预期 {expected}"

另一个排查手段是打印前几个样本的标签,肉眼确认标签没有周期性错位。

5.3 DEAP的阈值选4还是5,结果能差好几个点

现象:同一模型同一数据,两次实验准确率差3~5个百分点,往上查代码没发现任何改动。

原因:DEAP的valence/arousal是1~9连续打分,转二分类时要定阈值。阈值设在4还是5,正负样本比例完全不同,valence在4附近的样本会被分到不同类别,这些边界样本恰好是最难分的。

解决:在config里把阈值写死,投稿或报告时注明。比如valence_threshold = 5,arousal_threshold = 5,实验过程不修改。对比实验里所有模型用同一阈值,否则数字差异没有意义。另外DEAP的8路外周生理信号要不要一起用也是决定性问题,只用32导EEG和加上外周信号的结果不能直接比。

5.4 双向LSTM显存爆掉

现象:batch_size=64训练到一半CUDA out of memory,减小到16能跑但训练很慢。

原因:双向LSTM在反向传播时要保存每个时间步的隐状态和细胞状态,输入序列越长、rnn_hidden越大、batch_size越大,显存占用成倍增长。两个池化层已经把时间维度压到四分之一,但序列还是偏长。

解决:优先把num_layers降到1,其次把rnn_hidden降到64或96,再不行把LSTM换成GRU。GRU没有独立的cell state,参数量大约少25%,显存压力明显下降。还有一个办法是在进LSTM之前再加一层平均池化,把时间维度再压一半,准确率通常不会掉太多。

5.5 随机种子没固定,实验结果不可复现

现象:同一套代码跑三次,三个不同的准确率,你自己都不知道该用哪个数字。

原因:模型权重初始化、DataLoader的shuffle、GPU上的cuDNN算法选择都带随机性。

解决:在训练脚本入口固定种子。

import random import numpy as np import torch random.seed(42) np.random.seed(42) torch.manual_seed(42) if torch.cuda.is_available(): torch.cuda.manual_seed_all(42) torch.backends.cudnn.deterministic = True

cudnn.deterministic = True会牺牲一点训练速度,但换来实验结果可复现。脑电情绪识别数据集本来就不大,一个模型训练几十轮也就是几十分钟的事,多花那点时间换可复现性是值得的。从那以后我每次跑实验前都先检查这五行代码在不在。

6. 模型可信度检查:两个协议对比与混淆矩阵诊断

拿到模型和结果后,先别急着看准确率数字,做一次可信度检查。我一般会跑两轮实验:一轮subject-dependent,同被试数据划分训练和验证;一轮subject-independent,按被试整体留出。两个准确率的差距能告诉你模型到底学到了情绪还是记住了被试。

如果subject-independent准确率接近随机水平,说明模型更多是在记忆被试ID和个体脑电特征,没有学到跨被试通用的情绪模式。这种情况下增加网络层数没用,优先回去查预处理:滑窗是否跨trial、标准化是否泄漏、标签是否错位。反过来,如果subject-dependent和subject-independent差距不大,说明模型泛化能力真实可信。

混淆矩阵是诊断分类偏差最直接的工具。SEED上negative和neutral互相串是常见现象,这两个类别在脑电特征上本就接近;DEAP上arousal二分类则是低唤醒和高唤醒边界样本难分。如果某个类别的精确率和召回率差距特别大,先看这类样本数量是不是明显少于其他类,类别不平衡时只看准确率不够,要加看加权F1。

from sklearn.metrics import confusion_matrix, classification_report cm = confusion_matrix(labels_all, preds_all) print("Confusion Matrix:") print(cm) print(classification_report(labels_all, preds_all, target_names=['negative', 'neutral', 'positive']))

还有一个诊断技巧:trial级投票。同一个trial会被滑出多个窗口,把这批窗口的预测结果做个多数投票,再看投票后的标签是否和真实trial标签一致。如果同一个trial内部窗口预测来回跳,说明模型没有稳定的判别依据,先回查预处理和标签,而不是调模型结构。

从那以后我每次拿到新的脑电数据集,都强制走一遍“统一采样率→统一时间轴→统一标签字典→分trial滑窗→固定随机种子”这套流程,再开始动模型。最初我也跳过这些步骤直接上网络,结果花在返工上的时间比跑模型还多。希望这份资源能帮你把SEED、DEAP、SEED-IV上的脑电情绪识别跑得更顺,少踩我踩过的那些坑。

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

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

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

立即咨询