前两篇我们用同一份静息态数据完成了"看见信号"(实战 01)和"做出推断"(实战 02)。本篇换一个全新的战场:任务态 EEG 与脑机接口(BCI) 。我们将使用 BCI 领域最经典的公开数据------PhysioNet 运动想象数据库,围绕"想象左手 vs 右手握拳"这一任务,走完一条完整的 BCI 解码链路:EDF 格式解析 → 事件分段 → Mu/Beta 的 ERD/ERS 量化 → 头皮分布与通道可分性 → CSP 空间滤波 → LDA 分类 + 留一法验证,产出 6 张图和 5 张结果表。
先交代数据来源(重要) :脚本优先联网下载真实数据(S001R03),下载失败时自动降级为内置的可重复结构化模拟数据 (固定随机种子、已知"标准答案")。本次运行走的是模拟数据路径(结果摘要中已如实标注),因此本篇所有数字均来自该次模拟运行------但全文讲解的方法与代码对真实数据完全通用,联网环境下同一脚本即可产出真实结果。模拟数据反而送了我们一份礼物:我们知道真相埋在哪里,可以逐图验证流水线有没有把它挖出来。
读完本篇你将学会:
- 运动想象 BCI 的生理基础:感觉运动节律(Mu/Beta)、对侧控制原则、ERD/ERS 的定义与量化;
- EDF/EDF+ 格式的手工解析:头部字段、数字量到校准物理量、TAL 事件标注(T0/T1/T2);
- 任务态分析的三板斧:事件分段(epoching)、基线校正、事件锁定时频分析;
- 为什么"全脑平均频带功率"在左右手任务上必然失灵,以及 CSP(共空间模式)如何用数学手段把空间差异放大;
- 小样本(13 个试次)下负责任的模型验证:留一法交叉验证,以及"监督型空间滤波器必须放进交叉验证内部"这条防泄漏铁律。
前置阅读:实战 01(滤波、Welch、时频)、实战 02(稳健统计、窗的概念)。与前两篇不同,本篇不再依赖 UCI 数据集。
图的编号说明 :沿用专栏惯例,文中"图 N"对应脚本输出文件 0N_*.png。按教学逻辑,图 6(通道可分性)会排在图 5(CSP 分类)之前出场------先看"哪些通道有差异",再讲"怎么利用这些差异做分类",动机更顺。
1. 生理背景:为什么"想象动左手"能被脑电解码
1.1 感觉运动节律与对侧控制
前两篇打交道的 Alpha 是"枕叶的故事",本篇的主角 Mu 节律 是"中央区的故事":它覆盖在感觉运动皮层 (中央沟前后)上方,频率 8--12 Hz(与枕叶 Alpha 几乎同频段,但空间位置和功能完全不同------这是 EEG 里"同名不同人"的经典陷阱),常伴随 13--30 Hz 的 Beta 谐波。身体某一部位运动或想象运动时,对侧感觉运动皮层的 Mu/Beta 节律功率下降;任务结束后功率反弹,有时超过基线。
大脑对身体的运动控制是对侧支配:左半脑管右侧身体。因此:
- 想象/执行左拳 运动 → 右侧中央区(C4、FC4、CP4 一带)Mu 功率下降;
- 想象/执行右拳 运动 → 左侧中央区(C3、FC3、CP3 一带)Mu 功率下降。
C3、Cz、C4 恰好横跨左右手运动代表区,是运动想象 BCI 的"黄金三电极"。
1.2 ERD 与 ERS 的量化定义
功率下降叫事件相关去同步化(ERD, Event-Related Desynchronization) ,功率上升叫事件相关同步化(ERS)。标准化定义(Pfurtscheller 的经典公式):
ERD%=P任务−P基线P基线×100%\mathrm{ERD\%} = \frac{P_{\text{任务}} - P_{\text{基线}}}{P_{\text{基线}}} \times 100\%ERD%=P基线P任务−P基线×100%
负值 = 去同步化(功率下降),正值 = 同步化(功率上升)。脚本完全按此实现:基线取提示前 −1.5 ~ −0.2 秒 (避开提示前一刻的准备性变化),任务窗取提示后 0.5 ~ 3.5 秒(避开提示后 0.5 秒内由视觉提示本身诱发的非特异性电位)。两个窗口的选取都不是随意的,这是实验设计沉淀下来的经验。
2. 数据集与 EDF 格式:这次连文件解析都自己写
2.1 PhysioNet 运动想象数据库
本篇目标数据是 PhysioNet EEG Motor Movement/Imagery Database(eegmmidb) :由 Schalk 等人用 BCI2000 系统采集,109 名健康被试、64 导联、160 Hz 采样,每人 14 个 run,覆盖运动执行与运动想象。它是运动想象领域引用最多的基准数据。脚本选取 S001R03 (1 号被试的第 3 个 run:左/右拳运动想象),事件中 T1 = 左拳想象、T2 = 右拳想象、T0 = 静息。
2.2 EDF 文件结构:逐字段拆给你看
前两篇的 ARFF 是文本,这次是真正的二进制格式。EDF(European Data Format) 是医用电生理的事实标准,结构分两层:
- 固定头部(256 字节) :记录版本、被试信息、以及三个关键数字------头部总字节数(偏移 184--192)、数据记录条数
n_records(236--244)、每条记录时长(244--252)、信号通道数n_signals(252--256); - 变长头部 :每个信号一组字段,依次是标签(16 字节 × n)、换能器类型(80 字节)、物理单位(8 字节)、物理最小/最大值 (各 8 字节)、数字最小/最大值 (各 8 字节)、预滤波说明(80 字节)、每条记录的采样点数(8 字节)、保留位(32 字节)。
头部之后是数据本体:按"记录 → 通道"分块存放的 16 位整数。文件里存的是"数字量",必须校准回物理量(μV):
物理值=数字值×物理max−物理min数字max−数字min⏟scale+(物理min−scale×数字min)⏟offset\text{物理值} = \text{数字值} \times \underbrace{\frac{\text{物理max} - \text{物理min}}{\text{数字max} - \text{数字min}}}{\text{scale}} + \underbrace{\left(\text{物理min} - \text{scale} \times \text{数字min}\right)}{\text{offset}}物理值=数字值×scale 数字max−数字min物理max−物理min+offset (物理min−scale×数字min)
还记得实战 01 那份"悬浮在 4000+ 的原始计数"吗?EDF 的校准公式就是为了把那种原始计数换算成真实微伏------两个案例在这里闭环了。采样率也不用猜:每记录采样点数 ÷ 记录时长,例如 160 点/1 秒 = 160 Hz。
python
block = raw[cursor: cursor + byte_count]
data_by_signal[signal_idx][start: start + n] = np.frombuffer(block, dtype="<i2")
2.3 EDF+ 标注通道:事件藏在一条"假信号"里
EDF+ 规定:事件写在一个名为 EDF Annotations 的特殊信号通道里,内容是一条条 TAL(Time-stamped Annotations) 文本:+起始秒\x15持续秒\x14标签\x14\x00。脚本的 parse_edf_annotations() 把它还原成事件表:按 \x00 切段、按 \x14 分字段、保留 T0/T1/T2。此外脚本还留了一条后路:标注解析失败时,按该任务经典的"每 8 秒一次 4 秒提示"结构重建近似事件------解析层永远不让分析层空转。
2.4 双轨数据源与本次运行说明
load_recording() 的策略:本地有真实 EDF 直接用;没有就联网下载;下载失败则调用 simulate_motor_imagery_recording() 生成固定种子(20260717)的可重复模拟数据,并在控制台与结果摘要中明确标注来源。模拟器的构造思路值得一看,因为它本身就是一堂"EEG 成分解剖课":
python
noise = signal.lfilter([1], [1, -0.92], rng.normal(0, 1, size=times.size)) # 1/f 样粉红噪声
mu = 8.0 * sin(2π·(10±0.25)·t + 随机相位) # 每通道一根 Mu 振荡
beta = 3.5 * sin(2π·(20±0.45)·t + 随机相位) # 一根 Beta 振荡
drift = 2.0 * sin(2π·0.25·t + 随机相位) # 低频漂移
然后在任务时段给"对侧运动区通道"(想象左拳 → C4/FC4/CP4/C2,想象右拳 → C3/FC3/CP3/C1)叠加一个 10 Hz 调制成分,模拟任务相关的 Mu 功率变化。本次运行的数据即来自此模拟器 :27 通道、160 Hz、124 秒(19,840 样本)、13 个事件(T1 × 7、T2 × 6,从第 8 秒起每 8 秒一次、每次 4 秒)------这些数字在 事件标注表.csv 和结果摘要里都可以一一核对。因为我们知道调制被注入了哪些通道,后文每张图都可以"对答案"。
3. 预处理与事件分段:先看对齐,再谈分析
预处理沿用实战 02 的成熟配置:4 阶 Butterworth 1--45 Hz 零相位带通;陷波带了一个"按采样率判断"的保护------if sfreq/2 <= 51: 跳过,本数据奈奎斯特频率 80 Hz,因此 50 Hz 陷波正常执行。
分段(epoching)是任务态分析的标志动作:围绕每个 T1/T2 事件截取 0 ~ 4 秒 ,得到三维数组 13 试次 × 27 通道 × 640 点。越界(记录开头/结尾放不下完整窗)的事件自动丢弃。
【图 1】事件结构与中央区波形

