多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

DEAP脑电情绪二分类实战:FFT特征提取+SVM/KNN/决策树完整流程

DEAP脑电情绪二分类实战:FFT特征提取+SVM/KNN/决策树完整流程 简介面向刚接触脑电信号处理与机器学习的新手研究者这份基于DEAP脑电数据集的脑电情绪二分类项目提供了从信号处理到模型训练的一站式完整流程。项目覆盖快速傅里叶变换(FFT)频域转换、滤波去伪迹与归一化等数据预处理步骤并实现决策树、SVM、KNN三类经典模型均可直接调用库完成训练与测试便于快速理解脑电情绪识别的基本技术路线。压缩包共7个文件以Python脚本为主含特征提取、FFT处理、主流程整合等分工明确的模块另附项目说明文档与辅助内容压缩包整体仅8KB轻量易用。目前已有134人浏览学习。通过该资源读者可以掌握从原始脑电信号到特征提取、模型训练与评估的完整链路并基于这些简洁的基础模型进一步扩展实验、逐步深入更高级的识别方案。1. 用DEAP做脑电情绪二分类一个能快速跑通全流程的入门资源脑电情绪识别这几年在机器学习圈子里热度一直不低但真正上手的人往往卡在第一步——数据怎么读、特征怎么提、模型怎么选。这份基于DEAP数据集的脑电情绪二分类资源把从原始脑电信号到SVM/KNN/决策树出分类结果的完整链路都拆开了很适合想要在脑电数据上做一次完整实验的初学者。它用FFT做频域特征提取用三个可直接调库的经典分类器做二分类验证代码结构清晰能帮你绕过从「看了很多论文」到「跑通第一个实验」之间的那道坎。下面我把这个项目从数据格式到模型验证逐个拆开讲顺便把几个容易翻车的细节也一并说清楚。2. FFT特征提取管线把32通道脑电压成128维向量2.1 为什么情绪分类要先做FFT时域信号到频带能量的映射脑电信号本质上是微伏级别的时域电压波动直接看波形很难区分情绪状态。但神经生理学上有个基本规律不同情绪状态会改变大脑特定频带的能量分布。放松时alpha波活跃情绪唤醒时beta和gamma波能量上升。把时域波形做FFT转到频域等于把这个规律转成分类器能用的数值特征。DEAP数据集官方采样率是512Hz但网上流传最广的版本是降采样到128Hz的。一次试验63秒128Hz采样就是8064个采样点32个脑电通道。如果直接把原始时域信号作为特征每个trial就是32乘以8064超过25万维别说SVM神经网络都不好直接吃。用FFT把所有通道的频域包络压缩成几个频带的平均功率维度直接降到32通道乘以4个频带等于128维这个降维幅度是线性的几乎没有信息损失——至少对于情绪二分类这个任务足够了。2.2 拆解fft.py频带划分与功率谱特征计算这个项目里的fft.py核心逻辑并不复杂就是rfft算频谱然后按脑电频带切分功率谱取均值。我在复现时用scipy的rfft重写了核心函数代码更短且不容易写错索引。import numpy as np from scipy.fft import rfft, rfftfreq def band_power_features(signal, fs128.0, bandsNone): 对单通道脑电信号提取频带平均功率特征 signal : 一维数组形状为 (n_samples,) fs : 采样率DEAP降采样后为128Hz bands : 频带字典键为频带名值为(下限Hz, 上限Hz) if bands is None: bands {theta: (4, 8), alpha: (8, 13), beta: (13, 30), gamma: (30, 45)} n len(signal) freqs rfftfreq(n, d1.0 / fs) spectrum rfft(signal) # 功率谱 幅值谱的平方 power np.abs(spectrum) ** 2 feats [] for lo, hi in bands.values(): mask (freqs lo) (freqs hi) feats.append(np.mean(power[mask])) return np.array(feats)这里有几个关键点。rfft只计算正频率部分输出长度大约是n的一半计算量比完整FFT少一半这对一次处理32个通道的循环很重要。d1.0/fs这个参数决定频率轴的刻度如果传错后面所有频带索引都会错位特征值看上去正常但含义全偏了。频带划分沿用的是脑电研究里标准做法theta管情绪加工alpha管放松程度beta和gamma管唤醒。原项目里还有个细节频带边界用了左闭右开区间避免同一个频率点被两个频带重复计算。碰到底部噪声偏高的时候可以把theta下限从4Hz抬到5Hz对去噪效果比较明显。2.3 eeg_plot_code.py画图确认信号质量先说明一个现实脑电数据不是拿到就能直接提特征的。眼动伪迹、肌电干扰、电极接触不良产生的漂移都会让FFT算出来的频谱失真。eeg_plot_code.py这个绘图脚本就是用来做人工质检的。import matplotlib.pyplot as plt import numpy as np def plot_trial_signal(data_trial, channel0, fs128): data_trial: 形状为 (n_channels, n_samples) 的单个trial channel : 指定要查看的通道索引 fs : 采样率 n_samples data_trial.shape[1] time np.arange(n_samples) / fs plt.figure(figsize(12, 4)) plt.plot(time, data_trial[channel], lw0.6, colorsteelblue) plt.xlabel(Time (s)) plt.ylabel(Amplitude (uV)) plt.title(fChannel {channel 1} Raw Signal) plt.grid(alpha0.3) plt.show()看波形时重点关注两件事一是整段信号有没有明显的直流漂移也就是波形不是围绕0线上下波动而是缓慢地整体抬升或下降这种情况后期需要在预处理里做基线校正二是有没有突然出现的尖峰那往往是眨眼或头部动作留下的伪迹。正常脑电的幅值范围在正负50微伏左右如果某个通道峰峰值到了两三百微伏这个通道就要标记出来要么剔除要么后续用ICA去伪迹。频谱图也可以顺手看一眼。def plot_spectrum(signal, fs128): freqs np.fft.rfftfreq(len(signal), 1 / fs) power np.abs(np.fft.rfft(signal)) ** 2 plt.figure(figsize(10, 3)) plt.plot(freqs, power, lw0.8) plt.xlabel(Frequency (Hz)) plt.ylabel(Power) plt.xlim([0, 60]) plt.show()正常脑电频谱应该在低频段能量偏大、高频段平滑衰减。如果看到50Hz处有异常尖峰那是工频干扰DEAP数据本身没这个问题但自己采集数据时这是最常见的坑后面章节细说。3. 数据预处理与标签划分把DEAP的40次试验变成可训练样本3.1 DEAP数据长什么样trial、通道、标签的组织方式很多人在DEAP上第一次翻车是因为搞不清数据文件的结构。DEAP的mat文件里有两个核心变量data和labels。data的形状是(40, 40, 8064)含义是40次试验40个通道8064个采样点。40个通道里前32个是脑电通道后8个是眼电、肌电、皮电这类生理信号做脑电情绪识别时一般只取前32个。labels的形状是(40, 4)分别对应valence、arousal、dominance、liking四个维度的评分每个评分范围是1到9。项目要做的是情绪二分类通常就是用valence正负效价或者arousal唤醒度做标签。一次试验持续63秒其中前3秒是静息基线后面60秒才是真正的刺激呈现阶段。基线数据很重要后面预处理要用它做校正。3.2 预处理管线降采样、滤波、基线校正、归一化这个项目里把预处理分为滤波、去伪迹、归一化几个步骤。我按自己的习惯把完整管线串了一遍顺序是最关键的。import scipy.io as sio import numpy as np from scipy import signal as sig def load_deap_subject(file_path): 加载单个被试的DEAP数据文件 注意DEAP文件扩展名是.dat但内部是MAT v5格式 mat sio.loadmat(file_path) # squeeze_me会让shape变化这里不用 data mat[data] # (40 trials, 40 channels, 8064) labels mat[labels] # (40, 4) return data, labels def preprocess_trial(raw_trial, fs128, baseline_seconds3): raw_trial: 单个trial(40 channels, 8064) 返回: 去基线后的32通道数据 eeg raw_trial[:32, :] # 只保留脑电通道 # 1. 滤波4-45Hz带通再补一个50Hz陷波 b_band, a_band sig.butter(4, [4, 45], btypebandpass, fsfs) eeg sig.filtfilt(b_band, a_band, eeg, axis1) b_notch, a_notch sig.iirnotch(50, 30, fsfs) eeg sig.filtfilt(b_notch, a_notch, eeg, axis1) # 2. 基线校正减去前3秒的均值 baseline_len baseline_seconds * fs baseline_mean eeg[:, :baseline_len].mean(axis1, keepdimsTrue) eeg eeg - baseline_mean # 3. 数据已经是128Hz降采样版无需再降采样 return eeg为什么滤波一定要放在基线校正之前因为带通滤波本身会改变信号的直流分量先滤波再减基线去除漂移的效果更干净。filtfilt是零相位滤波不产生相位偏移但计算量比lfilter大一倍对8064个点来说完全不是问题。iirnotch陷波滤波器是专门对付50Hz工频干扰的DEAP数据干净不代表不需要这一步写进管线里是为了让它能复用到自采数据。有一个容易被忽略的细节loadmat不要带squeeze_me参数。一旦squeeze_meTrue只有一个trial时数据维度会从3维变成2维后面所有按索引切片的位置全部错位这个Bug非常隐蔽。3.3 怎么划出二分类标签valence阈值与Trial取舍标签划分是个看似简单但其实很关键的操作。DEAP的valence评分是1到9的连续值二分类就得先定阈值。def make_binary_label(labels, target_index0, threshold5.0, subject_meanFalse): labels: (40, 4) 的原始评分 target_index: 0valence, 1arousal, 2dominance, 3liking threshold: 固定阈值默认5.0 subject_mean: 是否用被试自己的均值做阈值 scores labels[:, target_index] if subject_mean: threshold scores.mean() return (scores threshold).astype(int)两种阈值策略差别很大。固定阈值5.0是DEAP论文里的常规做法含义直白评分5以上算正类。但现实情况是不同被试打分的尺度不一样有人喜欢打高分有人习惯打低分固定阈值会造成某些被试几乎全被分到同一个类别。按被试均值为阈值时每个被试都有自己的分类边界类别会更均衡但跨被试泛化时结果会偏乐观一点。我一般会两种都跑一遍看稳定性。如果两类样本比例严重失衡比如正负样本到7比3SVM和KNN的决策边界都会偏向多数类这时要考虑加权参数或者进行简单的过采样这个在第五章再展开。4. 模型选型与调参SVM、KNN、决策树在128维特征上的表现4.1 为什么选这三个模型先建立基线再谈深度模型很多人看到脑电情绪识别就想到深度学习但这项目里刻意选用了SVM、KNN和决策树这个选择是有道理的。DEAP二分类任务本质上是小样本问题即便用满32个被试每被试40个trial总共也只有1280个样本。这个量级下深度学习模型很难发挥优势反而容易过拟合到训练集上测试结果忽高忽低黑匣子一样的运行过程也让排错变得困难。SVM在小样本高维特征上是经典选择RBF核能处理线性不可分的情况KNN无训练过程天然适合做基准对照决策树提供完全可解释的决策路径能直观看出模型在依赖哪些频带特征。这个组合比直接上神经网络更扎实三个模型互相印证不容易被某个模型的偶然表现带偏。4.2 main_v1.py的完整流程从特征矩阵到分类报告main_v1.py把整个流程串起来了。梳理下来核心逻辑是这样的读取数据、提取FFT特征、划分训练测试集、标准化、训练三个模型、输出分类报告。import numpy as np from sklearn.model_selection import train_test_split from sklearn.preprocessing import StandardScaler from sklearn.svm import SVC from sklearn.neighbors import KNeighborsClassifier from sklearn.tree import DecisionTreeClassifier from sklearn.metrics import classification_report, accuracy_score def build_feature_matrix(data): data: (40 trials, 40 channels, 8064) 返回: (40 trials, 128 features) n_trials data.shape[0] X np.zeros((n_trials, 32 * 4)) for trial_idx in range(n_trials): feats [] for channel in range(32): signal data[trial_idx, channel, :] feats.extend(band_power_features(signal, fs128)) X[trial_idx, :] feats return X # 主流程单被试示例 X build_feature_matrix(subject_data) y make_binary_label(subject_labels, target_index0, threshold5.0) X_train, X_test, y_train, y_test train_test_split( X, y, test_size0.2, random_state42, stratifyy ) # 标准化必须先fit训练集再transform测试集 scaler StandardScaler().fit(X_train) X_train_scaled scaler.transform(X_train) X_test_scaled scaler.transform(X_test) models { SVM: SVC(kernelrbf, C10, gammascale, random_state42), KNN: KNeighborsClassifier(n_neighbors5, weightsdistance), DecisionTree: DecisionTreeClassifier(max_depth5, random_state42) } for name, model in models.items(): model.fit(X_train_scaled, y_train) y_pred model.predict(X_test_scaled) print(f{name}: {accuracy_score(y_test, y_pred):.3f}) print(classification_report(y_test, y_pred, zero_division0))特征矩阵构建时用了两层循环外层遍历trial内层遍历通道。每个trial的128维特征由32个通道分别算出4个频带功率后拼接而成。代码里有个潜在的性能瓶颈40个trial两层循环问题不大但如果扩展到32个被试就是要循环1280次FFT本身计算量也不小建议把所有trial的原始信号先拼成一个大数组一次性做rfft能省掉不少时间。train_test_split里的stratifyy参数很关键它保证划分后的训练集和测试集正负样本比例一致避免出现测试集全是正类这种极端情形。random_state固定为42确保每次运行结果可复现。4.3 三个模型的参数建议C、核函数、n_neighbors、max_depth模型关键参数推荐值调参方向SVMkernel / C / gammarbf / 10 / scaleC过大会过拟合128维特征下C1到10是安全区间KNNn_neighbors / weights5 / distance样本量小K值取3到7距离权重优先决策树max_depth / min_samples_split5 / 5深度超过5基本就过拟合了除非特征数增加到256以上SVM对特征缩放极其敏感这就是为什么要先StandardScaler。如果不做标准化脑电电力谱的数值范围很大有些通道的beta功率可能是theta功率的几十倍SVM的决策边界会被这些大数值特征绑架。gammascale是根据特征数量自动计算的值128维下效果不错不用手动去调。KNN用欧氏距离时同样受特征量纲影响。weightsdistance让离得更近的近邻权重更大比默认的uniform能提升一点点准确率代价是计算量稍大对128维的向量来说可以忽略。决策树的max_depth5是个保守值。脑电特征之间本身就存在相关性比如相邻通道的alpha功率几乎肯定高度相关树模型容易在这些冗余特征上反复分裂深度一大必然过拟合。min_samples_split5保证每个叶节点至少需要5个样本才能继续划分进一步抑制过拟合。5. 避坑DEAP二分类常见的五个问题与排查记录5.1 加载.dat文件报错文件扩展名误导与scipy兼容现象用sio.loadmat加载DEAP的.dat文件时报错说not a mat file或者文件格式无法识别。原因DEAP官方数据文件扩展名是.dat但内部其实是MATLAB的MAT v5格式。有些人想当然地把它当成文本或二进制文件读结果自然不对有人则用h5py去读因为新版MATLAB默认格式改成HDF5了但DEAP发布较早用的还是旧版格式。解决老老实实用scipy.io.loadmat读别管扩展名。如果编辑器里看不出文件头可以用file命令检查文件类型。顺带确认一下loadmat参数不要加squeeze_meTrue否则数据维度会被吃掉。5.2 归一化放在划分之前数据泄漏让结果虚高现象在单被试上跑SVM准确率能到95%以上换到新被试立马掉回60%。原因代码里先对整个数据集做了StandardScaler再划分训练测试集。标准化时已经用到了测试集的均值和方差等于把测试集的信息提前泄露给了模型测试准确率虚高是必然的。解决正确的顺序是先划分训练集和测试集再用训练集的均值和方差去标准化测试集。代码里体现为scaler.fit(X_train)然后transform两个集合。很多人习惯在数据预处理阶段顺手做标准化但在机器学习流程里这一步必须放到划分之后。5.3 固定阈值5.0导致类别不平衡部分被试样本全被分到同一类现象模型评估结果出现极端值某个被试的accuracy只有50%但sensitivity是100%specificity是0%。看混淆矩阵发现预测结果全是背景类。原因固定阈值5.0对评分习惯不同的被试不公平。某些被试的valence评分集中在3到7之间均值可能只有4.2正样本比例远低于负样本。极端情况下正样本只有两三个模型为了总体准确率干脆全部预测多数类。解决先检查每个被试的正负样本分布打印出来看。正负比失衡时改用被试自身均值作为阈值或者在分类器里加上class_weightbalanced将SVM和决策树都转换为代价敏感学习。伪影箱还好说但如果30个trial里有25个是负类这个被试的数据就要考虑从实验里剔除或做定向采样。5.4 FFT窗口选取不当导致特征漂移现象同一个trial前后提取两遍特征数值分布完全对不上。原因有些FFT实现会对整段信号零填充零填充会让频谱变平滑但对短数据段效果很差。还有一种常见做法是加窗函数比如汉明窗这会改变频带功率的绝对值。原项目代码里如果不处理窗口就是默认矩形窗也有隐患——当信号不是周期整数倍时矩形窗会产生频谱泄漏θ波的能量漏到δ频带里。解决固定实现统一处理方式。要么整个数据集都用不加窗的rfft要么统一加汉明窗。考虑到DEAP的trial长度是8064点对4Hz以上的频带来说频率分辨率约有0.016Hz频谱泄漏的影响不大。但代码里一旦出现加窗就必须保证所有trial用同一个窗函数否则特征的绝对值没有可比性。如果看频谱图发现θ带和α带之间存在明显的阶梯状突起优先考虑是不是窗口和频率分辨率不匹配。5.5 混入非脑电通道后8个生理信号通道被当成了特征现象模型效果出奇地好准确率接近100%但评估时用的是同一被试的trial泛化到跨被试任务时崩掉。原因DEAP的40通道里前32是脑电后8是EOG、EMG、GSR这类生理信号。眼电动则几百微伏EEG只有几十微伏FFT做出来眼电频带能量远高于脑电。分类器学到的是这个样本眼电活跃度高而不是情绪相关的脑电模式所以被试内效果虚高。解决构建特征矩阵之前强制只取前32个通道代码里用eeg raw_trial[:32, :]截断。如果后端8个通道的生理信号想用也行但需要单独建模不要和脑电混在一起提特征两者幅值量级不是一回事。6. 验证实验用五折交叉验证和混淆矩阵看模型真实水平很多初学者的第一个实验都是单次划分训练测试集跑出一个accuracy就收工了这种验证方式在脑电数据上不够可靠。DEAP里每个被试的40个trial本身存在时间连续性同一被试相邻trial之间信号模式高度相似随机划分训练测试集时模型很可能见过某个trial的近亲准确率偏乐观。推荐的验证策略是分层五折交叉验证配合逐被试的混淆矩阵看细节。代码可以这样组织from sklearn.model_selection import StratifiedKFold, cross_val_score def evaluate_models_cv(X, y, n_splits5): cv StratifiedKFold(n_splitsn_splits, shuffleTrue, random_state42) feature_scaler StandardScaler() for name, model in models.items(): acc_list [] for train_idx, test_idx in cv.split(X, y): X_train, X_test X[train_idx], X[test_idx] y_train, y_test y[train_idx], y[test_idx] X_train feature_scaler.fit_transform(X_train) X_test feature_scaler.transform(X_test) model.fit(X_train, y_train) acc_list.append(model.score(X_test, y_test)) vals np.array(acc_list) print(f{name}: {vals.mean():.3f} ± {vals.std():.3f})五折交叉验证里每次折叠的测试集占20%模型在5个不同子集上分别评估一次均值和方差一起看才可靠。方差大说明模型在不同数据子集上表现不稳定这个不稳定性本身比均值更有参考价值。混淆矩阵是另一个必须看的指标尤其当两类样本不平衡时from sklearn.metrics import confusion_matrix y_pred models[SVM].predict(X_test_scaled) cm confusion_matrix(y_test, y_pred) print(Confusion matrix:) print(cm) # 输出格式: # [[TN, FP], # [FN, TP]]只看accuracy很容易被多数类带偏。比如90%的样本都是负类全预测负类的准确率就是90%看起来挺高实际模型什么都没学会。混淆矩阵能让你一眼看出模型是真正学到了两个类别的界限还是只是靠类别先验信息在猜。脱离单个被试的评估之外跨被试验证才是脑电情绪识别里真正残酷的测试。用前20个被试训练后12个被试测试准确率通常会比被试内实验掉15到20个百分点。别把这个结果当成失败跨被试性能下降代表模型学到的是个体特异性模式而不是普遍的情绪神经机制。想提升跨被试泛化能力可以尝试把特征换成微分熵或者在预处理里加入被试级标准化把不同被试的信号幅度拉齐。这个项目以单被试分析为主对于入门来说这个起点足够扎实了。我自己的习惯是每次跑完一个脑电分类实验都强制走一遍单被试混淆矩阵加跨被试折线图这两步前者确认模型没有偷懒后者给自己一个客观的泛化预期。先把这两个基本动作养成肌肉记忆再往深度学习方向延伸也不迟。希望帮到你。本文还有配套的精品资源点击获取
返回列表