news 2026/9/16 16:29:08

DEAP脑电情绪识别二分类实战:从MNE数据处理到XGBoost建模

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
DEAP脑电情绪识别二分类实战:从MNE数据处理到XGBoost建模

简介:面向脑电情绪识别入门者与机器学习初学者的二分类算法实现,基于公开 DEAP 脑电数据集,完整覆盖快速傅里叶变换(FFT)特征提取、数据预处理与模型训练评估流程。代码共5个文件,包含4个 Python 脚本和1个说明文档,压缩包仅7KB,轻量易读。模型部分选用决策树、SVM、KNN 三种经典分类器,均直接调用常用库实现,逻辑清晰,适合新手对照学习。包内还附带脑电绘图的辅助脚本,方便可视化观察原始信号。当前已有3307人学习下载。通过学习这份资源,可掌握脑电数据从频域特征提取到分类器对比的完整思路,理解不同模型的适用场景与简单调参方法,为后续扩展深度学习情感识别打下基础。

1. 从DEAP出发,认知科学和算法工程师第一次有了同一个起点

提到脑电情绪识别,你最先想到的可能是让被试看图片、打分数,然后训练一个分类器来判断“高兴”或“难过”。这种流程最大的问题不在模型,而在数据:每台脑电设备的通道数不同,采样率不同,实验范式不同,预处理手法不同,论文与论文之间几乎无法横向对比。DEAP数据集的价值就在于它把这些变量全部固定下来:32名被试、40个视频、32通道脑电加8通道外周生理信号,128 Hz采样率,并提供了效价、唤醒度、支配度等从1到9的主观评分。于是,“基于DEAP脑电数据集的脑电情绪识别二分类算法”成了一个可复现、可比对、可迭代的标准实验场。

但标准不等于简单。DEAP里一个被试一个文件就是40次试验乘40通道乘7680个采样点,约120MB的浮点数据,第一次加载就容易卡在内存和格式上。而二分类的标签并非数据里现成的字段,需要根据评分阈值切出高低效价或高低唤醒度,这一步的处理方式会直接影响后续模型的准确率。本文会从环境搭建、数据读取、特征构造、模型选型一直讲到最后一层验证技巧,目标不是让你复现某个SOTA,而是让你自己能跑通一条从原始数据到准确率指标的完整链路。后续出现的关键词“DEAP数据集下载”“用MNE库搞定脑电ERP分析”“XGBoost二分类模型”“机器学习算法”都会在正文中自然落到对应步骤里。

2. 搭环境、读数据,先用MNE站稳脚跟

2.1 环境配置与依赖:别在版本上浪费实验时间

处理DEAP数据集的常见做法是使用MNE库,它是Python生态里专门读写脑电、脑磁、肌电等生理信号的工具箱,对EDF、BDF、CNT等格式支持完善,且自带部分滤波和伪迹去除函数。加上numpy、scikit-learn和xgboost,基本可以覆盖从数据加载到模型评估的全过程。

conda create -n deap python=3.9 conda activate deap pip install mne numpy matplotlib scikit-learn xgboost

安装命令看起来简单,但有两个参数值得注意:MNE在0.24版本之后默认使用raw.copy()才触发真正的数据拷贝,初学阶段很容易被视图机制绕晕,后续所有对原始数据的修改都会互相影响;scikit-learn和xgboost的版本不必追新,关键是和Python 3.9兼容即可。DEAP官方提供的.mat文件是MATLAB格式,依赖scipy的loadmat函数读取,MNE的read_epochs_eeglab在这里用不上,下面章节会给出直接加载的代码。

2.2 加载DEAP数据的完整代码与维度检查

DEAP官网下载的data_preprocessed_matlab目录下,每个被试对应一个S01.matS32.mat文件。文件内部包含datalabels两个变量,data的形状是40×40×7680,前一个40是视频数量,第二个40是通道数量,7680是128Hz下60秒试验的采样点数;labels的形状是40×4,四列依次为效价、唤醒度、支配度、喜欢度。

