☰
基于深度学习与注意力机制的运动想象脑电信号分类系统
2026/10/26 17:33:07 网站建设 项目流程

简介:一套面向本科毕业设计的运动想象脑电信号(EEG)分类项目,围绕深度学习与注意力机制展开,采用CNN-Transformer混合神经网络架构,既利用CNN提取局部时空特征,又借助Transformer建模全局依赖,适用于脑机接口、神经康复等研究场景,也适合需要完成类似课题的学生参考。资料共33个文件,以Python脚本为主(23个py),涵盖模型定义、训练验证、可视化等环节,另有MATLAB预处理脚本、权重文件、Excel结果表、说明文档等,压缩包整体约18.49MB,结构清晰便于检索。目前已有71人学习使用。从内容看,除完整的CNNTransformer主模型外,还包含EEGNet、Spatial_Temporal_Attention等对比模型,以及t-SNE降维、CAM热力图、脑电地形图、箱线图、统计检验等分析工具,可直接用于多分类运动想象任务,也可作为论文实验与毕业答辩的支撑材料。无论是复现实验还是借鉴方法设计,都有较高参考价值。

1. 从毕业设计标题到可跑通的系统:这个项目到底在解决什么问题

看到“基于深度学习与注意力机制的运动想象脑电信号分类系统”这个标题,很多人的第一反应是“这又是一份打包好的毕业设计代码”。但真正跑过一遍就会知道,运动想象脑电信号分类不是下载完zip、点一下训练就能交差的:脑电信号幅度只有几微伏,信噪比低,个体差异大,同一套权重换个受试者准确率可能直接掉十几个点。CNNTransformer 混合架构的出发点,是让 CNN 先把局部时空特征提取出来,再用 Transformer 建模长程依赖,这个方向理论上确实能比单一 CNN 或 LSTM 更稳,但前提是数据预处理、模型参数和训练策略必须一起调。

这套系统适合正在做本科毕业设计的学生,也适合准备进入脑机接口方向、手里有一定 Python 基础的人。你不需要一开始就复现最前沿的大模型,只需要理清“数据 → 预处理 → CNN 模块 → Transformer 模块 → 分类头 → 评估验证”这条主线。下文会按这个顺序讲,重点放在哪些参数会影响结果、哪些环节最容易翻车。

2. 为什么运动想象脑电信号需要 CNN+Transformer 混合架构:局部时空与全局时序的互补

2.1 运动想象脑电信号的底层形态:C3/C4/Cz 导联上的 μ 节律和 β 节律

运动想象脑电信号不像图像那样有清晰的边界,它的特征藏在频率和空间分布里。受试者想象左手、右手、双脚或舌头动作时,大脑对侧运动皮层会出现事件相关去同步(ERD),想象结束后往往伴随一次短暂的能量上升,也就是事件相关同步(ERS)。这两类现象主要出现在 μ 节律(8-12 Hz)和 β 节律(13-30 Hz)上,所以预处理阶段基本都会做带通滤波,把这两个频段保留下来。

在头皮电极布局中,C3、C4、Cz 正好覆盖中央运动区,几乎所有运动想象分类算法都会把这些导联当成核心输入。但只看单个导联远远不够,相邻导联之间的相位关系和幅度比值往往更有区分度。也就是说,算法必须同时利用“导联空间布局”和“时间局部波形”,这正是 CNN 卷积核能够发挥作用的地方。

如果直接把原始序列丢给 Transformer,也不是不行,但注意力矩阵的长度一旦超过上千个采样点,计算量会迅速膨胀;原始信号本身的噪声也会让全局注意力难以聚焦。所以常见做法是先用 CNN 在时间-通道二维图上做局部卷积,把信号压缩成紧凑的特征序列,再由 Transformer 学习跨时间步的依赖。这个顺序基本是当前 CNNTransformer 混合脑电解码方案的主流。

2.2 CNN 提取局部时空特征:二维卷积如何同时吃时间窗和通道

把一段脑电组织成矩阵,行为导联,列为采样点,二维卷积核可以同时沿两个方向滑动。一个小核比如 4×32,含义是覆盖 4 个导联和 32 个采样点。如果原始采样率是 250 Hz,32 个采样点大约 128 ms,刚好覆盖一个完整的 α 波周期;如果采样率降到 128 Hz,32 个点则对应 250 ms,能捕捉到更长时间范围的 β 波模式。

