ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

EEG脑电信号与RNN分类:MATLAB实现LSTM预处理与训练全流程解析

EEG脑电信号与RNN分类:MATLAB实现LSTM预处理与训练全流程解析

简介:这份代码包是基于论文《人类脑电图和递归神经网络在语音识别过程中表现出共同的时间动态》公开发布的MATLAB分析实现,系统开源,主要面向从事脑电信号处理、神经网络可解释性与语音认知研究的科研人员及高年级学生。包内共51个文件,几乎全部为.m脚本,另附1个README.md运行说明,压缩包整体仅77KB。脚本按“频域表示→刺激包络与模型输出交叉相关→刺激包络与EEG交叉相关→表征相似性分析(RSA)”的主线组织,覆盖窄带滤波、欧氏距离RDM计算、随机/训练网络对比、头皮拓扑可视化等关键步骤,方便读者按顺序执行并复现论文图表;依据README中的路径设置提示,可快速在本地环境中跑通整个流程。目前已有162人学习/下载,适合希望快速上手复现实验、深入理解语音识别过程中神经与模型时间动态对应关系的研究者。

1. 一个跑通的脑电信号分类项目:EEG 加 RNN 不只是拼接口

如果你第一次拿到脑电数据,把原始电压值直接扔进 LSTM,十分钟后大概率会怀疑人生。我见过 A 同学这么干,预处理只做了个去均值,模型训练到一半内存先炸了,调低 batch size 后准确率又掉到三成。EEG 信号分类真正难的从来不是网络结构,而是要处理采样率高、噪声大、试次边界不齐、个体差异明显这一整串麻烦。这个名为 rsamatlab代码-EEGAndRNNAnalysis 的项目解决的就是这件事:用 MATLAB 把脑电信号从原始文件一路处理成能喂给循环神经网络的序列数据,并在同一个框架里完成训练、评估与结果可视化,给“脑电图加神经网络分析”提供了一个可以直接改的系统开源方案。

适合谁?做脑机接口、认知实验或者生理信号分类的开发者;以及那些手上有 EEG 数据、想换掉传统特征加分类器流水线、但又不想从零写预处理和网络训练的人。接下来我会从架构、核心代码、跑通步骤到改造思路,把它完整拆开。

2. 架构设计与技术选型:为什么用 MATLAB 串起信号处理和深度学习

2.1 技术栈:三个工具箱的分工

这个项目构建在 MATLAB 生态上,核心依赖是三个工具箱:Signal Processing Toolbox 负责滤波、重参考、去伪迹等经典信号处理;Statistics Toolbox 处理特征统计、归一化和交叉验证分组;Deep Learning Toolbox 提供 LSTM 层、训练选项和评估指标。三者通过脚本逐级串联,形成一个从数据到分类结果的完整流水线。

选择 MATLAB 做这个项目在我看是合理的。EEG 分析里大量操作是滤波、时频分解、独立成分分析这类信号处理,MATLAB 在这块的函数库比通用编程语言成熟很多,尤其在处理多通道连续信号时的内存管理和矩阵操作非常高效。另一方面,Deep Learning Toolbox 让 LSTM 的搭建缩成几行配置,数据格式也能直接对接通道×时间矩阵,省掉了中间的数据序列化转换。

项目本身是系统开源的,结构上没有用 App 打包,也不依赖某个具体的采集设备。脚本是典型的研究型代码风格:按功能拆成多个 .m 文件,用主脚本统一调用,没有做成 GUI。也就是说,你要用它,不需要点界面,而是打开脚本、配好路径、改参数、运行。

2.2 目录结构与数据流

拿到项目后先别急着跑,花十分钟把目录看清楚。项目大概是这样的组织方式:

EEGAndRNNAnalysis/ ├── data/ # 原始EEG数据与试次信息 ├── preprocess/ # 滤波、去伪迹、分段的前处理函数 ├── features/ # RSA分析与特征构造脚本 ├── models/ # LSTM网络定义与训练入口 ├── evaluate/ # 分类报告与混淆矩阵输出 ├── utils/ # 加载数据、画图、指标计算等公共函数 └── run_pipeline.m # 全流程主脚本

从数据流的角度看,整条链路是:连续脑电信号进入 preprocess 模块,经过滤波、剔除坏段和重参考切分成固定长度的时间窗;然后进入 features 模块,在这里做 RSA 分析得到表征相似性矩阵,把高维原始信号压缩成试次间相似性结构;最后把处理后的序列张量交给 models 模块里的 LSTM 网络做分类。每个模块都有一到两个可独立运行的函数,方便你单独调试某一步。

