
简介面向脑电信号情感识别与深度学习入门者这份代码资源以DEAP情感数据集为对象完整实现了从脑电预处理、特征提取到CNN-LSTM混合模型训练、评估与推理的流程适合需要复现情感计算实验或搭建时序信号分类模型的研究者和开发者。资源包共35个文件、约11.86MB主要包含Jupyter Notebook实验脚本、Python训练与测试脚本、已保存的模型权重以及大量结果可视化图片覆盖了带通滤波、频域分析、标签分布等预处理环节也支持对比LSTM、GRU、SimpleRNN等不同循环网络结构在DEAP上的表现。已有2521人学习下载。除完整工程代码外资源还提供混淆矩阵、损失曲线、频域分布等图表可直接评估模型性能模型权重文件便于加载预训练模型进行推理或继续训练。整体将数据处理、模型搭建、训练验证与结果分析串联成一条可运行的流程是一份适合扩展和复现的完整工程参考对脑机接口、人机交互等应用方向也有参考价值。1. 从一段脑电信号到“情绪标签”这个CNN-LSTM项目到底做了什么拿到一份DEAP脑电情感数据集面对的是一堆上千维的脑电波想判断受试者此刻的情绪是积极还是消极、唤醒度是高是低传统做法是把每个通道的均值、方差、频段功率全抽出来喂给SVM效果往往卡在60%出头的准确率上。这个项目换了一条路把基于深度学习的DEAP脑电信号识别方案做成了一整套可复现的代码用CNN从原始波形里提取空间局部特征再用LSTM对时间维度建模把“通道关系”和“时序依赖”两层信息同时吃进去。对刚入门脑电深度学习的人来说它是一份能对照着改的完整工程对想快速出结果对比算法的熟手它把数据预处理、模型定义、训练评估串成了一条能直接跑通的链路。2. 技术选型与数据理解为什么CNN要接LSTMDEAP数据怎么组织2.1 DEAP数据集解剖通道、采样率与实验结构DEAP是公开的脑电情感数据集做情绪识别的基本都绕不开它。有人会觉得DEAP只是个“数据包”往模型里一塞就开始训练其实它的组织形式直接决定了你的数据加载和预处理代码怎么写。这套数据记录了几十名受试者观看视频片段时的脑电信号活学活用的关键是理解两个数组。每个受试者对应一个mat文件里面有两个变量data变量形状是(试验数, 40, 8064)40代表的是40个通道的信号其中32个是脑电通道按国际10-20系统排布另外8个是眼电、肌电、皮电这类外周生理信号8064是采样点数采样率128Hz每段实验63秒63乘以128正好等于8064说明预处理阶段把3秒基线信号和60秒正式实验信号一起保留了下来。labels变量形状是(试验数, 4)4列分别是valence、arousal、dominance、liking四个维度的主观评分范围1到9。import scipy.io import numpy as np mat scipy.io.loadmat(data/s01.mat) data mat[data] # 形状 (40, 40, 8064) labels mat[labels] # 形状 (40, 4) print(data.shape, labels.shape) # (40, 40, 8064) (40, 4)这里的data和labels的取值在DEAP数据集里是固定约定加载代码几乎不用改。但新手最容易忽略的是40通道里只有前32个是真正的脑电信号如果你做的是脑电情感识别而不是多模态生理信号识别通常要把后面8个通道切掉。2.2 CNN与LSTM的分工空间特征与时间依赖为什么这个项目选了CNN-LSTM而不是端到端堆Transformer或者纯CNN这里的逻辑值得展开。脑电信号有两个显著特点一是通道之间存在空间关系——头顶相邻电极的电位变化往往是相关的frontal和occipital区域的活动模式也各有规律二是信号本身是强时间序列——情绪状态不是一个瞬间的波形特征而是持续几百毫秒甚至几秒的节律变化。CNN负责前者把通道当卷积的输入通道在时间维度上滑动卷积核学的是“哪几个电极在什么时间段产生了有区分度的波形组合”。LSTM负责后者CNN输出的特征序列里还保留着时间先后顺序LSTM的门控结构能记住长时间尺度的依赖比单纯在CNN后面接global pooling更能捕捉情绪唤起过程中的动态演变。import torch.nn as nn class CNNLSTM(nn.Module): def __init__(self, n_channels32, seq_len512, lstm_hidden64, num_classes2): super().__init__() self.conv1 nn.Conv1d(in_channelsn_channels, out_channels64, kernel_size5, padding2) self.conv2 nn.Conv1d(in_channels64, out_channels128, kernel_size5, padding2) self.relu nn.ReLU() self.lstm nn.LSTM(input_size128, hidden_sizelstm_hidden, num_layers1, batch_firstTrue) self.fc nn.Linear(lstm_hidden, num_classes) def forward(self, x): # x 形状: (batch, seq_len, n_channels) x x.permute(0, 2, 1) # 变成 (batch, n_channels, seq_len) 供 Conv1d 使用 x self.relu(self.conv1(x)) x self.relu(self.conv2(x)) x x.permute(0, 2, 1) # 换回 (batch, seq_len, 128) 给 LSTM out, (h, c) self.lstm(x) out self.fc(h[-1]) # 取最后一层隐状态 return outConv1d的输入要求是(batch, channel, length)所以中间必须做一次permuteLSTM取h[-1]是因为h的形状是(num_layers, batch, hidden_size)当num_layers1时它等价于取最后一个时间步的输出。padding2配合kernel_size5能保证卷积前后序列长度不变这样LSTM看到的序列长度和原始输入一致省去了算padding的麻烦。2.3 项目目录结构与文件职责这份资源的目录结构是照着“预处理和训练分离、配置和代码分离”的思路搭的下载下来解压后应该长这样deap_cnn_lstm/ ├── config.py # 全局参数路径、学习率、batch_size、epochs ├── data_preprocess.py # 加载mat文件、滑窗切样本、标签映射、z-score标准化 ├── dataset.py # 自定义Dataset和DataLoader逻辑 ├── model.py # CNNLSTM模型定义 ├── train.py # 训练主脚本含早停和模型保存 ├── evaluate.py # 加载ckpt输出混淆矩阵和分类报告 ├── utils.py # 画损失曲线、画混淆矩阵 └── requirements.txt # 依赖清单config.py集中管理所有超参数是我比较推荐的习惯。脑电深度学习实验的调参次数非常多——窗口长度、重叠率、卷积核大小、LSTM隐藏层维度任何一个变量的改动都不应该去翻训练脚本源码改配置文件就够了。3. 核心模块拆解数据预处理到CNN-LSTM分类的完整链路3.1 数据加载与标准化如何把DEAP原始格式变成模型输入预处理是整个项目里最影响结果的一环。原始数据每个被试只有40段实验40个样本对深度学习来说远远不够所以第一件事是按固定窗口切分。常见做法是每4秒切成一个样本窗口重叠率50%这样一段63秒的实验能切出大约30个样本所有被试加起来能到上万级别。切完之后做z-score标准化这一步能够消除不同被试之间的绝对幅值差异——有的人头皮电阻大、信号整体幅值偏高但这不代表情绪更强。def preprocess_subject(data, labels, window_len512, stride256): data: (40, 40, 8064) 原始数据 labels: (40, 4) 四个维度的评分 window_len: 窗口长度512采样点约等于4秒 stride: 步长256采样点约等于2秒即50%重叠 eeg data[:, :32, :] # 只保留前32个脑电通道 samples, targets [], [] for trial_idx in range(eeg.shape[0]): trial eeg[trial_idx] # (32, 8064) for start in range(0, trial.shape[1] - window_len, stride): window trial[:, start:start window_len] # (32, 512) samples.append(window) targets.append(labels[trial_idx]) return np.array(samples), np.array(targets)window_len的数值不是拍脑袋定的。脑电情绪识别里4秒窗口是被反复验证过的经验值太短小于1秒单个窗口包含的节律信息不足CNN很难提取到有意义的频段特征太长大于10秒会把情绪变化过程中的多个状态混在一起LSTM学到的反而是噪声。stride设为window_len的一半是为了让相邻窗口有信息重叠模型对时间偏移更鲁棒。3.2 标签映射把连续评分变成可学习的分类目标DEAP的标签是1到9的连续评分直接做回归当然可以但这个项目选择的是分类路线。最常见的映射方式是按5分做二值化valence大于5记为积极情绪1小于等于5记为消极情绪0。如果你想区分高唤醒和低唤醒就看arousal那一列用同样的阈值切。def build_binary_label(labels, dimensionvalence): # dimension 可选 valence, arousal, dominance, liking dim_idx {valence: 0, arousal: 1, dominance: 2, liking: 3}[dimension] score labels[:, dim_idx] return (score 5).astype(int) # 1: 高分组, 0: 低分组阈值5是一个经验性的中位切分不是严格的心理学量表分界。实际跑的时候建议先看一眼数据分布再决定阈值比如针对valence维度DEAP数据集中多数被试的评分集中在4到6之间如果正负样本比例明显失衡可以考虑用中位数代替固定值5。3.3 样本平衡与数据增强切完窗口后会遇到一个香农口中的经典问题正负样本数量往往不均衡。同一个被试40段实验如果8段是高分、32段是低分切窗之后低分样本是高分样本的4倍模型会偷懒地把所有输入都判成低分。这个项目里处理方式是简单的加权采样在DataLoader里按类别权重重新采样代码量不大但非常有效。from torch.utils.data import WeightedRandomSampler def make_sampler(labels): unique, counts np.unique(labels, return_countsTrue) class_weights 1.0 / counts sample_weights class_weights[labels] return WeightedRandomSampler(sample_weights, num_sampleslen(labels), replacementTrue)WeightedRandomSampler做的事情是每个样本被抽中的概率和它的类别权重成正比样本少的类别会被更频繁地抽到。replacementTrue表示允许重复抽取训练时每个epoch看到的样本组合不完全一样相当于给模型制造了更多样化的梯度。如果嫌随机采样不够还可以在预处理里给原始信号叠加高斯噪声做数据增强脑电信号对这个非常敏感噪声幅度控制在信号标准差的1%到2%就够。3.4 训练循环与早停避免过拟合的关键参数模型本身不复杂真正决定效果的是训练策略。这个项目在train.py里实现了一个带早停和最佳模型保存的训练循环每轮训练后在验证集上算准确率如果本轮验证准确率超过了历史最好值就把模型权重保存下来否则如果连续多轮没有提升就提前终止。def train_one_epoch(model, dataloader, optimizer, criterion, device): model.train() total_loss, correct, total 0, 0, 0 for x, y in dataloader: x, y x.to(device), y.to(device) optimizer.zero_grad() out model(x.float()) loss criterion(out, y) loss.backward() optimizer.step() total_loss loss.item() * len(y) correct (out.argmax(1) y).sum().item() total len(y) return total_loss / total, correct / totalx的dtype要注意如果是用numpy整型数组直接构建的tensor里面可能是int64而Conv1d只接受float32类型的输入所以训练循环里必须调用x.float()。这是一个很隐蔽的报错点一跑就报“Expected object of scalar type Float but got scalar type Long”。criterion用的是CrossEntropyLossout.argmax(1)取的是每个样本在两类上得分更高的那个索引正好对应0和1的标签。4. 训练跑通从环境配置到结果复现的完整命令4.1 环境要求与依赖清单这份代码用到的依赖不算多核心是PyTorch和Scipy建议Python版本3.8以上。requirements.txt里大概长这样torch1.10.0 numpy1.21.0 scipy1.7.0 matplotlib3.4.0 scikit-learn1.0.0安装时直接pip install -r requirements.txt即可。如果你用GPU请先根据CUDA版本安装对应的PyTorch版本没有GPU也能跑只是训练时间会拉长不少。看项目的时候我一般习惯先pip list确认numpy和scipy版本——有些老版本的scipy加载mat文件时会遇到io.loadmat对v7.3格式的兼容问题DEAP官方的mat文件如果检测到HDF5格式需要改用h5py。4.2 一键训练参数说明与输出解释训练脚本支持命令行参数覆盖配置文件里的默认值典型的启动命令是python train.py --data_dir ./data --batch_size 128 --epochs 50 --lr 0.001 --gpu 0各参数的含义并不难理解data_dir指向存放s01.mat到s32.mat的目录batch_size控制每轮喂给模型的样本数显存不足时先把它调到64或32epochs是最大轮数因为有早停机制实际执行的轮数通常会小于这个值lr是初始学习率脑电信号的小数据集一般不适合用太大的学习率0.001是安全起点。训练日志会打印每个epoch的训练损失、训练准确率和验证准确率验证准确率提升时还会额外打印一个* best model saved *的标记。训练结束后checkpoints/目录下会出现一个best_model.pt文件这就是之后评估和二次开发要用的权重。4.3 评估与可视化评估环节在evaluate.py里单独实现它做的事情是加载best_model.pt在测试集上跑一遍推理输出分类报告和混淆矩阵同时把训练过程中的损失曲线画出来。python evaluate.py --data_dir ./data --ckpt checkpoints/best_model.pt --out_dir ./results跑完之后results/下会生成两个文件confusion_matrix.png和training_curve.png。看混淆矩阵时要留意一个容易被忽略的细节如果模型预测全落在某个类别上说明出现了类别不平衡问题如果损失曲线在训练集上持续下降但验证集准确率纹丝不动基本可以判断过拟合了这时候回到config.py里把dropout调高或者把LSTM的num_layers从2降到1。5. 避坑指南脑电识别里最常见的几个翻车现场5.1 数据泄漏时间窗口重叠导致的“虚假高分”现象验证集准确率高达95%以上换到新的被试数据上一落千丈。原因切窗时步长小于窗口长度相邻窗口有重叠而训练集和测试集是按“每个被试的一部分窗口”随机划分的。同一个时间段的窗口被拆进了两个集合模型实际上记住了时间片段本身的模式而不是情绪模式。解决切分数据集时以“被试”或至少“原始终端实验”为单位。把每个被试的全部窗口放进同一个集合保证训练集和测试集不共享同一段原始记录。推荐的做法是留出20%的被试作为测试集完全按照subject-dependent评估。5.2 标签分布不均模型学会“猜”而不是识别现象准确率看起来有70%但混淆矩阵显示模型几乎把全部样本预测为多数类少数类的召回率接近0。原因DEAP标签按5分阈值切分后很多被试的valence评分整体偏低导致消极样本数量远大于积极样本。模型发现全预测为消极就能拿到不错的准确率梯度在多数类方向上被反复强化。解决先统计训练集中两个类别的数量再决定用WeightedRandomSampler或者在损失函数里给少数类加权重。修改train.py里的CrossEntropyLoss传入一个weight张量少数类的权重设为其样本数倒数就能立刻缓解。5.3 显存溢出参数调大后一跑就爆显存现象训练时报CUDA out of memory整个进程直接崩溃。原因batch_size设得过大或者窗口长度太大导致LSTM展开的步数过多计算图中间变量占满了显存。解决这是脑电模型里最常见的调整手段把batch_size从128降到32观察loss是否还能正常下降。另一个做法是把窗口长度从512降到256代价是牺牲一部分时间上下文信息。真不想降batch_size就在Dataset的__getitem__里把每个样本转成float32再返回DOUBLE类型会额外占用两倍显存。5.4 标准化时机错误全局标准化让训练集和测试集产生依赖现象测试集准确率比验证集低不少而且每次重新划分数据集结果波动很大。原因在切分数据之前对整个数据集做了z-score标准化测试集的均值和方差实际上参与了标准化计算这属于另一种更隐蔽的数据泄漏。解决数据预处理的时候先按通道计算训练集的mean和std把这两组数值存成json文件后续对验证集和测试集标准化时只用训练集的统计量绝不重新计算。我在做数据预处理时有个习惯所有统计量都用一个单独的processor对象持有测试阶段从配置文件里重新加载保证训练和推理走同一条路径。6. 二次开发换成你自己的脑电数据并提高准确率拿到这份代码最值得做的第一件事是把手上的DEAP数据换成自己的数据源。假设你用便携式脑电设备采集了一批数据导出格式是CSV每行是一个时间点每列是一个通道然后有一段标签记录情绪状态改造点主要集中在data_preprocess.py里。import pandas as pd def load_csv_eeg(csv_path, channel_names, fs128): df pd.read_csv(csv_path) eeg df[channel_names].values.T # 转成 (n_channels, n_samples) return np.array([eeg]) # 伪装成单个被试的数组兼容下游处理核心思路是让下游的窗口切分和模型定义保持不变只替换数据入口。需要同步改的地方是config.py里的n_channels和window_len两个参数。你的设备通道数大概率不是32采样率也可能不是128Hz窗口长度要根据新采样率重新换算原来4秒对应512个采样点如果新设备是256Hz就改成1024。想在准确率上再进一步可以给模型加一个轻量级注意力机制。LSTM的隐状态序列包含每个时间步的信息但它们对最终分类的贡献并不相等。在model.py里对LSTM的输出做一个简单的注意力加权代码不超过十行却能让模型关注情绪变化中最关键的时段import torch import torch.nn.functional as F class AttentionPool(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_size) weights torch.softmax(self.attn(lstm_out).squeeze(-1), dim1) return torch.sum(lstm_out * weights.unsqueeze(-1), dim1)把最后一个时间步的h[-1]替换成AttentionPool的输出模型就变成了带注意力的CNN-LSTM-Attention结构。我从那以后每换一套新数据都会强制走一遍同样的验证流程先小模型跑通再调结构最后才动超参数。这套流程看着朴素但帮我把“模型跑不起来”这类问题的排查时间缩短了至少一半希望帮到你。本文还有配套的精品资源点击获取