ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

DEAP脑电情绪识别:从RNN到BiLSTM的改进与PyTorch实现

DEAP脑电情绪识别:从RNN到BiLSTM的改进与PyTorch实现 简介基于DEAP数据集的脑电情绪识别改进源码包针对Acharya D.等人在论文中提出的LSTM/RNN模型进行优化将觉醒度准确率由88.6%提升至92.17%效价准确率由88.72%提升至94.46%。资源面向深度学习、神经网络方向的研究者与开发者尤其适合从事脑电信号处理、情感计算及多模态生理信号建模的入门到进阶学习者。压缩包共65个文件约94.43MB涵盖20个Python脚本、14篇PDF论文、12个训练好的h5模型、8个CSV数据记录并包含PSO、GWO、CS、MRMR等特征选择与优化算法的完整实现以及LSTM、RNN、CNN对比模型代码目录按预处理、特征提取、模型训练、结果保存等模块清晰组织。已有1509人学习下载。学习者可系统掌握DEAP数据读取、FFT特征提取、LSTM/RNN模型搭建与调优全过程直接复现改进后的高精度结果也可基于现有代码扩展CNN与LSTM的对比实验。1. 基于DEAP的脑电情绪识别为什么LSTM和RNN值得再改一版DEAP数据集是目前做情绪识别绕不开的基准32个电极采集的脑电信号配合40段被试视频诱发的Valence、Arousal等评分标签天然就是给时间序列分类用的。很多论文的最初版代码就是拿RNN在DEAP上跑出基线再换LSTM提升几个百分点。但这个网络热词里带改进版本的标题之所以吸引人是因为传统RNN在脑电这种长序列上存在梯度衰减LSTM虽然缓解了却往往没有把脑电的电极空间关系和时域依赖同时用起来。所以这篇内容不是把原论文附件重新讲一遍而是从一线工程师的角度把论文里写的基线模型和改进后值得上线的模型之间的差距补齐。适合正在复现DEAP相关论文的人也适合手里有脑电数据、打算用PyTorch搭一个能出结果的情绪分类器的开发者。下面从数据预处理、RNN/LSTM的模型改动到训练调优一步步给出可复现的代码和参数。2. DEAP脑电数据预处理与序列特征构建2.1 读取DEAP的.dat文件并切分基线DEAP官方给了python版本的读数据方式但很多论文代码里直接一次性读入所有数据导致训练时无法按被试划分。改进的第一步是写一个只依赖numpy的加载器把data和labels拆出来保留被试和试次维度。import numpy as np def load_deap_data(filepath, trial_len60, baseline_len3, fs128): data np.load(filepath, allow_pickleTrue) x data[data] # shape: (40, 40, 32, 7680) y data[labels] # shape: (40, 40, 4) # x[subject, trial, channel, sample] # 去掉前baseline_len秒 cut_samples int((trial_len - baseline_len) * fs) x x[:, :, :, :cut_samples] return x, y x, y load_deap_data(data_preprocessed_matlab/s01.dat) print(x.shape, y.shape)这里把前3秒基线去掉是因为DEAP原始记录里前3秒是基线屏幕不包含情绪刺激信息。参数上fs128是因为DEAP离线数据已经统一降到128Hz60秒刺激去掉3秒基线后剩下57秒共7296个采样点。如果保留基线LSTM会学到一个常数输入反而干扰情绪相关特征。2.2 标准化与差分特征给LSTM的输入序列原始脑电信号单个样本是(32, 7296)直接丢给LSTM会把通道当特征、时间当成步长。常见做法是检查电极的均值方差再在时间维度做一阶差分突出信号变化。def normalize_sequence(x, eps1e-8): # x: (channels, time_steps) mean x.mean(axis1, keepdimsTrue) std x.std(axis1, keepdimsTrue) return (x - mean) / (std eps) def preprocess_for_rnn(x, fs128): # 降采样到64Hz降低LSTM时间步数 step fs // 64 x_down x[:, :, ::step] # 在时间轴上做一阶差分 x_diff np.diff(x_down, axis2) return x_diff降采样到64Hz后每个样本的序列长度从7296变成3648差分化后是3647。这一步既减少LSTM展开的时间步又不丢失情绪识别需要的主流频段信息。DEAP后面几个被试的基线漂移明显标准化用每个通道自己的均值和标准差而不是全局统计主要是为了保留被试之间的个体差异。2.3 标签选择分类目标决定模型结构DEAP的labels有四个值valence、arousal、dominance、liking。很多改进版本的论文只挑valence和arousal把评分大于中位数映射为二分类。标签处理应该紧跟实验假设。def make_binary_labels(y, targetvalence, subject_ids[0,1,2,3]): # y: (subjects, trials, 4) valence y[..., 0] arousal y[..., 1] if target valence: label valence else: label arousal # 按所有被试的中位数切分 median np.median(label[subject_ids]) return (label median).astype(np.int64)用所有被试中位数切分会造成标签不均衡有些被试本身普遍正性情绪。改进版本更推荐在每个被试内部按中位数切分这样模型学到的是个体相对于自身情绪的偏移而不是全局绝对评分。这个调整可以直接影响LSTM分类头的收敛速度。3. 从RNN到LSTM再到双向LSTM论文代码中的改进脉络3.1 原始RNN为什么会在脑电长序列上失败传统RNN每一步更新只靠一个隐藏状态穿过时间DEAP序列即使降采样后也有3647步反向传播经过这么多时间步梯度要么爆炸要么消失。脑电信号又存在明显的瞬态波形比如150-250ms的P300样成分RNN很难把这种短时模式保留到序列末尾。很多论文基线里RNN的测试准确率只有五成多不是任务难而是结构不匹配。3.2 LSTM带来的门控改进与三个必调参数LSTM用输入门、遗忘门、输出门控制信息流动其中遗忘门解决长依赖问题它的输入是上一时刻隐藏状态和当前时刻输入。网络热词里经常搜lstm遗忘门的输入是什么数据在DEAP场景下就是每个时间步的电极特征向量和上一步的输出隐藏状态。import torch.nn as nn class LSTMBaseline(nn.Module): def __init__(self, input_size32, hidden_size64, num_layers2, num_classes2): super().__init__() self.lstm nn.LSTM(input_size, hidden_size, num_layers, batch_firstTrue, bidirectionalFalse) self.classifier nn.Sequential( nn.Dropout(0.4), nn.Linear(hidden_size, num_classes) ) def forward(self, x): # x: (batch, time_steps, channels) out, _ self.lstm(x) # 取最后一个时间步 out out[:, -1, :] return self.classifier(out)三个必调参数是hidden_size、num_layers和dropout。hidden_size决定隐状态容量DEAP训练样本量不大64到128之间比较合理num_layers超过2层在脑电上容易过拟合且训练时间翻倍dropout建议放在LSTM输出和全连接之间或者用nn.LSTM(dropout0.2)只在层间生效。最后一个时间步的隐藏向量有时丢失早期特征可以换成均值池化但基线代码里用末尾最省事。3.3 改进一双向LSTM让情绪特征前后呼应脑电信号是记录的数据不是因果推理流当前瞬间的响应和前后几秒都有关所以双向LSTM能用后文信息修正前文表示。常见实现是用bidirectionalTrue输出维度变成hidden_size * 2。class BiLSTMClassifier(nn.Module): def __init__(self, input_size32, hidden_size64, num_layers2, num_classes2): super().__init__() self.lstm nn.LSTM(input_size, hidden_size, num_layers, batch_firstTrue, bidirectionalTrue) self.classifier nn.Sequential( nn.Dropout(0.3), nn.Linear(hidden_size * 2, 32), nn.ReLU(), nn.Linear(32, num_classes) ) def forward(self, x): out, _ self.lstm(x) # 把前向和后向拼接后取平均 out out.mean(dim1) return self.classifier(out)这里改用时间步均值池化原因是双向拼接后最后一个时间步只代表前向末尾和后向最后一个信息不完整平均池化能把整个序列的信息做压缩。双向LSTM的参数量接近翻倍训练时观察DEAP验证集有没有明显回升如果只是从70%涨到71%那说明数据量不够支撑多出来的参数。3.4 改进二注意力机制替代末状态池化很多改进版本论文会加上注意力。注意力不会凭空创造信息而是让模型自己找到与情绪分类最相关的时间片段。脑电情绪诱发时通常是在刺激开始后的第10到第40秒反应最明显注意力机制能让分类头忽略前几秒的过渡段。class AttentionPooling(nn.Module): def __init__(self, hidden_size): super().__init__() self.score nn.Sequential( nn.Linear(hidden_size * 2, 64), nn.Tanh(), nn.Linear(64, 1) ) def forward(self, lstm_output): # lstm_output: (batch, time_steps, hidden_size * 2) weights self.score(lstm_output).squeeze(-1) # (batch, time_steps) weights torch.softmax(weights, dim1) context torch.bmm(weights.unsqueeze(1), lstm_output).squeeze(1) return context注意力权重可以可视化画出某个样本在时间轴上的注意力分布能直接看出模型是否把权重集中在情绪诱发片段。这种做法比单纯提高准确率更有说服力尤其论文复现和写报告时需要这种证据。代码里score层的输出维度要跟LSTM输出维度对齐双向LSTM是hidden_size * 2单向则是hidden_size。4. 把论文代码跑起来训练参数、调优与结果复现4.1 数据划分避免被试泄漏DEAP有32个被试论文代码里如果按试次随机混洗划分训练测试会导致同一个被试的样本同时出现在训练集和测试集里模型记住了个体特征跨被试效果会很差。改进版本必须按被试划分。subjects list(range(32)) # 用前24被试训练后8被试测试 train_subjects subjects[:24] test_subjects subjects[24:] def build_tensor_dataset(x, y_all, subject_list, targetvalence): x_list, y_list [], [] for subj in subject_list: x_list.append(x[subj]) y_list.append(make_binary_labels(y_all, target, [subj])) return np.concatenate(x_list), np.concatenate(y_list)按试次划分测试准确率高但对实际应用没意义。跨被试划分后准确率通常会掉10个百分点这是正常现象不要因为数字不好看就改划分策略。训练时可以把前24个被试再留出4个做早期停止避免用测试集调参。4.2 超参数表从论文基线调到改进版本下面是改进版本常用的一组起始参数拿DEAP数据训练时可以直接套用。参数名基线RNN改进LSTM调整理由时间步数72963647降采样减少计算量输入特征维度3232全部电极通道隐含层数12加深提升表达注意力机制无有突出诱发段学习率0.010.001避免梯度震荡batch_size12864更大batch过拟合dropout0.00.3抑制过拟合分类器softmaxMLPsoftmax增强分类边界学习率从0.01降到0.001是改进版最关键的调整。LSTM这类循环网络对学习率敏感太高容易震荡太低收敛慢。设成0.001时配合Adam优化器一般50轮内能稳定。4.3 训练循环与早停验证训练循环里要记录每个epoch的训练损失和验证准确率同时保存最优参数。import torch from torch.utils.data import TensorDataset, DataLoader def train_model(model, x_train, y_train, x_val, y_val, epochs80, lr0.001): train_ds TensorDataset(torch.tensor(x_train, dtypetorch.float32), torch.tensor(y_train, dtypetorch.long)) train_loader DataLoader(train_ds, batch_size64, shuffleTrue) opt torch.optim.Adam(model.parameters(), lrlr) loss_fn nn.CrossEntropyLoss() best_acc 0.0 for epoch in range(epochs): model.train() for batch_x, batch_y in train_loader: opt.zero_grad() out model(batch_x) loss loss_fn(out, batch_y) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0) opt.step() # 验证 model.eval() val_x torch.tensor(x_val, dtypetorch.float32) val_y torch.tensor(y_val, dtypetorch.long) with torch.no_grad(): pred model(val_x).argmax(dim1) acc (pred val_y).float().mean().item() if acc best_acc: best_acc acc torch.save(model.state_dict(), best_model.pt) print(fepoch {epoch:02d} loss {loss:.4f} val_acc {acc:.4f})代码里的clip_grad_norm_把梯度范数裁剪到1.0这是改进版里必加的一行专门处理LSTM在长序列上梯度爆炸的问题。如果不加可能训练到第20轮时loss变成NaN。早停用保存的最佳模型参数而不是最后一个epoch防止验证准确率过山车。4.4 参数坑序列长度和batch_firstDEAP预处理后样本的时间步是3647直接放进LSTM显存占用很大。如果显卡不够常见做法是把时间步再切短比如每10秒为一个窗口窗口之间重叠5秒再把所有窗口的预测结果投票得到整段样本标签。这里要注意batch_firstTrue不然输入张量得手动转成(seq_len, batch, features)用错后loss会一直无法下降而且报错信息往往难以定位。5. 落地改进版的实用技巧注意力可视化与窗口投票5.1 用注意力权重找情绪诱发段改进版本比原始论文多出来的一个价值就是可以把注意力权重变成可解释的证据。训练完成后挑一个测试样本把每个时间步的softmax权重取出来画折线图再标上刺激开始的位置。def extract_attention_weights(model, x_sample): model.eval() x torch.tensor(x_sample, dtypetorch.float32).unsqueeze(0) # batch1 with torch.no_grad(): lstm_out, _ model.lstm(x) weights model.attention.score(lstm_out).squeeze(-1) weights torch.softmax(weights, dim1) return weights.squeeze(0).numpy()如果注意力权重集中在中间某个10秒窗口说明模型学到了情绪诱发的核心时间段。如果权重均匀分布甚至集中在开头那可能是预处理没去掉基线或者LSTM没有收敛。这个技巧在写论文或做实验报告时可以直接当作图表素材。5.2 窗口投票最终预测实际部署时新用户不可能只做60秒鉴别实验更多是持续几十上百秒的脑电数据。把长信号切成窗口每个窗口单独判断再投票合成最终情绪分类。窗口长度选10秒重叠5秒对LSTM来说输入长度800步左右计算延迟远小于实时125ms要求。def sliding_window_predict(model, eeg_signal, fs128, win_len10, overlap5): step (win_len - overlap) * fs samples [] for start in range(0, eeg_signal.shape[1] - win_len * fs 1, step): win eeg_signal[:, start:start win_len * fs] samples.append(preprocess_for_rnn(win[None, :, :], fs)[0]) samples np.array(samples) # (n_windows, channels, time_steps) samples torch.tensor(samples, dtypetorch.float32).transpose(1, 2) with torch.no_grad(): logits model(samples) preds logits.argmax(dim1) # 投票 final 1 if preds.mean() 0.5 else 0 return final输入eeg_signal的shape是(channels, total_samples)预处理好每个窗口后做差分化。窗口投票让模型不再依赖完整60秒也让改进版本更容易接到真实脑电设备的实时数据流上。实际测试时可以把窗口重叠率提高到50%通常能在时延和准确率之间找到更合理的平衡点。本文还有配套的精品资源点击获取
返回列表