ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

运动想象脑电分类实战:CNN+Transformer混合框架详解

运动想象脑电分类实战:CNN+Transformer混合框架详解 简介针对运动想象脑电信号分类任务提供一套融合CNN与Transformer框架的本科毕业设计方案适合人工智能、生物医学工程等专业学生用于课题复现、方案参考与二次开发。资源包共31个文件大小约18.45MB其中23个Python源码覆盖模型定义、训练、交叉验证及结果可视化2个Matlab脚本实现数据预处理与特征提取另有模型权重、数据文件、说明文档等便于按模块对照学习。已有1254人学习下载获得不少相关方向学习者的关注。项目在CNN提取局部时空特征、Transformer提取全局依赖的混合框架基础上创新加入Grad-CAM对脑电地形图进行可视化解释同时提供t分布随机邻域嵌入、箱线图、统计检验等多种分析脚本可全面评估模型性能并理解分类决策依据。代码经测试可稳定运行答辩平均分达96分附有使用说明适合作为本科毕业设计、课程设计或项目初期演示的基础工程。1. 运动想象脑电信号分类为什么绕不开TransformerCNN打底Transformer补长程依赖运动想象脑电信号分类 做到中期你会发现一个反直觉的现象同样是四分类任务CNN在验证集上能爬到一个不错的位置但对双脚和舌头这两类混淆矩阵里总有一团解不开的雾。原因不在于卷积核不够大而在于运动想象是一个会延续数百毫秒的认知状态不是一帧一帧的局部特征堆叠。CNN擅长提取局部时间空间特征却抓不住序列上更远的上下文。Transformer的出现给这个老任务提供了另一条路把CNN编码后的特征当成一组时间步用多头注意力建模长程依赖。这套毕设资源正是这么设计的采用CNNTransformer混合框架CNN负责从原始EEG通道里提取局部时空特征Transformer负责在特征序列上做全局建模最终接一个四分类头。它比纯Transformer更容易在小样本EEG上收敛因为CNN已经做了大量的降维和局部特征抽取Transformer只需要处理相对低维的特征序列。适合正在做毕业设计的学生也适合想从传统BCI特征工程切到深度学习的从业者——你能一次性拿到从预处理、模型搭建到验证的完整闭环代码。2. 搭建CNNTransformer混合框架从EEG裁剪到多头注意力参数2.1 为什么是CNNTransformer而不是纯Transformer在EEG分类场景里大多数人犯的第一个错误是照搬NLP的Transformer结构处理原始信号。脑电采样率通常250Hz一个4秒的trial就有1000个采样点纯Transformer要对这1000个点做自注意力计算量不小而且EEG信噪比低单点没有任何语义信息。Transformer不是为这种强噪声的低层传感器数据设计的直接喂原始序列等于让它在噪声里找信号。CNN的好处是把原始信号转化为“特征图”这很像把像素变成视觉特征。运动想象的典型标志是C3、C4等中央区通道在8-30Hz频段出现事件相关去同步ERD左手、右手、脚、舌头引发的ERD分布在不同空间区域。CNN可以先在时间维上用卷积核滑动捕捉每个通道上8-30Hz的节律成分再在空间维上用卷积核跨所有通道加权把物理位置信息融合起来。两步完成后特征序列已经具备了局部时空上下文。接下来交给Transformer的是这些“已经有人味”的特征而不是原始噪声。这么做的好处很明显序列长度被卷积池化缩短通常在几十到一百多步特征维度升到一定高度自注意力可以捕捉CNN感受野范围外的时域关系比如想象任务开始和结束之间的整体变化趋势。我自己的经验是CNN特征提取器的参数量控制在几万到十几万Transformer部分再加几十万整个模型已经能在几分钟内在一张消费级显卡上完成训练。2.2 数据预处理事件切片、滤波与按通道标准化动手搭模型前先要把原始EEG整理成模型能吃的张量。以BCI Competition IV 2a为例原始数据是多个被试的脑电每个被试22通道采样率250Hz四分类运动想象。常见做法是把每个trial切成从cue后0.5秒到2.5秒的一段窗口太短抓不到完整的ERD太长又混入任务结束后的恢复期。import mne import numpy as np def load_epochs(raw_file): raw mne.io.read_raw_gdf(raw_file, preloadTrue) raw.filter(8, 30) # 保留mu和beta频段 events mne.find_events(raw, stim_channelSTI014) epochs mne.Epochs(raw, events, event_id{left: 769, right: 770, feet: 771, tongue: 772}, tmin0.5, tmax2.5, baselineNone, pickseeg, rejectdict(eog150e-6)) data epochs.get_data() # (n_trials, n_channels, n_samples) # 按通道做z-score标准化只用当前被试的数据 mean data.mean(axis(0, 2), keepdimsTrue) std data.std(axis(0, 2), keepdimsTrue) data (data - mean) / (std 1e-6) return data, epochs.events[:, 2]这里的filter(8, 30)先把信号限定在运动想象相关的mu/beta频段tmin取了0.5秒而不是0是因为指令出现后被试的动作准备需要几百毫秒取0.5秒能避开事件标记附近的瞬态噪声。标准化时按通道计算均值和标准差而不是全脑混在一起否则幅值大的枕叶通道会主导整个样本。有人问我怎么确定tmax取2.5而不是4.0。我的习惯是先把每个类别的平均功率画出来看找ERD最明显的区间。如果直接从cue开始取前2秒某些被试的ERD还没完全展开取到4秒又会把任务后反馈阶段的伪迹收进来。2到2.5秒是多数公开数据集实验设计里的合理默认值你换成自己的数据时最好先做一次窗口扫描而不是沿用这里的参数。提示reject参数里的eog阈值只是粗过滤真正分离眼电伪迹最好用ICA。后面避坑章节会专门讲。2.3 CNN特征提取模块时间卷积加空间卷积预处理完的数据形状是(n_trials, n_channels, n_samples)但卷积模块期望的输入通常是(batch, channels, height, width)或类似图像。我们可以把通道放在第二个维度时间放在最后一个维度用二维卷积但核只在一个维度上移动。下面是常用的精简实现import torch import torch.nn as nn class CNNEncoder(nn.Module): def __init__(self, n_channels22, n_times501, out_channels32): super().__init__() # 时间卷积每个通道单独做滑动平均 self.conv_time nn.Sequential( nn.Conv2d(1, out_channels, kernel_size(1, 32), padding(0, 16)), nn.BatchNorm2d(out_channels), nn.ELU(), nn.Conv2d(out_channels, out_channels, kernel_size(1, 16), padding(0, 8)), nn.BatchNorm2d(out_channels), nn.ELU(), nn.MaxPool2d(kernel_size(1, 2)) ) # 空间卷积把所有通道融合成一个特征平面 self.conv_spatial nn.Sequential( nn.Conv2d(out_channels, out_channels, kernel_size(n_channels, 1)), nn.BatchNorm2d(out_channels), nn.ELU(), ) def forward(self, x): # x: (B, 1, n_channels, n_times) x self.conv_time(x) # (B, C, n_channels, n_times/2) x self.conv_spatial(x) # (B, C, 1, n_times/2) x x.squeeze(2) # (B, C, n_times/2) return x.permute(0, 2, 1) # (B, seq_len, d_model)这段卷积设计有一个值得记住的原则先时间后空间。第一层时间卷积的kernel_size是(1, 32)相当于在一个通道内部沿时间方向取32个采样点的窗padding取16保证输出长度不变。第二层时间卷积核是(1, 16)融合更大范围的节律信息池化把时间长度减半既能降低Transformer的序列长度又顺带做了局部平移不变。空间卷积的核是(n_channels, 1)一次就把全部通道加权求和等价于EEGNet里的深度可分离卷积的下一步。为什么不用纯二维卷积把空间和时间同时卷因为EEG的通道排布不像图像那样有严格的拓扑结构强行用图像卷积会把相邻通道关系硬编码进去。我这里用(通道数, 1)的核让每个输出通道自行学习跨通道的加权组合更符合运动想象的空间分布特征。如果你想进一步压缩序列长度可以把池化改成kernel4、stride2但要注意感受野不能覆盖整个时间窗口。2.4 Transformer编码器位置编码、分类token与多头注意力参数CNN编码器输出的序列长度大约是(n_times / 2)对250Hz采样、去掉0.5到2.5秒的窗口n_times大约是501池化后就是250左右。这个长度对Transformer来说很舒服。下面把位置编码加到特征序列上再送入标准的TransformerEncoder。class PositionalEncoding(nn.Module): def __init__(self, d_model, max_len1024): super().__init__() pe torch.zeros(max_len, d_model) position torch.arange(0, max_len, dtypetorch.float).unsqueeze(1) div_term torch.exp(torch.arange(0, d_model, 2).float() * (-np.log(10000.0) / d_model)) pe[:, 0::2] torch.sin(position * div_term) pe[:, 1::2] torch.cos(position * div_term) self.register_buffer(pe, pe.unsqueeze(0)) def forward(self, x): return x self.pe[:, :x.size(1)] class TransformerEncoderTail(nn.Module): def __init__(self, d_model64, nhead8, num_layers3, num_classes4): super().__init__() self.pos PositionalEncoding(d_model) encoder_layer nn.TransformerEncoderLayer( d_modeld_model, nheadnhead, dim_feedforward256, dropout0.1) self.transformer nn.TransformerEncoder(encoder_layer, num_layersnum_layers) self.cls_token nn.Parameter(torch.zeros(1, 1, d_model)) self.head nn.Linear(d_model, num_classes) def forward(self, x): # x: (B, seq_len, d_model) cls_tokens self.cls_token.expand(x.size(0), -1, -1) x torch.cat([cls_tokens, x], dim1) x self.pos(x) x self.transformer(x) return self.head(x[:, 0])我把d_model设成64nhead设成8序列长度250左右配合3层encoder整体参数量大约三四十万一块笔记本GPU就能跑。d_model和nhead的匹配要留意nhead必须整除d_model64/88每个头分到的维度太小特征表达能力不够如果改成d_model128、nhead8每个头分16维效果会更好但显存翻倍。你也可以保留d_model64把nhead降到4。位置编码用的是正弦余弦固定编码。很多人问EEG是否适合用可学习位置编码我的经验是运动想象trial里的时间顺序固定正余弦编码足够且可学习位置编码在小数据上容易过拟合。TransformerEncoderLayer里的dropout我设为0.1如果验证集波动大可以调到0.3尤其在你只有几十个被试的场合。完整模型就是把CNNEncoder和TransformerEncoderTail串起来。需要注意forward里卷积模块输入的维度要带一个通道维代码里可以用x.unsqueeze(1)把(batch, n_channels, n_samples)变成(batch, 1, n_channels, n_samples)。这里不再展开后面避坑章节我会提到一个容易踩的维度坑。3. 训练与调参损失函数、one-cycle学习率和四个过拟合信号3.1 损失函数与标签权重运动想象四分类的标签是左、右、脚、舌多数公开数据集各类别数量接近直接用交叉熵就行。但当你把多个被试的数据合并到一起某些动作的样本会明显少于其他类比如被试注意力不集中导致某个block被拒绝这时就需要给损失函数加类别权重。常见做法是用样本数量的倒数归一化。from collections import Counter import torch def make_class_weights(labels): counts Counter(labels.numpy()) total len(labels) n_classes len(counts) weights torch.tensor( [total / (n_classes * counts[c]) for c in sorted(counts.keys())], dtypetorch.float32 ) return weights criterion nn.CrossEntropyLoss(weightmake_class_weights(train_labels).to(device))这段代码的逻辑是某个类别样本越少权重越大。如果四个类别样本数是1000、1000、900、1100分母是类数乘各次数稀有类别的损失权重略微放大。注意权重必须基于训练集统计不能在验证集上重新算否则又会引入泄漏。实际调试时如果发现少数类准确率明显偏低把权重调成中位数/该类数量也有效但别一下把权重调成100倍那样模型会严重偏向低方差类别。3.2 优化器与one-cycle学习率策略训练CNNTransformer混合模型有一点和纯CNN不同Transformer部分收敛较慢且对学习率更敏感。我一般会用AdamW配合one-cycle学习率调度而不是固定lr跑到底。OneCycle先把学习率从低升到峰值再降到很低相当于给模型一个热身和冲刺过程在试次数量有限时能明显提升最终精度。from torch.optim import AdamW from torch.optim.lr_scheduler import OneCycleLR optimizer AdamW(model.parameters(), lr3e-4, weight_decay1e-2) total_steps len(train_loader) * epochs scheduler OneCycleLR( optimizer, max_lr6e-3, total_stepstotal_steps, pct_start0.2, anneal_strategycos, ) for epoch in range(epochs): for batch_x, batch_y in train_loader: loss criterion(model(batch_x), batch_y) optimizer.zero_grad() loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm1.0) optimizer.step() scheduler.step()max_lr取6e-3initial_lr是3e-4相差20倍。这个比例在CNNTransformer混合模型里比较合适纯Transformer可能需要更小的max_lr。pct_start0.2表示前20%步数做线性warmup从3e-4升到6e-3后面的步数按余弦曲线衰减到接近0。还要注意梯度裁剪max_norm1.0因为Transformer编码器在小数据集上容易出现梯度爆炸不裁剪的话前几步loss可能直接变成NaN。warmup为什么重要Transformer里的LayerNorm和残差连接在初始阶段输出方差很大如果一上来就用大学习率位置编码和特征分布会被破坏后面很难恢复。我在实际项目里也试过固定lr3e-4跑到底效果会比OneCycle低2%-3%在四分类任务上这是很可观的差距。batch size的选择同样影响one-cycle的效果。我之前在单卡上把batch size设为32序列长度250模型参数量不大显存完全够。如果把batch size降到8或4one-cycle的学习率峰值要相应降低否则warmup后梯度噪声太大会导致震荡。一个常见做法是先用batch size32如果显存不够则按比例把max_lr乘以sqrt(new_batch/old_batch)。比如batch从32降到8max_lr就乘以0.5。3.3 四个过拟合信号与应对手段训练时间一长模型很容易在训练集上记住被试个体差异。我总结了四个信号训练loss持续下降但验证loss在20个epoch后开始回升验证集混淆矩阵里某两个类别永远分不开把所有被试随机打乱后准确率很高按被试划分却掉到随机概率模型权重范数持续增大且dropout无效。对应手段首先是加数据增强。EEG增强和图像增强不一样不能随便平移翻转。我常用的只有三种加高斯噪声、随机屏蔽一小段时间、随机打乱通道排列但保持通道一致性。下面是一个简单的时间屏蔽增强class TimeMaskAugment: def __init__(self, mask_ratio0.1): self.mask_ratio mask_ratio def __call__(self, x): seq_len x.size(-1) masked_len int(seq_len * self.mask_ratio) start torch.randint(0, seq_len - masked_len, (1,)).item() x[..., start:start masked_len] 0 return xmask_ratio我建议取0.05到0.15。太大会打断ERD的连续性让模型学到“看到整个零段就判为某类”的捷径。在训练循环里每个batch前调用这个增强注意不要对验证集用。第二个手段是Early Stopping。我习惯验证集loss连续10个epoch不下降就保存最佳模型并停止。最好同时保存训练过程中的最佳checkpoint因为最后一步往往不是验证集最好的点。第三是权重衰减AdamW的weight_decay1e-2已经比原版Adam的常见配置重。如果还过拟合可以先增大dropout到0.3再考虑减少Transformer层数。最后一个容易被忽略的信号是验证集乱打乱分准确率很高。这种情况往往不是过拟合而是数据已泄漏。泄漏的源头通常在预处理和划分顺序上下一章专门排查。另一个我自己常看的监控指标是训练集和验证集的损失曲线是否同步。如果验证集loss开始回升最好回滚到最佳checkpoint而不是在谷底之后继续训练。我会记录每个epoch的模型文件名只保留验证loss最低的那个并把早停耐心设为10这样能省掉不少算力浪费。4. 运动想象分类常见问题排查伪迹泄漏、通道选择与标签错位的四个坑4.1 验证集95%新被试只剩50%预处理泄漏的坑现象模型在验证集上准确率很高接近95%但换一个被试、换一套采集设备后准确率跌到50%上下。很多人会怀疑模型泛化能力差其实更常见的是预处理阶段就把验证集的信息融进了训练。原因在做z-score标准化时有人对整份数据一次性算均值方差train和test都被同一个mean/std变换。这种标准化本身用到了test的统计量属于信息泄漏。另一个隐蔽版本是在ICA去伪迹之前把train和test拼在一起做ICA分解这样test的独立成分直接参与了训练。解决所有从数据计算出来的统计量都必须只由训练集计算然后保存到文件里加载训练集和测试集时再统一使用。def apply_scaler_saved(scaler_path, data): mean np.load(scaler_path _mean.npy) std np.load(scaler_path _std.npy) data (data - mean) / (std 1e-6) return data训练阶段你只运行scaler_fit(train_data)保存mean和std测试阶段加载这两个文件。ICA也一样先只对训练集拟合ICA分解矩阵测试时用同一分解矩阵投影。按照这个规则做之后很多“泛化差”问题会消失一半。4.2 加了额叶通道反而掉分信号与伪迹的取舍现象有人喜欢把所有通道都送进模型尤其是额叶的Fp1、Fp2结果卷积模型学到的主要是眨眼和眼动伪迹准确率不升反降。原因额叶通道上眼电伪迹幅值可以达到100微伏以上而运动想象相关的mu/beta节律通常只有10到20微伏。CNN在时间卷积时首先响应的就是大幅值的伪迹空间卷积又会把这些伪迹扩散到其他通道。也就是说若不加伪迹处理模型用几个伪迹通道就能低代价地“猜到”部分标签泛化能力极差。解决有两种路线。第一种是只保留运动想象最相关的中央区通道例如C3、C4、Cz以及周围临近通道第二种是用ICA去掉明显的EOG成分再保留全通道。ICA处理时注意把EOG成分的特征位于额叶、低频、幅值大识别出来并置零。常见做法是这样from mne.preprocessing import ICA ica ICA(n_components20, methodfastica, random_state42) ica.fit(raw.filter(8, 30)) eog_idx, scores ica.find_bads_eog(raw, ch_name[Fp1, Fp2], threshold2.5) ica.exclude eog_idx raw_clean ica.apply(raw)这里的find_bads_eog用额叶通道作为EoG参考threshold控制识别灵敏度。注意ICA拟合务必在训练集对应的连续数据上做不能先拼接再分解。我在实践中发现先ICA再去掉额叶通道也是不少论文的常规组合但直接砍掉通道是最保险的基线。4.3 某个动作准确率接近随机事件标签与时间窗错位现象模型对“左手”“右手”都很准但对“脚”和“舌头”始终是随机水平即使换了模型结构也没用。原因很多公开数据集的event code不是整齐的四个值。BCI IV 2a里打开原始事件列表会发现标记有两组769-772是四种运动想象的起始但是还有783、768这样的注释标记。有人直接用mne.find_events默认参数把多个event code混进来做epoch导致部分trial没有对应标签或者标签后移了一个采样点。还有人在事件序列里直接用np.where(events770)找右手却忽略了cue之前有个baseline标记占位时间窗对不准。解决第一步打印事件分布确认每个code数量是否与实验记录一致。events mne.find_events(raw, stim_channelSTI014) unique, counts np.unique(events[:, 2], return_countsTrue) print(dict(zip(unique, counts)))如果看到769、770、771、772的count大致相等比如一个block里每个动作出现几十次而其他code数量很少那说明事件取对了。接着我用mne.Epochs函数时会让event_id映射到这四个code。第二步是验证epoch后的时间点是否正确画一个试次的波形看ERD是否出现在预期区间。如果ERP在cue后0.3秒就开始而你的tmin0.5那么你截掉了启动阶段就会影响分类效果。还有一种更隐蔽的错位在编写数据流水线时用了两个不同的索引变量一个来自trial索引一个来自epoch顺序最后把标签torch.cat时错位。解决的办法是每次都保存一个event列表从epochs.events而不是直接从raw事件里取标签让标签自始至终跟着同一个trial。4.4 按试次随机划分等于变相泄漏必须按被试划分现象用同一个被试的试次划分为train和test十折交叉验证平均准确率0.9以上感觉很成功但换到与训练被试完全无关的测试数据上准确率只有0.6。这是运动想象深度学习里最常见的翻车现场。原因同一被试的邻近试次高度相关。脑电受电极位置、阻抗、情绪状态影响同一被试的trial之间存在重复的个体特征。按试次随机划分时验证集和训练集都包含同一个被试的样本模型实际上记住了被试而非运动想象本身一到新被试个体特征完全失效。解决用GroupKFold按被试分组划分。from sklearn.model_selection import GroupKFold gkf GroupKFold(n_splits5) for train_idx, val_idx in gkf.split(X, y, groupssubject_ids): model create_model() train(model, X[train_idx], y[train_idx]) val_acc evaluate(model, X[val_idx], y[val_idx]) print(ffold val acc: {val_acc:.3f})GroupKFold保证同一被试的所有样本要么全在训练集要么全在验证集。如果你做被试无关评估更严格的做法是留一个被试出来测试其余所有被试训练。这个指标才代表模型真实泛化能力论文里如果不给被试无关数据基本可以直接拒稿。5. 验证模型混淆矩阵、被试无关交叉验证与基线对比5.1 混淆矩阵与分类指标一眼定位“老混”的类别训练完模型第一步不是看总准确率而是打印混淆矩阵。四个类别共16格扫一眼就能发现规律如果“脚”被频繁预测成“舌头”八成是时间窗截错了因为脚和舌头的ERD空间分布都在中央区位置相近若“左手”和“右手”互相混淆则可能是通道排列或标签顺序出了问题。from sklearn.metrics import confusion_matrix, classification_report y_true np.concatenate(all_true) # 所有batch的真实标签 y_pred np.argmax(np.concatenate(all_prob), axis1) cm confusion_matrix(y_true, y_pred) report classification_report( y_true, y_pred, target_names[left, right, feet, tongue], digits3 ) print(cm) print(report)保存好样本到all_true/all_prob时注意每个batch的顺序固定不要用字典存储否则会丢掉顺序信息。分类报告里除了precision和recall我还会看macro-average F1它比accuracy更能反映小类别的表现。比如某个类别recall只有0.25即使整体准确率0.85这个模型也不可靠。5.2 被试内交叉验证与被试无关验证两种指标都要算运动想象的评估策略至少有两种。被试内交叉验证是指同一个被试的数据划分成几折用来评估模型在该被试自己数据上的稳定性被试无关验证是指一个被试的数据完全不参与训练用来评估跨个体泛化。有的论文只报告前者指标很漂亮但落地时没有意义。我建议至少输出两种指标并写清协议。给你一个可复用的实验协议评估方式训练集验证集适用场景被试内交叉验证同被试部分试次同被试余下试次个体BCI校准被试无关验证其余被试全部试次目标被试试次免校准通用BCI被试内交叉验证可以用GroupKFold按试次分组但更严格的做法是用连续时间块划分别把同一时间段内的强相关试次混进不同折。被试无关验证则应该用LeaveOneSubjectOut训练9个被试测试第10个轮流来。这里的数值往往不如被试内高但这才是你模型真正的竞争力。留出法的细节也要注意。不要用整个测试集来筛选超参否则测试集会被“看过”多次变成隐性训练集。我会把每个被试的数据按block顺序切出前20%作为测试集再在剩余部分上做交叉验证。这样做的好处是测试样本和训练样本没有严格时间重叠比随机抽样更接近在线使用场景。5.3 与基线比较固定随机种子、报告均值和偏差CNNTransformer是否值得用要跟纯CNN如EEGNet和纯Transformer放在同一条流水线下对比。对比实验最容易被坑的是随机种子。深度学习训练噪声很大单跑一次的结果不具备说服力必须固定随机种子并重复至少5次报告均值加减标准差。def train_once(seed): setup_seed(seed) model create_model() # 训练逻辑... return test_acc accs [train_once(seed) for seed in [0, 1, 2, 3, 4]] mean_acc np.mean(accs) std_acc np.std(accs) print(fCNNTransformer: {mean_acc:.3f} ± {std_acc:.3f})我的经验里CNNTransformer相比EEGNet这类纯CNN在被试无关场景下通常高2到3个百分点但这不是绝对的。如果你的数据只有十几个试次Transformer部分很容易过拟合这时纯CNN反而更稳。所以对比实验一定得做不要想当然。如果计算资源够可以加一个带置信区间的可视化图。但比起漂亮的曲线评审更看重你是否做了多被试的个体差异分析。我会额外画一个每被试准确率散点图看看模型在哪些被试上失败再结合波形或ERD强度解释原因。6. 把模型用到你自己的数据集数据格式转换与复用技巧6.1 从EDF/CSV到模型输入的整数转换你在自己实验室采集的数据通常是EDF、CSV或二进制格式第一步要统一成模型输入的numpy数组。CSV通常每一行是一个采样点列是通道第一步用pandas读进来再应用与全流程一致的滤波和标准化。import pandas as pd import mne df pd.read_csv(subject01.csv) raw_data df.values[:, 1:] # 假设第一列是time sfreq 250 filtered mne.filter.filter_data( raw_data.T, sfreqsfreq, l_freq8, h_freq30) print(filtered.shape) # (n_channels, n_samples)读进来之后要做的事把形状从(n_samples, n_channels)转成(n_trials, n_channels, n_times)这一步依赖你的标记系统。如果没有标记可以用刺激通道阈值检测或者直接按固定间隔切窗。拿到试次后套用预处理函数把每个trial按训练时保存的mean/std做标准化。注意这里必须用训练时的scaler而不是重新计算。6.2 复用已有模型冻结卷积、微调Transformer如果你手头有之前在公共数据集上训练好的CNNTransformer模型想迁移到自采数据我的建议是重新初始化分类头微调Transformer的后两层冻结CNN卷积层。因为CNN提取的mu/beta节律特征是通用的但不同采集设备的空间分布差异大冻结CNN反而能减少过拟合。再提供一个技巧把通道顺序对齐。公共数据集往往是C3、C4、Cz这样的传统10-20系统顺序你的设备可能是按接线顺序排列必须重排成与预训练模型一致。重排后哪怕通道数不同也要把对应位置填到相同索引多余通道可以丢弃或补零。从那以后我每次拿到新数据都会先打印出一个trial的波形图确认事件码、伪迹形态和信号量级之后才敢把数据送进模型。这个习惯帮我省了至少两个下午的傻等也让我敢对每个调整参数负责。希望帮到你。本文还有配套的精品资源点击获取
返回列表