这里需要注意,二维卷积的“空间维度”是导联,并不代表电极在头皮上的物理距离,但卷积核仍然能学习到导联之间的空间相关性,这跟脑电的分布特性天然吻合。除了标准 Conv2D,EEG 分类里常用的还有深度可分离卷积:先对每个通道独立做时间卷积,再用 1×1 卷积混合通道。这种结构参数量更小,也更贴合脑电通道之间噪声基底不一致的特点。如果你的基础模型跑得不够好,可以把普通卷积换成分组卷积或深度可分离卷积试试。

下面是一个轻量的 CNN 局部特征提取示意,输入形状是(B, 1, C, T):

import torch import torch.nn.functional as F # 假设输入为一个 batch,单通道,22 个导联,128 个采样点 x = torch.randn(16, 1, 22, 128) # 卷积核:通道方向 4,时间方向 16;输出 32 个特征图 conv1 = torch.nn.Conv2d(1, 32, kernel_size=(4, 16), padding=(0, 8)) x = F.relu(conv1(x)) # 形状变为 (16, 32, 19, 128) x = F.max_pool2d(x, kernel_size=(1, 4)) # 形状变为 (16, 32, 19, 32) print(x.shape)

这里的 padding 只在时间方向补了 8,保证时间长度不变;通道方向因为卷积核是 4,输出导联数从 22 变成 19。最大池化只压时间维,把 128 压到 32。这样后续 Transformer 只需要处理 32 个时间步,而不是原始 128 步,计算量能下降不少。

2.3 Transformer 补上全局依赖:多头自注意力在脑电时间序列上的意义

CNN 的局部感受野有限,一层卷积只能看到附近几十毫秒。但运动想象解码不一定只靠局部:ERD 可能持续数百毫秒,而且不同导联上的幅度变化存在先后顺序。Transformer 里的自注意力机制会计算任意两个时间步之间的相似度,让模型直接学习“这个导联在 300 ms 处开始下降,对侧导联在 500 ms 后出现回升”这类全局关系。

多头注意力机制的作用是把特征投影到多个子空间,每个头可以关注不同的成分:有的头看频率形态,有的头看导联相关性,有的头看时间顺序。这种多角度建模对脑电这种多成分叠加信号很有效。标准 Transformer 本身没有顺序概念,因此必须加位置编码。EEG 时间序列不是文本,我一般会在时间长度不超过 128 时使用可学习位置编码,长度更长时用固定正弦编码,数值会更稳定。

还要注意,在进入 Transformer 前最好对 CNN 输出做一个线性投影,把特征维度统一到d_model。直接拿卷积输出当序列,通道维一旦不是注意力头数的整数倍,训练会变得很难收敛。用nn.Linear做一次投影,本质上是在让 Transformer 的输入分布更规整,这对后续收敛帮助很大。

2.4 为什么不用单一模型:纯 CNN、LSTM 和纯 Transformer 各自输在哪

很多同学会问:既然 CNN 能提局部特征,为什么不直接加深 CNN?既然 Transformer 能看全局,为什么不全部用 Transformer?这里列个常用对比:

模型擅长短板
纯 CNN局部时空模式提取长程依赖需要堆很深,感受野才够
LSTM/GRU时序依赖建模双向模型才能看上下文,训练慢且容易梯度衰减
纯 Transformer全局注意力,长程关系对局部噪声敏感,序列过长时计算量爆炸
CNN + TransformerCNN 先降维提纯,注意力再建模全局参数和调参复杂度更高

脑电分类数据量通常不大,公开数据集里一个受试者也就几百个 trial,切窗后样本虽然变多,但独立信息有限。Transformer 层数堆到 4 层以上很容易过拟合。我自己的经验是 CNN 模块控制在两三个 Block,Transformer 编码器一到两层足够,重点放在数据预处理和评估策略上,而不是盲目加深网络。

3. 先把数据备好:公开数据集、预处理流程与样本组织方式

3.1 数据集怎么选:BCI Competition IV 2a 的结构与标签

做运动想象脑电分类,最常见的选择是 BCI Competition IV Dataset 2a。它包含 9 名受试者,每名受试者有两个 session,每个 session 包含 288 次试验,共四类运动想象任务:左手、右手、双脚、舌头。数据用 22 个 Ag/AgCl 电极采集,采样率 250 Hz。这个数据集被引用得非常多,好处是你的结果能直接和论文对比,这对毕业设计来说非常关键。

