SEED数据集EEG情绪识别实战:从数据读取到跨被试域适应全链路
2026/9/24 19:12:23 网站建设 项目流程

简介:这份资源面向希望上手脑电情绪识别的研究生、算法初学者与相关课程学习者,围绕公开的SEED数据集提供一套可运行的EEG情绪识别实践代码。包内共18个文件,以Python脚本、XML配置、Markdown说明与TXT结果记录为主,另有docx实验记录和iml工程文件,压缩包约10.65MB,体量轻便,便于快速部署与二次修改。代码部分包含基于DE_LDS特征的SVM分类流程与原始EEG信号的CNN训练脚本,并配有utils工具模块,覆盖从数据读取、特征提取到模型训练与结果记录的完整链路;results目录下的日志与结果文档可用于对照实验表现,README与说明文件则帮助理解目录组织与运行方式。目前已有640人学习下载,适合作为情绪识别入门练手、课程设计或小论文复现的参考方案,读者可据此掌握SEED数据预处理、传统机器学习与深度学习两条基线思路,并在此基础上调整特征与网络结构开展对比实验。

1. 拿到 SEED 数据集做 EEG 情绪识别:先搞清楚这条链路到底难在哪

SEED 数据集在 EEG 情绪识别圈子里算是绕不开的基准,15 名被试、62 导联、三类情绪(积极、中性、消极),用 62 通道电极帽按国际 10-20 系统采集,采样率 1000Hz,每名被试看 15 个电影片段、每个片段约 4 分钟,看完立刻做情绪自评。很多人第一次拿到这份数据,第一反应是「数据挺规整,跑个分类不就完了」,然后被现实按在地上摩擦——跨被试准确率死活上不去,同一被试内轻松 90%+,换个人直接掉到 50% 出头,接近瞎猜。

这条链路真正的难点不在模型结构,而在三件事:一是 EEG 信噪比极低,情绪相关的频段能量变化很容易被眼电、肌电、工频干扰淹没;二是样本量看着大、实际有效独立样本少,同一段视频切出来的窗口高度相关,交叉验证稍不注意就数据泄漏;三是 SEED 官方给的预处理版本和原始版本差异不小,用哪个、怎么对齐,直接决定你后面所有实验能不能复现。这篇笔记就按「数据怎么读进来 → 特征怎么提 → 模型怎么搭 → 跨被试怎么救 → 坑在哪」的顺序,把一条能跑通、能复现的 SEED 情绪识别链路讲清楚,适合刚接触 EEG 情绪识别、想拿 SEED 做第一个完整实验的人,也适合做过但跨被试一直翻车、想找排查方向的人。

2. SEED 数据集的读取与预处理:从 .mat 到可训练张量

2.1 先认清 SEED 的文件结构和两种版本

SEED 常见的有两个版本:原始版(Preprocessed_EEG)和官方预处理版(Preprocessed_EEG 里已经做了降采样到 200Hz、带通滤波、去眼电等)。原始版采样率 1000Hz,官方预处理版是 200Hz,很多论文里写的「SEED 上 200Hz」指的就是后者。文件按被试组织,每个被试一个 .mat,里面按 session 和视频片段存成多个变量,比如djc_eeg1djc_eeg2这种命名,变量里是[62, T]的矩阵,T 是时间点。标签单独放在label.mat里,三类情绪对应 1/0/-1(积极/中性/消极),顺序和视频片段编号对应。

我一般直接用官方预处理版,理由是省掉一大半滤波和去噪的调试时间,而且大部分公开结果都是在这个版本上报的,方便横向对比。如果你要做的是「原始信号去噪」这类研究,才需要回到 1000Hz 原始版自己走滤波链路。

2.2 用 Python 把 .mat 读成 numpy 并切窗

import scipy.io as sio import numpy as np # 读取单个被试的预处理 EEG 和标签 eeg = sio.loadmat('Preprocessed_EEG/1_20131027.mat') label = sio.loadmat('Preprocessed_EEG/label.mat')['label'].flatten() # 该被试的 15 段视频数据,变量名形如 djc_eeg1 ... djc_eeg15 segments = [] for i in range(1, 16): key = f'djc_eeg{i}' seg = eeg[key] # shape: [62, T] segments.append(seg) # 按 1 秒窗、无重叠切分(200Hz 下每窗 200 点) win_len = 200 X, y = [], [] for seg_idx, seg in enumerate(segments): n_win = seg.shape[1] // win_len for w in range(n_win): window = seg[:, w*win_len:(w+1)*win_len] # [62, 200] X.append(window) y.append(label[seg_idx]) X = np.array(X) # [N, 62, 200] y = np.array(y) print(X.shape, y.shape)

