脑电信号处理实战 03 | 运动想象脑机接口入门:ERD/ERS、CSP 空间滤波与左右拳想象解码

前两篇我们用同一份静息态数据完成了"看见信号"(实战 01)和"做出推断"(实战 02)。本篇换一个全新的战场:任务态 EEG 与脑机接口(BCI) 。我们将使用 BCI 领域最经典的公开数据------PhysioNet 运动想象数据库,围绕"想象左手 vs 右手握拳"这一任务,走完一条完整的 BCI 解码链路:EDF 格式解析 → 事件分段 → Mu/Beta 的 ERD/ERS 量化 → 头皮分布与通道可分性 → CSP 空间滤波 → LDA 分类 + 留一法验证,产出 6 张图和 5 张结果表。

先交代数据来源(重要) :脚本优先联网下载真实数据(S001R03),下载失败时自动降级为内置的可重复结构化模拟数据 (固定随机种子、已知"标准答案")。本次运行走的是模拟数据路径(结果摘要中已如实标注),因此本篇所有数字均来自该次模拟运行------但全文讲解的方法与代码对真实数据完全通用,联网环境下同一脚本即可产出真实结果。模拟数据反而送了我们一份礼物:我们知道真相埋在哪里,可以逐图验证流水线有没有把它挖出来。

读完本篇你将学会:

  1. 运动想象 BCI 的生理基础:感觉运动节律(Mu/Beta)、对侧控制原则、ERD/ERS 的定义与量化;
  2. EDF/EDF+ 格式的手工解析:头部字段、数字量到校准物理量、TAL 事件标注(T0/T1/T2);
  3. 任务态分析的三板斧:事件分段(epoching)、基线校正、事件锁定时频分析;
  4. 为什么"全脑平均频带功率"在左右手任务上必然失灵,以及 CSP(共空间模式)如何用数学手段把空间差异放大;
  5. 小样本(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) 是医用电生理的事实标准,结构分两层:

  1. 固定头部(256 字节) :记录版本、被试信息、以及三个关键数字------头部总字节数(偏移 184--192)、数据记录条数 n_records(236--244)、每条记录时长(244--252)、信号通道数 n_signals(252--256);
  2. 变长头部 :每个信号一组字段,依次是标签(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(右拳想象)。读三件事:

  1. 事件时序规整:从第 8 秒起,任务块以 8 秒为周期交替出现,每块 4 秒------与事件表完全一致,说明标注解析与时间轴对齐无误;
  2. 波形幅度正常:滤波后信号在 ±25 μV 量级波动,没有实战 01 里那种几千"μV"的怪物,预处理输出健康;
  3. 肉眼分不出左右 :盯着蓝色块和橙色块里的波形看,看不出明显差别------正常。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)=10log⁡10P(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 数学实现四步(脚本逐行对应)

  1. 估计两类平均协方差 :每个试次算通道 × 通道协方差矩阵(shrinkage_cov():先按迹归一化消除试次间整体幅度差异,再加 8% 收缩------小样本下协方差估计不稳,往单位阵方向"拽"一点换取稳健),同类试次平均得到 Σ左\Sigma_{\text{左}}Σ左、Σ右\Sigma_{\text{右}}Σ右;
  2. 白化 :对复合协方差 Σ左+Σ右\Sigma_{\text{左}}+\Sigma_{\text{右}}Σ左+Σ右 做特征分解,构造白化矩阵 PPP,使 P⊤(Σ左+Σ右)P=IP^\top(\Sigma_{\text{左}}+\Sigma_{\text{右}})P = IP⊤(Σ左+Σ右)P=I------把"总功率"在所有方向上拉平;
  3. 联合对角化 :在白化空间对 P⊤Σ左PP^\top \Sigma_{\text{左}} PP⊤Σ左P 再做特征分解。关键性质:此时 Σ左\Sigma_{\text{左}}Σ左 与 Σ右\Sigma_{\text{右}}Σ右 被同时对角化,且两类特征值互补(和为 1)------左类特征值接近 1 的方向,右类必接近 0;
  4. 取两端、提特征 :选特征值最大的 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()
相关推荐
遥感知识服务2 小时前
Sentinel-1 + DEM + FwDET + 随机森林:从快速水深初估到多因子误差修正
算法·随机森林·sentinel
来一碗刘肉面2 小时前
顺序表与链表的比较
数据结构·算法·链表
alphaTao2 小时前
LeetCode 每日一题 2026/7/13-2026/7/19
算法·leetcode
QN1幻化引擎4 小时前
Dalin L — 我造了一门支持中文编程的语言,完整移植到 Rust 了
人工智能·算法·机器学习
Rainy Blue8834 小时前
C转C++速成
c语言·c++·算法
txzrxz4 小时前
二分图详解
数据结构·c++·算法·图论·深度优先搜索·二分图染色
gaosushexiangji4 小时前
数字图像相关DIC系统在结构振动模态测量中的应用
算法
KaMeidebaby4 小时前
卡梅德生物技术快报|如何制备单克隆抗体:小众禽类靶点单抗制备实操流程:双载体抗原交叉筛选完整工艺记录
人工智能·python·深度学习·算法·机器学习
北域码匠5 小时前
Karatsuba乘法超详细解析(原理+流程+性能分析+纯原生C#无第三方库完整实现)
算法·c#·大数乘法·高精度计算·数据结构与算法·分治算法·karatsuba 乘法