ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

基于MATLAB的EEG信号预处理与RNN分类实战指南

基于MATLAB的EEG信号预处理与RNN分类实战指南

简介:这是一份面向认知神经科学与计算建模研究者的 MATLAB 分析代码,围绕「人类脑电图与递归神经网络在语音识别过程中表现出共同的时间动态」这一论文展开,适合具备一定 MATLAB 与 EEG 处理基础、希望复现或拓展该研究的读者。资源包共 51 个文件,以 50 个 .m 脚本和 1 个 README.md 为主,压缩包约 77KB,脚本按频域表征、刺激包络与网络输出互相关、表征相似性分析(RSA)等模块组织,并附带被试数据与网络 beta 生成脚本。运行前需按说明依次执行各脚本,并将路径更新为本地系统路径。目前已有 162 人学习下载。借助这套代码,读者可以完整走通从 EEG 预处理、频域与窄带表征、RDM 构建到网络输出与脑电跨相关统计的流程,理解随机网络与训练网络输出对比、层级 RNN 与 EEG 表征相似性比较等关键分析思路,为自身实验的数据处理与建模提供可复用的脚本框架与排错参考。

1. 从一份脑电数据到可复现实验:这个项目到底在做什么

如果你手头有一批 EEG 数据,想跑一遍完整的「预处理 → 特征提取 → RNN 分类」流程,却不想从零搭 pipeline,那这个项目值得花半小时拆一拆。它把脑电图信号处理和循环神经网络分析串成了一条可执行的链路,核心场景是:给定多通道时序脑电信号,先做滤波、分段、去伪迹,再喂给 RNN 做分类或回归,最后输出指标和可视化结果。适合两类人:一类是刚接触 EEG 分析、想找个能跑通的参考实现的研究生;另一类是想把 RNN 用到时序生理信号上、但不确定数据该怎么组织的工程师。项目本身不依赖特定硬件,一台能跑 MATLAB 的机器就能起步,数据格式和参数都留了改的口子。

2. 技术选型与目录结构:为什么是 MATLAB + RNN 这套组合

2.1 选 MATLAB 而不是 Python 的现实理由

EEG 分析这个圈子有个很实际的情况:大量采集设备和预处理工具箱(比如 EEGLAB 生态)都是 MATLAB 优先,很多实验室的原始数据直接就是 .set / .mat 格式。这个项目选 MATLAB 作为主语言,本质上是顺着数据源走,省掉了格式转换的中间层。RNN 部分用 MATLAB 的 Deep Learning Toolbox 实现,好处是训练循环、GPU 调度、层定义都能用内置 API 搞定,不用自己写反向传播。

常见做法是:预处理阶段用 MATLAB 脚本处理 .mat 或 .set 文件,把每个被试的数据整理成统一维度的数组,再统一送进网络。这样做的代价是 MATLAB 工具箱授权问题,但换来的是和现有实验流程的无缝对接。如果你所在的环境只有 Python,那这个项目的价值更多在于流程设计思路,代码需要做一次移植。

2.2 目录结构拆解

项目名里的 EEGAndRNNAnalysis 已经点明了两个核心模块。按这类项目的常见组织方式,目录大致会分成这几块:

EEGAndRNNAnalysis/ ├── data/ # 原始与处理后数据 │ ├── raw/ # 原始 EEG 文件(.mat / .set) │ └── processed/ # 分段、滤波后的数据 ├── preprocessing/ # 预处理脚本 │ ├── load_eeg.m # 数据读取与通道对齐 │ ├── bandpass_filter.m # 带通滤波 │ └── epoch_segment.m # 按事件分段 ├── models/ # 网络定义与训练 │ ├── build_rnn.m # RNN 层结构定义 │ └── train_model.m # 训练循环与超参 ├── utils/ # 通用函数 │ ├── normalize.m # 归一化 │ └── plot_results.m # 结果可视化 └── main.m # 主入口,串起全流程

这个结构的好处是职责清晰:预处理、建模、工具函数分开,改滤波参数不会碰到网络定义,换网络结构也不影响数据加载。我一般会建议在 data/ 下再加一个 interim/ 目录,存放中间态数据,方便调试时不用每次从头跑预处理。

2.3 数据流与模块依赖

整个流程的数据流是单向的:raw → preprocessing → processed → models → 输出指标。main.m 作为入口,按顺序调用各模块。这里有个设计细节值得注意:预处理和建模之间通过文件系统解耦,而不是在内存里直接传递变量。这样做的好处是预处理跑一次可以反复用于不同网络结构的实验,坏处是磁盘 IO 增加。对于 EEG 这种数据量不算特别大的场景,这个取舍是合理的。