这段代码做了三件事:按变量名把 15 段数据取出来、按固定窗长切分、把片段标签广播到每个窗口。win_len=200对应 200Hz 下的 1 秒窗,这是 SEED 上最常用的窗长之一,再短(0.5 秒)频域分辨率不够,再长(4 秒)单段视频切出来的窗口数太少、样本不够。切窗时不要重叠,重叠窗会让训练集和验证集高度相关,后面交叉验证的分数会虚高,这是新手最容易踩的坑之一。

2.3 标签对齐和通道选择的两个细节

标签对齐上,label.mat里的顺序是固定的 15 个片段,但不同被试的文件里变量编号不一定严格按 1 到 15 排列,读之前最好先print(eeg.keys())确认一遍,别默认djc_eeg1就是第一段。通道选择上,62 导联全用是最稳的,但如果你要做轻量化或者可穿戴方向,常见做法是挑出情绪相关较强的额区和中颞区通道(比如 Fp1、Fp2、F3、F4、F7、F8、T7、T8、P3、P4 等),通道数能砍到 20 以内,代价是准确率通常掉 3 到 8 个百分点,具体掉多少要自己在验证集上测。

提示:SEED 官方预处理版已经做过降采样和滤波,不要再叠加一次 50Hz 陷波,否则会把情绪相关的低频成分一起削掉,我见过有人这么干完准确率直接掉 10 个点。

3. 特征提取:频段能量、微分熵和它们为什么在 SEED 上管用

3.1 为什么 SEED 上微分熵是默认首选特征

SEED 上最经典的特征是微分熵(Differential Entropy, DE),论文里报得最多的也是它。原因不玄学:DE 在固定频段内和高斯分布下的对数能量近似等价,而 EEG 各频段(delta 1-4Hz、theta 4-8Hz、alpha 8-14Hz、beta 14-30Hz、gamma 30-50Hz)的能量变化和情绪唤醒度、效价都有相关性,尤其是 beta 和 gamma 在积极情绪下能量上升比较明显。DE 相比直接算功率谱密度,数值更稳定、对窗长不敏感,做跨被试时鲁棒性更好。

3.2 用 scipy 算 DE 特征的最小实现

from scipy.signal import welch import numpy as np bands = {'delta': (1, 4), 'theta': (4, 8), 'alpha': (8, 14), 'beta': (14, 30), 'gamma': (30, 50)} def compute_de(window, fs=200): # window: [62, 200] feats = [] for ch in range(window.shape[0]): sig = window[ch, :] freqs, psd = welch(sig, fs=fs, nperseg=min(200, len(sig))) for name, (lo, hi) in bands.items(): idx = np.logical_and(freqs >= lo, freqs < hi) band_power = np.trapz(psd[idx], freqs[idx]) # 高斯假设下 DE = 0.5 * log(2*pi*e*sigma^2),用 band_power 近似 de = 0.5 * np.log(2 * np.pi * np.e * band_power + 1e-8) feats.append(de) return np.array(feats) # [62*5] X_de = np.array([compute_de(w) for w in X]) print(X_de.shape) # [N, 310]

welch用默认的汉宁窗做功率谱估计,nperseg取窗长本身,保证频率分辨率够分到 1Hz 级别。np.trapz对频段内 PSD 积分得到该频段能量,再套 DE 公式。加1e-8是防止 log(0),这个细节不做的话遇到全零通道会直接出 nan,后面模型训练报错还不好定位。每个通道 5 个频段,62 通道就是 310 维,这个维度对 SVM 和浅层网络都还算友好。

3.3 特征归一化和几种常见组合的取舍

DE 特征做完之后,一定要做被试内归一化,常见做法是对每个被试的每个特征维度做 z-score,或者按 session 做归一化。不做归一化的话,不同被试之间的绝对能量差异会盖过情绪带来的相对变化,跨被试直接崩。除了 DE,常见的还有 PSD、Hjorth 参数、微分不对称(DASM)、有理不对称(RASM)等,DASM/RASM 是左右脑对称通道的 DE 差值/比值,对效价区分有一定帮助,可以拼在 DE 后面一起送进模型,维度翻倍但通常能涨 1 到 3 个点。特征不是越多越好,我试过把 DE、PSD、Hjorth 全拼一起,维度到 1000+,结果因为冗余和过拟合,验证集反而掉了,最后只留 DE + DASM。

注意:归一化的统计量只能用训练集算,再应用到验证集和测试集。用全量数据算均值和方差是典型的数据泄漏,分数会虚高但换被试就现原形。

4. 模型搭建:从 SVM 基线到 EEGNet 的落地选择

4.1 先用 SVM 把基线跑出来