import scipy.io as sio import numpy as np mat = sio.loadmat('data_preprocessed_matlab/s01.mat') data = mat['data'] # shape: (40, 40, 7680) labels = mat['labels'] # shape: (40, 4) print(data.shape) print(labels.shape) print(labels[:3]) # 前三个视频的评分

这段代码做三件事:用loadmat读入MATLAB的v7.3格式文件,取出数据张量和标签矩阵,再打印形状与情感评分做肉眼检查。注意DEAP的官方说明中,前32通道是脑电信号,对应国际标准10-20系统的32个电极位置;后8通道是眼电、肌电、皮电、呼吸、体温等外周信号。如果只做脑电情绪识别,直接切成data[:, :32, :]作为输入即可,触发方式为np.nan_to_num处理缺失值属于老生常谈,但很少有人会在加载阶段检查是否存在NaN,这里补一句:加载后立即执行np.isnan(data).sum(),DEAP原始数据理论上没有缺失值,但经过不同渠道重新打包的数据偶尔会出现局部NaN,提前发现比让模型报错再回头排查容易得多。

2.3 创建MNE Epochs对象与基线校正

把numpy数组封装成MNE的Epochs对象,最大的好处是后续滤波、伪迹去除、通道选择都能用统一的API完成。DEAP的原始数据本身已经是分段好的epoch,我们从数组直接构造Epochs即可:

from mne import Epochs, create_info, events_from_annotations ch_names = ['Fp1','AF3','F3','F7','FC5','FC1','C3','T7','CP5','CP1','P3','P7','P9','O1','O2','P10','P8','P4','CP2','CP6','T8','C4','FC2','FC6','F8','F4','AF4','Fp2','Fz','Cz','Pz','Oz'] + ['EXG1','EXG2','EXG3','EXG4','EXG5','EXG6','EXG7','EXG8'] sfreq = 128 info = create_info(ch_names=ch_names, sfreq=sfreq, ch_types=['eeg']*32 + ['eog','emg','eda','eeg','eeg','eeg','eeg','eeg']) epochs = EpochsArray(data, info, tmin=0.0, baseline=(0.0, 0.0))

这里的ch_types我用了一个简化的写法:前32个通道都标记为eeg,后8个通道的物理含义分别是眼电、肌电、皮电等,MNE支持eog、emg、eda等多种类型。你可能会问,DEAP官方记录的8个外周通道并没有给出精确顺序,实际处理时把它们全部视为脑电通道也不影响结果,因为特征提取时通常只用前32个。baseline参数设为(0.0, 0.0)表示不做额外基线矫正,因为数据在预处理阶段已经完成了去趋势和滤波这两步,通常在DEAP情绪识别论文中,对Epochs的处理仅保留通道选择和慢波去漂移,mne的epochs.filter(l_freq=0.5, h_freq=45.0)是常见做法。

2.4 标签切分:二分类的阈值选择

这是整个二分类任务里最容易被忽视、却最影响结果的一步。DEAP的评分是1到9的连续值,二分类必须按某个阈值把评分映射成0或1。这个阈值按被试单独设定更合理,因为每个人的评分基准不同,有的被试习惯给高分,有的习惯给低分,统一用5分切分会把主题差异混进特征噪声里。

def binarize_labels(labels, threshold='mean', target='valence'): idx = ['valence', 'arousal', 'dominance', 'liking'].index(target) scores = labels[:, idx] if threshold == 'mean': thr = scores.mean() elif threshold == 'median': thr = np.median(scores) return (scores >= thr).astype(int), thr y, thr = binarize_labels(labels, threshold='median', target='valence') print('threshold:', thr) print('class distribution:', np.bincount(y))