这张图是分段前的"对齐质检":C3、Cz、C4 三条滤波后波形错位堆叠,蓝色半透明块 = T1(左拳想象),橙色 = T2(右拳想象)。读三件事:
- 事件时序规整:从第 8 秒起,任务块以 8 秒为周期交替出现,每块 4 秒------与事件表完全一致,说明标注解析与时间轴对齐无误;
- 波形幅度正常:滤波后信号在 ±25 μV 量级波动,没有实战 01 里那种几千"μV"的怪物,预处理输出健康;
- 肉眼分不出左右 :盯着蓝色块和橙色块里的波形看,看不出明显差别------正常。Mu 调制是 8--12 Hz 成分在特定区域的功率变化,叠加在同量级背景节律上,时域肉眼不可见。这正是我们必须走到时频和空间域的原因,和实战 01 里"睁闭眼波形肉眼难分"是同一个道理。
4. 事件锁定时频:ERD/ERS 在哪里发生
4.1 方法要点与一个重要取舍
对 C3、C4 两通道,分别收集某条件下所有试次的 −2 ~ +5 秒片段,先在试次间平均,再对平均段做时频分析(1 秒窗、85% 重叠、PSD 模式),最后按提示前 −1.5 ~ −0.2 秒的基线逐频率归一化,转成 dB:
ERD/ERSdB(t,f)=10log10P(t,f)Pˉ基线(f)\mathrm{ERD/ERS}{dB}(t, f) = 10\log{10}\frac{P(t,f)}{\bar{P}_{\text{基线}}(f)}ERD/ERSdB(t,f)=10log10Pˉ基线(f)P(t,f)
蓝 = 功率下降(ERD),红 = 功率上升(ERS),色标限幅 ±5 dB。
避坑(真实数据必读) :"先平均再时频"保留的是锁相(evoked)成分 ------非锁相的诱导节律在试次平均中会相互抵消。本模拟器注入的调制相位固定,所以 survives 平均;但真实 EEG 的 ERD 大部分是非锁相的诱导活动 ,规范做法是每个试次先单独算时频功率、再跨试次平均。教学脚本为流程简洁采用了前者,换真实数据时建议改过来。
4.2 【图 2】C3/C4 的 ERD/ERS 时频图

四个面板 = 2 通道 × 2 条件,0 秒竖线为提示 onset,绿色浅带标出 Mu(8--12 Hz)、粉色浅带标出 Beta(13--30 Hz)。对照模拟器的"标准答案"读图:
- C3 -- 右拳想象 (C3 是被注入调制的通道):提示后 Mu 带出现贯穿任务期的红色横带------功率相对基线显著改变(此处表现为升高/ERS 型);
- C3 -- 左拳想象(C3 未被注入):任务期以蓝色斑块为主(10--30 Hz 功率下降);
- C4 两个面板的符号模式与 C3 恰好对调------这正是"对侧调制"在时频域的镜像签名。
一句话:同一通道在两种条件下的时频图案明显不同,且差异集中在 Mu/Beta 带、锁定在任务窗口内 ------结果摘要里的检查项"图 2 中 C3/C4 周围出现任务相关的节律调制"成立。顺带说明符号问题:模拟器给注入通道叠加的是固定相位 10 Hz 成分,它与该通道原有 Mu 振荡的相位关系是随机的,因此注入通道的功率变化以升高 为主(两个不同相位的正弦叠加,功率期望是相加而非相消)。真实生理中典型表现是对侧 ERD(下降);本案例要关注的重点不是符号,而是"对侧空间模式"------它才是分类器真正的食粮,图 3 会把它看得更清楚。
5. 把 ERD 画回头皮:空间模式现形
5.1 从公式到表格
compute_erd_table() 对每个事件、每个通道计算 Mu 与 Beta 的 ERD%(基线 −1.5~−0.2 s vs 任务 0.5~3.5 s,Welch 积分),结果入 Mu_Beta_ERD统计表.csv(13 事件 × 27 通道 × 2 频带 = 702 行)。摘要报告的全通道平均 Mu ERD 为 +23.32%------正值,原因即 4.2 节讨论的相位叠加效应:注入通道以功率升高为主,拉高了全通道均值。
5.2 【图 3】Mu 频段 ERD 近似头皮分布

左右两张头皮俯视图(鼻尖朝上,色标 ±60% 对称),按条件把各通道的试次平均 ERD% 画成散点。对照注入的"标准答案",这张图给出了全篇最干净利落的一次验证:
| 条件 | 图中热区(深红) | 模拟器注入的通道 | 命中? |
|---|---|---|---|
| 左拳想象 | FC4、C4、C2、CP4(右侧运动区) | FC4、C4、C2、CP4 | ✔ 完全命中 |
| 右拳想象 | FC3、C3、C1(左侧运动区,CP3 亦偏暖) | FC3、C3、C1、CP3 | ✔ 命中 |
左想象 → 右侧亮,右想象 → 左侧亮------对侧调制模式被流水线完整还原 。枕区(O1/O2)在两个条件下基本无色,顶区只有零散暖点,空间选择性良好。这就是运动想象 BCI 的根基信号:左右手任务在中央区产生空间上镜像对称的调制图案 。脚本的头皮坐标是教学近似投影(图注已声明不用于临床定位),写论文时应换标准 10--10 坐标或 mne.viz.plot_topomap。
6. 一个必要的"失败":总体功率分不出左右
【图 4】感觉运动通道的频带功率比较

把 7 个感觉运动通道(FC3/FC4/C3/C4/Cz/CP3/CP4)、13 个试次的频带功率按条件求均值 ± 标准误(对数纵轴),实测均值(频带功率总体均值.csv):
| 频带 | 左拳想象 | 右拳想象 |
|---|---|---|
| Theta 4--8 | 11.58 | 11.37 |
| Alpha 8--13 | 38.69 | 39.14 |
| Mu 8--12 | 38.01 | 38.45 |
| Beta 13--30 | 10.91 | 10.86 |
| Low-γ 30--45 | 1.129 | 1.150 |
五对柱子几乎一模一样 。这正常吗?太正常了------这正是本图的教学使命。左右手任务的差异是空间对侧 的:左想象时右侧升/左侧变,右想象时镜像翻转。把左右半球的通道放在一起平均,方向相反的变化相互抵消,均值自然纹丝不动。回想实战 02,我们用"条件 × 频带"的总体比较检验睁闭眼(那是全脑性效应,平均是合理的);而面对空间结构化的效应,平均就是信息的坟墓。
本篇第一课:选分析维度要匹配效应的空间尺度。 全局效应可以平均,局部效应必须保通道。图 4 的"失败"直接引出了下一步------逐通道比较。
7. 通道可分性排序:先把"有信号的电极"找出来
【图 6】Mu ERD 左右任务差异排序

