
简介面向SEED脑电数据集研究者的机器学习代码合集针对目前公开研究较少的上海交通大学SEED数据集汇集多种主流模型的工程文件涵盖EEG信号分类、特征提取、模型对比与结果记录等环节。资源包共200个文件约78.93MB以Python脚本86个、CSV数据表50个、Jupyter Notebook8个及MAT数据文件为主另有XML配置、Markdown说明和少量文档便于按算法模块查阅。代码中包括4D-CNN、CNNSVM、RGNN、DANN等实现的完整工程并附有各方法在SEED上的准确率记录如4D-CNN约94%、CNNSVM约73%、RGNN约67%还含有TensorFlow训练日志、训练矩阵样例及多受试者EEG片段CSV可用于复现实验、评估不同网络结构也可作为新方法的对比基线。已有4357人学习下载适合正在做脑电情绪识别、需要快速上手SEED数据集的中高级机器学习研究者。1. SEED 数据集不是普通表格一套能跑通情绪识别的代码整理笔记从网上下载好 seed 数据集解压后看到一堆.mat文件没有配好的加载脚本这是不少人第一次做脑电情绪识别的第一道坎。SEED 数据集是 EEG 情绪识别里最常见的公开数据集之一里面是 62 通道的脑电信号不是表格不能直接按 CSV 读进来当特征用。这篇笔记把我整理 seed 数据集相关代码的完整思路写出来怎么读入数据、怎么对齐标签、怎么做微分熵特征、怎么训练分类器最后再列几个我实际踩过的坑。适合刚开始接触脑电分类、想快速搭一条可运行 pipeline 作为基线的人也适合想把手里零散脚本整理成工程化代码的从业者。2. 把 seed 数据集读进内存mat 文件结构与标签对齐的示例代码讲解2.1 先打印 keys不要猜数据结构seed 数据集官方发布格式多为 MatLab 的.mat文件但网上的整理版结构并不统一。有的文件里直接是eeg和label两个键有的还会嵌套data结构体。我见过不少人在这一步翻车靠猜键名写代码结果loadmat出来是一堆自己不认识的对象。正确做法是先打印出文件的键再看每个键的 shape、dtype最后决定怎么取数。from scipy import io path data/1_1.mat raw io.loadmat(path) # 过滤掉 scipy 自动生成的 __header__、__version__ 等键 keys [k for k in raw.keys() if not k.startswith(__)] print(keys)逻辑很简单loadmat返回的是 Python 字典所有真实数据键都不会以__开头。打印 keys 后再逐个看 shape。这一步花两分钟能省下后面调试两小时。参数上要注意loadmat对 MatLab 导出的大矩阵默认按m×n读取不会自动转成通道数在前所以拿到手先确认eeg.shape是(62, n_samples)还是(n_samples, 62)这将直接影响后面的切片。2.2 最小读取代码一份 session 的读入与试次切割我这里以最常见的整理版为例每个被试每个 session 单独一个.mat文件里面eeg键是 62 通道的原始信号label键是这段 session 对应的情绪标签。需要先说明这不是官方固定的命名但社区里不少代码整理都采用了这种结构。读取代码如下。from scipy import io import numpy as np path data/1_1.mat fs 1000 # SEED 数据集的采样率单位 Hz raw io.loadmat(path) eeg raw[eeg] # 期望 shape: (62, n_samples) label raw[label].flatten() # 期望 shape: (n_trials,) print(eeg.shape, label.shape) n_trials len(label) n_samples eeg.shape[1] trial_len n_samples // n_trials # 把连续信号切成一个个试次第三维是试次编号 segments eeg[:, :n_trials * trial_len].reshape(62, trial_len, n_trials) print(segments.shape)这段代码做的事情是先看eeg和label的形状再根据试次数把连续信号切块。这里默认所有试次等长seed 数据集的诱发片段长度基本一致所以用整除是可行的。如果n_samples不能被n_trials整除说明文件里可能还包含其他事件标记或休息段此时要回头检查数据来源不要用reshape硬切。trial_len一般接近 240000 点也就是 4 分钟片段。2.3 标签对齐label 的顺序与试次顺序必须一致标签对齐是最容易错的地方。有些整理版 label 数组只有 15 个数字对应 15 个电影片段有些则是每个采样点一个标签需要按片段边界做多数投票。我的习惯是先把 label 打出来看取值确认只有 3 类后再做对齐。# 假设 label 长度为试次数取值是 0/1/2 trial_ids np.arange(n_trials) print(dict(zip(trial_ids, label))) # 如果 label 是每个采样点一个值按片段长度投票 if len(label) n_trials: label np.array([np.bincount(label[i * trial_len:(i 1) * trial_len]).argmax() for i in range(n_trials)])flatten()这一步很重要因为loadmat读出的 label 常见 shape 是(15, 1)而不是(15,)不展平的话后面和数组做索引会对不上。做投影时要确认 label 的顺序与文件内试次顺序一致source 文件一旦顺序打乱后面所有统计都失真。我建议在写完整链路前先用一个 session 数据把 label 和对应视频编号打印出来人工核对一遍。2.4 把多个 session 整理成统一样本索引一个完整实验通常有多个 sessionseed 数据集里每个被试至少有两个以上 session。整理代码时我喜欢建一个统一的表格记录每个样本来自哪个被试、哪个 session、哪个试次后面分组验证全靠它。import pandas as pd rows [] for subject in range(1, 16): for session in range(1, 4): mat_path fdata/{subject}_{session}.mat # 读入并切割得到 segments, label # ... for trial_idx, lbl in enumerate(label): rows.append({ subject: subject, session: session, trial: trial_idx, label: int(lbl), }) meta pd.DataFrame(rows) print(meta.groupby(label).size())这份 meta 表就是一个黑匣子的钥匙后面不管是按 session 划分训练集还是按被试划分验证集都从这张表取索引而不是在特征矩阵里硬数位置。很多代码跑完发现结果特别好最后才发现是训练和测试样本重叠了根本原因是分区没走统一索引。3. 原始信号到可用特征滑窗分段、频带滤波与微分熵计算3.1 为什么不做整段信号分类而是切窗口脑电信号是非平稳的四分钟的情绪诱发片段内部会有很大波动。直接对整段试次求一个特征去分类相当于把每个 15 次试次的独特性都揉成平均值高分类器性能上限不高。常见做法是先滑窗将每个试次切成 1 秒左右的短时窗口再对每个窗口提取特征。窗口短了特征能捕捉到情绪状态的变化窗口长了样本量不够。seed 数据集采样率 1000Hz我用 1 秒窗口、0.5 秒步长既有足够样本量也保证相邻窗口之间有一定关联性。3.2 微分熵为什么适合脑电情绪识别微分熵Differential EntropyDE是对连续信号熵的一种度量对高斯分布的信号来说它等同于对数方差加常数。情绪识别研究里 DE 特征在 beta、gamma 等高频段往往比功率谱密度更稳定。计算时先按频带滤波再求每个通道每个频带的方差最后取对数。频带选择有论文用 delta、theta、alpha、beta、gamma 五段也有用四个频段的。我一般保留五个频段因为高频段的情绪区分度经常是最高的。3.3 滤波与微分熵计算代码下面这段代码实现单窗口的 DE 特征提取。输入是一个(62, window_len)的窗口信号输出是(310,)的特征向量即 62 通道乘 5 个频带。from scipy.signal import butter, filtfilt import numpy as np BANDS [(1, 3), (4, 7), (8, 13), (14, 30), (31, 50)] def butter_bandpass(low, high, fs, order4): nyq fs / 2 b, a butter(order, [low / nyq, high / nyq], btypeband) return b, a def extract_de_from_window(window, fs1000): # window shape: (62, n_points) feats [] for low, high in BANDS: b, a butter_bandpass(low, high, fs, order4) filtered filtfilt(b, a, window, axis1) var_per_channel np.var(filtered, axis1) de 0.5 * np.log(2 * np.pi * np.e * var_per_channel) feats.append(de) return np.concatenate(feats) # (5 * 62,)代码逻辑是先对每个频带做带通滤波然后用滤波后的信号方差计算微分熵。filtfilt是零相位滤波不会引入相位偏移比lfilter更适合离线脑电处理。order4是巴特沃斯滤波器的阶数阶数太高会出现数值不稳定太低频带选择性差。窗口长度建议至少为阶数的五倍也就是 20 个采样点以上实际操作里 1000 个点完全没有问题。3.4 滑窗生成样本矩阵与归一化的正确姿势对每个试次的连续信号从第 0 个采样点开始每隔hop_len取一个窗口。注意每个窗口不能跨过试次边界所以最后一个窗口要停在trial_len - window_len处。def sliding_windows(segments, window_len1000, hop_len500): n_chan, trial_len, n_trials segments.shape win_feats [] win_labels [] win_trial_ids [] for trial_idx in range(n_trials): trial_data segments[:, :, trial_idx] # (62, trial_len) n_windows (trial_len - window_len) // hop_len 1 for w in range(n_windows): start w * hop_len end start window_len win trial_data[:, start:end] feats extract_de_from_window(win) win_feats.append(feats) win_labels.append(label[trial_idx]) win_trial_ids.append(trial_idx) return np.array(win_feats), np.array(win_labels), np.array(win_trial_ids)window_len1000是一个 1 秒窗口hop_len500是 0.5 秒重叠。重叠比例越高样本数越多但相邻窗口特征相关性也越高之后做验证时一定要按试次分组。归一化时只能在训练集上计算均值和标准差不能用全量数据。这个问题放在后面避坑章节详说这里记住一句话标准化的 fit 永远在训练折内做。4. 从特征到分类结果SVM 最小模型、参数搜索与跨 session 验证4.1 为什么先拿 SVM 当基线很多人一上来就上 LSTM、Transformer结果在小规模数据集上反而跑不过简单模型。seed 数据集每个试次能切出几百个窗口但独立事件数只有几百个深度模型很容易过拟合。我一般先把 SVM 或线性分类器作为跑通管线的基线确认数据链路无误后再决定是否需要上深度模型。SVM 对高维小样本分类有天然优势RBF 核可以捕捉非线性关系而且训练速度快代码调试成本低。4.2 组装特征和分组标签训练前先把所有窗口的特征拼成一个大矩阵X标签拼成y同时保留每个窗口所属的 trial 编号用于分组验证。如果不保留 trial 编号后面用随机划分就会把同一个试次的窗口分到训练集和测试集造成数据泄漏。from sklearn.svm import SVC from sklearn.preprocessing import StandardScaler from sklearn.pipeline import make_pipeline X all_feats # shape: (n_windows, 310) y all_labels # shape: (n_windows,) groups all_trial_ids # shape: (n_windows,)每个窗口属于哪个 trial model make_pipeline(StandardScaler(), SVC(kernelrbf, C1.0, gammascale)) model.fit(X, y)StandardScaler()放在 pipeline 里可以在训练时只对训练数据做标准化。SVC的gammascale是 scikit-learn 默认值它会根据输入特征的数量自动计算 gamma适合作为第一轮基线。注意这里只是示意代码真正验证时要按 trial 分组不能直接train_test_split乱切。4.3 必调参数C、gamma、窗口长度和滑窗步长SVM 的C控制误分类惩罚C越大越容易过拟合gamma 控制 RBF 核的宽度gamma 越大决策边界越复杂。我习惯先在[0.1, 1, 10]里试 C在[scale, 0.01, 0.001]里试 gamma用网格搜索加分组交叉验证避免靠运气调参。from sklearn.model_selection import GridSearchCV from sklearn.model_selection import LeaveOneGroupOut param_grid { svc__C: [0.1, 1, 10], svc__gamma: [scale, 0.01, 0.001], } cv LeaveOneGroupOut() search GridSearchCV(model, param_grid, cvcv, scoringaccuracy, n_jobs-1) search.fit(X, y, groupsgroups) print(search.best_params_) print(search.best_score_)LeaveOneGroupOut会把每个groups组当作一个测试块这里是按试次分组比按窗口随机划分严格得多。网格搜索参数时必须把groups传给fit否则GridSearchCV内部的交叉验证不会感知试次结构。窗口长度和滑窗步长也是重要超参数但它们对结果的影响往往比 C 和 gamma 更大有精力的话也应该放在外层循环里一起试。4.4 用跨 session 验证避免数据泄漏不同 session 之间可能存在录制时间不同、被试状态变化等差异如果训练集和测试集来自同一个 session 的相邻窗口性能会被虚高。更严格的做法是 leave-one-session-out即把某被试某一个 session 当作测试集其余 session 当训练集。这样测出来的准确率更接近真实泛化能力。for test_session in range(1, 4): train_idx meta[meta[session] ! test_session].index test_idx meta[meta[session] test_session].index X_train, X_test X[train_idx], X[test_idx] y_train, y_test y[train_idx], y[test_idx] local_model make_pipeline(StandardScaler(), SVC(C1.0, kernelrbf)) local_model.fit(X_train, y_train) acc local_model.score(X_test, y_test) print(test_session, acc)循环里要确保train_idx和test_idx是从窗口样本索引推出来的而不是从原始试次索引推出来的。我的做法是先构造一个全局window_meta表每个窗口带着 subject、session、trial、window 编号再按这个表取索引。索引一致实验才可复现。5. SEED 代码整理避坑5 个让新手反复返工的细节5.1 标签和试次错位训练集指标却很好看现象代码跑完训练集准确率 99%测试集准确率只有 60% 左右跟你对不上。原因最常见的不是模型问题而是标签在试次、窗口两个层级上错位。有些整理版在生成窗口时把试次编号从 1 开始但 label 数组从 0 开始或者某个文件里 label 顺序与视频顺序不一致。更隐蔽的是窗口样本顺序按试次从小到大排列但随机划分时没有按试次分组同一个试次的窗口被拆进训练和测试导致模型见过同一段信号的邻居窗口。解决把标签对齐做成一个显式步骤不要只靠顺序索引。每个窗口都带上subject、session、trial三个字段做成 DataFrame训练前打印分组数量确认每个试次的窗口数一致。验证时用LeaveOneGroupOut或GroupShuffleSplitgroup 设置为试次编号。5.2 mat 文件里的“字典套数组”坑现象loadmat读出来的label不是一维数组而是(15, 1)或者(1, 15)的形状后面跟len()或索引对不上。原因MatLab 保存标量或列向量时在 Python 里会保留二维结构。再往前一层有些整理版把数据存成data.eeg这种结构体loadmat会嵌套成dict取数时容易多一层.item()。解决写一个通用取值函数对每个键做np.squeeze()再根据实际 shape 做一次判断。如果是结构体套数组先取.item()再转数组。我习惯在读取代码里加两个 assertlabel.ndim 1和eeg.shape[0] 62不满足就立刻报错不要等到模型训练完才发现问题。5.3 滤波边界出现 NaN 或数值爆炸现象filtfilt报ValueError: The length of the input vector x must be at least padlen或者滤波后靠近窗口两端的值出现很大跳变。原因filtfilt默认会做边界填充窗口太短、阶数太高时填充长度不够或数值发散。另一个原因是原始信号含 NaN滤波会把 NaN 往外扩散。解决先检查原始 eeg 数据有 NaN 用中值替掉或整段剔除。再检查窗口长度window_len至少大于阶数的三到五倍我通常用 1000 点足够安全。如果不想用filtfilt可以改成lfilter配合padlen参数但脑电离线处理我更推荐零相位滤波。5.4 先全局标准化再划分数据等于作弊现象用一个 session 训练另一个 session 测试性能明显下降但如果在整个数据集上算好 mean 和 std 再切分性能又涨回去心里很开心。原因这是教科书级别的数据泄漏。标准化是在全量数据上算的测试集统计量已被混入训练过程。对脑电这种个体差异大的数据全局标准化会把不同被试的振幅差异抹掉让模型误以为测试域的分布和训练域一样。解决所有标准化只在训练折内计算。pipeline 里的StandardScaler每次fit只看到训练数据transform测试数据时用的是训练集的统计量。不要手动在全量X上先调用scaler.fit(X)再划分。5.5 复现论文结果差很多不一定是代码错现象按论文描述做了但准确率差 8% 到 15%。一开始怀疑代码查了半天没发现明显 bug。原因论文里没写的细节往往才是决定性能的。频带边界是 1-3Hz 还是 1-4Hz窗口长度是 1 秒还是 4 秒滑窗步长是 0.5 秒还是 1 秒SVM 的 C 和 gamma 是固定还是搜索是否做去眼电伪迹是否剔除坏通道。这些参数组合不同准确率差异可以超过十个百分点。另外有些论文用的是提取好的 DE 特征不是原始信号直接切窗特征来源不同结果也会不一样。解决先找数据集官方或作者是否提供了提取好的特征文件如果提供了直接在自己模型上复现排除特征提取差异。再固定随机种子固定分组策略把 train 流程每一步打印出来保存成配置 json方便向后对齐。把复现目标从“达到论文数字”改成“在同样特征和分组条件下达到接近数字”这样定位问题更高效。6. 让整套代码可复用的工程习惯先跑通 1 个 session 再跑全量我现在的习惯是拿到任何 seed 数据集第一件事不是跑完整实验而是先写一个 sanity check 脚本只加载一个被试的一个 session切割几个窗口提取一次特征跑一个极小的 SVM看整条链路通不通。这个脚本要尽量短能在一分钟内跑完只输出三个数字样本数、特征维度、分类准确率。import numpy as np from scipy import io from sklearn.svm import SVC def run_sanity_check(path, fs1000): raw io.loadmat(path) eeg raw[eeg] label np.squeeze(raw[label]) assert eeg.shape[0] 62 trial_len eeg.shape[1] // len(label) segments eeg[:, :len(label) * trial_len].reshape(62, trial_len, len(label)) from scipy.signal import butter, filtfilt def de_feat(win): feats [] for low, high in [(1, 3), (4, 7), (8, 13), (14, 30), (31, 50)]: b, a butter(4, [low / (fs / 2), high / (fs / 2)], btypeband) filt filtfilt(b, a, win, axis1) feats.append(0.5 * np.log(2 * np.pi * np.e * np.var(filt, axis1))) return np.concatenate(feats) X [] y [] for t in range(len(label)): seg segments[:, :, t] for start in range(0, trial_len - 1000, 500): X.append(de_feat(seg[:, start:start1000])) y.append(label[t]) model SVC(C1.0, kernelrbf, gammascale) model.fit(X, y) print(sanity ok:, len(y), windows, acc, model.score(X, y)) run_sanity_check(data/1_1.mat)这个脚本的核心价值是快速暴露“通道顺序、标签顺序、滤波长度、特征维度”这类低级错误。如果这一步通过了再把它扩展成完整的特征提取模块和训练验证脚本。如果这一步都跑不通不要急着上网格搜索先回头把数据结构搞明白。为了让实验结果可复现我会在每次全量训练前固定random.seed(42)和np.random.seed(42)并把超参数写成一个 config 字典连同最好的交叉验证分数一起存成 json。这样下一次翻出代码还能知道当时跑的是哪一组参数。还有一个技巧是每隔三个窗口做一次特征抽样比如只取每五个窗口中的一个用来快速验证模型逻辑。这不能用来做最终实验但能极大缩短调试反馈周期。我现在整理 seed 数据集的任何一段代码都会坚持先跑通最小 case再复制到全量数据。这套顺序让我少走了很多弯路希望帮到你。本文还有配套的精品资源点击获取