数据集里的标签通常是 0、1、2、3,对应四类运动想象。需要注意,原始数据可能有不同格式,常见的是.mat或.gdf。加载之后,要把事件时间点和标签严格对齐。最容易出的问题是把标签和采样点搞混:事件时间点指向的是某一段信号起始位置,而不是分类时刻本身。建议先画一次原始波形和事件标记,确认每个 trial 的起点和长度后再写预处理流程。

3.2 预处理 pipeline:带通滤波、分段、基线校正与伪迹剔除

预处理这一步直接决定模型能学到什么。我一般的做法是:带通滤波、分段、基线校正、异常 trial 剔除。下面是一个简化版代码,包含了核心步骤:

import numpy as np from scipy import signal def preprocess_eeg(raw, fs, event_times, labels): # raw: (n_channels, n_samples),单位 uV # fs: 采样率;event_times: 每个 trial 开始采样点;labels: 对应标签 # 1) 带通滤波 8-30 Hz,Butterworth 4 阶,零相位 b, a = signal.butter(4, [8, 30], btype='bandpass', fs=fs) filtered = signal.filtfilt(b, a, raw, axis=1) # 2) 分段:事件前 0.5s 到事件后 4.0s,共 4.5s t_pre, t_post = 0.5, 4.0 win_len = int((t_pre + t_post) * fs) start_off = int(t_pre * fs) segments = np.zeros((len(event_times), raw.shape[0], win_len)) for i, ev in enumerate(event_times): start = ev - start_off if start >= 0 and start + win_len <= raw.shape[1]: segments[i] = filtered[:, start:start + win_len] else: segments[i] = filtered[:, :win_len] # 越界时截取,简单处理 # 3) 基线校正:减去每个 trial 前 200 ms 的平均幅度 base_len = int(0.2 * fs) baseline = segments[:, :, :base_len].mean(axis=-1, keepdims=True) segments -= baseline # 4) 剔除标签异常片段 valid = labels < 4 return segments[valid], labels[valid]

这里的带通滤波为什么用filtfilt而不是lfilter:因为lfilter会造成相位偏移,让事件起点发生位移;filtfilt是零相位滤波,对时间对齐更友好。8-30 Hz 覆盖 μ 和 β 节律,如果你观察到高频成分有用,可以扩到 40 Hz,但肌电干扰也会跟着进来。分段窗口不是越长越好:太长会包含其他认知过程,太短会截断 ERD/ERS,4.5 s 是比较折中的选择。

基线校正只对直流漂移有效,对随机噪声无能为力。如果数据质量差,可以用 ICA 去掉眼电和心电,但 ICA 对数据长度敏感,短 trial 上容易过拟合。我一般先做幅度阈值检测,把超过 ±100 μV 的 trial 直接删掉,而不是一上来就 ICA。这个经验放在处理竞赛公开数据时尤其有用。

3.3 将数据切片成模型输入:时间窗、重叠、通道归一化

预处理后每个样本形状是(22, 1125)。如果直接把 1125 个采样点作为 Transformer 输入,注意力矩阵就是 1125×1125,显存和计算量都吃不消。常见做法是切成短窗,比如以 1 秒为窗口、0.25 秒为步长,每个 trial 能产生大约 15 个窗口。这样做既增加了训练样本量,又不会让窗口之间完全独立。关键约束是:同一个 trial 切出来的窗口不能跨训练集和验证集,否则就是数据泄漏。

下面是一个简单的滑动窗口实现:

def sliding_window(segments, labels, win_len=128, stride=32): """ segments: (n_trials, n_channels, n_time) win_len: 新窗口采样点数 stride: 步长采样点数 """ n_trials, n_ch, n_time = segments.shape windows, y, trial_ids = [], [], [] for t in range(n_trials): starts = range(0, n_time - win_len + 1, stride) for s in starts: windows.append(segments[t, :, s:s + win_len]) y.append(labels[t]) trial_ids.append(t) return np.stack(windows), np.array(y), np.array(trial_ids)

这里返回trial_ids很重要,后面划分数据集时必须以trial_id为最小单位,不能按窗口索引随机切。参数上窗口长度一般取 0.5~1 s,步长取窗口的 25% 左右;通道归一化按每个受试者每个导联的均值和标准差做。如果你有多个 session,最好分开统计统计量,不要用全局统计,否则跨 session 时分布会偏移。

4. 构建 CNN-Transformer 混合分类模型:核心模块与关键参数

4.1 CNN 局部时空特征提取模块:Conv2D + BatchNorm + SE注意力

