简介这份资源面向脑电情绪识别方向的初学者与算法实践者围绕DEAP数据集提供一套可运行的EEG情绪分类项目源码帮助读者理解从原始脑电信号到情绪标签预测的完整链路。压缩包共11个文件约3.85MB包含4个dat数据文件、3个Python脚本、2个csv特征与标签表格、1份README说明及1份LICENSE脚本覆盖数据加载、特征向量构建与SVM分类等关键环节dat与csv则承载标签和特征数据。项目涉及功率谱密度、频带功率等特征提取思路以及支持向量机等分类模型的训练与评估适合作为课程设计、毕业设计或情绪识别入门实验的参考实现。目前已有301人学习下载读者可据此复现DEAP情绪分类流程并在此基础上替换特征或分类器进行二次开发。1. 从 DEAP 到情绪标签EEG-Emotion-classification 这条链路到底在做什么DEAP 数据集是脑电情绪识别绕不开的公开基准32 名被试、每段 60 秒的音乐视频诱发信号、32 导联 EEG 加上外周生理信号标签是 valence、arousal、dominance、liking 四个维度的 1–9 自评分数。EEG-Emotion-classification 这类工程的核心任务就是把连续脑电变成「高兴/平静/压抑/激动」这种可分类的离散标签再训练一个模型去预测它。听起来简单真正动手你会发现两个卡点一是标签怎么二值化二是 eeg 去噪和 deap 数据集下载这两步就能劝退一半人。这篇笔记按我实际跑通的顺序拆数据怎么拿、标签怎么切、去噪怎么做、特征怎么提、模型怎么训、坑在哪。适合已经拿到 DEAP 原始文件、想跑出一个能复现 baseline 的从业者也适合刚接触脑电情绪识别、想搞清楚整条链路边界的新手。2. DEAP 数据落盘与标签二值化先把输入对齐2.1 deap 数据集下载与目录结构确认DEAP 官方只提供申请下载拿到的是data_preprocessed_python目录里面是s01.dat到s32.dat每个文件用 pickle 存了一个 dict。很多人卡在「下载完不知道里面是什么」先跑一段探查代码把结构看清楚比盲目写训练脚本省事得多。import pickle import numpy as np # DEAP 每个被试文件是 pickle 序列化的 dict with open(data_preprocessed_python/s01.dat, rb) as f: subject pickle.load(f, encodinglatin1) print(subject.keys()) # dict_keys([labels, data]) print(subject[data].shape) # (40, 40, 8064) 试次 x 通道 x 采样点 print(subject[labels].shape) # (40, 4) valence/arousal/dominance/liking print(subject[labels][:3]) # 前 3 个试次的四维自评分数data的维度含义是 40 个试次、40 个通道前 32 个是 EEG后 8 个是外周信号、8064 个采样点。采样率 128Hz60 秒视频去掉 3 秒基线后正好 8064 点。labels是 1–9 的连续分数不是类别必须自己切。这一步的常见错误是直接拿data全部 40 通道喂模型外周信号混进去会让结果虚高EEG 情绪识别只取前 32 导。2.2 标签二值化的阈值选择与踩坑valence 和 arousal 的 1–9 分怎么变 0/1是整个任务里最容易被忽视、又最影响结果的一步。常见做法有三种中位数 5 切分、被试内均值切分、被试内中位数切分。我一般用被试内中位数因为它能抵消个体打分尺度差异——有人天生打高分有人天生打低分用全局 5 切会把这种偏差带进标签。import numpy as np def binarize_by_subject_median(labels, dim0): labels: (n_trials, 4) 四维自评 dim: 0valence, 1arousal 返回: (n_trials,) 的 0/1 标签 scores labels[:, dim] median np.median(scores) # 被试内中位数 binary (scores median).astype(int) return binary # 对单个被试 y_valence binarize_by_subject_median(subject[labels], dim0) print(np.bincount(y_valence)) # 检查类别是否均衡阈值参数就一个median但要注意如果某个被试的分数恰好大量等于中位数会让这些样本全归 0类别比例可能到 7:3。稳妥做法是加一个判断分数等于中位数时随机分配或归到多数类并在日志里打印每个被试的正负样本比。这一步不做后面模型 F1 忽高忽低你根本找不到原因。2.3 跨被试拼接与内存控制32 个被试拼起来是 32×401280 个试次每个试次 32×8064 的 float64全加载约 2.6GB普通笔记本会吃紧。我一般转成 float32 并只保留需要的频段或者用生成器按被试懒加载。import os, pickle import numpy as np def load_all_subjects(rootdata_preprocessed_python): X, y [], [] for i in range(1, 33): path os.path.join(root, fs{i:02d}.dat) with open(path, rb) as f: subj pickle.load(f, encodinglatin1) eeg subj[data][:, :32, :].astype(np.float32) # 只取 32 导 EEG label binarize_by_subject_median(subj[labels], dim0) X.append(eeg) y.append(label) return np.concatenate(X), np.concatenate(y) X, y load_all_subjects() print(X.shape, y.shape) # (1280, 32, 8064) (1280,)astype(np.float32)把内存砍一半[:, :32, :]切掉外周通道。如果还是不够就在循环里直接做去噪和特征提取不要先存全量原始信号。跨被试拼接后训练集和测试集必须按被试划分不能随机打乱试次否则同一被试的信号同时出现在两边准确率会虚高十几个点这是新手最常翻的车。3. eeg 去噪与频段特征把 8064 点变成可训练向量3.1 带通滤波与工频陷波的参数怎么定原始 DEAP 已经做过一定预处理但 50Hz 工频和基线漂移还在。情绪相关的频段是 delta(1–4)、theta(4–8)、alpha(8–13)、beta(13–30)、gamma(30–45)所以带通 1–45Hz 足够再叠一个 50Hz 陷波。用 MNE 或 scipy 都行我倾向 scipy依赖轻。from scipy.signal import butter, filtfilt, iirnotch def bandpass_filter(data, low1.0, high45.0, fs128, order4): data: (n_channels, n_samples) nyq fs / 2.0 b, a butter(order, [low/nyq, high/nyq], btypeband) return filtfilt(b, a, data, axis-1) def notch_filter(data, freq50.0, fs128, q30.0): b, a iirnotch(freq/(fs/2.0), q) return filtfilt(b, a, data, axis-1) eeg X[0] # (32, 8064) eeg notch_filter(eeg, 50.0, 128) eeg bandpass_filter(eeg, 1.0, 45.0, 128) print(eeg.shape)order4是 Butterworth 的阶数太高会振铃太低滚降不够filtfilt做零相位滤波避免情绪事件在时间上被平移。陷波的q30决定陷波带宽50Hz 用 30 比较稳。注意 fs 必须和实际采样率一致DEAP 是 128Hz写成 256 会让所有频段错位特征全废。3.2 微分熵特征情绪识别里最稳的一类DEAP 上做情绪分类微分熵Differential Entropy, DE是性价比最高的特征比功率谱密度更抗噪维度也低。对每个频段、每个通道算 DE32 导×5 频段160 维直接能喂 SVM 或浅层网络。import numpy as np from scipy.signal import welch def band_de(signal, fs128, band(8, 13)): 计算单通道某频段的微分熵signal: (n_samples,) f, psd welch(signal, fsfs, npersegfs*2) idx np.logical_and(f band[0], f band[1]) # 微分熵近似为对数功率谱均值 de 0.5 * np.log(2 * np.pi * np.e * np.var(signal[idx]) 1e-8) return de BANDS {delta: (1, 4), theta: (4, 8), alpha: (8, 13), beta: (13, 30), gamma: (30, 45)} def extract_de(eeg, fs128): eeg: (32, 8064) - (32*5,) feats [] for ch in range(eeg.shape[0]): for name, band in BANDS.items(): feats.append(band_de(eeg[ch], fs, band)) return np.array(feats, dtypenp.float32) feat extract_de(eeg) print(feat.shape) # (160,)welch的npersegfs*2是 2 秒窗情绪信号变化慢窗太短谱估计抖太长丢时间分辨率。1e-8防止 log 零。DE 的物理含义是高斯假设下信号的对数能量脑电近似高斯所以这个近似在情绪任务里站得住。如果要做时序建模就按 1 秒一段切每段算 DE得到 (n_segments, 160) 的序列。3.3 特征标准化与跨被试归一化DE 特征在不同被试间量级差异大直接拼起来训练模型会偏向方差大的被试。标准做法是每个被试内部做 z-score或者用训练集统计量去标准化测试集。def zscore_per_subject(feats): feats: (n_trials, n_features) 单个被试 mu feats.mean(axis0, keepdimsTrue) sigma feats.std(axis0, keepdimsTrue) 1e-8 return (feats - mu) / sigma # 按被试分组标准化后再拼接注意标准化统计量只能来自训练集测试集用训练集的 mu/sigma否则信息泄漏。跨被试实验里如果每个被试单独标准化等于用了测试被试的分布严格来说也是泄漏论文里要写清楚是 subject-dependent 还是 subject-independent 设定。4. 模型选型与训练从 SVM 到浅层 CNN 的取舍4.1 先用 SVM 跑通 baseline拿到 160 维 DE 特征第一件事不是上深度学习而是 SVM 跑一个 baseline确认特征和标签对齐没问题。RBF 核、C 和 gamma 网格搜一下几分钟出结果。from sklearn.svm import SVC from sklearn.model_selection import cross_val_score, GroupKFold from sklearn.preprocessing import StandardScaler from sklearn.pipeline import make_pipeline # 假设 feats_all: (1280, 160), y_all: (1280,), groups: (1280,) 被试编号 pipe make_pipeline(StandardScaler(), SVC(kernelrbf, C1.0, gammascale)) cv GroupKFold(n_splits10) scores cross_val_score(pipe, feats_all, y_all, groupsgroups, cvcv, scoringf1) print(scores.mean(), scores.std())GroupKFold保证同一被试不跨折scoringf1因为类别可能不均衡。DEAP 上 subject-independent 的 valence 二分类SVM 大概 55%–62%arousal 略高。如果跑出 90% 以上先怀疑标签泄漏或随机划分不要高兴太早。4.2 浅层 CNN 的输入形状与通道设计想再往上抬用 EEGNet 或自建浅层 CNN。输入是 (32, 8064) 的原始信号或 (32, 5) 的频段特征。原始信号进 CNN 计算量大我一般用 DE 序列或直接把 5 频段 DE 当通道。import torch import torch.nn as nn class EEGNetLite(nn.Module): def __init__(self, n_channels32, n_bands5, n_classes2): super().__init__() self.conv1 nn.Conv2d(1, 16, kernel_size(1, 5), padding(0, 2)) self.bn1 nn.BatchNorm2d(16) self.conv2 nn.Conv2d(16, 32, kernel_size(n_channels, 1)) self.bn2 nn.BatchNorm2d(32) self.drop nn.Dropout(0.5) self.fc nn.Linear(32 * 1 * n_bands, n_classes) def forward(self, x): # x: (batch, 1, n_channels, n_bands) x torch.relu(self.bn1(self.conv1(x))) x torch.relu(self.bn2(self.conv2(x))) x self.drop(x) x x.view(x.size(0), -1) return self.fc(x)conv2的 kernel 是(n_channels, 1)做的是空间滤波把 32 导压成 1 维这是 EEG 分类里经典的深度可分离思路。Dropout(0.5)在 1280 样本量下很关键不加基本过拟合。学习率 1e-3、Adam、batch 32 起步早停看验证集 F1。4.3 训练循环里必须记录的三个量训练脚本不打印关键量等于闭眼调参。我固定记录训练 loss、验证 F1、以及每个被试的预测分布。def train_one_epoch(model, loader, optimizer, criterion, device): model.train() total_loss 0 for x, y in loader: x, y x.to(device), y.to(device) optimizer.zero_grad() logits model(x) loss criterion(logits, y) loss.backward() optimizer.step() total_loss loss.item() * x.size(0) return total_loss / len(loader.dataset) # 验证时按被试统计 F1能看出模型是否只对部分被试有效按被试统计 F1 能暴露一个大问题模型可能对某几个被试特别好、对其余接近随机平均下来还行但完全没有泛化性。这种结果发论文会被拒做产品更不能用。5. 避坑与排查这五个地方我全踩过5.1 现象准确率 95% 以上换被试就崩原因随机划分试次同一被试的信号同时进训练和测试模型记住了这个人的脑电指纹。解决一律用 GroupKFold 或留一被试交叉验证报告 subject-independent 结果。5.2 现象滤波后信号幅度异常、模型不收敛原因filtfilt的padlen默认值对短信号会报错或边界失真或者 fs 写错导致频段错位。解决确认 fs128信号长度足够必要时手动设padlen滤波后画一段波形肉眼确认。5.3 现象DE 特征全是 NaN 或极大值原因某段信号方差为 0 或接近 0log 里加了 1e-8 仍溢出或者通道数据本身有坏段。解决提取前检查np.isnan和方差坏段用邻近段插值或直接丢弃并在日志里记录丢弃数量。5.4 现象类别比例 8:2F1 却很高原因用了 accuracy 当指标模型全预测多数类。解决换 F1 或 AUC并在数据加载时打印每个被试的正负样本比不均衡严重时用 class_weight 或过采样。5.5 现象换台机器结果对不上原因随机种子没固定或者 sklearn/torch 版本差异导致默认参数变化。解决固定np.random.seed、torch.manual_seed把版本写进实验记录别信「默认应该一样」。6. 把结果做扎实交叉验证策略与一个可复现的评估习惯走到这一步模型能跑、指标能看但要让结果站得住评估策略比模型结构重要得多。DEAP 只有 32 个被试subject-independent 设定下留一被试LOSO是最严格的但 32 折跑起来慢折中方案是 10 折 GroupKFold每折留 3–4 个被试做测试。我一般两个都跑LOSO 看下限GroupKFold 看稳定性两者差距大说明模型对特定被试敏感。评估时除了 F1我固定加两个量混淆矩阵和每被试 F1 的箱线图。混淆矩阵看模型是不是偏向某一类箱线图看被试间方差。如果箱线图里有一半被试 F1 低于 0.5那这个模型实际上没有可用性平均 F1 再好看也是假的。下面这段是我常用的评估封装直接抄。import numpy as np from sklearn.metrics import f1_score, confusion_matrix def evaluate_per_subject(y_true, y_pred, groups): 按被试输出 F1 和混淆矩阵 results {} for g in np.unique(groups): mask groups g f1 f1_score(y_true[mask], y_pred[mask], zero_division0) cm confusion_matrix(y_true[mask], y_pred[mask]) results[g] {f1: f1, cm: cm} f1s [v[f1] for v in results.values()] print(fmean F1{np.mean(f1s):.3f}, std{np.std(f1s):.3f}, fmin{np.min(f1s):.3f}, max{np.max(f1s):.3f}) return results参数就三个但zero_division0必须加否则某被试预测全是一类时 sklearn 会报 warning 甚至 NaN。跑完看 min 和 max如果 min 低于 0.4回去查那个被试的数据是不是有坏段或标签切分异常。还有一个习惯每次实验把配置存成 json包括滤波参数、频段定义、标签切分方式、随机种子、模型超参。DEAP 这种小数据集结果对配置极其敏感三个月后你根本记不住当时用的哪个阈值。我吃过这个亏同一份代码两次跑出差 5 个点的 F1最后发现是标签二值化从被试内中位数换成了全局 5日志里没记白查两天。最后说句实在的EEG 情绪识别在 DEAP 上的天花板不高subject-independent 二分类能稳定到 65% 以上就算不错别被某些论文的 95% 带偏。把数据对齐、去噪、标签切分、评估策略这四步做扎实比换十个模型都管用。希望帮到你。本文还有配套的精品资源点击获取