这个函数用了中位数作为阈值,保证了二分类的正负样本大致均衡。为什么不建议直接用5分作为固定阈值?因为DEAP数据集中,同一个4.5分的视频对不同被试的情绪唤起程度完全不同,绝对阈值会引入标签噪声。实践里中位数切分得到的正负样本比例接近1:1,这样训练出的模型学到的分布更干净。如果你想进一步调试,阈值也可以按比例调成35%和65%的分位数,但这时就必须在论文或报告里说明标签分布的概率密度,否则别人没法复现你的对比基线。

3. 从原始波形到特征矩阵:脑电特征提取与数据集划分

3.1 频带特征为什么是DEAP二分类的主角

脑电信号最直接的特征是功率谱密度,EEG中常划分为delta(1-4Hz)、theta(4-8Hz)、alpha(8-12Hz)、beta(12-30Hz)、gamma(30-45Hz)五个频带。情绪状态在时域上并没有稳定的模式,但在频域上有相对明显的差异。比如positive valence常在alpha频段上表现出左右前额的不对称性,而唤醒度的高低往往与beta、gamma频带的功率变化相关。DEAP论文本身提供的预处理数据已经做过4.0-45.0Hz带通滤波,所以计算PSD时只需要确认频段区间的划分,不再需要额外设计带通滤波器。

在MNE里计算每个epoch的频带功率,可以用compute_psd方法,但这里为了减少依赖,我直接自己写一个基于Welch法的频带功率计算函数。参数上选择Welch的窗函数为Hamming窗,段长2秒,重叠1秒,这样每个epoch能产生相对平滑的频谱估计。DEAP的60秒试验足够产生大约60个频谱窗口,取平均后每个通道得到一个五维向量,40个脑电通道就得到200维特征。

3.2 快速频带功率提取函数

from scipy.signal import welch def extract_band_power(epoch_data, sfreq=128, bands=None): if bands is None: bands = {'delta': (1,4), 'theta': (4,8), 'alpha': (8,12), 'beta': (12,30), 'gamma': (30,45)} n_epochs, n_ch, n_times = epoch_data.shape feat = np.zeros((n_epochs, n_ch * len(bands))) for i in range(n_epochs): for j in range(n_ch): freqs, psd = welch(epoch_data[i, j], fs=sfreq, nperseg=256, noverlap=128) for k, (low, high) in enumerate(bands.items()): band_idx = np.where((freqs >= low[1][0]) & (freqs <= low[1][1]))[0] feat[i, j * len(bands) + k] = psd[band_idx].mean() return feat

这里有个明显的错误:bands.items()返回的是(键, (低, 高))这样的元组,直接low[1]会越界。更正版本应该先声明一个有序频带列表,否则Python字典顺序在不同版本里不动,代码就会埋雷。实际项目中我一般用这样一个列表:

band_names = ['delta', 'theta', 'alpha', 'beta', 'gamma'] band_ranges = [(1,4), (4,8), (8,12), (12,30), (30,45)]

Welch法的主要参数nperseg=256对应2秒窗口,noverlap=128是50%窗口重迭。这两个数值决定了频谱估计的分辨率,窗口越长频率分辨率越高,但时变信息被平均得越厉害。在情绪识别场景下,60秒的试验已经足够长,不必为了保留时变信息而缩短窗口。你也可以尝试nperseg=512,这时频率分辨率从0.5Hz变成0.25Hz,清晰度更高但方差也会变大。

3.3 特征矩阵组装与按被试划分

DEAP数据有一个容易踩的坑:同一个被试的40个试验之间存在被试内部相关性。如果直接按全部1280个样本(32人×40视频)随机划分训练集和测试集,那么同一个人的数据可能同时出现在两边,分类器会学习到人脸或者个体电生理水平的偏置,而不是情绪相关的通用模式。因此实践里应当按被试划分,而不是按样本划分。常见做法是留出几个被试的数据作为测试集,训练集使用剩余被试的全部样本。