别一上来就上深度网络。SEED 上 SVM + DE 是一个很强的基线,被试内经常能到 85% 以上,跨被试也有 55% 到 65%。先把这个基线跑通,你才知道后面的深度模型是真涨了还是只是调参调出来的。

from sklearn.svm import SVC from sklearn.preprocessing import StandardScaler from sklearn.pipeline import make_pipeline from sklearn.model_selection import cross_val_score clf = make_pipeline(StandardScaler(), SVC(kernel='rbf', C=1.0, gamma='scale')) # 被试内 5 折,注意按时间块分折,不要随机打乱窗口 scores = cross_val_score(clf, X_de, y, cv=5, scoring='accuracy') print(scores.mean(), scores.std())

StandardScaler放在 pipeline 里是为了让归一化只在每折的训练部分 fit,避免泄漏。C=1.0gamma='scale'是稳妥起点,SEED 上 C 在 0.1 到 10 之间调,gamma 用 scale 通常够用。交叉验证这里有个关键点:按时间块分折,比如前 12 个窗口训练、后 3 个验证,而不是随机打乱。随机打乱会让相邻高度相关的窗口同时出现在训练和验证集,分数虚高 5 到 10 个点,这个坑我在早期实验里踩过不止一次。

4.2 EEGNet 在 SEED 上的输入形状和关键参数

EEGNet 是 EEG 分类里最常被拿来当深度基线的紧凑网络,参数量小、不容易过拟合,适合 SEED 这种样本量中等的场景。输入形状是[batch, 1, channels, time],SEED 上就是[N, 1, 62, 200]