提示:如果你要改成端到端的内存传递,记得在 main.m 里把中间变量显式保存,否则调试时很难定位是预处理出错还是网络训练出错。

3. 预处理模块实战:从原始信号到网络输入张量

3.1 数据加载与通道对齐

EEG 数据最烦人的地方是不同被试的通道数、采样率、事件标记可能不一致。预处理第一步就是把这些统一。下面是一个典型的数据加载函数:

function [eegData, fs, chanlocs] = load_eeg(filePath) % 读取 .mat 或 .set 格式的 EEG 数据 % 输入: filePath - 数据文件路径 % 输出: eegData - [通道数 x 采样点] 矩阵 % fs - 采样率 % chanlocs - 通道位置信息 [~, ~, ext] = fileparts(filePath); if strcmp(ext, '.set') % 兼容 EEGLAB 结构 EEG = pop_loadset(filePath); eegData = EEG.data; fs = EEG.srate; chanlocs = EEG.chanlocs; else % 原生 .mat 结构,假设变量名为 eeg loaded = load(filePath); eegData = loaded.eeg; fs = loaded.fs; chanlocs = loaded.chanlocs; end % 通道对齐:只保留公共通道 eegData = double(eegData); end

这段代码的关键点是分支处理不同格式,以及把数据统一转成 double 类型——MATLAB 里有些 EEG 数据是 single 或 int16,不转的话后续滤波会出精度问题。参数方面,fs 决定了后续滤波器的截止频率设计,chanlocs 在可视化时用得到。如果你的数据通道顺序不一致,需要在加载后加一步重排,常见做法是按通道名排序。

3.2 带通滤波与工频陷波

EEG 有效频段通常在 0.5–45 Hz,工频干扰在 50 Hz 或 60 Hz。滤波这一步做不好,后面网络学到的全是噪声。典型实现:

function filtered = bandpass_filter(data, fs, lowCut, highCut) % 零相位带通滤波,避免相位失真 % lowCut/highCut: 通带边界,单位 Hz % 使用 filtfilt 做双向滤波 nyq = fs / 2; [b, a] = butter(4, [lowCut highCut] / nyq, 'bandpass'); % 按通道逐个滤波 filtered = zeros(size(data)); for ch = 1:size(data, 1) filtered(ch, :) = filtfilt(b, a, data(ch, :)); end % 工频陷波 wo = 50 / nyq; % 50Hz 工频 bw = wo / 35; [bn, an] = iirnotch(wo, bw); for ch = 1:size(filtered, 1) filtered(ch, :) = filtfilt(bn, an, filtered(ch, :)); end end

这里用 filtfilt 而不是 filter,是因为 filtfilt 做前向-反向两次滤波,相位不失真,这对后续按时间点对齐事件标记很重要。butter 的阶数选 4 是常见折中,阶数太高容易不稳定。iirnotch 的带宽参数 bw 控制陷波宽度,太窄滤不干净,太宽会伤到邻近频段。我一般会先画一下滤波前后的功率谱,确认 50 Hz 峰值确实被压下去了再往下走。

3.3 分段与归一化

滤波之后要按事件标记把连续信号切成 epoch,每个 epoch 对应一次刺激或一个时间窗。然后做归一化,让网络输入尺度一致:

function epochs = epoch_segment(data, events, fs, tRange) % 按事件时间点切分数据 % tRange: [起始秒, 结束秒],相对于事件点 % 输出: [通道数 x 时间点 x epoch数] startOffset = round(tRange(1) * fs); endOffset = round(tRange(2) * fs); nEpochs = length(events); nChans = size(data, 1); nPoints = endOffset - startOffset + 1; epochs = zeros(nChans, nPoints, nEpochs); for i = 1:nEpochs idx = events(i) + startOffset : events(i) + endOffset; if idx(1) < 1 || idx(end) > size(data, 2) continue; % 边界不完整的 epoch 跳过 end epochs(:, :, i) = data(:, idx); end % 逐 epoch 逐通道 z-score 归一化 for i = 1:nEpochs for ch = 1:nChans seg = epochs(ch, :, i); epochs(ch, :, i) = (seg - mean(seg)) / (std(seg) + eps); end end end