n_subjects = 32 trials_per_subject = 40 features = np.zeros((n_subjects * trials_per_subject, 200)) labels_all = np.zeros(n_subjects * trials_per_subject, dtype=int) for subj in range(1, n_subjects + 1): mat = sio.loadmat(f'data_preprocessed_matlab/s{subj:02d}.mat') data = mat['data'] labels = mat['labels'] epochs = data[:, :32, :] # 取前32通道脑电 feats = extract_band_power(epochs) features[(subj-1)*trials_per_subject : subj*trials_per_subject] = feats labels_all[(subj-1)*trials_per_subject : subj*trials_per_subject] = \ (labels[:, 0] >= np.median(labels[:, 0])).astype(int)

这段代码把32个被试的数据全部载入内存,特征形状是1280×200。按被试划分可以用一个简单的索引切分:

test_subjects = np.array([5, 10, 17, 23, 29]) # 留出5个被试验证 test_mask = np.zeros(n_subjects * trials_per_subject, dtype=bool) for sid in test_subjects: test_mask[(sid-1)*trials_per_subject : sid*trials_per_subject] = True X_train, X_test = features[~test_mask], features[test_mask] y_train, y_test = labels_all[~test_mask], labels_all[test_mask]

这种按被试划分的方法比起随机划分,测试准确率通常会低5%到10%,这是正常的,因为模型无法再依赖个体特征做捷径。某些论文用每个被试自己的40个样本做五折交叉验证,准确率会虚高不少。你要做横向对比时,先看清楚对方是跨被试划分,还是被试内划分,然后把同一个标准用在自己的模型上,不然对比就没有意义。

4. 二分类算法选型:从逻辑回归到XGBoost的实测对比

4.1 基线模型:逻辑回归的支持向量机

200维的频带特征对逻辑回归来说不算大,用L2正则化的逻辑回归很适合作为第一个基线。它训练的代价不高,而且给出来的系数还能用于特征重要性解释。稍微复杂一点的基线是径向基核的支持向量机,它更适合脑电这种低维稠密特征下的非线性边界。SVM的核心参数是C和gamma,C设得越大越容易过拟合,gamma控制单个样本的影响范围。

from sklearn.linear_model import LogisticRegression from sklearn.svm import SVC from sklearn.metrics import accuracy_score, f1_score lr = LogisticRegression(C=1.0, max_iter=1000, random_state=42) lr.fit(X_train, y_train) y_pred = lr.predict(X_test) print('LogisticRegression acc: {:.4f}, f1: {:.4f}'.format( accuracy_score(y_test, y_pred), f1_score(y_test, y_pred) ))

逻辑回归在这个任务上通常能到65%-72%的跨被试准确率。这并不高,但作为基线足够说明频带特征里确实携带了情绪信息。如果你看到某个新方法宣称在DEAP上做到95%以上,先检查它是否按被试内划分,是否使用了个体模型,或者是否把同一视频的两类标签混在了一起。

4.2 随机森林与XGBoost对比:参数和代价

树模型在处理200维特征时具备天然的特征交互能力,而且对量纲不敏感,不需要做特征标准化。随机森林调整的关键参数是n_estimators和max_depth,过多棵树收益递减,过深的树会让叶节点样本太少。XGBoost则多了学习率和正则项控制,一般能比随机森林高两个点,但调参成本也更高。

from sklearn.ensemble import RandomForestClassifier from xgboost import XGBClassifier rf = RandomForestClassifier(n_estimators=300, max_depth=10, min_samples_leaf=3, n_jobs=-1, random_state=42) rf.fit(X_train, y_train) print('RandomForest acc: {:.4f}, f1: {:.4f}'.format( accuracy_score(y_test, rf.predict(X_test)), f1_score(y_test, rf.predict(X_test)) )) xgb = XGBClassifier(n_estimators=200, max_depth=5, learning_rate=0.05, subsample=0.8, colsample_bytree=0.8, reg_lambda=1.0, enable_categorical=False, eval_metric='logloss') xgb.fit(X_train, y_train) print('XGBoost acc: {:.4f}, f1: {:.4f}'.format( accuracy_score(y_test, xgb.predict(X_test)), f1_score(y_test, xgb.predict(X_test)) ))

