资讯详情 DEAP脑电信号识别CNN-LSTM代码:不堆层也能做到85%情感分类准确率
📅 2026/10/10 22:40:15
简介面向脑电信号情感识别任务的CNN-LSTM完整代码包以DEAP公开数据集为基础适用于深度学习、生物信号处理与人机交互方向的研究者。压缩包共35个文件整体约11.86MB包含Jupyter Notebook训练脚本、Python预处理与测试代码、模型权重文件h5/npy以及滤波曲线、频域分布、混淆矩阵等多张可视化图表结构上覆盖数据清洗、特征提取、模型训练与评估验证等环节。已有2521人学习下载。通过该资源可快速复现基于脑电信号的情感识别流程理解CNN提取局部特征、LSTM捕捉时序依赖的混合建模思路并利用保存的权重直接进行预测或微调对搭建同类实验具有直接参考价值。1. DEAP脑电信号识别CNN-LSTM代码不用堆层也能把情感分类做到85%很多人拿到DEAP数据集的第一反应是直接照搬图像分类的CNN结构去跑脑电信号结果验证集准确率一直卡在50%上下跟抛硬币一样。问题不在CNN本身而在于脑电信号和图像的数据形态完全不同——DEAP里每个样本是32通道的时序信号通道是空间维度时间才是序列维度这两者一旦搞反模型结构再深也学不到东西。这篇笔记就是要把DEAP脑电信号识别CNN-LSTM代码完整拆开从数据预处理到模型结构、训练参数、评估方式全部讲清楚让你照着走一遍就能复现出一个可靠的基线而不是靠运气调参。2. DEAP数据集预处理把40个受试者的原始信号切成能训练的样本2.1 先搞懂DEAP的文件结构和数据形状DEAP数据集的原始文件是每个受试者一个.mat文件命名是data_sub01.mat、data_sub02.mat一直到data_sub40.mat。每个文件里面存了两块内容data字段是脑电信号形状是40 × 40 × 8064第一维是40个trail试验第二维是40个通道第三维是8064个采样点labels字段是标签形状是40 × 4对应valence、arousal、dominance、liking四个维度的主观评分1到9的连续值。这里的8064是怎么来的DEAP的采样率是128Hz每个trail录了63秒128乘63等于8064。前3秒是baseline基线信号后面60秒才是受试者观看视频时的脑电。所以拿到手的第一件事不是直接丢进模型而是要把baseline切掉不然基线段的信号会作为噪声混进样本里把分类边界搅浑。另外40通道里前32个是EEG脑电通道后8个是眼电、肌电等生理信号做脑电分类一般只用前32通道。import scipy.io as sio import numpy as np def load_deap_subject(file_path, eeg_channels32, fs128, baseline_seconds3): data sio.loadmat(file_path) raw data[data] # (40, 40, 8064) labels data[labels] # (40, 4) eeg raw[:, :eeg_channels, fs * baseline_seconds:] # 切掉前3秒基线 # eeg形状: (40, 32, 7680)每个trail只剩60秒信号 return eeg, labels这段代码里值得注意的参数是fs * baseline_seconds用采样率乘以秒数换算成采样点数128乘以3得到384意味着从第384个采样点开始截取。这里不要写死成384一旦数据集换采样率数字就得跟着改用变量算更稳。eeg_channels取32也是DEAP的约定如果要做全通道对比实验这里可以改成40跑一版看看但基线模型建议先用32通道因为后8个体电信号会引入跟情感无关的噪声。2.2 标准化在滑窗之前做还是之后做差别很大脑电信号每个人、每个trail的幅值范围差异很大同一个受试者不同时间的信号也可能差出几倍。如果不做标准化模型会把个体的幅值特征学进去而不是真正的情感模式。常见做法是对每个trail做z-score标准化也就是减去该trail的均值再除以标准差。但这里有一个容易踩的坑如果先把整个trail标准化再滑窗训练集和测试集的样本会共享这个trail的统计量严格来说不算泄漏但会导致模型对个体差异过度敏感。我一般建议先划分窗口再在每个窗口内部做标准化这样每个样本的统计量是独立的训练出来的模型泛化会好一些。def zscore_per_window(windows): windows形状: (n_windows, n_channels, n_timesteps) mean windows.mean(axis(1, 2), keepdimsTrue) # 每个窗口独立计算均值 std windows.std(axis(1, 2), keepdimsTrue) std[std 1e-6] 1e-6 # 防除零 return (windows - mean) / stdaxis(1, 2)是关键它告诉程序沿着通道和时间两个维度同时计算均值得到的是每个窗口单独的一个平均数。窗口内有极端值也不会把阈值崩掉因为标准差做了下限保护。实际跑实验时还会遇到一种情况某些trail的脑电信号幅值极小std接近零不设std[std 1e-6] 1e-6的话标准化后就全是NaN训练直接断掉。2.3 滑窗切分窗口长度和重叠率直接决定样本数量DEAP每个受试者只有40个trail就算40个人全部用上也才1600个样本对深度学习来说远远不够。滑窗是解决样本量问题的标准手段。常见参数是窗口长度2秒256个采样点、重叠率50%stride为128个采样点这样每个trail能从7680个点里切出59个窗口一个受试者能产生2360个样本40个人就是94400个训练CNN-LSTM基本够用。def sliding_window(eeg, window_len256, stride128): eeg形状: (n_trials, n_channels, n_timesteps) windows [] for trial in eeg: n_steps trial.shape[1] for start in range(0, n_steps - window_len 1, stride): window trial[:, start:start window_len] windows.append(window) return np.stack(windows) # 形状: (n_windows, 32, 256)窗口长度的选择会影响模型看到的时序信息量。512个点4秒能让LSTM看到更长的上下文但样本数量会减半128个点1秒样本够多但单窗口内的时序模式不够完整。我的经验是先用2秒做基线模型收敛稳定后再调窗口长度做对比实验。还有一个容易被忽略的地方range的终止条件是n_steps - window_len 1不加这个1最后一个完整窗口会被丢掉虽然只少一个样本但对比实验时会引入不必要的误差。3. 搭建CNN-LSTM模型先用CNN抽空间特征再用LSTM建模时序依赖3.1 为什么是CNN-LSTM而不是纯CNN或纯LSTMDEAP的脑电数据本质上是多通道时间序列空间维度和时间维度都有信息。纯CNN擅长提取局部空间模式但它对长距离时序依赖的建模能力偏弱而情感刺激引发的脑电响应往往持续数秒单靠卷积很难抓住这种长程关系。纯LSTM能建模时序却对通道之间的空间关系不敏感32个通道的信号如果直接拼成一个向量喂进去LSTM很难自动发现额叶和颞叶通道之间的协同模式。CNN-LSTM混合结构正好互补前面的卷积层在时间维度上做局部特征提取把原始信号压缩成高层特征序列后面的LSTM在这个特征序列上建模时序依赖。这种结构在DEAP情感分类上是验证过的组合论文里最常见的做法就是两层一维卷积加一层或两层LSTM参数量不大但效果稳定。3.2 完整的CNN-LSTM模型代码下面是模型的核心部分输入是(batch_size, 32, 256)也就是一个batch的窗口数据32个EEG通道256个时间步。import torch import torch.nn as nn class CNNLSTM(nn.Module): def __init__(self, in_channels32, seq_len256, hidden_size64, num_classes2): super().__init__() # 第一层卷积: 时间维度上做局部特征提取 self.conv1 nn.Conv1d(in_channels, 64, kernel_size5, padding2) self.bn1 nn.BatchNorm1d(64) self.pool1 nn.MaxPool1d(kernel_size2) # 第二层卷积: 进一步压缩时间维度 self.conv2 nn.Conv1d(64, 128, kernel_size3, padding1) self.bn2 nn.BatchNorm1d(128) self.pool2 nn.MaxPool1d(kernel_size2) # LSTM: 对卷积特征序列建模时序依赖 self.lstm nn.LSTM(input_size128, hidden_sizehidden_size, num_layers2, batch_firstTrue, dropout0.3) self.fc nn.Linear(hidden_size, num_classes) self.dropout nn.Dropout(0.5) def forward(self, x): # x: (batch, 32, 256) x self.pool1(torch.relu(self.bn1(self.conv1(x)))) # (batch, 64, 128) x self.pool2(torch.relu(self.bn2(self.conv2(x)))) # (batch, 128, 64) x x.transpose(1, 2) # (batch, 64, 128) out, (hn, cn) self.lstm(x) # out: (batch, 64, hidden) out out[:, -1, :] # 取最后一个时间步 out self.dropout(out) return self.fc(out)这个结构里每个组件都有明确作用。Conv1d是在时间维度上扫卷积核32个通道作为输入通道数第一层输出64个特征图。池化层把时间长度从256压到64这样进入LSTM的序列长度只有64步大幅减少LSTM的计算量。transpose(1, 2)这一步很多人会漏——LSTM要求输入是(batch, seq_len, feature)而卷积输出是(batch, feature, seq_len)不转置就直接报维度错误。3.3 前向传播的维度变化理解建议动手把每一层的输出形状在纸上推一遍这是理解模型的关键。输入(batch, 32, 256)经过第一层卷积后变成(batch, 64, 256)padding2且kernel5保证了长度不变池化后变成(batch, 64, 128)。第二层卷积保持128通道不变池化后变成(batch, 128, 64)。此时时间维度已经从256压到64相当于把2秒的脑电信号切成了64个时间片段每个片段由128维特征描述。LSTM在这个64步序列上建模最后取最后一个时间步的隐藏状态作为整个窗口的表征过一层全连接输出类别概率。如果不想取最后一个时间步也可以对LSTM输出做平均池化效果通常会好1到2个百分点但均值池化会让模型对短时突变不敏感。取最后一个时间步保留了更多末端信息在情感分类任务里窗口末尾往往是情绪最强烈的时刻。两种都可以试基线用最后一步就够。4. 训练与评估把DEAP的连续评分转成分类任务选对损失函数是关键4.1 标签处理二分类还是回归DEAP的标签是1到9的连续评分处理方式决定了训练目标。最常见的做法是取阈值5大于5视为高维度positive class小于等于5视为低维度negative class比如valence大于5表示正性情绪这样就把情感分类变成了二分类问题。也有人直接做回归用MSE损失预测连续分数但回归评估指标不如分类直观对从业者来说二分类更常用。def binary_labels(labels, threshold5.0, target_dim0): 将DEAP标签二值化 target_dim: 0valence, 1arousal, 2dominance, 3liking scores labels[:, target_dim] return (scores threshold).astype(int)target_dim参数让你可以灵活选择预测哪个维度。做情感识别任务一般用valence和arousal两个维度分别训练模型然后对比两个模型的准确率。阈值取5.0是DEAP论文里常用的分割点如果想做三分类低中高可以用4和6两个阈值切成三段但数据分布会变得不均衡需要加权重调整。4.2 训练循环分类交叉熵加Adam优化器训练代码的逻辑比较固定但有三处细节会影响最终效果每个epoch要打乱数据顺序、验证集要和训练集完全独立、学习率要配合步数衰减。def train_one_epoch(model, train_loader, optimizer, criterion, device): model.train() running_loss 0.0 correct 0 total 0 for x_batch, y_batch in train_loader: x_batch, y_batch x_batch.to(device), y_batch.to(device) optimizer.zero_grad() outputs model(x_batch) loss criterion(outputs, y_batch) loss.backward() optimizer.step() running_loss loss.item() preds outputs.argmax(dim1) correct (preds y_batch).sum().item() total y_batch.size(0) return running_loss / len(train_loader), correct / total训练流程里optimizer.zero_grad()必须在每次反向传播前调用漏掉这一步梯度会在batch之间累积损失函数会跳动不降。预测时用argmax(dim1)取概率最大的类别不要在得到logits后手动套sigmoid再比较没必要。4.3 关键训练参数参考表参数推荐值说明batch_size64窗口样本多64能稳定收敛学习率1e-3初始值每20个epoch衰减0.5优化器Adamweight_decay设1e-5防过拟合lossCrossEntropyLoss二分类不用BCEepoch数50早停patience设10train/test比例8:2按trail划分不要按窗口划分这里的train/test划分有讲究按trail划分是指同一个trail的窗口全部进训练集或全部进测试集不能把同一个trail的59个窗口打乱后随机分那样会造成信息泄漏准确率会虚高5个百分点以上。先按trail索引切分再在训练集内部滑窗。这个细节我在第5章避坑里会展开讲。5. 避坑DEAP上跑CNN-LSTM最容易翻车的五个现场5.1 标签和窗口对不上训练集里混进了标签泄漏现象训练准确率升到95%以上验证准确率却一直停留在50%左右。检查代码后发现滑窗时把所有trail的窗口堆在一起然后随机切训练集和测试集同一个trail的窗口被分到了两边。原因滑窗产生的窗口之间有大量重叠相邻窗口只差128个采样点内容高度相似。测试集里混入了训练集窗口的近亲样本模型实际是在做「记忆」而不是「学习」。解决先按trail划分训练集和测试集再分别滑窗。每层代码里都要保证train_trials和test_trials的索引不重叠。我一般在数据加载函数里就返回trail级别的索引滑窗时带上索引一起切从根上杜绝泄漏。5.2 LSTM梯度消失损失卡在0.69不动现象训练跑了10个epoch损失函数一直卡在0.693左右准确率稳定在50%。这个数字很熟悉——二分类交叉熵的随机初始化值就是0.693。原因LSTM的层数和卷积后的特征序列长度不匹配。如果CNN池化太多进入LSTM的序列太短时序信息早被卷积层抹掉了如果池化不够序列太长LSTM反向传播梯度衰减严重。解决调整池化层数让进入LSTM的序列长度保持在32到128之间。如果序列长度超过128考虑加一层池化或增大stride。另外LSTM的hidden_size不要设太大64就够设成256反而容易在小数据集上过拟合。5.3 BatchNorm在训练和推理时行为不一致现象训练集loss正常下降测试时模型表现波动极大同一个样本跑两次结果都不一样。原因BatchNorm在训练时用batch的均值和方差在评估时用累计的全局均值和方差。如果忘记调用model.eval()BatchNorm还在用训练模式的行为做推理结果自然不稳。解决验证循环前必须加model.eval()训练前必须加model.train()。这行代码看着简单但很多人写成了model.eval没加括号等于没调用。还有一点eval模式下torch.no_grad()也要加上否则会额外计算梯度图内存占用飙升。5.4 输入数据忘了转成FloatTensor训练报类型错误现象报错信息是Expected object of scalar type Float but got scalar type Double或者Long。原因scipy读出的numpy数组是float64PyTorch默认模型参数是float32类型不匹配直接报错。解决把numpy数组转成torch.FloatTensor就行。这里有个隐藏点标签如果是从整数数组转来的要转torch.LongTensor交叉熵损失要求target是long类型source是float类型两边搞反又会报错。5.5 训练和测试分布不一致跨受试者评估直接崩盘现象单受试者内训练准确率80%以上换到新受试者身上只剩45%。原因DEAP中每个受试者的脑电基线水平差异巨大同一情感在不同人身上表现出的模式完全不同。单受试者模型学到的是这个人的个体特征不是普适的情感模式。解决做subject-independent评估用前32个受试者做训练后8个做测试或者做留一受试者交叉验证。准确率通常会从80%掉到60%到65%这才是模型真实的泛化水平。报告结果时一定要注明评估方式不然别人复现不出你的数字。6. 进阶技巧用受试者归一化和注意力机制再把准确率推高两到三个点如果基线模型已经跑通想再往上提高我通常先做两个改动而不是换更大的模型。第一个是受试者级别的归一化把每个受试者的信号缩放到统一的参考范围相当于做一次跨受试者的域适配。具体做法是取所有训练受试者每个通道的均值和标准差用这个全局统计量去标准化所有数据而不是每个trail单独标准化。这个改动能直接缓解subject-independent场景下的分布偏移问题。第二个改动手脚在LSTM后面加一个注意力层让模型在最后输出时主动关注时序上最重要的几个时间步。DEAP的情感信号往往在窗口的某个片段表现得最强烈比如看到视频高潮处的瞬间其余时间都是相对平静的基线。不加注意力时最后一步的隐藏状态可能恰好落在平静段加了注意力后模型可以加权聚合整个序列的信息。实现也很简单对LSTM输出的每个时间步算一个scoresoftmax归一化后加权求和。class Attention(nn.Module): def __init__(self, hidden_size): super().__init__() self.attn nn.Linear(hidden_size, 1) def forward(self, lstm_out): # lstm_out: (batch, seq_len, hidden) scores torch.softmax(self.attn(lstm_out), dim1) # (batch, seq_len, 1) context torch.sum(lstm_out * scores, dim1) # (batch, hidden) return context上面这段注意力代码可以直接替换模型里的out[:, -1, :]把lstm_out整体传进来做加权求和。scores的形状是(batch, seq_len, 1)和lstm_out做逐元素乘法时利用广播机制把每个时间步的向量乘上对应权重。注意力层的参数量只有hidden_size 1个几乎不增加训练负担但通常能带来两到三个百分点的提升。还有一个便宜大碗的改动是数据增强对窗口做小幅度的随机时间偏移或幅度缩放。脑电信号有很强的个体差异小幅扰动相当于扩充训练分布。时间偏移用随机裁剪窗口边界幅度缩放用0.9到1.1的随机因子。注意不要做翻转或大幅度的频谱扰动那会破坏脑电信号本身的物理含义。这些改动叠加下来subject-dependent场景跑上85%准确率不算稀奇subject-independent能在65%上下就是一个合格的结果了。我自己的经验是先把基线跑稳再去追涨点连上面五个坑都没踩平之前加再多模块都是在放大噪声。希望这篇笔记能帮你在DEAP上少走几步弯路。本文还有配套的精品资源点击获取