做法朴素而有效:对每个通道,取 |左想象平均 ERD − 右想象平均 ERD|,全通道排序取前 14。结果(百分点):
FC3 108.2 | C2 90.2 | C1 89.6 | C4 83.4 | CP4 80.5 | FC4 72.2 | C3 55.3 | CPz 48.5 | P3 42.5 | FC6 40.8 | FC1 40.7 | CP5 38.1 | O1 37.2 | CP3 29.5
前 7 名恰好是模拟器注入调制的 8 个通道中的 7 个 (仅 CP3 落到第 14 位)------又一次与标准答案对上了。这张横条图在工程上的用途是通道选择:64 导全用上,特征维度高、噪声通道多;按可分性取头部通道(比如前 8--12 个),往往反而提升小样本下的分类表现。在真实 BCI 产品里,这一步还对应着"个体化电极配置"------不同被试的最强通道位置有个体差异,图 6 就是给每个被试定制通道子集的入口。
但逐通道比较有个天花板:它只看"一个通道自己的左右差异",而头皮 EEG 是多个脑源在电极上的混合。有没有办法把多个通道线性组合起来,让组合后的信号左右差异最大?------这就是 CSP。
8. CSP + LDA:把空间模式压成四个特征
8.1 CSP 的直觉
共空间模式(Common Spatial Pattern)要找一组空间滤波器 www(每个通道一个权重的线性组合),使得滤波后信号的方差在条件 A 下尽量大、在条件 B 下尽量小(以及对称的另一组:B 大 A 小)。方差 = 功率,所以 CSP 就是在找"两类任务功率差异最极端的空间投影方向"。对左右手任务,这些方向通常就对应"左运动区加权"和"右运动区加权"------算法会自己从数据里把图 3 那两个镜像热区挖出来,不需要我们告诉它解剖知识。
8.2 数学实现四步(脚本逐行对应)
- 估计两类平均协方差 :每个试次算通道 × 通道协方差矩阵(
shrinkage_cov():先按迹归一化消除试次间整体幅度差异,再加 8% 收缩------小样本下协方差估计不稳,往单位阵方向"拽"一点换取稳健),同类试次平均得到 Σ左\Sigma_{\text{左}}Σ左、Σ右\Sigma_{\text{右}}Σ右; - 白化 :对复合协方差 Σ左+Σ右\Sigma_{\text{左}}+\Sigma_{\text{右}}Σ左+Σ右 做特征分解,构造白化矩阵 PPP,使 P⊤(Σ左+Σ右)P=IP^\top(\Sigma_{\text{左}}+\Sigma_{\text{右}})P = IP⊤(Σ左+Σ右)P=I------把"总功率"在所有方向上拉平;
- 联合对角化 :在白化空间对 P⊤Σ左PP^\top \Sigma_{\text{左}} PP⊤Σ左P 再做特征分解。关键性质:此时 Σ左\Sigma_{\text{左}}Σ左 与 Σ右\Sigma_{\text{右}}Σ右 被同时对角化,且两类特征值互补(和为 1)------左类特征值接近 1 的方向,右类必接近 0;
- 取两端、提特征 :选特征值最大的 2 个 + 最小的 2 个方向(
n_components=2,共 4 个滤波器),试次滤波后取归一化方差的对数(log-variance)作为特征。取对数因为方差比值近似对数正态分布,log 之后更接近线性可分。
python
z = filters @ epoch # 4 个空间滤波后的"虚拟通道"
var = np.var(z, axis=1); var /= var.sum()
features.append(np.log(var)) # 4 维 CSP log-var 特征
8.3 LDA 与留一法:小样本的诚实验证
分类器是最朴素的 LDA:类内协方差 SwS_wSw,投影方向 w=Sw−1(μ左−μ右)w = S_w^{-1}(\mu_{\text{左}}-\mu_{\text{右}})w=Sw−1(μ左−μ右)(数值上用 pinv 更稳),阈值取两类均值投影的中点。纯 NumPy 实现,不依赖 scikit-learn。
验证方式用留一法(LOO) :13 个试次轮流当测试集,其余 12 个训练。注意脚本里一个极为关键的细节------每一折都在训练折内部重新训练 CSP:
python
for test_idx in range(len(y)):
filters = train_csp_filters(x[train_idx], y[train_idx], ...) # fold 内重训
为什么强调这个?CSP 是监督型 滤波器------它用了标签信息。如果先用全部数据训 CSP、再交叉验证分类器,测试试次的信息已经通过滤波器泄漏进了特征,准确率会被高估,这是 BCI 论文里最常见的错误之一。所有用到标签的步骤,都必须关在交叉验证的折内。
8.4 【图 5】CSP 特征空间与分类验证