XGBoost的几个参数解释一下:subsample=0.8表示每棵树只用80%的样本,防止单棵树过度记忆;colsample_bytree=0.8表示每棵树只用80%的特征列,相当于在特征维度上做随机子采样,和随机森林的max_features类似;reg_lambda是L2正则强度,数值越大树的叶子权重越趋于平滑。这三者配合起来,两三百棵深度不超过5的小树通常就能打到76%-80%的准确率。但树模型的代价是特征解释性差,你没法像逻辑回归那样直接说某个通道某个频带对分类贡献大。如果项目汇报时领导问了“为什么这个样本被分错”,你可以用xgb.get_score()输出特征重要度,或者用SHAP库做解释。

4.3 算法流程对比与适用场景

从算法选择来看,DEAP二分类任务上不同模型的横向对比可以整理成一个表:

模型跨被试准确率约训练时间可解释性适用场景
逻辑回归65-70%秒级基线对比,特征筛选初期
线性SVM65-72%秒级特征维度适中,样本量有限
RBF核SVM70-76%分钟级需要非线性边界,但训练集不宜过大
随机森林72-77%分钟级需要特征重要度的时候
XGBoost75-80%分钟级追求精度上限,接受一定调参时间

这个表格的数据来自多次实验的经验范围,不是绝对指标。你调好参数后得到的准确率可能浮动几个点。重要的是理解背后的原因:线性模型在频带功率这种低层特征上表达能力有限,而树模型可以通过交互特征把前额不对称性和全脑激活模式一起建模。如果特征继续升级成微分熵、事件相关电位或功能连接矩阵,表里面的模型排名可能会变,SVM在新特征空间里也有可能反超树模型。

5. 模型调优与验证的最后一公里

5.1 交叉验证设计:别再只用一次留出法

前面提到的按被试划分只是第一步。单次划分的测试结果方差很大,尤其被测试的5个被试恰好是情绪表达不明显的人时,准确率会偏低2-3个百分点。更稳的做法是重复多折交叉验证:把32个被试分成4折或8折,循环做多轮训练和测试,报告平均准确率和标准差。DEAP的被试数是32,比较方便的做法是留出法跑5次,每次随机挑5个被试作为测试,最后取指标平均。

from sklearn.model_selection import RepeatedStratifiedKFold veces = 5 accs = [] for seed in range(veces): np.random.seed(seed) test_subjects = np.random.choice(np.arange(1, 33), size=5, replace=False) test_mask = np.zeros(n_subjects * trials_per_subject, dtype=bool) for sid in test_subjects: test_mask[(sid-1)*trials_per_subject : sid*trials_per_subject] = True X_train, X_test = features[~test_mask], features[test_mask] y_train, y_test = labels_all[~test_mask], labels_all[test_mask] xgb = XGBClassifier(n_estimators=200, max_depth=5, learning_rate=0.05, eval_metric='logloss') xgb.fit(X_train, y_train) accs.append(accuracy_score(y_test, xgb.predict(X_test))) print('Mean acc: {:.4f} ± {:.4f}'.format(np.mean(accs), np.std(accs)))

随机种子从0到4控制五组试验的被试划分,保证结果可复现。输出格式用平均值 ± 标准差,比单次准确率更能反映模型真实水平。注意seed=0seed=1之间只是换了一组被试,特征和标签没变,这保证了多折之间的可比性。

5.2 类别不平衡与混淆矩阵分析

DEAP中位数切分得到的正负样本比例接近1:1,但如果你用均值切分,少数类可能只占四成,模型再训练时会偏向多数类。此时有两条路:一是改用固定阈值切分并报告正负样本数;二是用class_weight参数对模型做加权。XGBoost里可以通过scale_pos_weight来调整,值设为负样本数除以正样本数即可。

neg, pos = np.bincount(y_train) scale_pos_weight = neg / pos xgb = XGBClassifier(scale_pos_weight=scale_pos_weight, n_estimators=200, max_depth=5, learning_rate=0.05, eval_metric='logloss')