分段时最容易翻车的是边界处理:如果事件点靠近数据末尾,切出来的 epoch 会越界。上面的 continue 是简单处理,更稳妥的做法是丢弃这些 epoch 并记录数量,避免样本量悄悄变少。归一化用 z-score 而不是 min-max,是因为 EEG 信号近似零均值,z-score 更符合它的统计特性。eps 是防止除零的后悔药,别省。

4. RNN 建模与训练:网络结构、超参与训练循环

4.1 RNN 层结构定义

EEG 是典型的多通道时序信号,RNN 天然适合处理这种输入。项目里网络结构大致是「序列输入 → RNN 层 → 全连接 → softmax」:

function layers = build_rnn(inputSize, numHidden, numClasses) % 构建 RNN 分类网络 % inputSize: 每步输入维度(通道数) % numHidden: 隐藏单元数 % numClasses: 分类类别数 layers = [ sequenceInputLayer(inputSize, 'Name', 'input') lstmLayer(numHidden, 'OutputMode', 'last', ... 'Name', 'lstm') dropoutLayer(0.3, 'Name', 'dropout') fullyConnectedLayer(numClasses, 'Name', 'fc') softmaxLayer('Name', 'softmax') classificationLayer('Name', 'output') ]; end

这里用 LSTM 而不是普通 RNN,是因为 EEG 序列里长程依赖比较明显,普通 RNN 梯度消失严重。OutputMode 设为 last 表示只取最后一个时间步的输出做分类,适合「整段信号对应一个标签」的任务。dropout 0.3 是防过拟合的常规操作,EEG 数据被试少的时候尤其重要。如果你的任务是逐时间点分类,把 OutputMode 改成 sequence,后面接 sequenceFullyConnectedLayer。

4.2 训练循环与超参设置

网络定义好之后,训练部分主要靠 trainingOptions 控制:

function net = train_model(XTrain, YTrain, XVal, YVal, layers) % 训练 RNN 模型 % XTrain: cell 数组,每个元素是一个 [通道 x 时间] 序列 options = trainingOptions('adam', ... 'MaxEpochs', 50, ... 'MiniBatchSize', 16, ... 'InitialLearnRate', 1e-3, ... 'LearnRateSchedule', 'piecewise', ... 'LearnRateDropFactor', 0.5, ... 'LearnRateDropPeriod', 10, ... 'ValidationData', {XVal, YVal}, ... 'ValidationFrequency', 10, ... 'Shuffle', 'every-epoch', ... 'Verbose', false, ... 'Plots', 'training-progress'); net = trainNetwork(XTrain, YTrain, layers, options); end

几个参数值得说清楚:MiniBatchSize 设 16 是因为 EEG epoch 数量通常不大,batch 太大会导致梯度更新次数太少;学习率用 piecewise 衰减,每 10 个 epoch 降一半,这是训练后期收敛更稳的常见做法。ValidationFrequency 设 10 表示每 10 次迭代验证一次,太频繁会拖慢训练。Shuffle 设为 every-epoch 能打乱样本顺序,避免被试顺序带来的偏差。如果你的数据类别不平衡,记得在 classificationLayer 里加 classWeights。

4.3 输入数据的组织方式

RNN 在 MATLAB 里要求输入是 cell 数组,每个 cell 是一个 [特征维度 x 时间步] 的矩阵。从预处理出来的 [通道 x 时间 x epoch] 转过去需要一步 reshape:

function X = prepare_rnn_input(epochs) % 把 [通道 x 时间 x epoch] 转成 cell 数组 nEpochs = size(epochs, 3); X = cell(nEpochs, 1); for i = 1:nEpochs % 转置成 [时间 x 通道],再转置回网络期望的 [通道 x 时间] X{i} = squeeze(epochs(:, :, i)); end end

这里有个容易搞混的点:MATLAB 的 sequenceInputLayer 期望的输入维度是「特征数 × 时间步」,而预处理出来的 epochs 是「通道 × 时间」,正好对应,不需要转置。但如果你从 Python 那边搬代码过来,numpy 的习惯是「时间 × 特征」,那就得转一下。这个维度问题是最常见的翻车点,训练报维度不匹配的错,十有八九是这里。

5. 避坑与排查:EEG + RNN 流程里最容易出问题的几个地方

5.1 滤波后信号幅度异常

现象:滤波完画图发现信号幅度比原始数据大了一个量级,或者出现明显振荡。原因通常是滤波器阶数过高导致数值不稳定,或者 filtfilt 的填充方式在数据边界产生了瞬态。解决办法是把 butter 阶数降到 2–4,并在滤波前对数据两端做镜像填充,或者直接丢弃滤波后前若干秒的数据。我一般会在滤波后加一步幅度检查,超过原始信号 3 倍标准差的直接报警。