- A 面板(特征空间) :13 个试次在前两维 CSP log-var 特征中的位置。蓝色圆点(左拳想象)聚在左下一团(CSP1 约 −2.3 ~ −1.6),橙色方块(右拳想象)聚在右上一团(约 −1.4 ~ −0.75),两团之间隔着一个清晰的空档------CSP 把 21 个运动区通道压缩成 4 个数字,而类间分离度肉眼可见。这正是图 3 那个镜像空间模式被算法捕获后的样子。
- B 面板(留一法混淆矩阵) :真实左拳 7 个试次,6 个判对、1 个误判为右拳;真实右拳 6 个试次,5 个判对、1 个误判为左拳。合计 11/13 = 84.6% ,与结果摘要一致(逐试次明细见
CSP_LDA留一法分类结果.csv,错的是第 3、6 号试次)。
84.6% 该怎么评价?二分类随机水平 50%,13 个试次下 11 个判对,二项检验 p ≈ 0.011------过了 0.05 线,但试次数太少,置信区间还很宽(约 55%--98%)。规范的说法是:在本数据上观察到了高于随机的可解码性,距离"模型可靠"还需要更多试次与跨 session 验证。下篇会用置换检验把这个判断做得更严格。
9. 结果表格与 MATLAB 对照
9.1 五张表的分工
| 文件 | 内容 | 用途 |
|---|---|---|
事件标注表.csv |
13 个事件的 onset、时长、标签 | 实验设计核查、分段复现 |
试次_通道_频带功率表.csv |
13 试次 × 27 通道 × 5 频带功率长表 | 统计建模、画论文图 |
Mu_Beta_ERD统计表.csv |
每事件每通道的 Mu/Beta ERD% | 图 3/图 6 数据源 |
CSP特征表.csv |
13 试次的 4 维 CSP log-var 特征 + 标签 | 换分类器、做置换检验 |
CSP_LDA留一法分类结果.csv |
每试次真实/预测标签与对错 | 混淆矩阵、错误分析 |
外加自动生成的 第三阶段方法与结果摘要.txt------延续了实战 02"让脚本替你写方法初稿"的传统,并新增"数据来源"声明(真实/模拟),可复现性叙事完整。
配套代码:eeg_third_case_motor_imagery.py(Python 3.9+,仅依赖 numpy / pandas / scipy / matplotlib)。联网环境自动下载 PhysioNet S001R03 真实数据;离线时自动生成固定种子的教学模拟数据,并在输出中明确标注来源。运行后生成 figures/(本篇 6 张图)与 results/(5 张 CSV + 方法摘要)。
python
from __future__ import annotations
import math
import struct
import sys
import textwrap
import urllib.request
from dataclasses import dataclass
from pathlib import Path
import matplotlib
# 批量保存科研图片时使用非交互式后端,避免弹窗阻塞。
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
from scipy import signal, stats
# Windows/Anaconda 控制台有时会使用 cp1252 等非中文编码;这里主动切到 UTF-8,
# 避免中文科研说明在 print 时触发 UnicodeEncodeError。
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8")
if hasattr(sys.stderr, "reconfigure"):
sys.stderr.reconfigure(encoding="utf-8")
# ============================================================
# 1. 路径、数据源与全局参数
# ============================================================
PROJECT_DIR = Path(__file__).resolve().parent
DATA_DIR = PROJECT_DIR / "data"
FIGURE_DIR = PROJECT_DIR / "figures"
RESULT_DIR = PROJECT_DIR / "results"
DATASET_NAME = "PhysioNet EEG Motor Movement/Imagery, S001R03"
DATASET_PAGE = "https://physionet.org/content/eegmmidb/1.0.0/"
EDF_URL = "https://physionet.org/files/eegmmidb/1.0.0/S001/S001R03.edf"
EDF_PATH = DATA_DIR / "S001R03.edf"
RANDOM_SEED = 20260717
SAMPLING_RATE_FALLBACK = 160.0
EVENT_PADDING_SEC = 0.25
EPOCH_TMIN = 0.0
EPOCH_TMAX = 4.0
BASELINE_WINDOW = (-1.5, -0.2)
ANALYSIS_WINDOW = (0.5, 3.5)
# 运动想象研究常用频带。这里重点关注感觉运动节律:Mu 与 Beta。
FREQ_BANDS = {
"Theta 4-8 Hz": (4.0, 8.0),
"Alpha 8-13 Hz": (8.0, 13.0),
"Mu 8-12 Hz": (8.0, 12.0),
"Beta 13-30 Hz": (13.0, 30.0),
"Low Gamma 30-45 Hz": (30.0, 45.0),
}
# 64 通道 10-10 系统近似二维坐标,用于轻量级头皮散点图。
# 坐标不是医学定位工具,只用于教学型可视化。
CHANNEL_POSITIONS = {
"Fc5.": (-0.55, 0.35), "Fc3.": (-0.32, 0.42), "Fc1.": (-0.12, 0.46),
"Fcz.": (0.00, 0.48), "Fc2.": (0.12, 0.46), "Fc4.": (0.32, 0.42),
"Fc6.": (0.55, 0.35), "C5..": (-0.62, 0.05), "C3..": (-0.38, 0.08),
"C1..": (-0.15, 0.10), "Cz..": (0.00, 0.11), "C2..": (0.15, 0.10),
"C4..": (0.38, 0.08), "C6..": (0.62, 0.05), "Cp5.": (-0.55, -0.28),
"Cp3.": (-0.34, -0.32), "Cp1.": (-0.13, -0.35), "Cpz.": (0.00, -0.36),
"Cp2.": (0.13, -0.35), "Cp4.": (0.34, -0.32), "Cp6.": (0.55, -0.28),
"F3..": (-0.38, 0.68), "Fz..": (0.00, 0.74), "F4..": (0.38, 0.68),
"P3..": (-0.38, -0.62), "Pz..": (0.00, -0.68), "P4..": (0.38, -0.62),
"O1..": (-0.24, -0.90), "Oz..": (0.00, -0.96), "O2..": (0.24, -0.90),
}
# 兼容 MNE 常用通道名写法,便于在图中显示得更自然。
DISPLAY_NAME = {
"Fc5.": "FC5", "Fc3.": "FC3", "Fc1.": "FC1", "Fcz.": "FCz", "Fc2.": "FC2",
"Fc4.": "FC4", "Fc6.": "FC6", "C5..": "C5", "C3..": "C3", "C1..": "C1",
"Cz..": "Cz", "C2..": "C2", "C4..": "C4", "C6..": "C6", "Cp5.": "CP5",
"Cp3.": "CP3", "Cp1.": "CP1", "Cpz.": "CPz", "Cp2.": "CP2", "Cp4.": "CP4",
"Cp6.": "CP6", "F3..": "F3", "Fz..": "Fz", "F4..": "F4", "P3..": "P3",
"Pz..": "Pz", "P4..": "P4", "O1..": "O1", "Oz..": "Oz", "O2..": "O2",
}
COLORS = {
"left": "#0072B2",
"right": "#D55E00",
"rest": "#7A7A7A",
"mu": "#009E73",
"beta": "#CC79A7",
"grid": "#D8D8D8",
"text": "#2F2F2F",
}
@dataclass
class EegRecording:
"""统一保存 EEG 数据、采样率、通道名和事件表。"""
data_uv: np.ndarray
sfreq: float
channels: list[str]
events: pd.DataFrame
source: str
@dataclass
class EpochSet:
"""事件分段后的三维数据:试次数 x 通道数 x 时间点。"""
data_uv: np.ndarray
labels: np.ndarray
times: np.ndarray
channels: list[str]
sfreq: float
# ============================================================
# 2. 字体、目录与下载
# ============================================================
def setup_chinese_matplotlib() -> None:
"""配置中文字体;若系统没有对应字体,matplotlib 会自动回退。"""
matplotlib.rcParams["font.sans-serif"] = [
"Microsoft YaHei",
"SimHei",
"Noto Sans CJK SC",
"Arial Unicode MS",
"DejaVu Sans",
]
matplotlib.rcParams["axes.unicode_minus"] = False
matplotlib.rcParams["figure.dpi"] = 140
matplotlib.rcParams["savefig.dpi"] = 220
matplotlib.rcParams["axes.edgecolor"] = "#333333"
matplotlib.rcParams["axes.labelcolor"] = COLORS["text"]
matplotlib.rcParams["xtick.color"] = COLORS["text"]
matplotlib.rcParams["ytick.color"] = COLORS["text"]
def ensure_directories() -> None:
"""创建第三阶段项目自己的输出目录,不触碰前两个 case。"""
DATA_DIR.mkdir(parents=True, exist_ok=True)
FIGURE_DIR.mkdir(parents=True, exist_ok=True)
RESULT_DIR.mkdir(parents=True, exist_ok=True)
def download_edf_if_needed() -> bool:
"""
下载 PhysioNet EDF 数据。
返回值表示是否获得了真实 EDF 文件。下载失败不会中断项目,
后续会切换到结构化模拟数据,以便完整教学流程仍可运行。
"""
if EDF_PATH.exists() and EDF_PATH.stat().st_size > 1_000_000:
print(f"[1] 已发现真实 EDF 数据:{EDF_PATH}")
return True
print(f"[1] 正在下载不同于前两阶段的公开数据:{DATASET_NAME}")
print(f" 来源页面:{DATASET_PAGE}")
try:
request = urllib.request.Request(
EDF_URL,
headers={"User-Agent": "EEGThirdCaseMotorImagery/1.0"},
)
with urllib.request.urlopen(request, timeout=60) as response:
content = response.read()
EDF_PATH.write_bytes(content)
print(f" 下载完成:{EDF_PATH},大小 {EDF_PATH.stat().st_size / 1024 / 1024:.2f} MB")
return True
except Exception as exc: # noqa: BLE001 - 教学脚本需要给出友好降级路径
print(f" 下载失败:{exc}")
print(" 将自动生成可重复的运动想象结构模拟 EEG 数据。")
return False
# ============================================================
# 3. 轻量 EDF 读取与事件解析
# ============================================================
def _decode_ascii(raw: bytes) -> str:
"""EDF 头字段常有空格填充,统一清理为普通字符串。"""
return raw.decode("latin-1", errors="ignore").strip()
def _parse_float(text: str, default: float = 0.0) -> float:
try:
return float(text)
except ValueError:
return default
def _parse_int(text: str, default: int = 0) -> int:
try:
return int(float(text))
except ValueError:
return default
def parse_edf_annotations(raw_samples: np.ndarray, sfreq: float) -> pd.DataFrame:
"""
解析 EDF+ annotation 通道。
EDF+ 标注以 TAL 文本形式编码,包含 onset、duration 和事件标签。
PhysioNet 运动想象数据中常见标签:
T0 = 休息
T1 = 左拳运动/想象
T2 = 右拳运动/想象
"""
del sfreq # 标注通道已经被解码为近似字节值,不再需要采样率。
values = np.clip(np.round(raw_samples), 0, 255).astype(np.uint8)
text = bytes(values).decode("latin-1", errors="ignore")
rows: list[dict[str, float | str]] = []
for item in text.split("\x00"):
if not item or "\x14" not in item:
continue
parts = item.split("\x14")
onset_duration = parts[0].replace("\x15", " ").strip()
labels = [p.strip() for p in parts[1:] if p.strip()]
if not onset_duration or not labels:
continue
fields = onset_duration.split()
onset = _parse_float(fields[0].lstrip("+"), np.nan)
duration = _parse_float(fields[1], np.nan) if len(fields) > 1 else np.nan
if not np.isfinite(onset):
continue
for label in labels:
if label in {"T0", "T1", "T2"}:
rows.append({"onset_sec": onset, "duration_sec": duration, "label": label})
events = pd.DataFrame(rows).drop_duplicates().sort_values("onset_sec").reset_index(drop=True)
if events.empty:
return events
events["condition_cn"] = events["label"].map({"T0": "静息", "T1": "左拳想象", "T2": "右拳想象"})
return events
def read_edf_physionet(path: Path) -> EegRecording:
"""
读取 PhysioNet EDF 文件。
为了保持单文件项目,本函数实现了本项目所需的 EDF 子集读取:
读取头信息、校准数字量到物理量,并提取 EDF Annotations 事件。
"""
raw = path.read_bytes()
header_bytes = _parse_int(_decode_ascii(raw[184:192]))
n_records = _parse_int(_decode_ascii(raw[236:244]))
record_duration = _parse_float(_decode_ascii(raw[244:252]), 1.0)
n_signals = _parse_int(_decode_ascii(raw[252:256]))
cursor = 256
def take_fields(width: int) -> list[str]:
nonlocal cursor
fields = [_decode_ascii(raw[cursor + i * width: cursor + (i + 1) * width]) for i in range(n_signals)]
cursor += n_signals * width
return fields
labels = take_fields(16)
_ = take_fields(80) # transducer type
dimensions = take_fields(8)
physical_min = np.array([_parse_float(x) for x in take_fields(8)], dtype=float)
physical_max = np.array([_parse_float(x) for x in take_fields(8)], dtype=float)
digital_min = np.array([_parse_float(x) for x in take_fields(8)], dtype=float)
digital_max = np.array([_parse_float(x) for x in take_fields(8)], dtype=float)
_ = take_fields(80) # prefiltering
samples_per_record = np.array([_parse_int(x) for x in take_fields(8)], dtype=int)
_ = take_fields(32) # reserved
if header_bytes != cursor:
cursor = header_bytes
data_by_signal: list[np.ndarray] = []
total_samples = samples_per_record * n_records
for signal_idx in range(n_signals):
data_by_signal.append(np.empty(total_samples[signal_idx], dtype=np.int16))
for record_idx in range(n_records):
for signal_idx in range(n_signals):
n = samples_per_record[signal_idx]
byte_count = n * 2
block = raw[cursor: cursor + byte_count]
cursor += byte_count
start = record_idx * n
data_by_signal[signal_idx][start: start + n] = np.frombuffer(block, dtype="<i2")
annotation_idx = [i for i, label in enumerate(labels) if "annotation" in label.lower()]
eeg_idx = [i for i in range(n_signals) if i not in annotation_idx]
calibrated: list[np.ndarray] = []
for i in eeg_idx:
scale = (physical_max[i] - physical_min[i]) / (digital_max[i] - digital_min[i])
offset = physical_min[i] - scale * digital_min[i]
calibrated.append(data_by_signal[i].astype(float) * scale + offset)
data_uv = np.vstack(calibrated)
eeg_channels = [labels[i] for i in eeg_idx]
sfreq = samples_per_record[eeg_idx[0]] / record_duration
events = pd.DataFrame()
if annotation_idx:
ann_i = annotation_idx[0]
ann_scale = (physical_max[ann_i] - physical_min[ann_i]) / (digital_max[ann_i] - digital_min[ann_i])
ann_offset = physical_min[ann_i] - ann_scale * digital_min[ann_i]
ann_values = data_by_signal[ann_i].astype(float) * ann_scale + ann_offset
events = parse_edf_annotations(ann_values, samples_per_record[ann_i] / record_duration)
# 如果标注解析失败,使用该 run 的经典 4 秒提示结构构建近似事件。
if events.empty:
onsets = np.arange(4.2, data_uv.shape[1] / sfreq - 4.0, 8.0)
labels_cycle = ["T1", "T2"] * (len(onsets) // 2 + 1)
events = pd.DataFrame(
{
"onset_sec": onsets[: len(labels_cycle)],
"duration_sec": 4.0,
"label": labels_cycle[: len(onsets)],
}
)
events["condition_cn"] = events["label"].map({"T1": "左拳想象", "T2": "右拳想象"})
return EegRecording(
data_uv=data_uv,
sfreq=sfreq,
channels=eeg_channels,
events=events,
source=f"真实数据:{DATASET_NAME} ({path.name})",
)
def simulate_motor_imagery_recording() -> EegRecording:
"""
生成可重复的教学模拟数据。
模拟思路:左右拳想象时,对侧中央区 Mu/Beta 功率下降,模拟 ERD;
同时保留 1/f 背景噪声、低频漂移和通道间差异。
"""
rng = np.random.default_rng(RANDOM_SEED)
sfreq = SAMPLING_RATE_FALLBACK
duration_sec = 124
times = np.arange(int(duration_sec * sfreq)) / sfreq
channels = [
"Fc5.", "Fc3.", "Fc1.", "Fcz.", "Fc2.", "Fc4.", "Fc6.",
"C5..", "C3..", "C1..", "Cz..", "C2..", "C4..", "C6..",
"Cp5.", "Cp3.", "Cp1.", "Cpz.", "Cp2.", "Cp4.", "Cp6.",
"P3..", "Pz..", "P4..", "O1..", "Oz..", "O2..",
]
data = []
for channel in channels:
noise = signal.lfilter([1], [1, -0.92], rng.normal(0, 1, size=times.size))
mu = 8.0 * np.sin(2 * np.pi * (10.0 + rng.normal(0, 0.25)) * times + rng.uniform(0, 2 * np.pi))
beta = 3.5 * np.sin(2 * np.pi * (20.0 + rng.normal(0, 0.45)) * times + rng.uniform(0, 2 * np.pi))
drift = 2.0 * np.sin(2 * np.pi * 0.25 * times + rng.uniform(0, 2 * np.pi))
data.append(3.5 * noise + mu + beta + drift)
data_uv = np.vstack(data)
onsets = np.arange(8.0, 112.0, 8.0)
labels = np.array(["T1", "T2"] * 7)[: len(onsets)]
events = pd.DataFrame({"onset_sec": onsets, "duration_sec": 4.0, "label": labels})
events["condition_cn"] = events["label"].map({"T1": "左拳想象", "T2": "右拳想象"})
left_motor = ["C3..", "Fc3.", "Cp3.", "C1.."]
right_motor = ["C4..", "Fc4.", "Cp4.", "C2.."]
for onset, label in zip(onsets, labels):
idx = (times >= onset) & (times < onset + 4.0)
taper = np.sin(np.linspace(0, np.pi, idx.sum())) ** 2
affected = left_motor if label == "T2" else right_motor
for channel in affected:
if channel in channels:
ci = channels.index(channel)
erd = 7.5 * np.sin(2 * np.pi * 10.0 * times[idx]) * taper
data_uv[ci, idx] -= erd
return EegRecording(
data_uv=data_uv,
sfreq=sfreq,
channels=channels,
events=events,
source="结构化模拟数据:网络不可用时的教学备用数据",
)
def load_recording() -> EegRecording:
"""优先使用真实 PhysioNet 数据,失败时自动降级到模拟数据。"""
has_real_file = download_edf_if_needed()
if has_real_file:
try:
recording = read_edf_physionet(EDF_PATH)
print(f"[2] EDF 读取完成:{recording.data_uv.shape[0]} 通道,采样率 {recording.sfreq:.1f} Hz")
print(f" 事件数量:{len(recording.events)}")
return recording
except Exception as exc: # noqa: BLE001
print(f"[2] EDF 读取失败:{exc}")
print(" 将使用可重复模拟数据继续完整流程。")
recording = simulate_motor_imagery_recording()
print(f"[2] 模拟数据生成完成:{recording.data_uv.shape[0]} 通道,采样率 {recording.sfreq:.1f} Hz")
return recording
# ============================================================
# 4. 预处理、分段与特征
# ============================================================
def bandpass_filter(data_uv: np.ndarray, sfreq: float, low: float = 1.0, high: float = 45.0) -> np.ndarray:
"""零相位 Butterworth 滤波,保留常用 EEG 研究频段。"""
nyq = sfreq / 2.0
high = min(high, nyq - 1.0)
sos = signal.butter(4, [low / nyq, high / nyq], btype="bandpass", output="sos")
return signal.sosfiltfilt(sos, data_uv, axis=1)
def notch_filter_if_possible(data_uv: np.ndarray, sfreq: float) -> np.ndarray:
"""按采样率条件进行 50 Hz 陷波;若 Nyquist 不允许则跳过。"""
if sfreq / 2.0 <= 51.0:
return data_uv
b, a = signal.iirnotch(w0=50.0, Q=30.0, fs=sfreq)
return signal.filtfilt(b, a, data_uv, axis=1)
def make_epochs(recording: EegRecording, cleaned_uv: np.ndarray) -> EpochSet:
"""围绕 T1/T2 事件截取运动想象 epoch。"""
motor_events = recording.events[recording.events["label"].isin(["T1", "T2"])].copy()
start_offset = int(round(EPOCH_TMIN * recording.sfreq))
end_offset = int(round(EPOCH_TMAX * recording.sfreq))
epochs = []
labels = []
for _, row in motor_events.iterrows():
start = int(round(float(row["onset_sec"]) * recording.sfreq)) + start_offset
end = int(round(float(row["onset_sec"]) * recording.sfreq)) + end_offset
if start < 0 or end > cleaned_uv.shape[1] or end <= start:
continue
epochs.append(cleaned_uv[:, start:end])
labels.append(str(row["label"]))
if not epochs:
raise RuntimeError("没有可用的 T1/T2 事件分段,请检查 EDF 标注或模拟数据。")
epoch_data = np.stack(epochs, axis=0)
times = np.arange(epoch_data.shape[2]) / recording.sfreq + EPOCH_TMIN
return EpochSet(
data_uv=epoch_data,
labels=np.array(labels),
times=times,
channels=recording.channels,
sfreq=recording.sfreq,
)
def band_power_welch(data_uv: np.ndarray, sfreq: float, band: tuple[float, float]) -> np.ndarray:
"""对任意二维/三维 EEG 数据计算指定频段平均功率。"""
f, pxx = signal.welch(data_uv, fs=sfreq, nperseg=min(int(2 * sfreq), data_uv.shape[-1]), axis=-1)
mask = (f >= band[0]) & (f <= band[1])
return np.trapz(pxx[..., mask], f[mask], axis=-1)
def compute_band_power_table(epochs: EpochSet) -> pd.DataFrame:
"""输出每个 trial、通道、频带的功率表,方便后续统计或论文制表。"""
rows = []
for band_name, band in FREQ_BANDS.items():
power = band_power_welch(epochs.data_uv, epochs.sfreq, band)
for trial_idx, label in enumerate(epochs.labels):
for channel_idx, channel in enumerate(epochs.channels):
rows.append(
{
"trial": trial_idx + 1,
"label": label,
"condition_cn": "左拳想象" if label == "T1" else "右拳想象",
"channel": DISPLAY_NAME.get(channel, channel),
"band": band_name,
"power_uv2": power[trial_idx, channel_idx],
}
)
return pd.DataFrame(rows)
def compute_erd_table(recording: EegRecording, cleaned_uv: np.ndarray) -> pd.DataFrame:
"""计算 Mu/Beta 相对基线功率变化,用于解释 ERD/ERS。"""
rows = []
for _, event in recording.events[recording.events["label"].isin(["T1", "T2"])].iterrows():
onset = float(event["onset_sec"])
label = str(event["label"])
for band_name in ["Mu 8-12 Hz", "Beta 13-30 Hz"]:
band = FREQ_BANDS[band_name]
baseline_slice = slice(
max(0, int((onset + BASELINE_WINDOW[0]) * recording.sfreq)),
max(0, int((onset + BASELINE_WINDOW[1]) * recording.sfreq)),
)
analysis_slice = slice(
int((onset + ANALYSIS_WINDOW[0]) * recording.sfreq),
min(cleaned_uv.shape[1], int((onset + ANALYSIS_WINDOW[1]) * recording.sfreq)),
)
if baseline_slice.stop <= baseline_slice.start or analysis_slice.stop <= analysis_slice.start:
continue
base_power = band_power_welch(cleaned_uv[:, baseline_slice], recording.sfreq, band)
task_power = band_power_welch(cleaned_uv[:, analysis_slice], recording.sfreq, band)
erd_percent = (task_power - base_power) / np.maximum(base_power, 1e-12) * 100.0
for channel, value in zip(recording.channels, erd_percent):
rows.append(
{
"label": label,
"condition_cn": "左拳想象" if label == "T1" else "右拳想象",
"channel": DISPLAY_NAME.get(channel, channel),
"band": band_name,
"erd_percent": value,
}
)
return pd.DataFrame(rows)
def select_motor_channels(channels: list[str]) -> list[int]:
"""优先选择中央运动区通道;若真实数据命名差异较大,则退回到可用前 24 个 EEG 通道。"""
preferred = [
"Fc5.", "Fc3.", "Fc1.", "Fcz.", "Fc2.", "Fc4.", "Fc6.",
"C5..", "C3..", "C1..", "Cz..", "C2..", "C4..", "C6..",
"Cp5.", "Cp3.", "Cp1.", "Cpz.", "Cp2.", "Cp4.", "Cp6.",
]
idx = [channels.index(ch) for ch in preferred if ch in channels]
if len(idx) >= 8:
return idx
return list(range(min(24, len(channels))))
def shrinkage_cov(epoch: np.ndarray) -> np.ndarray:
"""计算归一化协方差,并加入轻微收缩提升小样本稳定性。"""
centered = epoch - epoch.mean(axis=1, keepdims=True)
cov = centered @ centered.T
cov /= max(np.trace(cov), 1e-12)
alpha = 0.08
return (1 - alpha) * cov + alpha * np.eye(cov.shape[0]) / cov.shape[0]
def train_csp_filters(x_train: np.ndarray, y_train: np.ndarray, n_components: int = 3) -> np.ndarray:
"""
训练 CSP 空间滤波器。
CSP 通过寻找两类协方差差异最大的方向,把左右运动想象的空间模式放大。
"""
cov_left = np.mean([shrinkage_cov(epoch) for epoch in x_train[y_train == "T1"]], axis=0)
cov_right = np.mean([shrinkage_cov(epoch) for epoch in x_train[y_train == "T2"]], axis=0)
composite = cov_left + cov_right
eigvals, eigvecs = np.linalg.eigh(composite)
eigvals = np.maximum(eigvals, 1e-12)
whitening = eigvecs @ np.diag(1.0 / np.sqrt(eigvals)) @ eigvecs.T
s_left = whitening.T @ cov_left @ whitening
d, b = np.linalg.eigh(s_left)
order = np.argsort(d)
filters = whitening @ b[:, order]
chosen = np.r_[0:n_components, -n_components:0]
return filters[:, chosen].T
def csp_logvar_features(x: np.ndarray, filters: np.ndarray) -> np.ndarray:
"""CSP 后取归一化 log-variance,是 BCI 经典特征。"""
features = []
for epoch in x:
z = filters @ epoch
var = np.var(z, axis=1)
var = var / np.maximum(var.sum(), 1e-12)
features.append(np.log(np.maximum(var, 1e-12)))
return np.vstack(features)
def fit_lda(x: np.ndarray, y: np.ndarray) -> tuple[np.ndarray, float]:
"""二分类 LDA 的最小实现,避免额外依赖 scikit-learn。"""
x1 = x[y == "T1"]
x2 = x[y == "T2"]
m1 = x1.mean(axis=0)
m2 = x2.mean(axis=0)
sw = np.cov(x1, rowvar=False) + np.cov(x2, rowvar=False)
sw = np.atleast_2d(sw) + 1e-4 * np.eye(x.shape[1])
w = np.linalg.pinv(sw) @ (m1 - m2)
threshold = 0.5 * (m1 + m2) @ w
return w, threshold
def predict_lda(x: np.ndarray, w: np.ndarray, threshold: float) -> np.ndarray:
"""LDA 预测:投影值高于阈值判为 T1,否则判为 T2。"""
scores = x @ w
return np.where(scores >= threshold, "T1", "T2")
def leave_one_out_csp_lda(epochs: EpochSet) -> tuple[pd.DataFrame, pd.DataFrame, np.ndarray, list[int]]:
"""小样本条件下采用 leave-one-out 验证,给出更透明的分类表现。"""
motor_idx = select_motor_channels(epochs.channels)
x = epochs.data_uv[:, motor_idx, :]
y = epochs.labels
results = []
for test_idx in range(len(y)):
train_idx = np.array([i for i in range(len(y)) if i != test_idx])
if len(np.unique(y[train_idx])) < 2:
continue
filters = train_csp_filters(x[train_idx], y[train_idx], n_components=2)
x_train_feat = csp_logvar_features(x[train_idx], filters)
x_test_feat = csp_logvar_features(x[[test_idx]], filters)
w, threshold = fit_lda(x_train_feat, y[train_idx])
pred = predict_lda(x_test_feat, w, threshold)[0]
results.append(
{
"trial": test_idx + 1,
"true_label": y[test_idx],
"true_condition_cn": "左拳想象" if y[test_idx] == "T1" else "右拳想象",
"pred_label": pred,
"pred_condition_cn": "左拳想象" if pred == "T1" else "右拳想象",
"correct": bool(pred == y[test_idx]),
}
)
# 用全体数据训练一次 CSP,用于画特征散点。
filters_all = train_csp_filters(x, y, n_components=2)
features = csp_logvar_features(x, filters_all)
feature_df = pd.DataFrame(features, columns=[f"CSP{i + 1}_logvar" for i in range(features.shape[1])])
feature_df["label"] = y
feature_df["condition_cn"] = np.where(y == "T1", "左拳想象", "右拳想象")
return pd.DataFrame(results), feature_df, filters_all, motor_idx
# ============================================================
# 5. 科研风格中文可视化
# ============================================================
def save_figure(fig: plt.Figure, filename: str) -> None:
"""统一保存图片,减少边距并关闭 figure。"""
fig.savefig(FIGURE_DIR / filename, bbox_inches="tight")
plt.close(fig)
print(f" 已保存图像:{FIGURE_DIR / filename}")
def style_axis(ax: plt.Axes) -> None:
"""轻量科研图样式:去掉上右边框,保留灰色辅助网格。"""
ax.spines["top"].set_visible(False)
ax.spines["right"].set_visible(False)
ax.grid(True, color=COLORS["grid"], linewidth=0.6, alpha=0.8)
def plot_event_overview(recording: EegRecording, cleaned_uv: np.ndarray) -> None:
"""图 1:展示中央区通道波形和 T1/T2 事件结构。"""
channels_to_plot = [ch for ch in ["C3..", "Cz..", "C4.."] if ch in recording.channels]
if not channels_to_plot:
channels_to_plot = recording.channels[:3]
channel_idx = [recording.channels.index(ch) for ch in channels_to_plot]
max_sec = min(64.0, cleaned_uv.shape[1] / recording.sfreq)
n = int(max_sec * recording.sfreq)
t = np.arange(n) / recording.sfreq
fig, ax = plt.subplots(figsize=(12, 5.2))
offset = 0.0
spacing = np.nanpercentile(np.abs(cleaned_uv[channel_idx, :n]), 95) * 3.0
spacing = max(spacing, 10.0)
for i, ch_i in enumerate(channel_idx):
y = cleaned_uv[ch_i, :n] + offset
ax.plot(t, y, linewidth=0.8, label=DISPLAY_NAME.get(recording.channels[ch_i], recording.channels[ch_i]))
ax.text(t[-1] + 0.25, offset, DISPLAY_NAME.get(recording.channels[ch_i], recording.channels[ch_i]), va="center")
offset += spacing
visible_events = recording.events[recording.events["onset_sec"] <= max_sec]
for _, row in visible_events.iterrows():
onset = float(row["onset_sec"])
label = str(row["label"])
color = COLORS["left"] if label == "T1" else COLORS["right"] if label == "T2" else COLORS["rest"]
ax.axvspan(onset, onset + float(row.get("duration_sec", 4.0) or 4.0), color=color, alpha=0.10)
if label in {"T1", "T2"}:
ax.text(onset + 0.15, offset - spacing * 0.15, "左拳想象" if label == "T1" else "右拳想象",
color=color, fontsize=9, rotation=90, va="top")
ax.set_title("图1 运动想象 EEG 时间结构:中央区波形与任务事件标注", fontsize=13, weight="bold")
ax.set_xlabel("时间(秒)")
ax.set_ylabel("滤波后电位(μV,按通道错位显示)")
ax.set_xlim(0, max_sec + 4)
style_axis(ax)
ax.text(
0.01,
-0.24,
"图例说明:蓝色半透明区表示 T1(左拳想象),橙色半透明区表示 T2(右拳想象);"
"该图用于检查事件时序是否与 EEG 波形对齐。",
transform=ax.transAxes,
fontsize=9,
color=COLORS["text"],
ha="left",
va="top",
)
save_figure(fig, "01_运动想象事件结构与中央区波形.png")
def plot_time_frequency_erd(recording: EegRecording, cleaned_uv: np.ndarray) -> None:
"""图 2:C3/C4 的事件锁定时频 ERD/ERS。"""
target_channels = [ch for ch in ["C3..", "C4.."] if ch in recording.channels]
if len(target_channels) < 2:
target_channels = recording.channels[:2]
fig, axes = plt.subplots(len(target_channels), 2, figsize=(12, 7), sharex=True, sharey=True)
if len(target_channels) == 1:
axes = np.array([axes])
for row_idx, channel in enumerate(target_channels):
ch_i = recording.channels.index(channel)
for col_idx, label in enumerate(["T1", "T2"]):
segments = []
for _, event in recording.events[recording.events["label"] == label].iterrows():
onset = float(event["onset_sec"])
start = int((onset - 2.0) * recording.sfreq)
end = int((onset + 5.0) * recording.sfreq)
if start < 0 or end > cleaned_uv.shape[1]:
continue
segments.append(cleaned_uv[ch_i, start:end])
ax = axes[row_idx, col_idx]
if not segments:
ax.text(0.5, 0.5, "无可用片段", ha="center", va="center", transform=ax.transAxes)
continue
average_segment = np.mean(np.vstack(segments), axis=0)
f, tt, sxx = signal.spectrogram(
average_segment,
fs=recording.sfreq,
nperseg=int(1.0 * recording.sfreq),
noverlap=int(0.85 * recording.sfreq),
scaling="density",
mode="psd",
)
rel_time = tt - 2.0
freq_mask = (f >= 4) & (f <= 40)
baseline_mask = (rel_time >= -1.5) & (rel_time <= -0.2)
baseline = np.mean(sxx[freq_mask][:, baseline_mask], axis=1, keepdims=True)
erd_db = 10 * np.log10(np.maximum(sxx[freq_mask], 1e-16) / np.maximum(baseline, 1e-16))
im = ax.pcolormesh(rel_time, f[freq_mask], erd_db, cmap="RdBu_r", shading="auto", vmin=-5, vmax=5)
ax.axvline(0, color="#222222", linewidth=1.0)
ax.axhspan(8, 12, color=COLORS["mu"], alpha=0.12)
ax.axhspan(13, 30, color=COLORS["beta"], alpha=0.08)
ax.set_title(f"{DISPLAY_NAME.get(channel, channel)} - {'左拳想象' if label == 'T1' else '右拳想象'}")
ax.set_xlabel("相对提示时间(秒)")
ax.set_ylabel("频率(Hz)")
fig.suptitle("图2 事件相关去同步化/同步化(ERD/ERS)时频图", fontsize=13, weight="bold")
cbar = fig.colorbar(im, ax=axes.ravel().tolist(), shrink=0.88, pad=0.02)
cbar.set_label("相对基线功率变化(dB)")
fig.text(
0.08,
0.02,
"图例说明:蓝色区域代表相对基线功率下降(ERD),红色区域代表功率增强(ERS);"
"绿色/粉色浅带分别提示 Mu 与 Beta 频段,0 秒为任务提示开始。",
fontsize=9,
color=COLORS["text"],
)
save_figure(fig, "02_C3_C4时频ERD_ERS.png")
def plot_band_power_topography(erd_table: pd.DataFrame) -> None:
"""图 3:Mu 频段 ERD 的近似头皮分布。"""
mu = erd_table[erd_table["band"] == "Mu 8-12 Hz"].copy()
if mu.empty:
return
summary = mu.groupby(["condition_cn", "channel"], as_index=False)["erd_percent"].mean()
fig, axes = plt.subplots(1, 2, figsize=(11, 5))
for ax, condition in zip(axes, ["左拳想象", "右拳想象"]):
subset = summary[summary["condition_cn"] == condition]
xs, ys, values, labels = [], [], [], []
for raw_name, pos in CHANNEL_POSITIONS.items():
name = DISPLAY_NAME.get(raw_name, raw_name)
matched = subset[subset["channel"] == name]
if matched.empty:
continue
xs.append(pos[0])
ys.append(pos[1])
values.append(float(matched["erd_percent"].iloc[0]))
labels.append(name)
head = plt.Circle((0, 0), 1.02, fill=False, color="#333333", linewidth=1.2)
ax.add_patch(head)
ax.plot([-0.12, 0, 0.12], [1.02, 1.13, 1.02], color="#333333", linewidth=1.0)
sc = ax.scatter(xs, ys, c=values, s=180, cmap="RdBu_r", vmin=-60, vmax=60, edgecolor="#333333", linewidth=0.5)
for x, y, name in zip(xs, ys, labels):
ax.text(x, y, name, fontsize=7, ha="center", va="center", color="#111111")
ax.set_aspect("equal")
ax.set_xlim(-1.15, 1.15)
ax.set_ylim(-1.1, 1.2)
ax.set_xticks([])
ax.set_yticks([])
ax.set_title(condition)
ax.set_frame_on(False)
fig.suptitle("图3 Mu 频段 ERD 的近似头皮分布", fontsize=13, weight="bold")
cbar = fig.colorbar(sc, ax=axes.ravel().tolist(), shrink=0.80, pad=0.03)
cbar.set_label("任务期相对基线功率变化(%)")
fig.text(
0.08,
0.03,
"图例说明:负值表示 Mu 节律去同步化增强;运动想象通常在对侧中央运动区出现更明显的功率下降。"
"坐标为教学近似投影,不用于临床定位。",
fontsize=9,
color=COLORS["text"],
)
save_figure(fig, "03_Mu频段ERD近似头皮图.png")
def plot_band_statistics(band_table: pd.DataFrame) -> None:
"""图 4:感觉运动通道的频带功率统计比较。"""
motor_channels = ["FC3", "FC4", "C3", "C4", "Cz", "CP3", "CP4"]
subset = band_table[band_table["channel"].isin(motor_channels)].copy()
if subset.empty:
subset = band_table.copy()
stats_df = (
subset.groupby(["condition_cn", "band"], as_index=False)["power_uv2"]
.agg(["mean", "sem"])
.reset_index()
)
bands = list(FREQ_BANDS.keys())
x = np.arange(len(bands))
width = 0.36
fig, ax = plt.subplots(figsize=(12, 5.5))
for offset, condition, color in [(-width / 2, "左拳想象", COLORS["left"]), (width / 2, "右拳想象", COLORS["right"])]:
values = []
errors = []
for band in bands:
row = stats_df[(stats_df["condition_cn"] == condition) & (stats_df["band"] == band)]
values.append(float(row["mean"].iloc[0]) if not row.empty else np.nan)
errors.append(float(row["sem"].iloc[0]) if not row.empty else 0.0)
ax.bar(x + offset, values, width=width, yerr=errors, capsize=3, color=color, alpha=0.82, label=condition)
ax.set_yscale("log")
ax.set_xticks(x)
ax.set_xticklabels(bands, rotation=20, ha="right")
ax.set_ylabel("频带功率(μV²,对数坐标)")
ax.set_title("图4 感觉运动相关通道的频带功率比较", fontsize=13, weight="bold")
ax.legend(frameon=False, title="任务条件")
style_axis(ax)
ax.text(
0.01,
-0.30,
"图例说明:柱高为试次与中央区通道平均功率,误差线为标准误;"
"Mu/Beta 频带差异常用于运动想象 BCI 特征构建。",
transform=ax.transAxes,
fontsize=9,
color=COLORS["text"],
)
save_figure(fig, "04_运动区频带功率统计比较.png")
def plot_csp_classification(classification_df: pd.DataFrame, feature_df: pd.DataFrame) -> None:
"""图 5:CSP 特征空间与 leave-one-out 分类结果。"""
if classification_df.empty or feature_df.empty:
return
accuracy = classification_df["correct"].mean()
labels = ["T1", "T2"]
confusion = pd.DataFrame(0, index=labels, columns=labels)
for _, row in classification_df.iterrows():
confusion.loc[row["true_label"], row["pred_label"]] += 1
fig, axes = plt.subplots(1, 2, figsize=(12, 5.3))
ax = axes[0]
for label, condition, color, marker in [
("T1", "左拳想象", COLORS["left"], "o"),
("T2", "右拳想象", COLORS["right"], "s"),
]:
subset = feature_df[feature_df["label"] == label]
ax.scatter(
subset["CSP1_logvar"],
subset["CSP2_logvar"],
s=70,
alpha=0.85,
color=color,
marker=marker,
label=condition,
edgecolor="#333333",
linewidth=0.4,
)
ax.set_xlabel("CSP1 log-variance")
ax.set_ylabel("CSP2 log-variance")
ax.set_title("A. CSP 特征空间")
ax.legend(frameon=False)
style_axis(ax)
ax = axes[1]
im = ax.imshow(confusion.values, cmap="Blues", vmin=0)
ax.set_xticks(np.arange(2))
ax.set_yticks(np.arange(2))
ax.set_xticklabels(["预测左拳", "预测右拳"])
ax.set_yticklabels(["真实左拳", "真实右拳"])
ax.set_title(f"B. 留一法分类混淆矩阵(准确率 {accuracy:.1%})")
for i in range(2):
for j in range(2):
ax.text(j, i, str(confusion.values[i, j]), ha="center", va="center", fontsize=14, weight="bold")
fig.colorbar(im, ax=ax, shrink=0.78, label="试次数")
fig.suptitle("图5 CSP-LDA 运动想象分类验证", fontsize=13, weight="bold")
fig.text(
0.08,
0.02,
"图例说明:CSP 将多通道 EEG 投影到最能区分两类运动想象的空间方向;"
"留一法用于小样本条件下评估模型是否捕捉到可复现的神经模式。",
fontsize=9,
color=COLORS["text"],
)
save_figure(fig, "05_CSP特征与LDA分类验证.png")
def plot_channel_discriminability(erd_table: pd.DataFrame) -> None:
"""图 6:通道层面的左右条件差异排名。"""
mu = erd_table[erd_table["band"] == "Mu 8-12 Hz"].copy()
if mu.empty:
return
pivot = mu.pivot_table(index="channel", columns="condition_cn", values="erd_percent", aggfunc="mean")
if not {"左拳想象", "右拳想象"}.issubset(pivot.columns):
return
pivot["左右差异绝对值"] = (pivot["左拳想象"] - pivot["右拳想象"]).abs()
top = pivot.sort_values("左右差异绝对值", ascending=False).head(14).sort_values("左右差异绝对值")
fig, ax = plt.subplots(figsize=(10, 6))
ax.barh(top.index, top["左右差异绝对值"], color="#56B4E9", alpha=0.88)
ax.set_xlabel("Mu ERD 左右任务差异绝对值(百分点)")
ax.set_ylabel("通道")
ax.set_title("图6 通道可分性排序:哪些电极最区分左右运动想象", fontsize=13, weight="bold")
style_axis(ax)
for y, value in enumerate(top["左右差异绝对值"]):
ax.text(value, y, f" {value:.1f}", va="center", fontsize=8)
ax.text(
0.01,
-0.16,
"图例说明:横条越长,表示该通道在左/右拳想象之间的 Mu ERD 差异越明显;"
"该结果可辅助后续通道选择和个体化 BCI 建模。",
transform=ax.transAxes,
fontsize=9,
color=COLORS["text"],
)
save_figure(fig, "06_Mu_ERD通道可分性排序.png")
# ============================================================
# 6. 结果保存与主流程
# ============================================================
def write_summary(
recording: EegRecording,
epochs: EpochSet,
classification_df: pd.DataFrame,
band_table: pd.DataFrame,
erd_table: pd.DataFrame,
) -> None:
"""写出中文方法摘要,方便作为课程报告素材。"""
accuracy_text = "未计算"
if not classification_df.empty:
accuracy_text = f"{classification_df['correct'].mean():.1%}"
event_counts = recording.events["label"].value_counts().to_dict()
band_summary = (
band_table.groupby(["condition_cn", "band"])["power_uv2"].mean().reset_index()
if not band_table.empty else pd.DataFrame()
)
mu_erd = erd_table[erd_table["band"] == "Mu 8-12 Hz"]["erd_percent"].mean() if not erd_table.empty else np.nan
summary = f"""
EEG 第三阶段进阶项目结果摘要
====================================
数据来源:
{recording.source}
公开数据主页:{DATASET_PAGE}
与前两个 case 的区别:本项目使用 PhysioNet 运动想象/运动执行 EEG EDF 数据,
不是 UCI EEG Eye State 眼睛睁闭状态数据。
数据规模:
- 通道数:{len(recording.channels)}
- 采样率:{recording.sfreq:.1f} Hz
- 样本点数:{recording.data_uv.shape[1]}
- 事件计数:{event_counts}
- 有效 T1/T2 epoch 数:{len(epochs.labels)}
方法路线:
1. 对 EEG 进行 1-45 Hz 零相位带通滤波,并在条件允许时进行 50 Hz 陷波。
2. 围绕 T1/T2 运动想象提示截取 0-4 秒 epoch。
3. 计算 Theta、Alpha、Mu、Beta、Low Gamma 频带功率。
4. 以提示前 -1.5 至 -0.2 秒为基线,估计 Mu/Beta ERD/ERS。
5. 使用中央感觉运动区通道训练 CSP 空间滤波器,再用 LDA 做左右运动想象分类。
关键结果:
- CSP-LDA 留一法分类准确率:{accuracy_text}
- 全通道平均 Mu ERD:{mu_erd:.2f}%(负值代表任务期 Mu 功率下降)
- 图像输出目录:{FIGURE_DIR}
- 表格输出目录:{RESULT_DIR}
解释提示:
运动想象 BCI 通常关注对侧感觉运动皮层的 Mu/Beta 节律去同步化。
如果图2和图3中 C3/C4 周围出现任务相关的蓝色区域或负 ERD,
说明该数据中存在可用于分类的运动想象节律调制。
"""
(RESULT_DIR / "第三阶段方法与结果摘要.txt").write_text(textwrap.dedent(summary).strip(), encoding="utf-8")
if not band_summary.empty:
band_summary.to_csv(RESULT_DIR / "频带功率总体均值.csv", index=False, encoding="utf-8-sig")
def save_tables(
recording: EegRecording,
band_table: pd.DataFrame,
erd_table: pd.DataFrame,
classification_df: pd.DataFrame,
feature_df: pd.DataFrame,
) -> None:
"""保存所有可复查表格。"""
recording.events.to_csv(RESULT_DIR / "事件标注表.csv", index=False, encoding="utf-8-sig")
band_table.to_csv(RESULT_DIR / "试次_通道_频带功率表.csv", index=False, encoding="utf-8-sig")
erd_table.to_csv(RESULT_DIR / "Mu_Beta_ERD统计表.csv", index=False, encoding="utf-8-sig")
classification_df.to_csv(RESULT_DIR / "CSP_LDA留一法分类结果.csv", index=False, encoding="utf-8-sig")
feature_df.to_csv(RESULT_DIR / "CSP特征表.csv", index=False, encoding="utf-8-sig")
def main() -> None:
"""第三阶段项目主流程。"""
setup_chinese_matplotlib()
ensure_directories()
recording = load_recording()
print("[3] 正在进行滤波、分段、频带功率和 ERD 分析...")
cleaned = bandpass_filter(recording.data_uv, recording.sfreq)
cleaned = notch_filter_if_possible(cleaned, recording.sfreq)
epochs = make_epochs(recording, cleaned)
band_table = compute_band_power_table(epochs)
erd_table = compute_erd_table(recording, cleaned)
print("[4] 正在训练并验证 CSP-LDA 分类模型...")
classification_df, feature_df, _, _ = leave_one_out_csp_lda(epochs)
print("[5] 正在生成中文科研风格可视化图像...")
plot_event_overview(recording, cleaned)
plot_time_frequency_erd(recording, cleaned)
plot_band_power_topography(erd_table)
plot_band_statistics(band_table)
plot_csp_classification(classification_df, feature_df)
plot_channel_discriminability(erd_table)
print("[6] 正在保存 CSV 表格和中文摘要...")
save_tables(recording, band_table, erd_table, classification_df, feature_df)
write_summary(recording, epochs, classification_df, band_table, erd_table)
print("\n完成:EEG_Third_Case 进阶项目已生成。")
print(f"Python 文件:{Path(__file__).resolve()}")
print(f"图像目录:{FIGURE_DIR}")
print(f"结果目录:{RESULT_DIR}")
if __name__ == "__main__":
main()