在标题里的“包含CNN局部时空特征提取模块”是项目的核心卖点。很多新手喜欢把脑电当成图像,直接搬 ImageNet 预训练网络,这是最大的误区。脑电导联数只有几十个,时间序列长度也不大,深层网络反而有害。正确做法是设计小卷积核、浅层网络,并在特征提取后加入轻量通道注意力。

这里的通道注意力我常用 SEBlock,也就是 Squeeze-and-Excitation。它首先对每个特征图做全局平均池化,得到一个通道描述向量;再用两个全连接层学习通道权重,最后把权重乘回原特征图。这个过程本质上是让网络自己决定“当前样本更应该看哪些导联的特征”。

import torch import torch.nn as nn class SEBlock(nn.Module): """Squeeze-and-Excitation 通道注意力""" def __init__(self, in_channels, reduction=8): super().__init__() self.avg_pool = nn.AdaptiveAvgPool2d(1) self.fc = nn.Sequential( nn.Linear(in_channels, in_channels // reduction), nn.ReLU(inplace=True), nn.Linear(in_channels // reduction, in_channels), nn.Sigmoid() ) def forward(self, x): b, c = x.shape[:2] w = self.avg_pool(x).view(b, c) w = self.fc(w).view(b, c, 1, 1) return x * w class CNNLocalBlock(nn.Module): """CNN 局部时空特征提取块""" def __init__(self, in_channels, out_channels, kernel_size=(8, 32)): super().__init__() self.conv = nn.Conv2d(in_channels, out_channels, kernel_size=kernel_size, padding=(0, kernel_size[1] // 2)) self.bn = nn.BatchNorm2d(out_channels) self.se = SEBlock(out_channels, reduction=4) self.relu = nn.ReLU(inplace=True) self.pool = nn.MaxPool2d((1, 4)) def forward(self, x): x = self.relu(self.bn(self.conv(x))) x = self.se(x) return self.pool(x)

这里的kernel_size=(8, 32)表示在导联方向覆盖 8 个通道,时间方向覆盖 32 个采样点。导联方向为什么用 8?因为头部电极分布中,运动皮层相关导联大致集中在一个区域,用 8 的卷积核能覆盖一定局部空间。时间方向 32 个采样点在不同采样率下对应不同时间长度,所以这个参数要按实际采样率调整,不能直接照抄。输出通道out_channels设为 32 或 64 都常见,数据量小就设小一些。

4.2 Transformer 编码器模块:多头自注意力 + 位置编码 + 前馈网络

CNN 输出是一堆特征图,Transformer 期望输入是序列。处理思路是:把特征图按时间步展开,每个时间步上的通道特征合并成一个向量,再通过线性投影得到d_model维输入。为了让 Transformer 知道时间顺序,还需要加入位置编码。下面是我常用的实现:

import math import torch import torch.nn as nn class PositionalEncoding(nn.Module): """固定正弦位置编码,让 Transformer 感知时间顺序""" def __init__(self, d_model, max_len=256): super().__init__() pe = torch.zeros(max_len, d_model) position = torch.arange(0, max_len, dtype=torch.float).unsqueeze(1) div_term = torch.exp(torch.arange(0, d_model, 2).float() * (-math.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 TransformerEncoderModule(nn.Module): def __init__(self, d_model=64, nhead=4, num_layers=2, dim_feedforward=128, dropout=0.1): super().__init__() self.pos_enc = PositionalEncoding(d_model, max_len=256) encoder_layer = nn.TransformerEncoderLayer( d_model=d_model, nhead=nhead, dim_feedforward=dim_feedforward, dropout=dropout, batch_first=True, activation='gelu' ) self.encoder = nn.TransformerEncoder(encoder_layer, num_layers=num_layers) def forward(self, x): return self.encoder(self.pos_enc(x))

这里nhead=4配合d_model=64,每个注意力头是 16 维,属于比较稳的组合。如果d_model=128,nhead=8更常见。前馈网络维度dim_feedforward一般设成d_model的 2 到 4 倍。num_layers=2对脑电数据通常已经足够,再多容易过拟合。激活函数用 GELU,比 ReLU 更平滑,收敛也更稳定。

4.3 分类头与损失函数:标签平滑与交叉熵怎么配合

Transformer 输出后,需要把整个序列压缩成一个全局表示。常见做法是平均池化,把(B, T, d_model)变成(B, d_model),再接一个线性分类头输出四类 logits。下面是一个组装后的模型骨架:

class CNNTransformerNet(nn.Module): def __init__(self, n_channels=22, n_classes=4, d_model=64, nhead=4, num_layers=2): super().__init__() self.cnn = CNNLocalBlock(1, 32, kernel_size=(8, 32)) self.transformer = TransformerEncoderModule( d_model=d_model, nhead=nhead, num_layers=num_layers ) self.classifier = nn.Linear(d_model, n_classes) # 用假输入计算 CNN 输出维度,避免手工数通道 with torch.no_grad(): dummy = torch.zeros(1, 1, n_channels, 128) dummy = self.cnn(dummy) _, _, ch_out, _ = dummy.shape self.proj = nn.Linear(32 * ch_out, d_model) def forward(self, x): x = self.cnn(x) # (B, 32, Ch', T') B, C, Ch, T = x.shape x = x.permute(0, 3, 2, 1).contiguous() # (B, T, Ch, C) x = x.view(B, T, Ch * C) # (B, T, Ch*C) x = self.proj(x) # (B, T, d_model) x = self.transformer(x) # (B, T, d_model) x = x.mean(dim=1) # 全局平均池化 return self.classifier(x)

分类时的损失函数,我用交叉熵但会开启标签平滑:

criterion = nn.CrossEntropyLoss(label_smoothing=0.1)

标签平滑会让模型不要把概率推到 1,这对脑电这种标签存在标注噪声、个体差异大的任务比较有效。很多人只关注模型结构,忽略了损失函数里这一个参数,结果训练出来的模型过度自信,测试集上反而脆。四类运动想象本身类别均衡,不需要额外加类别权重,但如果你自己采集了不均衡的数据,就要在损失函数里加weight。

5. 训练、评估与调参中的常见坑:避坑记录

5.1 一跑就爆内存:EEG 样本数太多,DataLoader 的 num_workers 和 shuffle 怎么设

现象:滑动窗口后样本量从几百变成几千甚至几万,把整个训练集一次性变成 CUDA tensor,GPU 直接 OOM;或者训练第一个 epoch 就卡住不动。

原因:不是模型太大,而是数据加载方式太粗暴。刚入门时很多人为了省事,直接train_x = torch.tensor(train_x).cuda(),数据集一旦切窗就会超出显存。

解决:用Dataset和DataLoader按批加载,batch 大小先设 32,显存不够就降到 16。训练集shuffle=True,验证集shuffle=False。

from torch.utils.data import TensorDataset, DataLoader train_ds = TensorDataset(torch.FloatTensor(train_x), torch.LongTensor(train_y)) val_ds = TensorDataset(torch.FloatTensor(val_x), torch.LongTensor(val_y)) train_loader = DataLoader(train_ds, batch_size=32, shuffle=True, num_workers=4, pin_memory=True) val_loader = DataLoader(val_ds, batch_size=64, shuffle=False, num_workers=2, pin_memory=True)

num_workers不要盲目设大,Windows 下经常设 0 才稳定,Linux 上 4 到 8 都可以。pin_memory=True能减少 GPU 数据传输阻塞,但如果内存不够反而会卡。训练前记得固定随机种子,否则结果无法复现。

5.2 验证集准确率很高、测试集很差:数据划分没有按个体分组

现象:随机划分训练集和验证集时,验证准确率能到 90% 以上;但换一批受试者测试,准确率掉到 60% 左右。

原因:同一个 trial 切出来的窗口高度相似,随机划分时这些窗口会同时出现在训练集和验证集里。模型实际上记了受试者的时间模式,而不是学习运动想象的通用规律。这个现象在脑电分类里非常典型,叫 subject-dependent 泄漏。

解决:按受试者分组。比如 9 个受试者,用前 7 个做训练,后 2 个做验证;更严格的做法是留一受试者交叉验证(LOSO),每次留一个受试者测试,最后报告平均准确率。

subject_ids = np.arange(9) train_sub = subject_ids[:7] val_sub = subject_ids[7:] train_mask = np.isin(trial_subject_ids, train_sub) val_mask = np.isin(trial_subject_ids, val_sub) train_x, train_y = x[train_mask], y[train_mask] val_x, val_y = x[val_mask], y[val_mask]

注意这里的trial_subject_ids必须跟着窗口索引一起生成。如果你在切窗后丢失了受试者信息,后面再想按个体划分就只能重做数据预处理。

5.3 训练 loss 不降:学习率过小、分类头初始化不合理还是标签错位?

现象:loss 一直停在 1.38 左右,四分类随机猜测的交叉熵就是log(4) ≈ 1.386。训练多个 epoch 完全没有下降趋势。

原因:最常见的三个原因:学习率设成了 1e-5,低到几乎没有更新;模型分类头输出维度不是 4,和标签 0-3 对不上;数据切片后标签没有和信号对齐,错位了。

解决:先用一个小 batch 跑一次 forward,确认输出 shape 是(B, 4)、标签范围是 0-3。学习率用 AdamW 默认的 1e-3,如果还不降再往 1e-4 调。训练前加一个快速断言,能把很多问题挡在跑长训练之前:

optimizer = torch.optim.AdamW(model.parameters(), lr=1e-3, weight_decay=5e-4) for x_b, y_b in train_loader: logits = model(x_b) assert logits.shape[1] == 4, f"分类头输出维度不对: {logits.shape}" assert y_b.min() >= 0 and y_b.max() <= 3, "标签越界" break

这种验证不是浪费时间,我至少有两次翻车都是标签错位,打印出来一眼就能看到。

5.4 Transformer 不收敛:位置编码和 dropout 的影响

现象:在 CNN 后面接 Transformer 后,训练损失比单独跑 CNN 更慢,甚至震荡;纯 CNN 很快到 80%,混合模型反而在 75% 徘徊。

原因:Transformer 对输入序列长度和特征尺度很敏感。如果序列长度超过 128,注意力分布会被拉平;没有位置编码时时间信息丢失;dropout 太小又会很快过拟合。

解决:先把序列长度限制在 128 以内,比如通过降采样或缩小窗口。位置编码用正弦编码并固定到 buffer 里,不要让它参与梯度更新。TransformerEncoderLayer 里的 dropout 设置为 0.1 通常比较合适。还有一个容易忽略的点:进入 Transformer 前,线性投影层和 CNN 输出之间最好先过一个 LayerNorm,能显著提升稳定性。

混合模型不一定在所有数据上都优于纯 CNN。如果你发现加 Transformer 反而变差,先检查是不是数据太少。数据总量低于几千个窗口时,Transformer 的优势很难发挥出来,这时候减少层数或增大 dropout 比换模型更有效。

5.5 不能只看准确率:类别不均衡和混淆矩阵会骗人

现象:测试集总体准确率 90%,但打开混淆矩阵发现只预测了两类,另外两类几乎全错。

原因:类别样本不均衡时,模型会偏向数量多的类;运动想象里也常见某两类特征太接近,比如舌头和双脚,模型容易混淆。准确率在类别不均衡时是很有误导性的。

解决:训练时关注每类的 precision 和 recall,同时计算 Kappa 系数。BCI 竞赛里 Kappa 是标准指标,它比准确率更能反映模型和随机猜测的差距。计算方式也非常简单:

from sklearn.metrics import cohen_kappa_score kappa = cohen_kappa_score(all_true, all_pred)

如果发现某个类别被系统性误判,不要急着调模型,先看那类样本的波形和预处理是否有问题。很多时候是带通滤波太窄,把某类运动想象的关键频率切掉了。

6. 让模型在测试集上真正可用:评估指标、混淆矩阵与一个提高稳定性的技巧

模型训练完,别急着把准确率报出去。先做三件事:按受试者分组的结果、混淆矩阵和滑窗投票。混淆矩阵能告诉你哪两类容易被混在一起;按受试者分组的结果能说明模型是否真的具备跨个体泛化能力。对脑电分类来说,分组评估比单点准确率重要得多。

滑窗投票是一个成本很低但收益明显的技巧。测试阶段,把一个 trial 切成的多个窗口分别送进模型,得到每个窗口的类别概率,然后对同一个 trial 的所有窗口概率求平均,再取最大的类别作为最终预测。单窗口可能落在噪声段,平均之后能把这些异常拉回来。

n_trials = len(trial_ids) probs = np.zeros((n_trials, 4)) counts = np.zeros(n_trials) for trial_id, p in zip(trial_ids, window_probs): probs[trial_id] += p counts[trial_id] += 1 probs /= counts[:, None] final_pred = probs.argmax(axis=1)

这个技巧适合所有切窗式的脑电分类系统。如果你用的是整段 trial 做单次预测,也可以做时间序列上的重叠切片再投票,效果类似。

去年我在自己的机器上复现一个类似项目时,随机划分数据测试准确率有 85%,按受试者分组后掉到 72%,加上滑窗投票才回到 76%。从那以后我养成了一个习惯:先把评估策略定好,再回头调模型参数。这个顺序反过来的话,很多调参努力其实是在拟合数据划分的漏洞。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询