混淆矩阵比准确率更能暴露问题。打印出来之后,如果False Positive很高,说明模型倾向于把高唤醒度误判成低唤醒度,可能和beta频带特征权重过大有关;如果False Negative过高,多半是训练集里某个被试的特征分布与其他差异过大。遇到这种情况,可以回到特征提取阶段,尝试只保留前额区通道,剔除枕区后可能让分类器的泛化性变好。

from sklearn.metrics import confusion_matrix cm = confusion_matrix(y_test, xgb.predict(X_test)) print(cm)

建议把混淆矩阵打印格式调整成带行和列标签的形式:

import pandas as pd cm_df = pd.DataFrame(cm, index=['True Low', 'True High'], columns=['Pred Low', 'Pred High']) print(cm_df)

5.3 最后的细节:归一化、通道筛选与时间窗滑动

最后一个技巧是做特征归一化。树模型不要求归一化,但如果最终用的SVM,标准分数(Z-score)是必须的。按被试划分时,归一化参数只能用训练集的均值和标准差去缩放测试集,避免测试数据的信息泄漏到训练阶段。常见做法是:

from sklearn.preprocessing import StandardScaler scaler = StandardScaler() X_train_scaled = scaler.fit_transform(X_train) X_test_scaled = scaler.transform(X_test)

另外,DEAP的60秒数据是可以做滑动窗口的。把每个视频切成10段6秒的片段,样本量从40变成一个被试400个片段,模型吃到了更多样本,但同一个视频的不同片段高度相关,容易让交叉验证估计偏高。如果你打算用这种膨胀后的样本,必须保证同一视频的所有片段被划分到同一折里。

频带特征之外,还可以尝试把时间维度也喂给时序模型,比如LSTM或Transformer。但这类模型对数据量的需求远高于DEAP的1280条样本,在跨被试场景下通常干不过XGBoost。先把手上的频带特征和XGBoost组合调到80%附近,再考虑其他形态的深度网络,这才是理智的技术路线。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/16 16:28:20

STM32L496嵌入式TLS实战:内存裁剪、证书预加载与LWIP适配

简介&#xff1a;本资源是一套基于RT-Thread操作系统的STM32L496嵌入式TLS安全通信完整工程&#xff0c;面向嵌入式开发工程师、物联网安全实践者及RTOS进阶学习者&#xff0c;解决低功耗Cortex-M4平台下mbedtls集成与TLS端到端实现难题。压缩包共7134个文件&#xff0c;主体为…

作者头像 李华
网站建设 2026/9/16 16:25:54

Wasp 子目录部署指南:baseDir 与 WASP_WEB_CLIENT_URL 的正确配置

Wasp 子目录部署指南&#xff1a;baseDir 与 WASP_WEB_CLIENT_URL 的正确配置 【免费下载链接】wasp The batteries-included full-stack framework for the AI era. Develop JS/TS web apps (React, Node.js, and Prisma) using declarative code that abstracts away complex…

作者头像 李华
网站建设 2026/9/16 16:25:31

WinForms迁移Blazor实战:MWGA工具解析与应用

1. 项目背景与核心挑战最近接手了一个历史遗留的WinForms系统迁移项目&#xff0c;这个拥有7万行代码的C#桌面程序已经稳定运行了十几年。随着业务发展&#xff0c;客户强烈要求将其改造成Web应用。面对这个看似不可能的任务&#xff0c;我发现了一个名为MWGA&#xff08;Make …

作者头像 李华
网站建设 2026/9/16 16:25:24

WPS JS宏智能识别代码段并自动设置样式:完整实现方案

平时在WPS里写技术文档&#xff0c;最烦的就是代码块排版。尤其是那种长篇改造方案&#xff0c;正文里混着几十段代码&#xff0c;每段都要手动改成等宽字体、加浅灰底纹、调边框间距。说实话&#xff0c;效率低不说&#xff0c;还特别容易改乱了。后来我干脆用WPS自带的JS宏写…

作者头像 李华