简介:面向脑电情绪识别研究者与深度学习入门者,这份资源整合论文原文与可运行源码,完整实现了结合层次RNN与CNN的双重情绪识别模型。模型同时处理基于序列的脑电频带功率表示与基于图像的特征向量表示,并通过显著性分析促进双分支联合学习,已在SEED、DEAP、SEED-IV、MPED四个公开数据集上验证。压缩包共21个文件、9.45MB,其中7个Python脚本覆盖特征提取、模型定义与训练流程,8个npy文件提供预处理的电极位置、输入图像及标签数据,另含PDF论文、环境配置文件及说明文档,便于快速复现实验环境。PDF论文详细阐述了方法细节,npy数据已按统一流程处理,省去自行转换脑电原始数据的繁琐步骤。目前已有2691人学习或下载,适合需要参考可运行代码、直接对比多数据集情绪识别效果的研究者。
1. RNN和CNN结合的脑电情绪识别:这个方案能让你少走三个月弯路
EEG情绪识别这个方向,论文不少,真正能复现的开源工程却不多。很多研究组把预处理、特征、模型各自封装在自己的脚本里,换个数据集就要重写一遍。手头这个「(论文加源码)RNN和CNN结合的脑电情绪识别(数据集为seed、deap、seed-iv).zip」,把SEED、DEAP、SEED-IV三份公开数据集上的完整闭环都串起来了:论文负责讲清楚RNN循环神经网络和CNN卷积神经网络为什么互补,源码把从原始mat文件到训练曲线的每一步都铺开。先说结论:在DEAP的32通道128Hz数据上,单用CNN做二分类,准确率往往卡在70%上下;把双向LSTM接在卷积特征后面,普遍能往上拉5到8个点。这个差距不是玄学——情绪诱发的脑电慢波分布在秒级时间尺度上,CNN感受野不够长,RNN循环神经网络恰好补上这一段。适合刚入脑机接口方向的研究生,也适合想用公开数据集跑基线却不想从零写预处理的一线工程师。
2. SEED、DEAP、SEED-IV三份数据集的预处理:读mat、去基线、算差分熵
做脑电情绪识别,第一道坎不是模型,是数据。三份数据集各有各的脾气:采集设备不同(62导与32导)、采样率不同(1000Hz采集与128Hz预处理)、刺激材料不同(中文影片片段与英文音乐视频)、标签体系不同(三分类、四分类与连续量表)。如果你只在一个数据集上调参,模型很可能学到的是这个数据集的采集噪声,而不是情绪本身的表征。把这套源码里的数据读取和预处理逻辑拆开看,能省掉大量翻论文查格式的时间。
2.1 三个数据集先摆在一张表里:不用来回翻论文
| 数据集 | 通道数 | 采样率 | 受试者 | 试次规模 | 标签形式 | 常见难度 |
|---|---|---|---|---|---|---|
| SEED | 62导EEG | 1000Hz采集,常用200Hz | 15人×3个session | 每个session 15段视频 | 负性/中性/正性三分类 | 中等 |
| DEAP | 32导EEG+8路外围信号 | 128Hz预处理版本 | 40人 | 每人40段音乐视频 | valence/arousal/dominance/liking连续量 | 二分类相对好做 |
| SEED-IV | 62导EEG | 同SEED | 15人×3个session | 每个session 24段视频 | 快乐/悲伤/恐惧/中性四分类 | 较难 |
DEAP的连续标签需要自己动手转二分类:社区惯例是把valence和arousal各自按5分阈值切开,大于5为正性/高唤醒,小于等于5为负性/低唤醒。SEED和SEED-IV则是直接给出离散情绪标签,但SEED是三分类、SEED-IV是四分类,后者对模型分辨力的要求明显更高。三份数据集都按受试者分组存放,跨session评估也顺理成章——同一个被试在不同日期的三次实验,本身就是天然的域偏移测试。
2.2 DEAP的读取:先拿前32导脑电通道,丢掉3秒基线
DEAP官方预处理好后的数据是mat文件,每个被试一个文件,变量名固定为data和labels。data的形状是(trial数量, 通道数量, 采样点数量),40个通道里前32个是真正EEG信号,后8个是EOG、EMG等外围生理信号,做情绪分析时通常直接丢掉。
import scipy.io as scio import numpy as np def load_deap_subject(mat_path, eeg_only=True, drop_baseline=True, fs=128): mat = scio.loadmat(mat_path) data = mat['data'] # 形状:(40个trial, 40个通道, 8064个采样点) labels = mat['labels'] # 形状:(40个trial, 4),对应valence/arousal/dominance/liking if eeg_only: data = data[:, :32, :] # 只保留前32导EEG,丢掉外围生理信号 if drop_baseline: data = data[:, :, 3 * fs:] # 每段前3秒是静息基线,只留刺激呈现的60秒 return data, labels这里有两个参数值得盯住。fs=128对应DEAP预处理版的采样率,8064个采样点等于128Hz乘以63秒,去掉前3秒基线后剩60秒、7680个点。eeg_only=True是社区最常见做法,因为外围信号和情绪的关系不是本文关心的事。如果之后要做跨数据集迁移,把数据统一成(trial, 通道, 时间)这种格式是第一步。
2.3 SEED和SEED-IV的差分熵特征:每个通道变成5个频带的能量
SEED官方同时提供了原始波形和微分熵(DE)特征两种版本。社区里跑RNN+CNN混合模型时,用DE特征比用原始波形更常见,因为DE特征把脑电的频带能量压缩成紧凑表示,模型不需要自己从时域波形里学出频带信息。微分熵的定义简洁:对某个频带内的信号,DE等于0.5 * log(2πeσ²),本质就是该频带信号方差的对数能量。
from scipy import signal def de_feature_per_window(win, fs=200): """win形状为(n_channel, n_samples),逐通道逐频带计算微分熵,返回(n_channel*5,)""" bands = [(1, 3), (4, 7), (8, 13), (14, 30), (31, 45)] # delta/theta/alpha/beta/gamma feats = [] for ch in range(win.shape[0]): # 遍历每一个EEG通道 for low, high in bands: # 遍历5个频带 b, a = signal.butter(4, [low, high], btype='bandpass', fs=fs) y = signal.filtfilt(b, a, win[ch].astype(np.float64)) de = 0.5 * np.log(2 * np.pi * np.e * np.var(y) + 1e-8) feats.append(de) return np.array(feats, dtype=np.float32)注意signal.butter传入了fs参数,这是SciPy 1.2.0之后的写法,旧版本需要手动把截止频率归一化到[0, 1]。1e-8是防log(0)的保险,实测中不加这个常数,某些通道方差恰好为0时会直接算出-inf。SEED的原始采样率是1000Hz,但社区常用200Hz重采样版本,fs=200时一个1秒窗口就是200个采样点,切窗逻辑和DEAP完全一致。
2.4 统一成同一种tensor格式:(样本数, 通道数×频带数, 时间步)
DEAP用原始波形、SEED用DE特征,两者进入模型前必须统一形状。做混合模型时,我习惯把DE特征也保留时间维:对一张1秒窗口内的信号,不把一个窗口的DE压成一根向量,而是按时间继续细分,让RNN循环神经网络有序列可读。常见做法是把1秒窗口切成10个100ms子窗,每个子窗算62通道×5频带=310维DE向量,于是每个样本变成(310, 10),再unsqueeze成(1, 310, 10)给卷积层。
def de_sequence(win, fs=200, sub_win=100): """把一段(n_channel, n_samples)信号切成子窗并提取DE,返回(n_channel*5, n_sub_windows)""" step = int(fs * sub_win / 1000) # 100ms子窗对应的采样点数 n_sub = win.shape[1] // step cols = [] for i in range(n_sub): seg = win[:, i * step:(i + 1) * step] cols.append(de_feature_per_window(seg, fs=fs)) return np.stack(cols, axis=1) # (n_channel*5, n_sub)这一步做完,三个数据集在模型眼里就是同一种模态了。DEAP原始波形可以直接保持(32, 时间步)进入模型,也可以照上面方式转成频带序列再进模型;SEED和SEED-IV则几乎都是走DE序列这条路线。统一成(batch, 通道数×频带数, 时间步)之后,第3章的模型代码就能完全复用,不需要为每个数据集各写一套模型。
3. 模型结构选型:CNN卷积神经网络提空间与局部时域特征,RNN循环神经网络管长程依赖
模型结构是整个方案的核心,也是论文里最容易读晕的部分。先把思路理清:脑电情绪识别里,CNN负责什么、RNN负责什么、两者拼接时怎么对齐维度,这三个问题想透,源码里的训练部分就是水到渠成的事。
3.1 为什么纯CNN会在情绪识别上翻车
图像上的深度学习cnn模型大杀四方,但把同样的套路搬来脑电上,效果往往不如预期。原因不复杂:图像的空间关系是静态的,而EEG是低信噪比、强时序依赖的信号。情绪诱发的慢波节律集中在theta到gamma频带,单次事件相关电位只有几十毫秒,但情绪状态的建立和维持是秒级的。普通1D卷积的感受野等于kernel_size × 层数,想覆盖到一秒以上的上下文必须堆很深,或者靠空洞卷积拉感受野。社区里纯CNN方案能跑出好成绩的,几乎都用了dilation,这就把工程复杂度抬高了。
RNN循环神经网络论文里反复出现的一个结论是:LSTM或GRU在时间维上维护隐状态,能把整个窗口内数百个时间步的信息串在同一条路径上,天然适合秒级情绪节律的建模。所以「卷积提局部、循环提长程」这种分工是经过大量实验验证的合理组合,而不是为了拼凑网络结构硬凑热点。
3.2 结合方式怎么选:串行、并行、Attention三种搭法
RNN和CNN结合的主流方案有三类,按复现难度排序:
- 串行CNN→BiLSTM:输入先进卷积层提取局部特征,输出序列再交给双向LSTM建模长程依赖,最后池化接分类头。这是最常用也最稳的结构,训练时不容易翻车,建议新手先跑通这条线。
- 并行双分支:CNN分支和LSTM分支各自吃同一份输入,特征拼接后接全连接层。理论上能同时保留多粒度信息,但两个分支收敛速度不一致时,调参成本更高。
- CNN-BiLSTM-Attention:CNN输出的特征序列先进注意力层,再进LSTM或替代LSTM。效果通常最好,但训练也更敏感,属于调好血赚、调不好血压拉满的方案。
3.3 用Pytorch搭建一个可复现的CNN-BiLSTM混合模型
我一般先用串行结构打底。输入形状是(batch, in_channels, time_steps),其中in_channels决定拼接方式:DEAP原始波形是1,SEED的DE特征是62×5=310。下面这个模型定义可以直接抄:
import torch import torch.nn as nn class CNN_BiLSTM(nn.Module): def __init__(self, in_channels=310, hidden=128, n_layers=2, n_class=3, dropout=0.3): super().__init__() self.conv = nn.Sequential( nn.Conv1d(in_channels, 64, kernel_size=3, padding=1, bias=False), nn.BatchNorm1d(64), nn.ReLU(inplace=True), nn.MaxPool1d(2), # 时间步减半 nn.Conv1d(64, 128, kernel_size=3, padding=1, bias=False), nn.BatchNorm1d(128), nn.ReLU(inplace=True), ) self.lstm = nn.LSTM(128, hidden, num_layers=n_layers, batch_first=True, bidirectional=True, dropout=dropout) self.head = nn.Sequential( nn.Linear(hidden * 2, 64), nn.ReLU(inplace=True), nn.Dropout(dropout), nn.Linear(64, n_class), ) def forward(self, x): # x: (batch, in_channels, time_steps) h = self.conv(x) # (batch, 128, time_steps//2) h = h.permute(0, 2, 1) # (batch, time_steps//2, 128) out, _ = self.lstm(h) # (batch, time_steps//2, hidden*2) out = out.mean(dim=1) # 对所有时间步做平均池化 return self.head(out) # (batch, n_class)两个细节值得说明。第一,nn.LSTM设置batch_first=True之后,输入输出都是(batch, seq_len, feature),避免在时间维和batch维之间来回倒腾。第二,池化方式选的是对LSTM输出所有时间步取平均,而不是取最后时刻的隐状态——EEG信号没有自然句尾,最后一个时间步并不代表整个窗口的总结,平均池化在实验中更稳。hidden取128、n_layers取2是兼顾参数量和训练速度的常见配置,如果数据量小,可以把n_layers降到1防止过拟合。
3.4 输入布局的细节:通道、频带、时间三个维度怎么摆
in_channels是个容易踩坑的位置。把通道数×频带数直接拼成卷积的输入通道,是一种妥协但行之有效的做法:卷积核在时间维上滑动,同时隐式地混合通道与频带信息。如果你想把空间拓扑关系利用得更充分,可以把输入改成(batch, 1, 通道数, 时间步),用2D卷积同时滑通道和时间两个方向,或者更进一步用图卷积处理电极位置。但对RNN+CNN这个标题来说,最简单的拼接方式反而是最容易复现的,先用in_channels=62*5=310跑通,再逐步往复杂结构走。
4. 把源码跑通的关键节点:数据划分、训练参数与评估代码
拿到源码后别急着python train.py。这套流程里最影响结果的东西,不是网络结构本身,而是数据划分方式和评估指标。同一个模型,用subject-dependent划分可能刷到90%以上,换成subject-independent立刻跌到60%多,这种落差在脑电情绪识别里极其常见。
4.1 两种评测设定:subject-dependent是自嗨,subject-independent才是真实水平
按窗口随机划分训练集和测试集,叫subject-dependent设定:同一个人的大量数据同时出现在两边,模型记住了这个人的脑电特征,换个人立刻失效。这种设定下跑出来的分数没有参考价值。正经做法是留一被试交叉验证(LOSO):15个受试者里轮流拿1个做测试,剩下14个做训练,最终报告15次测试的平均准确率和标准差。社区报告数字时都会注明评测方式,看到论文里不注明的,要留个心眼。
subjects = list(range(15)) # 假设15个被试,按受试者索引切分 for test_sub in subjects: train_subs = [s for s in subjects if s != test_sub] # 用 train_subs 对应的全部trial组装训练集 # 用 test_sub 对应的全部trial组装测试集 # 模型训练与评估完成后,记录 test_sub 上的准确率这段骨架代码的关键是:切分发生在受试者维度,而不是数据行维度。先把每个被试的所有数据和标签按索引分组,再在训练循环外面套这层循环。
4.2 训练配置抄这一组:先跑通再调参
| 参数 | 推荐值 | 说明 |
|---|---|---|
| 优化器 | Adam | 脑电任务上Adam比SGD收敛快,调参压力小 |
| 学习率 | 1e-3 | 带LSTM的模型不建议从1e-2起步,容易震荡 |
| batch_size | 64 | DE特征输入时显存压力小,可以适当加大 |
| 滑动窗口 | 1秒,50%重叠 | 1秒窗口能覆盖alpha节律的完整周期 |
| epoch上限 | 50 | 配合早停,实际多数在20~30轮收敛 |
| early stopping | patience=10 | 验证集指标连续10轮不提升就停 |
| 梯度裁剪 | clip=1.0 | 防止BiLSTM反向传播时梯度爆炸 |
| 权重衰减 | 1e-4 | 轻微正则,DE特征维度较高时有用 |
我习惯在train_one_epoch里就挂上梯度裁剪,而不是等loss变NaN再回来加。脑电数据噪声大,偶尔一个样本的梯度异常就能毁掉整个训练曲线,裁剪是最便宜的后悔药。
4.3 训练循环与评估:准确率之外一定要算macro F1
def train_one_epoch(model, loader, opt, criterion, clip=1.0): model.train() losses = [] for x, y in loader: opt.zero_grad() logits = model(x) loss = criterion(logits, y) loss.backward() nn.utils.clip_grad_norm_(model.parameters(), clip) opt.step() losses.append(loss.item()) return float(np.mean(losses)) @torch.no_grad() def evaluate(model, loader, criterion): model.eval() preds, trues, loss_sum = [], [], 0.0 for x, y in loader: logits = model(x) loss_sum += criterion(logits, y).item() * x.size(0) preds.append(logits.argmax(dim=1).cpu().numpy()) trues.append(y.cpu().numpy()) preds = np.concatenate(preds) trues = np.concatenate(trues) acc = accuracy_score(trues, preds) f1 = f1_score(trues, preds, average='macro') return acc, f1, loss_sum / len(trues)criterion用nn.CrossEntropyLoss(),如果类别不均衡可以在初始化时传入weight参数。evaluate里计算loss时乘了x.size(0),是为了在batch大小不一致时仍能按样本数归一化,否则最后的平均loss会被大batch主导。准确率之外务必同时打印macro F1,特别是在SEED-IV四分类任务上,单独看acc可能被某个占比偏高的类别带偏。
4.4 LOSO完整流程:把三份数据集都跑一遍,大概需要多久
DEAP跑40折LOSO、SEED和SEED-IV跑15折LOSO,每折都要独立训练一个模型。如果只用CPU跑,DEAP一折大约几十分钟到几小时,SEED因为62通道DE特征维度高,时间更长。建议先用一到两个被试做冒烟测试,确认整个pipeline能跑通、指标不会太低,再挂机跑完整LOSO。源码里如果默认只跑subject-dependent,记得改成LOSO之后,数值会大幅缩水,别慌,那是真实的泛化水平。
5. 避坑:复现RNN+CNN脑电情绪识别时最常见的4个问题
这套流程的坑集中在数据准备和训练设定两片区域。下面四条是我在这类项目里反复踩过的,按出现频率排序,每一条都按「现象→原因→解决」拆开。
5.1 训练集95%,测试集55%:标准化把测试集信息泄漏进去了
现象:模型在训练集上收敛得又快又漂亮,验证集却一塌糊涂,差距大到不像是正常泛化落差。原因:预处理时先对全部数据调用了StandardScaler.fit(),再切分训练集和测试集,导致测试集的均值和标准差已经参与过训练。这是脑电情绪识别里最常见的泄漏方式。解决:把scaler的fit操作严格限定在训练集上,测试集只用同一个已拟合的scaler做transform。注意,DE特征提取时用到的全局统计量同理,也要在训练集上计算。
提示:凡是在全数据集上做的任何统计操作,包括均值和方差、归一化、异常值截断,全部都有可能造成跨受试者信息泄漏。
5.2 按窗口随机切分后成绩虚高:相邻窗口来自同一个trial
现象:用滑动窗口切出成千上万个样本,直接train_test_split随机划分后测试集准确率比按trial切分高出10个百分点以上。原因:窗口之间有重叠,stride小于窗口长度时相邻样本高度相似,同一个trial的数据被同时分到了训练集和测试集,相当于测试集里混入了训练样本的近亲。解决:先按trial维度和subject维度切分,再在每个trial内部滑窗生成样本,保证任何窗口都不会跨界。LOSO天然规避了这个问题。
5.3 loss突然变NaN,或者训练集都不收敛
现象:训练到某一步loss变成NaN,或者loss一直维持在初始值附近不动弹。原因:LSTM在长序列上反向传播路径很深,梯度爆炸是常事;另一种可能是学习率太高,模型在loss曲面边缘来回弹跳。解决:梯度裁剪clip_grad_norm_挂上,clip=1.0起步;学习率从1e-3往下调,如果还不收敛就试3e-4。还有一个冷门原因:输入没有做float32转换,某些数据读进来是float64,Pytorch和numpy混用时会悄悄报类型错误。
5.4 只报acc不报macro F1:SEED-IV上四分类被主导类别支配
现象:SEED-IV跑完准确率看着有75%,看一眼混淆矩阵才发现其中一个类别几乎没被预测出来。原因:四分类任务里三个负性情绪类别特征相似,模型倾向于把所有样本都判成占比最高的那一类,acc照样不低。解决:监控macro F1而不是单看acc;给CrossEntropyLoss传入按类别频率倒数归一化的weight;评估时把混淆矩阵打印出来,逐类别看precision和recall。这也是社区后来普遍要求报告macro F1的原因——acc在这个任务上真的会骗人。
6. 进阶:用留一被试交叉验证和滑动窗口投票,让结果经得起审稿人追问
基础流程跑通之后,有两件事能让结果可信度上一个台阶:一是把LOSO的结果完整报告出来,带均值和标准差;二是用滑动窗口投票替代单窗口预测,让每个trial的预测更稳。
LOSO完整跑完,你会拿到一组逐被试准确率,比如15个数字。不要只报平均值,把标准差也写上,因为某些被试的脑电模式就是和大多数人格格不入,导致那一折准确率特别低,这是数据集本身的性质,不是你的bug。报告格式可以写成74.2 ± 8.5%,审稿人看到±号比看到单独一个百分比要放心得多。
滑动窗口投票的实现很直接:同一个trial被切成了多个窗口,每个窗口单独过模型得到预测分布,把所有窗口的概率平均,取平均概率最大的类别作为这个trial的最终预测。
def trial_vote(model, windows): """windows: (n_win, in_channels, time_steps),返回trial级预测""" preds = [] for w in windows: logits = model(w.unsqueeze(0)) # (1, n_class) preds.append(torch.softmax(logits, dim=1)) prob = torch.stack(preds).mean(dim=0) # 对窗口维度取平均 return prob.argmax(dim=1).item()窗口投票能在原有窗口级指标上再涨1到3个百分点,因为它抹平了单个窗口里噪声尖峰造成的误判。
还有一个值得做的对照实验:把SEED的DE特征直接换成原始波形输入,模型结构完全不动,看看指标掉多少。如果掉得不多,说明模型本身在从时域波形里学到有用的频带信息;如果掉得厉害,说明DE特征预处理才是这套流程的隐形功臣。这种对比实验写在论文里格外有说服力,因为它回答了一个审稿人必问的问题:你的增益到底来自模型还是来自特征工程。
这套方向值不值得投入,取决于你的目标。如果只是跑基线、验证idea,SEED和DEAP的LOSO流程性价比极高;如果想冲SOTA,注意力机制和跨数据集预训练是下一步该碰的东西。我做这个方向最深的一个教训是:第一次在DEAP上跑subject-dependent拿到85%准确率,以为自己调通了,换成LOSO直接掉到62%,才知道之前的分数没有任何参考价值。从那以后,任何脑电情绪识别的结果,我都先问一句:这是哪种评测设定出来的?希望帮到你。
本文还有配套的精品资源,点击获取