import torch import torch.nn as nn class EEGNet(nn.Module): def __init__(self, n_ch=62, n_time=200, n_cls=3, F1=8, D=2, F2=16, drop=0.5): super().__init__() self.block1 = nn.Sequential( nn.Conv2d(1, F1, (1, 64), padding=(0, 32), bias=False), nn.BatchNorm2d(F1), nn.Conv2d(F1, F1*D, (n_ch, 1), groups=F1, bias=False), # 深度卷积 nn.BatchNorm2d(F1*D), nn.ELU(), nn.AvgPool2d((1, 4)), nn.Dropout(drop) ) self.block2 = nn.Sequential( nn.Conv2d(F1*D, F1*D, (1, 16), padding=(0, 8), groups=F1*D, bias=False), nn.Conv2d(F1*D, F2, (1, 1), bias=False), nn.BatchNorm2d(F2), nn.ELU(), nn.AvgPool2d((1, 8)), nn.Dropout(drop) ) self.fc = nn.Linear(F2 * (n_time // 32), n_cls) def forward(self, x): x = self.block1(x) x = self.block2(x) x = x.flatten(1) return self.fc(x) model = EEGNet() print(sum(p.numel() for p in model.parameters())) # 参数量约几千

F1=8D=2F2=16是 EEGNet 原论文的默认配置,SEED 上直接能用。第一个卷积核(1, 64)在时间维上做滤波,相当于学一个可训练带通;深度卷积(n_ch, 1)在通道维上做空间滤波,这是 EEGNet 能跨通道泛化的关键。drop=0.5在 SEED 上偏大,样本少的时候可以调到 0.25 到 0.3,我一般先用 0.5 跑一版,过拟合明显再降。训练时学习率 1e-3、Adam、batch size 64 是稳妥起点,epoch 不用太多,20 到 30 就够,再多就开始过拟合。

4.3 被试内和被试间实验该怎么设计

被试内(within-subject):每个被试单独训练一个模型,用该被试自己的数据分训练/测试。这个设定下准确率普遍高,SVM 和 EEGNet 都能到 85% 到 95%,但它的实际意义有限,因为真实场景里你不可能给每个新用户重新采一遍标定数据。

被试间(cross-subject):留一被试法(LOSO),用 14 个被试训练、1 个被试测试,轮流一遍。这个设定才是真正有落地价值的,也是 SEED 上分数最难看的,浅层方法普遍 55% 到 70%,深度方法加上域适应能到 70% 到 85%。做跨被试时,训练集内部的验证划分也要按被试分,不能按窗口随机分,否则你调出来的超参在真正跨被试时会失效。

5. 跨被试掉点排查:那些让准确率一夜回到解放前的坑

5.1 现象:被试内 90%,跨被试 50% 出头

原因:不同被试的 EEG 绝对幅值、阻抗、电极位置微小差异导致特征分布整体偏移,模型学到的是「这个被试长什么样」而不是「情绪长什么样」。解决:先做被试内 z-score 归一化,再上域适应方法,比如在特征层加 CORAL 或 MMD 损失对齐训练被试和测试被试的二阶统计量,或者用 AdaBN 在测试时更新 BatchNorm 的 running stats。我一般先试 AdaBN,改动最小、不用重新训练,往往能拉回 5 到 10 个点。

5.2 现象:验证集分数很高,一换被试就崩

原因:数据泄漏。要么是切窗时重叠了,要么是归一化用了全量统计量,要么是交叉验证随机打乱了窗口。解决:切窗不重叠、归一化统计量只在训练折上算、交叉验证按时间块或被试分折。这三条挨个检查一遍,能排掉大部分「虚高」问题。

5.3 现象:训练 loss 一直降,验证 loss 早早开始涨

原因:过拟合。SEED 单被试有效样本其实不多,62 通道 310 维特征对 SVM 还好,对深度网络就偏少。解决:加 dropout、加 L2 正则、减小模型宽度(F1 从 8 降到 4)、或者做数据增强(加高斯噪声、时间裁剪、通道随机丢弃)。通道随机丢弃在 EEG 上效果不错,相当于让模型不依赖某几个特定通道,对跨被试也有帮助。

5.4 现象:某个被试怎么调都只有 40% 多

原因:个体差异极端,或者该被试数据本身质量差(阻抗高、动作多)。解决:先看该被试的原始信号,如果噪声明显,考虑剔除或单独做更强的去噪;如果信号正常但就是分不开,可能是该被试的情绪诱发效果本身弱,SEED 里确实有个别被试的自评和标签一致性偏低。这种情况不要死磕,把它当异常值处理,报告结果时说明剔除理由。

5.5 现象:换了台机器跑,结果对不上

原因:随机种子没固定、PyTorch 和 numpy 的随机性、GPU 非确定性算子。解决:固定torch.manual_seednp.random.seedrandom.seed,设置torch.backends.cudnn.deterministic = True,并且把数据划分的索引存下来复用。EEG 实验里结果复现不了,八成是这几个地方没锁死。

提示:排查跨被试问题时,先画一张 t-SNE 或 PCA 图,把训练被试和测试被试的 DE 特征投到二维看一眼。如果两团点完全不重叠,说明分布偏移严重,先做对齐再谈模型;如果本来就重叠得不错但模型还是分不开,那问题在模型或标签,不在域偏移。

6. 把跨被试准确率往上推的几个具体技巧

前面把链路和坑都过了一遍,最后落到一个具体问题:跨被试准确率卡在 65% 左右,怎么再往上推。我自己的习惯是分三步走,每一步都有明确的验证方式,不靠玄学调参。

第一步,特征层面做被试内标准化 + 通道级重参考。标准化前面说过,重参考是把每个通道减去所有通道的均值(CAR),能压掉一部分共模噪声,对跨被试的分布对齐有轻微帮助。这一步做完,LOSO 通常能涨 2 到 4 个点,验证方式是看 t-SNE 图上训练和测试被试的重叠度有没有变好。

第二步,模型层面加一个轻量的域对齐损失。以 EEGNet 为例,在 flatten 之后、fc 之前接一个 CORAL 损失,对齐源域和目标域特征的协方差矩阵:

def coral_loss(src, tgt): d = src.size(1) src_c = src - src.mean(0, keepdim=True) tgt_c = tgt - tgt.mean(0, keepdim=True) cov_s = src_c.t() @ src_c / (src.size(0) - 1) cov_t = tgt_c.t() @ tgt_c / (tgt.size(0) - 1) return ((cov_s - cov_t) ** 2).sum() / (4 * d * d) # 训练时:loss = ce_loss + lam * coral_loss(feat_src, feat_tgt)

lam从 0.1 开始试,0.5 到 1.0 之间通常效果最好,太大反而会让特征塌缩、分类性能下降。CORAL 的好处是不需要目标域标签,测试被试的数据可以直接拿来算协方差,属于无监督域适应,落地时不需要新用户标定。这一步在 SEED 的 LOSO 上一般能再涨 5 到 10 个点,具体多少取决于被试对。

第三步,测试时自适应(AdaBN)。把训练好的模型拿到测试被试上,用测试被试的无标签数据前向几遍,只更新 BatchNorm 层的 running mean 和 running var,其他参数冻结。这一步几乎零成本,但经常能再拉 3 到 5 个点。注意只更新 BN 统计量,不要动卷积核和全连接权重,否则容易过拟合到测试被试的少量数据上。

这三步做完,SEED 上 LOSO 到 75% 到 85% 是比较现实的区间,再往上就要考虑更复杂的域适应方法或者多源域融合了。最后说个我自己的习惯:每次跑完实验,把被试编号、随机种子、特征版本、模型配置和最终准确率记在一个表里,别嫌麻烦。EEG 实验的变量太多,不记录的话两周后你自己都说不清哪个结果对应哪套配置,想复现只能重跑,那才是真的血泪经验。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询