简介:面向脑电情感识别方向的深度学习学习者与研究者,提供一套基于DEAP数据集的CNN-LSTM完整实现代码,适合已具备Python与神经网络基础、希望复现或改进脑电情绪分类实验的中高级读者。压缩包共35个文件、约11.86MB,以15张png可视化图、6个ipynb实验笔记、3个py脚本及xml工程配置为主,另含h5与npy模型权重文件,覆盖从数据预处理、特征提取到建模训练的完整链路。资源中既有带通滤波、频域分布与混淆矩阵等结果图,也保留了LSTM与CNN1D结合GRU、SimpleRNN的多组对比实验记录,便于对照不同网络结构的训练效果与损失曲线。已有2522人学习下载,可帮助读者快速搭建脑电信号时空特征提取流程,理解模型保存与复用方式,并在此基础上调整超参数、替换模型结构以提升情感识别性能。
1. 从一段“跑不动”的脑电分类代码说起
DEAP 数据集在情绪识别圈子里几乎是绕不开的公开数据:32 名被试、每人 40 段一分钟的脑电与外围生理信号,标签是 valence、arousal、dominance、liking 四个维度的 1–9 自评。很多人第一次拿到它,直接套一个 CNN 或 LSTM 就开跑,结果要么准确率卡在 50% 上下晃,要么显存爆掉、要么训练十轮 loss 不降。这份 CNN-LSTM 代码包解决的正是这个落地断层:它把 DEAP 的原始.dat读取、通道筛选、切片、归一化、按被试划分训练测试集,到 CNN 提局部时频特征、LSTM 建模时序依赖、再到二分类评估的整条链路都写成了可执行脚本。适合两类人:一是做课程设计或毕设、需要一份能跑通 baseline 的学生;二是想把脑电情绪识别快速验证一遍、再决定要不要投入更多特征工程的研究者。下面按“数据怎么进、模型怎么搭、坑在哪、怎么验证”的顺序拆。
2. 数据管线:把 DEAP 的 .dat 变成 CNN-LSTM 能吃的张量
2.1 先搞清楚 DEAP 的原始结构
DEAP 每个被试一个.dat文件,用pickle序列化,读出来是一个字典,键包括data(40×40×8064)、labels(40×4)。data的 40 个通道里,前 32 个是脑电,后面依次是眼电、肌电、皮电、呼吸带等外围信号;8064 是 60 秒 × 128 Hz 的采样点。做脑电情绪识别,常见做法是只取前 32 个脑电通道,外围信号要么单独建模、要么直接丢掉,混在一起喂 CNN 只会让卷积核学到一堆和情绪无关的生理噪声。
代码包里读取部分一般长这样,我按自己的习惯补了通道筛选和标签二值化:
import pickle import numpy as np def load_subject(dat_path, eeg_channels=32, fs=128): with open(dat_path, 'rb') as f: subject = pickle.load(f, encoding='latin1') # data: (trials, channels, samples),只保留前 32 个脑电通道 eeg = subject['data'][:, :eeg_channels, :] labels = subject['labels'] # (40, 4): valence, arousal, dominance, liking return eeg, labels def binarize(labels, dim=0, threshold=5.0): # DEAP 标签是 1-9 自评,惯例以 5 为界做高低二分类 y = (labels[:, dim] >= threshold).astype(np.int64) return y逻辑说明:encoding='latin1'是读老版本 Python2 序列化文件的兼容写法,不加会在部分环境下报UnicodeDecodeError;threshold=5.0是 DEAP 论文里常用的高低情绪划分点,你也可以改成 4.5 或 5.5 做敏感性对比。参数上eeg_channels=32对应 DEAP 的脑电通道数,fs=128是采样率,后面切片窗口长度都靠它换算。
2.2 切片、归一化与按被试划分
60 秒整段直接喂模型有两个问题:样本太少(每人只有 40 个),且 LSTM 序列长度 8064 会让显存和梯度都很难受。常见做法是滑窗切片,比如 4 秒一个窗口、步长 2 秒,一段 60 秒能切出约 28 个样本,数据量直接翻二十多倍。归一化必须按通道做 z-score,且统计量只能从训练集算,否则测试集信息泄漏,准确率虚高。
def sliding_window(eeg, y, window=512, step=256): # window=512 对应 4 秒 @128Hz,step=256 对应 2 秒重叠 n_trials, n_ch, n_samples = eeg.shape xs, ys = [], [] for i in range(n_trials): for start in range(0, n_samples - window + 1, step): seg = eeg[i, :, start:start + window] xs.append(seg) ys.append(y[i]) return np.array(xs, dtype=np.float32), np.array(ys) def normalize_by_channel(x_train, x_test): # 逐通道计算训练集均值方差,测试集复用 mean = x_train.mean(axis=(0, 2), keepdims=True) std = x_train.std(axis=(0, 2), keepdims=True) + 1e-8 return (x_train - mean) / std, (x_test - mean) / std这里window=512、step=256是我一般会先试的起点:窗口太短(比如 1 秒)频域特征不够稳,太长(比如 10 秒)情绪状态可能已经漂移。normalize_by_channel里axis=(0,2)表示在样本和時間维上统计、保留通道维,这样每个通道有自己的均值方差,避免不同电极阻抗差异带来的量纲问题。划分上强烈建议按被试做 leave-one-subject-out 或固定留出几个被试,随机打乱样本会让同一段信号的相邻窗口同时进训练和测试,指标好看但没意义。
2.3 输入张量的形状要对齐
CNN-LSTM 的输入形状取决于你先卷积还是先循环。代码包常见的是(batch, channels, time)先过一维卷积,再转置成(batch, time', features)喂 LSTM。切片后x_train形状是(N, 32, 512),卷积后时间维会因池化缩短,转置前务必确认维度顺序,否则 LSTM 会把通道当时间步,训练能跑但学到的全是错的。这一步没有报错、只有指标异常,属于典型的“黑匣子”式翻车点。
3. CNN-LSTM 模型搭建:卷积提局部节律,循环抓时序演化
3.1 为什么是 CNN 接 LSTM 而不是二选一
脑电的判别信息有两个尺度:短时窗内的节律模式(比如 alpha、beta 频段的功率变化)适合卷积核去抓局部波形,而情绪在一分钟内的起伏是渐进的,适合循环网络建模时间依赖。纯 CNN 把时间维池化掉就丢了演化信息,纯 LSTM 直接吃 512 点原始波形参数量大、收敛慢。CNN-LSTM 的分工是:前段卷积做通道间和局部时间上的特征提取,后段 LSTM 在卷积输出的特征序列上做时序聚合。这个组合在 DEAP 上的 baseline 通常比单 CNN 高几个点,代价是训练更慢、调参更敏感。
3.2 一份可跑的模型定义
import torch import torch.nn as nn class CNNLSTM(nn.Module): def __init__(self, n_channels=32, n_classes=2, hidden=64): super().__init__() self.conv = nn.Sequential( nn.Conv1d(n_channels, 64, kernel_size=7, padding=3), nn.BatchNorm1d(64), nn.ReLU(), nn.MaxPool1d(2), nn.Conv1d(64, 128, kernel_size=5, padding=2), nn.BatchNorm1d(128), nn.ReLU(), nn.MaxPool1d(2), ) self.lstm = nn.LSTM(input_size=128, hidden_size=hidden, num_layers=2, batch_first=True, bidirectional=True, dropout=0.3) self.fc = nn.Linear(hidden * 2, n_classes) def forward(self, x): # x: (B, 32, 512) h = self.conv(x) # (B, 128, 128) h = h.transpose(1, 2) # (B, 128, 128) -> 时间步在前 out, _ = self.lstm(h) out = out[:, -1, :] # 取最后时间步 return self.fc(out)逻辑说明:两层Conv1d的kernel_size分别取 7 和 5,对应约 55ms 和 39ms 的感受野,覆盖几个完整周期;每次卷积后MaxPool1d(2)把 512 点压到 128 步,LSTM 序列长度可控。bidirectional=True让前向和后向隐状态拼接,hidden*2就是全连接输入维度。dropout=0.3放在 LSTM 层间,是脑电小样本场景下抑制过拟合的常用值。参数上hidden=64是保守起点,数据量上来可以加到 128,但要注意双向 LSTM 参数量是单向的两倍。
3.3 训练循环与关键超参
def train_one_epoch(model, loader, optimizer, criterion, device): model.train() total_loss = 0.0 for xb, yb in loader: xb, yb = xb.to(device), yb.to(device) optimizer.zero_grad() logits = model(xb) loss = criterion(logits, yb) loss.backward() # 梯度裁剪,防止 LSTM 梯度爆炸 torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) optimizer.step() total_loss += loss.item() * xb.size(0) return total_loss / len(loader.dataset)clip_grad_norm_的max_norm=1.0是 LSTM 训练的后悔药,脑电信号幅值波动大,不加裁剪偶尔会出现 loss 突然变 NaN。优化器一般用 Adam,学习率 1e-3 起步,batch size 32 或 64;类别不均衡时给CrossEntropyLoss传weight,按训练集两类样本数反比设置。评估阶段记得model.eval()加torch.no_grad(),否则 BatchNorm 的统计量会被测试数据污染。
4. 避坑与排查:五个真实翻车现场
4.1 准确率永远 50%:标签没对齐或维度取错
现象是训练 loss 能降、验证准确率贴着 50%。原因多半是labels[:, dim]的dim取错,或者切片时ys.append(y[i])用了整段标签但窗口和 trial 的对应关系错位。解决:打印前几个 batch 的yb分布,确认两类样本大致均衡;再抽查一个窗口,确认它确实来自对应 trial 的同一段信号。
4.2 loss 变 NaN:没做梯度裁剪或归一化统计量含零方差通道
现象是训练几轮后 loss 直接 NaN。原因一是 LSTM 梯度爆炸,二是某些通道在训练集上方差接近零,z-score 除出极大值。解决:加clip_grad_norm_,并在归一化时给std加1e-8;再检查是否有通道全零(电极脱落或数据损坏),有就整通道剔除。
4.3 显存爆掉:序列长度没压下来
现象是 batch size 调到 8 还 OOM。原因是直接把 8064 点喂 LSTM,或者卷积层没加池化。解决:先滑窗切片到 512 点,卷积后确保时间维降到百级;实在不够就把hidden从 128 降到 64,或改用梯度累积模拟大 batch。
4.4 指标虚高:随机划分导致同被试泄漏
现象是准确率 90% 以上,换一批被试就崩。原因是随机打乱样本,同一被试的相邻窗口同时进了训练和测试。解决:按被试划分,用 leave-one-subject-out 或固定留出若干被试做测试,报告跨被试指标才有参考价值。
4.5 训练极慢:数据在 CPU 和 GPU 间反复拷贝
现象是 GPU 利用率低、每轮要几十分钟。原因是DataLoader的num_workers=0,或张量没提前转成float32。解决:num_workers设 4 左右,pin_memory=True,切片后统一astype(np.float32),避免每个 batch 现场转换。
5. 验证与进阶:把 baseline 变成可信结论
跑通只是第一步,能不能信要看验证设计。我一般会固定三件事:一是按被试留出,至少留 5 个被试做测试,报告均值±标准差而不是单次结果;二是做标签阈值敏感性,valence 和 arousal 分别用 4.5、5.0、5.5 各跑一遍,看结论是否稳定;三是加一个多数类基线,DEAP 二分类里两类比例接近时基线约 50%,如果你的模型只到 55%,那点提升很可能在噪声范围内。
进阶方向有两个性价比高的:一是把输入从原始波形换成微分熵或频段功率,DEAP 上微分熵特征配合浅层模型往往比原始波形 CNN 更稳,可以作为对照;二是把 CNN-LSTM 的输出做被试内校准,用每个被试前若干 trial 的预测分布做阈值调整,跨被试指标通常能拉回几个点。下面这个评估片段是我每次都会挂上的:
from sklearn.metrics import accuracy_score, f1_score def evaluate(model, loader, device): model.eval() preds, gts = [], [] with torch.no_grad(): for xb, yb in loader: logits = model(xb.to(device)) preds.extend(logits.argmax(1).cpu().numpy()) gts.extend(yb.numpy()) return accuracy_score(gts, preds), f1_score(gts, preds, average='macro')average='macro'是为了在类别不均衡时不被多数类掩盖,配合准确率一起看。血泪经验是:任何一次指标提升,先怀疑数据泄漏,再怀疑超参,最后才相信模型变强了。从那以后我每次改完数据管线,都强制先跑一遍“打乱标签”的对照实验——如果打乱标签后准确率还明显高于 50%,那管线里一定有泄漏。希望这份拆解帮到你,少走几段弯路。
本文还有配套的精品资源,点击获取