有意思的是项目把 RSA(表征相似性分析)放在了特征提取阶段,而不是像普通分类项目那样直接做功率谱或时域统计。这个设计的价值在于:RSA 可以把不同试次间的表征距离结构化,让 LSTM 看到的输入不只是原始电压,而是一种更接近认知状态的表示。

3. 核心模块拆解:从连续脑电到分类结果的完整链路

3.1 预处理模块:滤波、分段与坏段剔除

EEG 数据的第一步几乎都是滤波。项目里设计了一个统一的预处理函数,输入是连续数据和采样率,输出是干净的分段时间序列。核心逻辑是:先做带通滤波保留有效频段,再针对工频干扰做陷波,最后按事件标记切分试次并剔除异常能量段。典型代码如下:

function [epochs, labels] = preprocess_eeg(rawData, fs, events, win) % 带通滤波,EEG一般保留0.5-40Hz filtData = bandpass(rawData, [0.5 40], fs); % 50Hz陷波(国内市电干扰) filtData = notchfilter(filtData, 50, fs); % 按事件标记切分试次:每个试次取[事件前0.2s, 事件后0.8s] trialIdx = round(events * fs); for i = 1:length(trialIdx) idx = trialIdx(i) + round([-0.2 0.8] * fs); epochs{i} = filtData(:, idx(1):idx(2)); labels(i) = get_label_from_event(events(i)); end % 剔除坏段:计算每段方差,去除方差异常大的试次 valid = cellfun(@(x) var(x(:)) < prctile(var(x(:)), 99), epochs); epochs = epochs(valid); labels = labels(valid); end

这段代码有三个关键参数需要开发者自己确认。带通范围 [0.5 40] 是常规做法,但如果你是处理高频成分较多的实验,可以把上界提到 100Hz,反之做慢皮层电位就降到 10Hz 附近。时间窗 [-0.2 0.8] 是另一个高频调整项,它决定了每条输入序列的长度,直接影响后续 LSTM 的时间步数。如果实验的事件间隔小于 1 秒,这个窗口会重叠到相邻试次,需要缩短到比如 [-0.1 0.5]。

3.2 RSA 特征提取:把多维时间序列转成相似性表征

预处理之后,项目没有直接把电压矩阵扔给网络,而是先做了一次 RSA 分析。这条链路是很多 EEG 分类项目里比较少见的。RSA 的核心是计算所有试次对之间的表征距离矩阵,然后把这个矩阵作为特征输入。因为 RSA 矩阵的行列对应试次,LSTM 可以把它当作“每个试次与其他试次的相似性模式”来学习。代码如下:

function [rdm, trialFeatures] = compute_rsa(epochs, labels) % 每个试次做时域特征提取,这里使用平均功率 trialFeatures = zeros(length(epochs), 64); for i = 1:length(epochs) trialFeatures(i,:) = mean(epochs{i}.^2, 2); end % 计算试次间相关距离得到RDM rdm = 1 - corr(trialFeatures'); % 用标签信息对RDM做归一化(类别内/类别间分离) uniqueLabels = unique(labels); for l=1:length(uniqueLabels) m = labels == uniqueLabels(l); within = mean(rdm(m,m)); rdm(m,m) = rdm(m,m) - within; end end

我一般会把 trialFeatures 再拼上原始通道均值,因为平均功率会丢掉时间动态信息,但 LSTM 的强项恰恰是学习时间依赖,完全用静态特征会让网络退化成一个普通分类器。如果你改这个项目,可以让 compute_rsa 同时输出特征矩阵和 RDM,然后并行输入 LSTM 的两个输入分支,或拼接成多通道序列。这里要非常注意 labels 的对齐:事件时间偏移半个采样点,都会导致标签错位,训练时看不出来,测试时准确率直接崩。

3.3 LSTM 模型:时间序列输入的构建与训练配置

从数据流看,LSTM 层的输入尺寸由两个因素决定:每个时间步的特征数(通道数、RSA 矩阵行数和窗口长度的乘积)和时间步长度。在运行入口里,数据会先被转换成 MATLAB 深度学习工具箱要求的格式,用 num2cell 转化成 cell 数组:

% 将数据转为LSTM需要的序列格式(时间优先) XTrain = cell(numTrials, 1); for i = 1:numTrials XTrain{i} = features{i}'; % 转成 时间步数 × 特征数 end % 定义网络 layers = [ sequenceInputLayer(size(XTrain{1}, 2)) bilstmLayer(64, 'OutputMode', 'last') dropoutLayer(0.2) fullyConnectedLayer(2) softmaxLayer classificationLayer]; options = trainingOptions('adam', ... 'MaxEpochs', 50, ... 'MiniBatchSize', 32, ... 'ValidationData', {XValidation, YValidation}, ... 'ValidationFrequency', 10, ... 'Plots', 'training-progress'); net = trainNetwork(XTrain, YTrain, layers, options);

这里两个配置直接影响最终效果。第一是 OutputMode 设置为 last,意味着网络只输出最后一个时间步的隐藏状态,用于序列级分类;如果你要做的是时间逐点分类或找出关键时间窗,要改成 sequence。第二是 dropout 放到了 BiLSTM 之后、全连接之前,这是防止小样本 EEG 数据过拟合的主要手段。EEG 试次往往只有几百条,50 个 epoch 的训练很容易让 loss 降到接近零,但验证准确率停在原地——dropout 和早停是这个项目的防过度自信机制。

3.4 评估模块:混淆矩阵与单试次准确率

训练完成之后,项目里单独封装了一个评估脚本,统一输出整体准确率、每类准确率、混淆矩阵和 loss 曲线。这部分代码不复杂,但看结果时容易被整体准确率骗过去。EEG 实验最常见的就是其中一类试次数远大于另一类,整体准确率 75% 但弱势类识别率只有 30%。因此项目里单独动了手,把混淆矩阵标准化成按行百分比,这样一眼就能看出类间混淆情况。

% 混淆矩阵归一化显示 [cm, order] = confusionmat(YTest, YPred); cmNorm = cm ./ sum(cm, 2); fprintf('整体准确率: %.2f%%\n', 100 * sum(diag(cm)) / sum(cm(:))); disp(array2table(cmNorm, 'VariableNames', order, 'RowNames', order));

如果你把这段代码跑在自己的数据上,我建议加一个 perm-test:随机打乱标签 1000 次重跑分类,得到准确率的零分布,然后看真实准确率落在哪个百分位。这个小步骤能让结果经得起审稿人追问。

4. 从零跑通:环境、数据准备和主脚本参数

4.1 环境要求与数据准备

开始之前确认三件事:MATLAB 版本至少 R2019b,因为 Deep Learning Toolbox 的序列输入层在这个版本之后才稳定支持可变长度序列;内存建议 16GB 以上,虽然单试次数据不大,但训练时如果按默认的 miniBatchSize 跑,GPU 预留显存至少要 6GB;如果你不开 GPU,训练时间会明显更久,建议先把 MaxEpochs 调小到 20 试跑通。

数据方面,项目没有绑定特定采集设备,只要你能把数据加载成 通道数×时间点数 的矩阵,并提供一个对应的事件时间标记数组,就能运行。典型的做法是自己写一个加载函数放到 utils 目录下,把 .edf 或 .set 文件读成统一格式。例如:

function [rawData, fs, events, labels] = load_my_data(subjectDir) % 读入EDF文件 [rawData, fs] = readedf(fullfile(subjectDir, 'record.edf')); % 读入事件描述表格(时间戳, 类型) evtTable = readtable(fullfile(subjectDir, 'events.csv')); events = evtTable.timestamp; labels = evtTable.type; end

4.2 主脚本参数说明

run_pipeline.m 是唯一需要你动手改的主脚本。打开后注意力放在前三十行,所有实验参数都集中定义在那里:

参数名典型值含义
fs250采样率,决定滤波范围和窗口大小
freqRange[0.5 40]带通滤波范围,可依据实验频带调整
trialWin[-0.2 0.8]试次截取区间,以事件为原点
numUnits64LSTM隐藏单元数量
dropoutRate0.2随机丢弃比例
maxEpochs50最大训练轮数
miniBatchSize32mini-batch大小
dataDirdata/存放原始数据的目录

首次运行建议按上表默认值先跑一个被试。整个过程会依次输出预处理日志、RSA 矩阵生成提示、网络训练进度条和一张训练过程图。训练结束后,控制台会打印整体准确率和每类召回率,同时保存一个 result.mat 文件,里面是 net、YPred、YTest 和混淆矩阵,方便你后续做统计分析。

4.3 运行时常见输出含义

训练进度图里有两条曲线,一条是交叉熵损失,另一条是验证准确率。如果验证准确率曲线随着训练收敛而在附近震荡,不用太慌——这是小样本 EEG 数据的常态;如果验证准确率持续走低而训练准确率逼近 100%,那基本是过拟合,这时下调 numUnits 或增大 dropoutRate。项目默认的验证频率是每 10 个 iteration 一次,一个 epoch 只有几个 iteration 的话,验证曲线会非常稀疏,可以改成每 5 个 iteration 一次,曲线会更平滑。

运行遇到函数未定义报错,绝大多数情况是 MATLAB 当前路径没有包含项目子目录。我习惯在脚本开头加两行:

cd(fileparts(mfilename('fullpath'))); addpath(genpath(pwd));

这段能把项目下所有子目录一次性加进路径,防止最常见的 addpath 遗漏。

5. 避坑指南:EEG 与 RNN 结合时最容易翻车的五个地方

5.1 数据泄漏:归一化和交叉验证的顺序搞反了

现象:训练准确率极高,测试准确率跟抛硬币差不多,但你没有改任何超参数。

原因:有人习惯先把所有数据统一归一化再做交叉验证拆分。这会让验证集的信息进入训练过程,属于典型的数据泄漏。EEG 数据里不同被试的幅值差异很大,一旦全局归一化,模型会依赖这种全局统计量来判断类别。

解决:把归一化写进训练流程内部,只在训练集上计算均值和标准差,再应用到验证集。你项目里如果用了 mapstd 或 normalize 函数,检查它的调用位置是否在划分之后。

5.2 序列方向弄反了:LSTM 记住了乱七八糟的时序

现象:模型在验证集上不收敛,loss 跳到 NaN。很多人在构造 XTrain 的时候直接用了 特征数×时间步 的矩阵,LSTM 期望的是 时间步×特征数 的 cell 序列。

原因:MATLAB 的 sequenceInputLayer 要求序列维度是时间优先。项目里做了一次转置,但如果你在改特征时手动拼接了新的特征数组,很可能又把维度带偏了。

解决:每次构造完 XTrain 后,用 size 验证一下维度顺序。排错时可以单独取一个样本,打印前五行看是不是时间步从左往右增长。这是花两分钟能省两小时的习惯。

5.3 采样率和时间窗口对不齐:所有试次长度不一样

现象:运行到 trainNetwork 时报错,说序列长度不一致导致 mini-batch 无法拼接。

原因:事件标记时间戳如果是从不同设备导出的,精度可能不一样,导致每个试次截出来的长度有几十个采样点的误差。

解决:在截取试次之后强制统一长度。常见做法是截取固定采样点数,比如 fs 乘以窗口秒数,而不是用窗口秒数直接换算。如果数据本身采样率浮动,先重采样到统一采样率,再做分段。

5.4 模型随机性导致结果没法复现

现象:同一个脚本在两次运行之间,准确率能差五个百分点。

原因:训练过程中权重初始化和 mini-batch 的随机洗牌都会引入随机性。MATLAB 默认不固定随机种子,EEG 数据试次数少时,这种波动会被放大。

解决:在脚本开头加一行 rng(42),固定全局随机数生成器。如果在做模型对比实验,一定要保证所有模型在同一个随机种子下训练,不然你对比的其实是随机噪声。

5.5 类别不平衡:全部预测成多数类

现象:整体准确率 80%,但看每类召回率,少数类只有 8%。

原因:EEG 实验中有人试次通常比无人试次多很多,交叉熵损失函数天然偏向让网络倾向预测大类别。

解决:项目提供的 loss 默认是均匀加权的,你可以改成加权交叉熵,方法是在分类层前面乘一个类别权重矩阵。或者换一个更直接的思路:在数据层面做少数类过采样,把重复若干次的数据加回去,然后再发给网络。

6. 二次开发:从更换特征到重构网络结构的三个改造方向

第一个方向是升级特征模块,把现有的平均功率换成时频特征。在 compute_rsa 函数里接入小波包变换,对每个试次提取 4 到 8 个频段的能量序列,然后拼接到原来的 trialFeatures 里。这样改动只需要在循环内加几行调用 cwt 函数的代码,LSTM 的输入端不需要调整。注意对每个频段单独做 z-score 归一化,否则幅值大的频段会压过其他信息。

第二个方向是改造网络结构,把 BiLSTM 换成 CNN-LSTM 并联结构。脑电信号里的空间模式(通道间关系)靠 LSTM 学得比较慢,如果先在通道维度上做一层卷积,可以让网络更快捕捉到空间特征。修改时把 sequenceInputLayer 后面插入一个 convolution1dLayer,卷积核大小为通道数,测试下来收敛速度有明显提升。

第三个方向是增强评估模块的统计说服力。我通常会在评估脚本里加入一个排列检验,随机打乱标签 1000 次并重新计算模型准确率,得到零分布,然后算出真实准确率的 p 值。这个功能放在 utils 目录里,和整体流程解耦,不会影响主线代码的运行。

回看整个改造习惯,我从这个项目里学到最关键的一条:改任何模块之前,先跑一次完整流程,留好基线结果,再动手改第一处代码。从那以后,我每次改 EEG 处理流程都强制走一遍全流程跑通加基线记录,判别改动是否有效全靠这个基准。这篇拆解从预处理、RSA、LSTM 到评估和改造都有了,希望帮到你上手。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进