5.2 训练 loss 不下降

现象:训练几十个 epoch,loss 一直在初始值附近晃。原因可能是学习率太大导致震荡,也可能是输入数据没归一化、量纲差异太大。先检查归一化那一步有没有真的执行,再试着把 InitialLearnRate 降到 1e-4。还有一个隐蔽原因是标签编码错误,比如类别标签从 0 开始而 MATLAB 期望从 1 开始,这种情况 loss 会卡住但不会报错,需要手动检查 YTrain 的取值范围。

5.3 验证集准确率远低于训练集

现象:训练集准确率 95%,验证集只有 60%。这是典型过拟合,EEG 数据被试少的时候特别常见。解决方向有三个:加大 dropout 比例、减少 LSTM 隐藏单元数、增加数据增强(比如加高斯噪声、时间平移)。常见做法是先把 numHidden 从 128 降到 64 试试,如果还不行再考虑跨被试的数据划分方式是否合理。

5.4 内存溢出

现象:跑预处理或训练时 MATLAB 报 out of memory。原因通常是 epochs 数组维度太大,[通道 × 时间 × epoch] 三个维度乘起来很容易上 GB。解决办法是分批处理,预处理阶段每处理完一批就存盘清空,训练阶段用 datastore 而不是一次性加载所有数据。另一个技巧是把数据转成 single 精度,内存直接减半,对 EEG 这种信噪比本来就不高的信号,精度损失可以接受。

5.5 结果不可复现

现象:同样的代码跑两次,结果不一样。原因是随机种子没固定。MATLAB 里需要在训练前设置 rng(42),并且注意 GPU 训练的随机性来源更多,完全复现比较困难。常见做法是至少固定 CPU 端的随机种子,并在论文或报告里注明训练轮数和早停策略,让别人能大致复现你的量级。

6. 二次开发与改造:从跑通到用起来的几个方向

跑通只是第一步,真正要用起来,通常得在这几个地方动刀。第一个方向是换网络结构。LSTM 不是唯一选择,如果你的数据时间步不长,GRU 参数更少、训练更快;如果要做逐时间点检测,把 OutputMode 改成 sequence 并接时序全连接层。改的时候注意输入输出的维度对应关系,建议先在 build_rnn.m 里把每层的输出维度注释清楚,改完用 analyzeNetwork 看一眼结构对不对。

第二个方向是加交叉验证。单次划分训练/验证集的结果波动很大,尤其是被试数量少的时候。常见做法是做被试间交叉验证:每次留一个被试做验证,其余做训练,循环一遍取平均。这个改动主要在 main.m 层面,把数据划分和训练循环包一层 for,但要注意每次重新初始化网络,别复用上一次的 net。

第三个方向是特征工程。纯端到端的 RNN 对数据量要求高,如果样本不够,可以先提取频带功率、Hjorth 参数等手工特征,再送进网络。这样输入维度从「通道 × 时间」变成「特征数 × 时间窗」,网络规模可以小很多。改造点在预处理和 prepare_rnn_input 之间加一个特征提取模块,输出格式保持 cell 数组不变。

第四个方向是部署和推理。训练好的 net 可以保存成 .mat,推理时用 predict 而不是 trainNetwork。如果要集成到采集系统里做实时分析,需要把预处理也做成流式处理,这块改动量最大,建议先用离线数据模拟流式输入验证逻辑。

下面是一个保存和加载模型的简单示例:

% 保存训练好的模型和归一化参数 save('model/rnn_model.mat', 'net', 'mu', 'sigma'); % 推理时加载 loaded = load('model/rnn_model.mat'); net = loaded.net; % 新数据要先做同样的归一化 XNew = (XNew - loaded.mu) ./ loaded.sigma; YPred = classify(net, XNew);

这里的关键是把归一化参数一起存下来,否则新数据的归一化方式和训练时不一致,结果会偏。我见过不止一次因为忘了存 mu/sigma 导致推理结果完全不对的情况。

从那以后我每次改完网络结构或预处理参数,都强制走一遍「固定种子 → 完整训练 → 保存模型和归一化参数 → 用独立数据推理」的闭环,确认端到端能复现再往下做。希望这些拆解能帮到你,拿到代码后先跑通 main.m,再按自己的数据格式改 load_eeg.m,基本就能上手了。

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

RELATED READING

延伸阅读

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