脑电信号处理实战 07|被试级泛化评估:心算状态解码、交叉验证泄漏对照与置换检验

实战 06 结尾悬置了三个问题:跨被试泛化还剩多少准确率?准确率高于运气吗?修正后的特征重要性长什么样? 本篇换上一份全新的真实数据------PhysioNet 心算任务数据库(eegmat,36 名被试),用一套"评估协议对照实验"把三个问题一次答完。

先把结局剧透给你,因为它是全篇的骨架:同一个模型、同一份特征,随机交叉验证给出 0.790,被试分组交叉验证只剩 0.471------低于机会水平,置换检验 p = 1.0。但与此同时,心算与静息的生理差异真实存在且高度显著(被试级配对检验 p = 0.008)。"效应真实"与"无法解码"同时成立,这不矛盾------本篇最后一节会讲透其中的机制,它是脑机接口从论文走向产品时最痛的一课。

数据来源说明:PhysioNet EEG During Mental Arithmetic Tasks(eegmat v1.0.0),真实数据,脚本无模拟分支。本次运行使用默认 8 名被试(心算表现 G/B 各半),共 954 个 4 秒 epoch(静息 714 / 心算 240)。

读完本篇你将学会:

  1. 为什么"随机打乱后交叉验证"在脑电数据上必然泄漏------以及泄漏值多少钱(本篇实测:+0.319);
  2. 被试分组交叉验证(GroupKFold)与留一被试法(LOGO)的正确用法与读法;
  3. 置换检验的完整实现:为什么要在被试内部打乱标签,零假设分布怎么读,"观测值低于机会水平"意味着什么;
  4. 两种特征重要性口径的对照:训练集内 MDI(偏倚) vs 验证折置换重要性(诚实)------实战 06 欠账的正式偿还;
  5. 本专栏最重要的一课:统计显著性 ≠ 可解码性,以及被试间变异如何吃掉一个真实的生理效应。

1. 数据集与任务:心算时的大脑在做什么

1.1 eegmat 数据库

乌克兰捷尔诺波尔医科大学团队采集的公开数据:36 名健康志愿者,每人两段记录------静息 (闭眼放松,约 3 分钟)与心算任务 (连续从 4000 减 7,约 1 分钟)。Neurocom 23 导系统,19 导头皮电极(10/20 系统),500 Hz 采样;采集端已做低通(约 30 Hz)与 50 Hz 陷波,并经 ICA 去除了眼电、肌电、心电伪迹。附带 subject-info.csv:年龄、性别、心算表现分组(G = 计数质量好 24 人,B = 差 12 人)。

任务设计背后的生理学预期非常明确,来自大量心算 EEG 文献:

  • 额区 Theta 升高:持续减法调用工作记忆,额中线 theta(frontal midline theta)是经典的认知负荷指标;
  • 枕区/顶区 Alpha 降低:从闭眼放松转入主动认知加工,枕叶 alpha 抑制------正是实战 01 的 Berger 效应在认知任务中的再现。

也就是说,我们先验地知道效应"应该"长什么样。这为后文所有的对照提供了靶子。

1.2 【图 1】数据与任务结构总览

三联图把数据的家底一次亮清:

  • A|电极布局:19 导按五个脑区着色(额、中央、顶、颞、枕)------后续特征工程就按这个分组做区域平均;
  • B|双状态波形 :示例被试的 Fz/Cz/Pz/O1 四通道前 4 秒。肉眼可见两条曲线都干净平稳(ICA 清理过的数据质量确实好),但静息与心算的区别------依然是那句老话------时域波形上看不出来,得去频域;
  • C|样本构成 :每名被试约 120 个 4 秒 epoch,静息(蓝)远多于心算(橙)------因为静息记录 3 分钟、任务记录 1 分钟。3:1 的类别不平衡是这份数据的固有属性,所以全篇主要指标一律采用平衡准确率(两类召回率的均值),普通准确率仅作对照展示。柱下的绿/粉方块标注心算表现 G/B 组。

1.3 数据形态学的两个纪律

专栏的老规矩,先摸清数据形态再动手:

  1. 频带上限压到 28 Hz :采集端低通约 30 Hz,Gamma 波段在记录时已被滤除------分析频带因此定为 Delta 1--4 / Theta 4--8 / Alpha 8--13 / Beta 13--28 Hz。尊重记录滤波器,是数据素养的一部分(对比实战 05:那里的天花板是 90 毫秒窗长,这里是 30 Hz 低通);
  2. 窗口取 4 秒、步进 2 秒:心算只有 1 分钟,窗口再长样本就太少;4 秒窗下 Welch 频率分辨率 0.5 Hz,Delta 下限 1 Hz 刚好够得着。

2. 特征工程与频谱验证:效应的第一次显形

2.1 24 维特征的设计逻辑

每个 epoch 提取 24 维可解释特征,三家组成:

  • 区域频带功率(20 维) :5 脑区 × 4 频带。注意实现顺序是先把区域内通道的波形平均、再对平均信号做 Welch------区域平均能在功率计算之前抵消部分非同步噪声,比"逐通道算功率再平均"的信噪比更高;
  • 比值与不对称(3 维)额区Theta/枕区Alpha(对数域里两功率之差即为比值------把 1.1 节的两个生理预期压进一个数)、额区Theta/Beta额区Alpha左右不对称((F4−F3)/(F4+F3),情绪/偏侧化研究中的经典量);
  • 全脑 RMS(1 维):整体幅度。

2.2 【图 2】区域功率谱:生理预期初步兑现

全部 8 名被试、954 个 epoch 的区域平均 PSD(均值 ± SEM)。左右两图分别是额区与枕区,四个频带用背景色标出:

  1. Alpha 峰清晰可见 (约 10--11 Hz 处的隆起),且静息(蓝)高于心算(橙)------额区、枕区皆然。心算时的 alpha 抑制,预期兑现;
  2. 额区 Theta 段(4--8 Hz)心算略高于静息------认知负荷的额 theta 效应,预期兑现;
  3. Beta 段两条曲线基本贴合------该频段在这份数据里区分度有限(记住这一点,图 7 会呼应)。

到这里为止,一切都像教科书一样顺利。(老读者应该记得实战 01 的小提醒:纵轴 "10" 后面的小方框是上标字符缺字形的渲染瑕疵,数据本身无恙。)但请警惕:PSD 是"全体 epoch 的平均",它展示的是均值层面的效应,而分类器面对的是单个 epoch。均值层面的胜利不等于逐样本的可分性------这个裂缝会在第 4 节变成深渊。


3. 被试级统计:先把"效应是真的"钉死

在做任何分类之前,先回答一个更基本的问题:心算 vs 静息的差异,在被试个体层面是否稳定存在?这一步用被试级统计:每名被试先对同状态 epoch 取平均,再做配对比较(n = 8,Wilcoxon 符号秩检验)。

3.1 【图 3】被试 × 特征的状态差热图

每行是一名被试,每列是一个特征,颜色 = 该被试"心算 − 静息"的差值。这张图是本篇最漂亮的结果之一:

  • 第一列(额区Theta/枕区Alpha)八格全红(+0.08 到 +0.61)------8 名被试无一例外,心算时这个比值都升高。跨被试方向一致性 100%;
  • 三个 Alpha 列(顶、中央、枕)几乎全蓝------7/8 的被试心算时 alpha 功率下降;
  • 唯一的"叛徒"是 S13:alpha 各列微正、比值列也是全场最低(+0.08)。请记住这个名字,图 6 里他还会出场。

定量结果(被试级效应统计.csv):额区Theta/枕区Alpha 的配对效应量 Cohen's dz = 1.62,Wilcoxon p = 0.0078------在 n = 8 的微小样本下依然显著。顶区、中央区、枕区的 Alpha 特征 dz 也都在 0.88--1.16。

3.2 【图 8】配对连线:效应长在每个被试身上

四个关键特征的分布图:箱体是 epoch 级分布,圆点是被试均值,灰线连接同一被试的静息与心算。看灰线的斜率:比值特征 7/8 上扬,三个 alpha 特征 7/8 下压------与图 3 完全互证。

到这里结论已经无可辩驳:心算引起的"额 theta 升、枕 alpha 降"是真实、稳定、跨被试一致的生理效应 。如果本篇到此为止,这会是一个皆大欢喜的故事。但请注意图 8 里另一个同样醒目、却容易被忽略的细节------它是第 7 节的伏笔:同一状态内,被试均值点散布得非常开(比如顶区 alpha 的静息均值从 0.55 横跨到 2.05),被试之间的基线差异,远大于状态造成的移动。先记住这个画面。


4. 评估的三重梦境:从 0.790 到 0.471

现在做分类。模型是随机森林(300 树,class_weight = balanced),特征就是第 2 节的 24 维,标签 = 静息/心算。唯一的变量是评估协议

4.1 【图 4】泄漏的代价:+0.319

  • 黄色柱(随机分层 5 折) :把 954 个 epoch 彻底打乱再分折,平衡准确率 0.790------看上去是个"能发"的结果;
  • 绿色柱(被试分组 5 折) :GroupKFold 保证同一被试的所有 epoch 只出现在训练侧或测试侧,平衡准确率 0.471------直接跌穿机会水平。

虚高幅度 +0.319。 泄漏的机理在前几篇讲过多次(相邻 epoch 共享 2 秒重叠数据、同一被试的记录特性被"开卷"),但本篇给出了全专栏最夸张的一次实测:它把"完全不可泛化"包装成"接近优秀"。如果你只在论文里见过泄漏评估的数字,现在你知道它们值多少钱了。

左图的普通准确率还藏着一个细节:分组口径下普通准确率仍有 0.643,比平衡准确率高得多------因为模型大量预测多数类(静息占 3/4),普通准确率被类别不平衡喂饱了。这就是为什么不平衡数据上必须看平衡准确率,本篇是实测演示。

4.2 【图 6】留一被试法:八个人的审判

GroupKFold 给出折均值,LOGO 则把问题问到极致:每次只用 7 个人训练、在从未见过的第 8 个人身上测试 。结果按成绩排序:S08 0.58、S03 0.52、S19 0.52、S00 0.50、S04 0.49、S06 0.44、S01 0.41、S13 0.29,均值 0.468。

两个读图要点:

  1. 全员贴地:8 名被试无一超过 0.6,最好成绩 0.58 也只是略高于机会。模型在新个体身上基本没有可用信息;
  2. S13 = 0.29:系统性地猜反 。0.29 不是"随机失败",而是"比随机还差很多"------模型在他身上学到的规律是反的。回看图 3:S13 正是那个 alpha 效应方向与众不同的被试。训练集里 7 个人教模型"alpha 降 = 心算",S13 身上却是"alpha 微升 = 心算"------个体差异不只增加噪声,还能翻转规律。这是跨被试泛化最阴险的敌人,图 3 与图 6 在这里完成闭环。

顺带说明:心算表现 G/B 组与泛化成绩没有明显对应(G 组的 S01 只有 0.41,B 组的 S19 有 0.52)------8 人的小样本也不足以检验这个假设,如实记录。


5. 置换检验:p = 1.0 的正式判决

5.1 设计:为什么在被试内置换

分组 CV 的 0.471 看着像"运气",但"像"不算数------需要零假设分布做正式对照。置换检验的做法:保持被试结构原封不动,只在每名被试内部打乱静息/心算标签,然后重跑整套被试分组交叉验证,重复 200 次。被试内置换是关键:它保留了"每个被试有自己的基线"这一数据结构,只摧毁"状态与特征的真实对应"------零假设问的是"如果标签与特征无关,模型纯靠结构巧合能拿多少分"。p 值按含观测值的保守公式计算:p = (1 + #{null ≥ observed}) / (1 + 200)。

5.2 【图 5】零分布与观测值

结果干净得近乎残酷:200 次置换的零假设分布紧紧缩在 0.492--0.514 (均值 0.500,SD 0.002),而观测值 0.471 落在整个零分布的左侧之外 ------200 次置换全部不低于观测值,p = 1.0000

两个层面都要读懂:

  1. 判决层面:分组 CV 的 0.471 不具备任何统计显著性------跨被试解码不成立,正式结论写死;
  2. 细节层面 :观测值不但不显著,还显著地低于机会水平 (零分布最左端都有 0.492)。这再次指向图 6 的机制:当若干被试(如 S13)的规律方向相反时,模型学到的"平均规律"在这些人身上是系统性反向的,把整体成绩拖到机会线以下。低于机会不是"倒霉",是结构性异质性的指纹。

至此,实战 06 留下的前两问有了完整答案:跨被试泛化还剩多少?------零(甚至为负)。高于运气吗?------不,p = 1.0。


6. 【图 7】特征重要性双口径:06 欠账的正式偿还

实战 06 里置换重要性因为在训练集上计算而全军覆没(67 个零)。本篇用正确姿势重赛:模型在训练折上拟合,重要性在留出的验证折上计算,五个分组折取平均;并与随机森林自带的 MDI(训练集内不纯度下降)并排对照。前 12 名特征的对照结果耐人寻味:

  1. MDI(黄)一片繁荣 :顶区|Alpha 高达 0.085,前排挤满各区域 Beta/Alpha 功率------但 MDI 天然偏爱取值范围大、与其他特征相关的高方差特征,且它在训练集上评估,天然乐观。图 2 告诉我们 Beta 段两条 PSD 几乎重合,MDI 却把 Beta 特征排在前列------与无模型证据直接矛盾
  2. 置换重要性(蓝)一片荒芜:几乎全部贴近 0 且误差棒巨大------这与第 5 节的判决完全一致:模型本来就没能泛化,"哪个特征帮它泛化"自然无从谈起;
  3. 唯一的例外恰好是最生理的那个额区Theta/枕区Alpha 的置换重要性为 +0.009(全场最高),正是 Wilcoxon 检验里 dz = 1.62、p = 0.008 的那一位。两个口径在"谁是真信号"上握手,在"信号有多强"上诚实------它有方向一致的真实效应,但强度不足以撑起跨被试解码。

这张图把两种重要性工具的性格刻得很清楚:MDI 是热情洋溢但爱夸大的线人,置换重要性(验证折口径)是沉默寡言但不说谎的法官。06 的教训(不要在训练集上算)加 07 的对照(两种口径并排看),可解释性分析的基本功就齐了。


7. 核心悖论:显著的效应,为什么解不了码

现在把全篇的证据摆到一张桌子上:

证据 结论
图 2 平均 PSD alpha 抑制、额 theta 升高,方向符合文献
图 3 被试热图 比值特征 8/8 同向,alpha 7/8 同向
Wilcoxon(n = 8) dz = 1.62,p = 0.008,显著
图 4 分组 CV / 图 6 LOGO 0.471 / 0.468,贴机会线
图 5 置换检验 p = 1.0,不可解码

生理效应真实显著,跨被试解码却彻底失败 ------两者凭什么同时成立?答案藏在图 8 里那个第 3 节请大家记住的画面:回到图 8,看被试均值点的散布。顶区 alpha 的静息均值从 0.55 到 2.05,横跨近 4 倍;而状态引起的移动只有约 0.3 个 log 单位。被试间的基线差异(≈1.5 log)是被试内效应(≈0.3 log)的 5 倍。

配对统计(Wilcoxon)检验的是"每个被试自己跟自己比,移动方向是否一致 "------它对被试间的基线差异完全免疫,所以显著。而分类器必须在绝对坐标系 里划线:它学到的阈值对基线 2.0 的被试和基线 0.6 的被试是同一道线------效应是把每个人的分布在自己内部 平移,而不是把两类样本在共同坐标里分开。效应真实存在,但它不生长在分类器能看见的地方。

这一课的价值超出本篇数据:"显著性 ≠ 可解码性"是神经工程界最常被混淆的两个概念。论文里 p < 0.01 的组间差异,离"能做出对新人有用的 BCI"之间,隔着被试间变异这座大山。翻过它的工程手段也都是现成的,按从轻到重排列:

  1. 被试内归一化:按每名被试自己的静息分布做 z-score,把"绝对功率"换成"相对自己的偏移"------直接对因下药(下篇实测);
  2. 个体化校准:BCI 产品标配------每个新用户先录几分钟基线,模型在他自己的数据上微调;
  3. 域自适应/迁移学习:把每名被试当作一个域,学域不变的表征;
  4. 更多被试:8 人的训练集覆盖不了人群变异的分布,36 人全量运行会让结论更稳(脚本改一行即可)。

最后如实交代本数据自带的局限:静息与心算是先后两段记录 ,状态与时间顺序混杂(疲劳、适应都可能贡献差异);8 名被试的默认子集不宜外推群体------这些已写入 run_metadata.jsonimportant_limitation 字段。


三条新方法笔记:

  1. 评估协议决定结论,模型只是配角:0.790 与 0.471 之间差了一整个"论文故事",而它们共享同一个模型同一批特征;
  2. 置换检验是显著性的终审法官:任何"高于机会"的声称都应附上零分布,被试内置换是保结构的正确零假设;
  3. 显著性与可解码性是两个物种:配对检验对基线差异免疫,分类器没有这种免疫------报效应时想清楚你的读者需要哪一种证据。

配套代码:eeg_seventh_case_subject_level_generalization.py(Python 3.9+,依赖 numpy / pandas / scipy / matplotlib / scikit-learn)。真实数据自动下载(PhysioNet eegmat),默认 8 名被试,改一行即可全量 36 名。运行后生成 figures/(本篇 8 张图)与 results/(7 张 CSV + 方法摘要 + 元数据)。

完整代码如下:

python 复制代码
import json
import time
import urllib.request
import warnings
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
from sklearn.ensemble import RandomForestClassifier
from sklearn.inspection import permutation_importance
from sklearn.model_selection import GroupKFold, LeaveOneGroupOut, StratifiedKFold

warnings.filterwarnings("ignore", category=FutureWarning)

# =============================================================================
# 0. 全局配置
# =============================================================================

BASE_URL = "https://www.physionet.org/files/eegmat/1.0.0"

ALL_SUBJECT_IDS = [f"Subject{i:02d}" for i in range(36)]           # 全量 36 名被试
# 默认 8 名:心算表现 G/B 各半(依据 subject-info.csv 的 Count quality),兼顾下载量与分组自由度
SUBJECT_IDS = ["Subject00", "Subject01", "Subject03", "Subject04",
               "Subject06", "Subject08", "Subject13", "Subject19"]

FS = 500                      # 标称采样率(实际以每个 EDF 头为准,见 read_edf)
WINDOW_SEC = 4.0              # 分析窗长(秒):4 s 窗可稳定估计 1 Hz 以上的频带功率
STEP_SEC = 2.0                # 窗滑动步长(秒):50% 重叠
STATE_LABELS = {0: "静息", 1: "心算"}

# 频带定义:上限压到 28 Hz ------ 采集端低通约 30 Hz,尊重记录滤波器是数据素养的一部分
BANDS = {"Delta 1-4": (1, 4), "Theta 4-8": (4, 8), "Alpha 8-13": (8, 13), "Beta 13-28": (13, 28)}

# 10/20 系统的 19 导按脑区归组(区域平均提升信噪比,也压缩特征维度)
REGIONS = {
    "额区": ["Fp1", "Fp2", "F3", "F4", "F7", "F8", "Fz"],
    "中央区": ["C3", "C4", "Cz"],
    "顶区": ["P3", "P4", "Pz"],
    "颞区": ["T3", "T4", "T5", "T6"],
    "枕区": ["O1", "O2"],
}

# 电极近似二维坐标(头顶俯视,仅用于示意图,不作精确定位)
CHANNEL_POSITIONS = {
    "Fp1": (-0.35, 0.92), "Fp2": (0.35, 0.92), "F7": (-0.72, 0.58), "F3": (-0.38, 0.55),
    "Fz": (0.00, 0.55), "F4": (0.38, 0.55), "F8": (0.72, 0.58), "T3": (-0.92, 0.05),
    "C3": (-0.42, 0.02), "Cz": (0.00, 0.02), "C4": (0.42, 0.02), "T4": (0.92, 0.05),
    "T5": (-0.72, -0.52), "P3": (-0.38, -0.55), "Pz": (0.00, -0.55), "P4": (0.38, -0.55),
    "T6": (0.72, -0.52), "O1": (-0.32, -0.90), "O2": (0.32, -0.90),
}

RANDOM_STATE = 20260717
N_PERMUTATIONS = 200          # 置换检验次数(教学默认;论文级建议 1000+)
RF_TREES_EVAL = 300           # 正式评估用的树数
RF_TREES_PERM = 120           # 置换检验循环内的树数(控制耗时)

PROJECT_DIR = Path(__file__).resolve().parent
DATA_DIR = PROJECT_DIR / "data"
FIGURE_DIR = PROJECT_DIR / "figures"
RESULTS_DIR = PROJECT_DIR / "results"

def setup_chinese_font() -> list[str]:
    """按优先级探测本机可用的中文字体(Windows/macOS/Linux 通吃),
    返回 font.sans-serif 回退列表 ------ 解决 Linux 上中文变方块的经典问题。"""
    from matplotlib import font_manager
    available = {f.name for f in font_manager.fontManager.ttflist}
    candidates = ["SimHei", "Microsoft YaHei", "PingFang SC", "Source Han Sans SC",
                  "Noto Sans CJK SC", "Noto Sans CJK JP", "WenQuanYi Zen Hei"]
    chosen = [name for name in candidates if name in available]
    return chosen + ["DejaVu Sans"]



COLORS = {
    "rest": "#0072B2", "task": "#D55E00", "accent": "#009E73",
    "warn": "#CC79A7", "grid": "#E5E5E5", "leak": "#E69F00",
}
plt.rcParams.update({
    "font.family": "sans-serif",
    "font.sans-serif": setup_chinese_font(),
    "axes.unicode_minus": False,
    "figure.dpi": 110, "savefig.dpi": 300, "savefig.bbox": "tight",
    "axes.grid": True, "grid.alpha": 0.35, "grid.color": COLORS["grid"],
    "axes.spines.top": False, "axes.spines.right": False,
    "axes.titlesize": 12, "axes.labelsize": 10, "legend.fontsize": 9,
})
np.random.seed(RANDOM_STATE)


def log(msg: str) -> None:
    print(f"[{time.strftime('%H:%M:%S')}] {msg}", flush=True)


# =============================================================================
# 1. 数据下载与 EDF 解析(真实数据唯一来源,无模拟分支)
# =============================================================================

def edf_expected_size(path: Path) -> int | None:
    """读取 EDF 头部,计算文件应有的字节数;读不出返回 None(非 EDF 文件)。"""
    try:
        with open(path, "rb") as fh:
            fh.seek(184)
            header_bytes = int(fh.read(8).decode("ascii", "ignore").strip())
            fh.seek(236)
            n_records = int(fh.read(8).decode("ascii", "ignore").strip())
            fh.read(8)
            n_signals = int(fh.read(4).decode("ascii", "ignore").strip())
            fh.seek(256 + (16 + 80 + 8 + 8 + 8 + 8 + 8 + 80) * n_signals)
            n_samples = [int(fh.read(8).decode("ascii", "ignore").strip()) for _ in range(n_signals)]
        return header_bytes + n_records * sum(n_samples) * 2
    except Exception:  # noqa: BLE001
        return None


def download_file(url: str, dest: Path, retries: int = 3) -> None:
    """带重试、缓存与完整性校验的下载器。失败即报错 ------ 本脚本不接受模拟数据兜底。"""
    if dest.exists() and dest.stat().st_size > 100 * 1024:
        if dest.suffix.lower() != ".edf":  # 非 EDF(如 csv):按大小粗验
            return
        expected = edf_expected_size(dest)
        if expected is not None and dest.stat().st_size == expected:  # EDF:按头部精确校验
            return
        log(f"检测到不完整缓存文件,重新下载:{dest.name}")
        dest.unlink()
    last_err = None
    for attempt in range(1, retries + 1):
        try:
            log(f"下载 {url} (第 {attempt} 次尝试)")
            urllib.request.urlretrieve(url, dest)
            return
        except Exception as err:  # noqa: BLE001
            last_err = err
            time.sleep(2 * attempt)
    raise RuntimeError(f"下载失败:{url}。请检查网络后重试;本案例只使用真实数据,不提供模拟分支。") from last_err


def ensure_dataset(subject_ids: list[str]) -> None:
    DATA_DIR.mkdir(parents=True, exist_ok=True)
    download_file(f"{BASE_URL}/subject-info.csv", DATA_DIR / "subject-info.csv")
    for sid in subject_ids:
        for suffix in ("_1", "_2"):  # _1=静息,_2=心算
            download_file(f"{BASE_URL}/{sid}{suffix}.edf", DATA_DIR / f"{sid}{suffix}.edf")


def read_edf(path: Path) -> tuple[pd.DataFrame, float]:
    """手写 EDF 解析器(复用实战 03 的方法):
    固定头(256B) + 变长头(标签/物理/数字范围/每记录采样数) + int16 数据块。
    仅保留 19 导头皮电极(剔除参考电极 A2-A1、ECG 与 EDF Annotations 标注通道),
    并按 物理值 = 数字值 × scale + offset 校准为 μV。采样率以文件头实测为准。"""
    scalp = {ch for chans in REGIONS.values() for ch in chans}
    with open(path, "rb") as fh:
        fh.seek(184)
        header_bytes = int(fh.read(8).decode("ascii", "ignore").strip())
        fh.seek(236)
        n_records = int(fh.read(8).decode("ascii", "ignore").strip())
        record_sec = float(fh.read(8).decode("ascii", "ignore").strip())
        n_signals = int(fh.read(4).decode("ascii", "ignore").strip())
        base = 256
        fh.seek(base)
        labels = [fh.read(16).decode("ascii", "ignore").strip() for _ in range(n_signals)]
        fh.seek(base + (16 + 80 + 8) * n_signals)
        phys_min = [float(fh.read(8).decode("ascii", "ignore").strip()) for _ in range(n_signals)]
        phys_max = [float(fh.read(8).decode("ascii", "ignore").strip()) for _ in range(n_signals)]
        dig_min = [float(fh.read(8).decode("ascii", "ignore").strip()) for _ in range(n_signals)]
        dig_max = [float(fh.read(8).decode("ascii", "ignore").strip()) for _ in range(n_signals)]
        fh.seek(base + (16 + 80 + 8 + 8 + 8 + 8 + 8 + 80) * n_signals)
        n_samples = [int(fh.read(8).decode("ascii", "ignore").strip()) for _ in range(n_signals)]
        fs_actual = n_samples[0] / record_sec
        keep = [i for i, lab in enumerate(labels)
                if lab.replace("EEG ", "") in scalp and n_samples[i] == n_samples[0]]
        series = {labels[i].replace("EEG ", ""): np.zeros(n_records * n_samples[i]) for i in keep}
        fh.seek(header_bytes)
        cursor = {labels[i]: 0 for i in keep}
        for _ in range(n_records):
            for s in range(n_signals):
                block = np.frombuffer(fh.read(n_samples[s] * 2), dtype="<i2")
                if s in keep:
                    scale = (phys_max[s] - phys_min[s]) / (dig_max[s] - dig_min[s])
                    offset = phys_min[s] - scale * dig_min[s]
                    lab = labels[s]
                    seg = cursor[lab]
                    series[lab.replace("EEG ", "")][seg:seg + n_samples[s]] = block * scale + offset
                    cursor[lab] = seg + n_samples[s]
    df = pd.DataFrame(series)
    df.insert(0, "time_sec", np.arange(len(df)) / fs_actual)
    return df, fs_actual


# =============================================================================
# 2. 分段与特征工程
# =============================================================================

def make_epochs(df: pd.DataFrame, fs: float, subject: str, state: int) -> list[dict]:
    """把连续记录切成 4 s 窗(2 s 步进)。每窗是一个候选样本,
    但样本之间并不独立 ------ 它们属于同一名被试,这正是被试级评估要处理的事。"""
    win, step = int(WINDOW_SEC * fs), int(STEP_SEC * fs)
    channels = list(df.columns[1:])
    values = df[channels].to_numpy()
    epochs = []
    for start in range(0, len(df) - win + 1, step):
        epochs.append({"subject": subject, "state": state,
                       "start_sec": start / fs, "data": values[start:start + win, :]})
    return epochs


def band_power(sig_1d: np.ndarray, fs: float, band: tuple[float, float]) -> float:
    """Welch 谱(2 s 段长,0.5 Hz 分辨率)下的频带积分(log10 域)。"""
    freqs, psd = signal.welch(sig_1d, fs=fs, nperseg=min(len(sig_1d), int(fs * 2)))
    mask = (freqs >= band[0]) & (freqs <= band[1])
    if mask.sum() < 2:
        return np.nan
    return float(np.log10(np.trapezoid(psd[mask], freqs[mask]) + 1e-12))


def epoch_features(epoch: dict, fs: float, channels: list[str]) -> dict:
    """24 维可解释特征:
    - 5 脑区 × 4 频带的区域平均 log10 功率(20 维)
    - 额区 Theta / 枕区 Alpha 比值(心算的经典预期:额 theta 升、枕 alpha 降)
    - 额区 Theta / Beta 比值
    - 额区 Alpha 左右不对称 (F4-F3)/(F4+F3)
    - 全脑 RMS(μV)
    """
    data = epoch["data"]
    feats: dict[str, float] = {}
    region_power: dict[str, dict[str, float]] = {}
    for region, chans in REGIONS.items():
        idx = [channels.index(c) for c in chans if c in channels]
        region_sig = data[:, idx].mean(axis=1)          # 区域平均:先平均波形再算功率
        region_power[region] = {}
        for band_name, band in BANDS.items():
            feats[f"{region}|{band_name}"] = band_power(region_sig, fs, band)
            region_power[region][band_name] = feats[f"{region}|{band_name}"]
    feats["额区Theta/枕区Alpha"] = region_power["额区"]["Theta 4-8"] - region_power["枕区"]["Alpha 8-13"]  # log 域之差即比值
    feats["额区Theta/Beta"] = region_power["额区"]["Theta 4-8"] - region_power["额区"]["Beta 13-28"]
    i3, i4 = channels.index("F3"), channels.index("F4")
    a3 = band_power(data[:, i3], fs, BANDS["Alpha 8-13"])
    a4 = band_power(data[:, i4], fs, BANDS["Alpha 8-13"])
    feats["额区Alpha左右不对称"] = float((a4 - a3) / (abs(a4) + abs(a3) + 1e-12))
    feats["全脑RMS"] = float(np.sqrt(np.mean(data ** 2)))
    return feats


def build_feature_table(subject_ids: list[str], quality_map: dict[str, str]) -> tuple[pd.DataFrame, float]:
    """总装:逐被试下载解析 → 分段 → 特征 → 长表(含 subject/state/quality 元信息)。"""
    rows, fs_report = [], FS
    for sid in subject_ids:
        for state, suffix in ((0, "_1"), (1, "_2")):
            df, fs = read_edf(DATA_DIR / f"{sid}{suffix}.edf")
            fs_report = fs
            channels = list(df.columns[1:])
            # 稳健居中:去中位数(数据已 ICA 清理,无需更多处理)
            df[channels] = df[channels] - df[channels].median()
            epochs = make_epochs(df, fs, sid, state)
            for ep in epochs:
                feats = epoch_features(ep, fs, channels)
                feats.update({"subject": sid, "state": state, "state_name": STATE_LABELS[state],
                              "quality": quality_map.get(sid, "?"), "start_sec": ep["start_sec"]})
                rows.append(feats)
        log(f"{sid} 完成(静息 3 min / 心算 1 min,500 Hz 实测 {fs_report:.0f} Hz)")
    table = pd.DataFrame(rows)
    return table, fs_report


FEATURE_COLS_EXCLUDE = {"subject", "state", "state_name", "quality", "start_sec"}


def get_feature_columns(table: pd.DataFrame) -> list[str]:
    return [c for c in table.columns if c not in FEATURE_COLS_EXCLUDE]


def grouped_folds(table: pd.DataFrame, max_splits: int = 5) -> GroupKFold:
    """折数自适应:被试数不足 5 时自动降折,避免 GroupKFold 崩溃。"""
    n_groups = table["subject"].nunique()
    return GroupKFold(min(max_splits, n_groups))


# =============================================================================
# 3. 三种评估协议:泄漏的、分组的、留一被试的
# =============================================================================

def make_model(n_trees: int = RF_TREES_EVAL) -> RandomForestClassifier:
    return RandomForestClassifier(n_estimators=n_trees, class_weight="balanced",
                                  random_state=RANDOM_STATE, n_jobs=-1)


def evaluate_protocols(table: pd.DataFrame, feature_cols: list[str]):
    """同一个模型、三种评估:
    A. 随机分层 5 折(epoch 级,同被试泄漏 ------ 专栏 06 批判过的做法,这里故意演示)
    B. 被试分组 GroupKFold(同一被试的所有 epoch 只在训练侧或测试侧)
    C. 留一被试法(LOGO,逐被试报告泛化差异)
    """
    X = table[feature_cols].to_numpy()
    y = table["state"].to_numpy()
    groups = table["subject"].to_numpy()

    records, fold_id = [], 0
    for tr, te in StratifiedKFold(5, shuffle=True, random_state=RANDOM_STATE).split(X, y):
        model = make_model().fit(X[tr], y[tr])
        pred = model.predict(X[te])
        fold_id += 1
        records.append({"协议": "随机分层5折(泄漏)", "折": fold_id,
                        "准确率": (pred == y[te]).mean(),
                        "平衡准确率": stats_balanced(y[te], pred)})
    for tr, te in grouped_folds(table).split(X, y, groups):
        model = make_model().fit(X[tr], y[tr])
        pred = model.predict(X[te])
        fold_id += 1
        records.append({"协议": "被试分组5折", "折": fold_id,
                        "准确率": (pred == y[te]).mean(),
                        "平衡准确率": stats_balanced(y[te], pred)})
    cv_table = pd.DataFrame(records)

    logo_rows = []
    for tr, te in LeaveOneGroupOut().split(X, y, groups):
        held = groups[te][0]
        model = make_model().fit(X[tr], y[tr])
        pred = model.predict(X[te])
        logo_rows.append({"留出被试": held, "quality": table.loc[te, "quality"].iloc[0],
                          "测试epoch数": len(te), "准确率": (pred == y[te]).mean(),
                          "平衡准确率": stats_balanced(y[te], pred)})
    logo_table = pd.DataFrame(logo_rows)
    return cv_table, logo_table


def stats_balanced(y_true: np.ndarray, y_pred: np.ndarray) -> float:
    """平衡准确率 = 两类召回率的平均(静息/心算 epoch 数不等,必须用它)。"""
    recalls = []
    for cls in (0, 1):
        mask = y_true == cls
        recalls.append((y_pred[mask] == cls).mean() if mask.any() else np.nan)
    return float(np.nanmean(recalls))


def permutation_test(table: pd.DataFrame, feature_cols: list[str], observed: float) -> pd.DataFrame:
    """置换检验:保持被试结构不变,仅在被试内部打乱静息/心算标签,
    重跑整套被试分组交叉验证,构建零假设分布。
    p = (1 + #{null >= observed}) / (1 + N) ------ 含观测值自身的保守校正。"""
    rng = np.random.default_rng(RANDOM_STATE)
    X = table[feature_cols].to_numpy()
    groups = table["subject"].to_numpy()
    null_scores = []
    for perm in range(1, N_PERMUTATIONS + 1):
        y_perm = table["state"].to_numpy().copy()
        for sid in np.unique(groups):
            idx = np.where(groups == sid)[0]
            y_perm[idx] = rng.permutation(y_perm[idx])     # 被试内置换,保持先验结构
        scores = []
        for tr, te in grouped_folds(table).split(X, y_perm, groups):
            model = make_model(RF_TREES_PERM).fit(X[tr], y_perm[tr])
            scores.append(stats_balanced(y_perm[te], model.predict(X[te])))
        null_scores.append(float(np.mean(scores)))
        if perm % 25 == 0:
            log(f"置换检验进度 {perm}/{N_PERMUTATIONS}")
    p_value = (1 + sum(s >= observed for s in null_scores)) / (1 + len(null_scores))
    return pd.DataFrame({"null_平衡准确率": null_scores, "观测值": observed, "p值": p_value})


def importance_two_ways(table: pd.DataFrame, feature_cols: list[str]) -> pd.DataFrame:
    """两种重要性口径的正面对照(实战 06 的欠账):
    - MDI:随机森林训练集内不纯度下降,偏倚、偏爱高基数相关特征
    - 置换重要性:每个分组折内、仅在验证折上计算,诚实反映泛化贡献
    """
    X = table[feature_cols].to_numpy()
    y = table["state"].to_numpy()
    groups = table["subject"].to_numpy()
    mdi = make_model().fit(X, y).feature_importances_
    perm_means, perm_stds = [], []
    for tr, te in grouped_folds(table).split(X, y, groups):
        model = make_model().fit(X[tr], y[tr])
        perm = permutation_importance(model, X[te], y[te], n_repeats=10,
                                      random_state=RANDOM_STATE, n_jobs=-1,
                                      scoring="balanced_accuracy")
        perm_means.append(perm.importances_mean)
        perm_stds.append(perm.importances_std)
    return pd.DataFrame({
        "特征": feature_cols,
        "MDI_训练集内": mdi,
        "置换重要性_验证折": np.mean(perm_means, axis=0),
        "置换重要性_std": np.mean(perm_stds, axis=0),
    }).sort_values("置换重要性_验证折", ascending=False)


def subject_level_effects(table: pd.DataFrame, feature_cols: list[str]) -> pd.DataFrame:
    """被试级统计(n = 被试数):每位被试先对同状态 epoch 取平均,再做
    心算 − 静息的配对 Wilcoxon 检验与配对 Cohen's dz。
    与 epoch 级检验相比,被试级检验的 n 小但独立 ------ 这才是能外推的证据。"""
    subj_state = table.groupby(["subject", "state"])[feature_cols].mean().unstack("state")
    rows = []
    for col in feature_cols:
        delta = subj_state[(col, 1)] - subj_state[(col, 0)]
        try:
            p = stats.wilcoxon(subj_state[(col, 1)], subj_state[(col, 0)]).pvalue
        except ValueError:
            p = np.nan
        rows.append({"特征": col, "静息均值": subj_state[(col, 0)].mean(),
                     "心算均值": subj_state[(col, 1)].mean(), "平均差值": delta.mean(),
                     "Cohen_dz": delta.mean() / (delta.std(ddof=1) + 1e-12), "p_wilcoxon": p})
    return pd.DataFrame(rows).sort_values("Cohen_dz", ascending=False)


# =============================================================================
# 4. 可视化(8 张科研风格中文图)
# =============================================================================

def fig01_dataset_overview(example: pd.DataFrame, fs: float, table: pd.DataFrame, out: Path) -> None:
    """图1|数据与任务结构总览:电极布局 + 双状态波形 + 样本构成。"""
    fig, axes = plt.subplots(1, 3, figsize=(15, 4.6), width_ratios=[1, 1.5, 1.1])
    # A. 电极布局(区域着色)
    ax = axes[0]
    theta = np.linspace(0, 2 * np.pi, 200)
    ax.plot(np.cos(theta), np.sin(theta), color="black", lw=1.2)
    ax.plot([0], [1.0], marker="^", color="black", markersize=7)
    region_color = dict(zip(REGIONS, ["#0072B2", "#009E73", "#D55E00", "#CC79A7", "#E69F00"]))
    for region, chans in REGIONS.items():
        pts = np.array([CHANNEL_POSITIONS[c] for c in chans])
        ax.scatter(pts[:, 0], pts[:, 1], s=55, color=region_color[region], label=region, zorder=3,
                   edgecolor="white", linewidth=1.0)
        for c, (x, y) in CHANNEL_POSITIONS.items():
            if c in chans:
                ax.text(x, y - 0.115, c, ha="center", fontsize=6.5, color="#333333")
    ax.set(xlim=(-1.25, 1.25), ylim=(-1.25, 1.2), aspect="equal")
    ax.axis("off"); ax.set_title("A|19 导电极与脑区分组", fontweight="bold")
    ax.legend(loc="lower center", ncol=5, frameon=False, bbox_to_anchor=(0.5, -0.14), fontsize=8)
    # B. 示例被试双状态波形(Cz,前 4 秒)
    ax = axes[1]
    for state, color, name in ((0, COLORS["rest"], "静息"), (1, COLORS["task"], "心算")):
        df_s, _ = read_edf(DATA_DIR / f"{example}{'_1' if state == 0 else '_2'}.edf")
        chans = [c for c in ["Fz", "Cz", "Pz", "O1"] if c in df_s.columns]
        t = df_s["time_sec"].to_numpy()[: int(4 * fs)]
        for k, ch in enumerate(chans):
            sig = df_s[ch].to_numpy()[: int(4 * fs)]
            sig = sig - np.median(sig)
            ax.plot(t, sig + k * 90, color=color, lw=0.8,
                    label=name if k == 0 else None)
        ax.text(4.02, 90 * (len(chans) - 1), "Fz", fontsize=8, color="#555555")
        ax.text(4.02, 90 * (len(chans) - 2), "Cz", fontsize=8, color="#555555")
        ax.text(4.02, 90 * (len(chans) - 3), "Pz", fontsize=8, color="#555555")
        ax.text(4.02, 0, "O1", fontsize=8, color="#555555")
    ax.set(xlabel="时间 (s)", ylabel="幅值 + 通道偏移 (μV)",
           title=f"B|{example} 静息 vs 心算(4 通道)")
    ax.title.set_fontweight("bold"); ax.legend(loc="upper right")
    # C. 样本构成:每被试 epoch 数(G/B 着色)
    ax = axes[2]
    counts = table.groupby(["subject", "state", "quality"]).size().unstack("state").reset_index()
    x = np.arange(len(counts))
    qcolor = counts["quality"].map({"G": "#009E73", "B": "#CC79A7"})
    ax.bar(x, counts[0], color=COLORS["rest"], label="静息 epoch")
    ax.bar(x, counts[1], bottom=counts[0], color=COLORS["task"], label="心算 epoch")
    for xi, c in zip(x, qcolor):
        ax.scatter(xi, -6, color=c, s=45, marker="s", clip_on=False)
    ax.set_xticks(x); ax.set_xticklabels(counts["subject"].str.replace("Subject", "S"), fontsize=8)
    ax.set(xlabel="被试(方块=心算表现:绿 G / 粉 B)", ylabel="4 s 窗 epoch 数",
           title="C|样本构成与 G/B 分组")
    ax.title.set_fontweight("bold"); ax.legend(loc="upper right")
    fig.suptitle("图 1|数据集与任务结构总览(eegmat,36 名被试中示例 8 名)", fontsize=13, fontweight="bold", y=1.03)
    fig.savefig(out); plt.close(fig)


def fig02_psd(table: pd.DataFrame, fs: float, out: Path) -> None:
    """图2|额区与枕区的平均功率谱:静息 vs 心算(epoch 级,均值 ± SEM)。"""
    # 逐 epoch 重建区域平均信号的 PSD 太耗时;改用特征表反演 + 原信号抽样重算
    fig, axes = plt.subplots(1, 2, figsize=(12, 4.8), sharey=True)
    sample_subjects = table["subject"].unique()
    for ax, region, chans in zip(axes, ["额区", "枕区"], [REGIONS["额区"], REGIONS["枕区"]]):
        psd_store = {0: [], 1: []}
        freqs = None
        for sid in sample_subjects:
            for state, suffix in ((0, "_1"), (1, "_2")):
                df, fs_act = read_edf(DATA_DIR / f"{sid}{suffix}.edf")
                idx = [df.columns.get_loc(c) - 1 for c in chans if c in df.columns]
                region_sig = df[[df.columns[i + 1] for i in idx]].mean(axis=1).to_numpy()
                region_sig = region_sig - np.median(region_sig)
                win, step = int(WINDOW_SEC * fs_act), int(STEP_SEC * fs_act)
                for start in range(0, len(region_sig) - win + 1, step):
                    f, p = signal.welch(region_sig[start:start + win], fs=fs_act,
                                        nperseg=int(fs_act * 2))
                    freqs = f
                    psd_store[state].append(p)
        for state, color, name in ((0, COLORS["rest"], "静息"), (1, COLORS["task"], "心算")):
            arr = np.array(psd_store[state])
            mean, sem = arr.mean(axis=0), arr.std(axis=0) / np.sqrt(len(arr))
            ax.semilogy(freqs, mean, color=color, lw=1.8, label=name)
            ax.fill_between(freqs, mean - sem, mean + sem, color=color, alpha=0.18, lw=0)
        band_colors = {"Delta": "#B0C4DE", "Theta": "#FFE4B5", "Alpha": "#C1E1C1", "Beta": "#F5C2CB"}
        for (bname, (lo, hi)), bc in zip({"Delta": (1, 4), "Theta": (4, 8),
                                          "Alpha": (8, 13), "Beta": (13, 28)}.items(), band_colors.values()):
            ax.axvspan(lo, hi, color=bc, alpha=0.35, lw=0)
            ax.text((lo + hi) / 2, ax.get_ylim()[1] * 0.55, bname, ha="center", fontsize=8, color="#555555")
        ax.set(xlim=(0, 32), xlabel="频率 (Hz)", title=f"{region}平均功率谱(全部 {len(sample_subjects)} 名被试)")
        ax.title.set_fontweight("bold"); ax.legend(loc="upper right")
    axes[0].set_ylabel("PSD (μV²/Hz, 对数)")
    fig.suptitle("图 2|静息 vs 心算的区域功率谱(均值 ± SEM,频带背景着色)", fontsize=13, fontweight="bold", y=1.02)
    fig.savefig(out); plt.close(fig)


def fig03_subject_feature_heatmap(effects_long: pd.DataFrame, top_features: list[str], out: Path) -> None:
    """图3|被试 × 特征的热图:每名被试的 心算 − 静息 差值。
    颜色的一致性(整列同号)才是跨被试可推广的证据。"""
    pivot = effects_long.pivot(index="subject", columns="feature", values="delta")[top_features]
    fig, ax = plt.subplots(figsize=(max(8, 0.9 * len(top_features)), 4.8))
    vmax = np.nanmax(np.abs(pivot.to_numpy()))
    im = ax.imshow(pivot.to_numpy(), cmap="RdBu_r", vmin=-vmax, vmax=vmax, aspect="auto")
    ax.set_xticks(range(len(top_features)))
    ax.set_xticklabels(top_features, rotation=28, ha="right", fontsize=7.5)
    ax.tick_params(axis="x", pad=2)
    ax.set_yticks(range(len(pivot.index)))
    ax.set_yticklabels([s.replace("Subject", "S") for s in pivot.index], fontsize=9)
    for i in range(pivot.shape[0]):
        for j in range(pivot.shape[1]):
            ax.text(j, i, f"{pivot.iloc[i, j]:+.2f}", ha="center", va="center", fontsize=7,
                    color="white" if abs(pivot.iloc[i, j]) > 0.6 * vmax else "black")
    plt.colorbar(im, ax=ax, label="心算 − 静息(log10 功率差等)")
    ax.set_title("图 3|被试 × 关键特征的状态差热图(每格 = 一名被试的 心算−静息)", fontweight="bold")
    fig.savefig(out); plt.close(fig)


def fig04_leak_vs_grouped(cv_table: pd.DataFrame, out: Path) -> None:
    """图4|同一模型、两种评估:随机分层(泄漏) vs 被试分组 ------ 专栏核心图。"""
    summary = cv_table.groupby("协议")[["准确率", "平衡准确率"]].agg(["mean", "std"])
    fig, axes = plt.subplots(1, 2, figsize=(11, 4.6), sharey=True)
    order = ["随机分层5折(泄漏)", "被试分组5折"]
    colors = [COLORS["leak"], COLORS["accent"]]
    for ax, metric in zip(axes, ["准确率", "平衡准确率"]):
        means = [summary.loc[p, (metric, "mean")] for p in order]
        stds = [summary.loc[p, (metric, "std")] for p in order]
        bars = ax.bar(order, means, yerr=stds, capsize=5, color=colors, width=0.55, alpha=0.9)
        for p, xi, c in zip(order, range(2), colors):
            pts = cv_table.loc[cv_table["协议"] == p, metric]
            ax.scatter(np.full(len(pts), xi) + np.linspace(-0.12, 0.12, len(pts)), pts,
                       color="black", s=18, zorder=3, alpha=0.7)
        for b, m in zip(bars, means):
            ax.text(b.get_x() + b.get_width() / 2, m + 0.012, f"{m:.3f}", ha="center", fontweight="bold")
        ax.axhline(0.5, color="#888888", ls="--", lw=1)
        ax.text(1.45, 0.505, "机会水平 0.5", fontsize=8, color="#666666", ha="right")
        ax.set(ylim=(0.4, 1.02), title=f"评估指标:{metric}")
        ax.title.set_fontweight("bold")
    inflation = summary.loc["随机分层5折(泄漏)", ("平衡准确率", "mean")] - \
        summary.loc["被试分组5折", ("平衡准确率", "mean")]
    axes[0].set_ylabel("得分(折均值 ± 折间 SD;黑点=各折)")
    fig.suptitle(f"图 4|泄漏的代价:随机折比被试分组高估 {inflation:+.3f}(平衡准确率口径)",
                 fontsize=13, fontweight="bold", y=1.02)
    fig.savefig(out); plt.close(fig)


def fig05_permutation_test(perm_table: pd.DataFrame, out: Path) -> None:
    """图5|置换检验:零假设分布 vs 观测值。"""
    null = perm_table["null_平衡准确率"].to_numpy()
    obs = perm_table["观测值"].iloc[0]
    p = perm_table["p值"].iloc[0]
    fig, ax = plt.subplots(figsize=(8.5, 4.6))
    ax.hist(null, bins=24, color="#9ECAE1", edgecolor="white", alpha=0.9, label=f"零假设分布({len(null)} 次置换)")
    ax.axvline(obs, color=COLORS["task"], lw=2.2, label=f"观测平衡准确率 = {obs:.3f}")
    ax.axvline(0.5, color="#888888", ls="--", lw=1)
    ax.text(0.5, ax.get_ylim()[1] * 0.96, "机会水平 0.5", rotation=90, va="top", ha="right", fontsize=8, color="#666666")
    span = max(0.06, abs(obs - 0.5) + 0.03, float(np.abs(null - 0.5).max()) + 0.03)
    ax.set_xlim(0.5 - span, 0.5 + span)
    ax.set(xlabel="被试分组交叉验证平衡准确率", ylabel="置换次数",
           title=f"图 5|置换检验:观测值落在零假设分布之外?  p = {p:.4f}")
    ax.title.set_fontweight("bold")
    ax.legend(loc="upper left")
    fig.savefig(out); plt.close(fig)


def fig06_logo(logo_table: pd.DataFrame, out: Path) -> None:
    """图6|留一被试法:逐被试泛化成绩 ------ 泛化能力的诚实分布。"""
    df = logo_table.sort_values("平衡准确率", ascending=False).reset_index(drop=True)
    x = np.arange(len(df))
    colors = df["quality"].map({"G": "#009E73", "B": "#CC79A7"}).fillna("#999999")
    fig, ax = plt.subplots(figsize=(9.5, 4.6))
    bars = ax.bar(x, df["平衡准确率"], color=colors, width=0.62)
    for b, v in zip(bars, df["平衡准确率"]):
        ax.text(b.get_x() + b.get_width() / 2, v + 0.01, f"{v:.2f}", ha="center", fontsize=8)
    mean_acc = df["平衡准确率"].mean()
    ax.axhline(mean_acc, color=COLORS["task"], ls="-.", lw=1.4,
               label=f"均值 {mean_acc:.3f}")
    ax.axhline(0.5, color="#888888", ls="--", lw=1)
    ax.text(len(df) - 0.5, 0.505, "机会水平 0.5", fontsize=8, color="#666666", ha="right")
    ax.set_xticks(x)
    ax.set_xticklabels([s.replace("Subject", "S") for s in df["留出被试"]], fontsize=9)
    ax.set(ylim=(0.35, 1.05), xlabel="被留出的被试(绿=心算表现 G,粉=B)", ylabel="该被试上的平衡准确率")
    ax.set_title("图 6|留一被试法:模型在'从未见过的人'身上表现如何", fontweight="bold")
    ax.legend(loc="lower left")
    fig.savefig(out); plt.close(fig)


def fig07_importance(imp_table: pd.DataFrame, out: Path, top_n: int = 12) -> None:
    """图7|特征重要性双口径:训练集内 MDI(偏倚) vs 验证折置换重要性(诚实)。"""
    df = imp_table.head(top_n).iloc[::-1]  # 重要者在上
    y = np.arange(len(df))
    fig, ax = plt.subplots(figsize=(9.5, 5.4))
    ax.barh(y + 0.19, df["MDI_训练集内"], height=0.36, color="#E69F00", label="MDI(训练集内,偏倚)")
    ax.barh(y - 0.19, df["置换重要性_验证折"], height=0.36, color="#0072B2",
            xerr=df["置换重要性_std"], label="置换重要性(验证折,诚实)")
    ax.axvline(0, color="#666666", lw=0.8)
    ax.set_yticks(y); ax.set_yticklabels(df["特征"], fontsize=9)
    ax.set(xlabel="重要性得分", title=f"图 7|前 {top_n} 名特征的两种重要性口径对照")
    ax.title.set_fontweight("bold")
    ax.legend(loc="upper center", bbox_to_anchor=(0.5, -0.10), ncol=2, frameon=False)
    fig.savefig(out); plt.close(fig)


def fig08_feature_distributions(table: pd.DataFrame, features: list[str], out: Path) -> None:
    """图8|关键特征的 epoch 级分布 + 被试均值配对连线。
    灰线连接同一被试的两个状态:连线的整体斜率就是被试级效应。"""
    n = len(features)
    fig, axes = plt.subplots(1, n, figsize=(3.4 * n, 4.6), sharey=False)
    if n == 1:
        axes = [axes]
    for ax, feat in zip(axes, features):
        data = [table.loc[table["state"] == s, feat].dropna() for s in (0, 1)]
        bp = ax.boxplot(data, positions=[0, 1], widths=0.5, patch_artist=True, showfliers=False,
                        medianprops={"color": "black", "lw": 1.4})
        for patch, c in zip(bp["boxes"], [COLORS["rest"], COLORS["task"]]):
            patch.set_facecolor(c); patch.set_alpha(0.75)
        subj_mean = table.groupby(["subject", "state"])[feat].mean().unstack()
        for _, row in subj_mean.iterrows():
            ax.plot([0, 1], [row[0], row[1]], color="#999999", lw=0.9, alpha=0.65, zorder=1)
        ax.scatter(np.zeros(len(subj_mean)), subj_mean[0], color=COLORS["rest"], s=18, zorder=3)
        ax.scatter(np.ones(len(subj_mean)), subj_mean[1], color=COLORS["task"], s=18, zorder=3)
        ax.set_xticks([0, 1]); ax.set_xticklabels(["静息", "心算"])
        ax.set_title(feat, fontsize=9.5); ax.title.set_fontweight("bold")
    axes[0].set_ylabel("特征取值(箱体=epoch 级,点/线=被试均值)")
    fig.suptitle("图 8|关键特征:静息 vs 心算(灰线连接同一被试)", fontsize=13, fontweight="bold", y=1.02)
    fig.savefig(out); plt.close(fig)


# =============================================================================
# 5. 结果落盘、方法摘要与主流程
# =============================================================================

def write_summary(path: Path, info: dict) -> None:
    text = f"""脑电信号处理实战 07 | 心算任务状态解码:方法摘要
================================================

1. 数据与预处理
   数据来源:PhysioNet EEG During Mental Arithmetic Tasks(eegmat v1.0.0,真实数据,无模拟分支)。
   被试:{info['n_subjects']} 名({info['subject_list']});每名两段记录:静息约 3 min、心算约 1 min。
   信号:19 导头皮 EEG(10/20),实测采样率 {info['fs']:.0f} Hz;采集端低通约 30 Hz、50 Hz 陷波、ICA 去伪迹(手册)。
   预处理:按通道去中位数;4 s 窗、2 s 步进分段;共 {info['n_epochs']} 个 epoch
   (静息 {info['n_rest']} / 心算 {info['n_task']})。

2. 特征(24 维)
   5 脑区 × 4 频带(Delta 1-4 / Theta 4-8 / Alpha 8-13 / Beta 13-28 Hz,上限尊重采集低通)的区域平均
   log10 Welch 功率;额区Theta/枕区Alpha、额区Theta/Beta、额区Alpha左右不对称、全脑 RMS。

3. 模型与评估
   模型:随机森林(300 树,class_weight=balanced)。评估三协议对照:
   随机分层 5 折(epoch 级泄漏)→ 平衡准确率 {info['leak_bal']:.3f};
   被试分组 5 折 → 平衡准确率 {info['group_bal']:.3f}(泄漏高估 {info['inflation']:+.3f});
   留一被试法 → 均值 {info['logo_mean']:.3f}(范围 {info['logo_min']:.2f}--{info['logo_max']:.2f})。
   置换检验:被试内标签打乱 {info['n_perm']} 次,观测平衡准确率 {info['group_bal']:.3f},p = {info['perm_p']:.4f}。

4. 特征证据
   被试级配对 Wilcoxon 最强特征:{info['top_effect']};
   验证折置换重要性最高特征:{info['top_perm']};MDI 口径最高:{info['top_mdi']}。

5. 局限声明
   本案例为方法学演示:静息/心算两段记录存在记录时间差(非同步随机化),状态效应可能混入时间/疲劳因素;
   默认 8 名被试的子集结果不宜外推群体;置换检验的零分布保留被试内结构,但不能排除所有非平稳性。
"""
    path.write_text(text, encoding="utf-8")


def main() -> None:
    t0 = time.time()
    FIGURE_DIR.mkdir(parents=True, exist_ok=True)
    RESULTS_DIR.mkdir(parents=True, exist_ok=True)
    log("【实战07】被试级泛化实战:心算任务状态解码(真实数据,无模拟分支)")

    # 1) 下载与解析
    ensure_dataset(SUBJECT_IDS)
    info_df = pd.read_csv(DATA_DIR / "subject-info.csv")
    quality_map = dict(zip(info_df["Subject"], info_df["Count quality"].map({0: "B", 1: "G"})))
    table, fs = build_feature_table(SUBJECT_IDS, quality_map)
    feature_cols = get_feature_columns(table)
    table.to_csv(RESULTS_DIR / "epoch_特征表.csv", index=False, encoding="utf-8-sig")
    log(f"特征表:{table.shape[0]} epoch × {len(feature_cols)} 特征(静息 "
        f"{(table['state'] == 0).sum()} / 心算 {(table['state'] == 1).sum()})")

    # 2) 三协议评估
    cv_table, logo_table = evaluate_protocols(table, feature_cols)
    cv_table.to_csv(RESULTS_DIR / "交叉验证_泄漏对照.csv", index=False, encoding="utf-8-sig")
    logo_table.to_csv(RESULTS_DIR / "留一被试法结果.csv", index=False, encoding="utf-8-sig")
    leak_bal = cv_table.query("协议 == '随机分层5折(泄漏)'")["平衡准确率"].mean()
    group_bal = cv_table.query("协议 == '被试分组5折'")["平衡准确率"].mean()
    log(f"随机分层(泄漏) 平衡准确率 {leak_bal:.3f} vs 被试分组 {group_bal:.3f}")

    # 3) 置换检验(被试内置换 × 分组CV全流程)
    perm_table = permutation_test(table, feature_cols, observed=group_bal)
    perm_table.to_csv(RESULTS_DIR / "置换检验.csv", index=False, encoding="utf-8-sig")
    log(f"置换检验 p = {perm_table['p值'].iloc[0]:.4f}")

    # 4) 重要性与被试级效应
    imp_table = importance_two_ways(table, feature_cols)
    imp_table.to_csv(RESULTS_DIR / "特征重要性_双口径.csv", index=False, encoding="utf-8-sig")
    effects = subject_level_effects(table, feature_cols)
    effects.to_csv(RESULTS_DIR / "被试级效应统计.csv", index=False, encoding="utf-8-sig")
    # 被试×特征差值长表(图3数据源)
    subj_state = table.groupby(["subject", "state"])[feature_cols].mean().unstack("state")
    delta_long = pd.DataFrame(
        [(sid, col, subj_state.loc[sid, (col, 1)] - subj_state.loc[sid, (col, 0)])
         for sid in subj_state.index for col in feature_cols],
        columns=["subject", "feature", "delta"])
    delta_long.to_csv(RESULTS_DIR / "被试x特征_状态差值.csv", index=False, encoding="utf-8-sig")

    # 5) 八张图
    log("绘制图 1-8 ...")
    fig01_dataset_overview(SUBJECT_IDS[0], fs, table, FIGURE_DIR / "01_数据与任务结构总览.png")
    fig02_psd(table, fs, FIGURE_DIR / "02_静息与心算_区域功率谱对比.png")
    top_dz = effects.reindex(effects["Cohen_dz"].abs().sort_values(ascending=False).index)["特征"].head(8).tolist()
    fig03_subject_feature_heatmap(delta_long, top_dz, FIGURE_DIR / "03_被试x特征_状态差热图.png")
    fig04_leak_vs_grouped(cv_table, FIGURE_DIR / "04_泄漏评估_vs_被试分组评估.png")
    fig05_permutation_test(perm_table, FIGURE_DIR / "05_置换检验_零分布与观测值.png")
    fig06_logo(logo_table, FIGURE_DIR / "06_留一被试法_逐被试泛化.png")
    fig07_importance(imp_table, FIGURE_DIR / "07_特征重要性_MDI与置换对照.png")
    top4 = effects.reindex(effects["Cohen_dz"].abs().sort_values(ascending=False).index)["特征"].head(4).tolist()
    fig08_feature_distributions(table, top4, FIGURE_DIR / "08_关键特征_状态分布与配对连线.png")

    # 6) 摘要与元数据
    write_summary(RESULTS_DIR / "第七阶段方法与结果摘要.txt", {
        "n_subjects": len(SUBJECT_IDS), "subject_list": ",".join(SUBJECT_IDS), "fs": fs,
        "n_epochs": len(table), "n_rest": int((table["state"] == 0).sum()),
        "n_task": int((table["state"] == 1).sum()),
        "leak_bal": leak_bal, "group_bal": group_bal, "inflation": leak_bal - group_bal,
        "logo_mean": logo_table["平衡准确率"].mean(), "logo_min": logo_table["平衡准确率"].min(),
        "logo_max": logo_table["平衡准确率"].max(),
        "n_perm": N_PERMUTATIONS, "perm_p": perm_table["p值"].iloc[0],
        "top_effect": effects.iloc[0]["特征"], "top_perm": imp_table.iloc[0]["特征"],
        "top_mdi": imp_table.sort_values("MDI_训练集内", ascending=False).iloc[0]["特征"],
    })
    metadata = {
        "dataset": "PhysioNet EEG During Mental Arithmetic Tasks (eegmat v1.0.0)",
        "data_url": BASE_URL, "subjects": SUBJECT_IDS,
        "simulated_data": False,
        "window_sec": WINDOW_SEC, "step_sec": STEP_SEC, "bands": BANDS,
        "model": "RandomForest(300, balanced)", "n_permutations": N_PERMUTATIONS,
        "grouped_cv_balanced_accuracy": float(group_bal),
        "leaky_cv_balanced_accuracy": float(leak_bal),
        "permutation_p": float(perm_table["p值"].iloc[0]),
        "important_limitation": "默认 8 名被试;静息/心算为先后两段记录,状态与时间顺序部分混杂;结果用于方法教学,不构成群体结论。",
        "runtime_min": round((time.time() - t0) / 60, 1),
    }
    (RESULTS_DIR / "run_metadata.json").write_text(
        json.dumps(metadata, ensure_ascii=False, indent=2), encoding="utf-8")
    log(f"全部完成,用时 {(time.time() - t0) / 60:.1f} 分钟。图 → figures/,表 → results/")


if __name__ == "__main__":
    main()
相关推荐
TechEdu2026061 天前
[通信与计算]离散时间信号处理:理论、算法与工程实践
信号处理·通信系统·信息通信
nnerddboy2 天前
脑电信号处理实战 03 | 运动想象脑机接口入门:ERD/ERS、CSP 空间滤波与左右拳想象解码
算法·信号处理
nnerddboy3 天前
脑电信号处理实战 02 | 从“看信号“到“做推断“:稳健预处理、条件统计与 Alpha 功能连接网络
信号处理
科恒盛远10 天前
【无标题】
fpga开发·硬件工程·信号处理
省四收割者13 天前
一文详解信号完整性(3)--规避SI问题的具体实现方法
嵌入式硬件·硬件架构·信号处理·智能硬件·pcb工艺·altium designer
国科安芯13 天前
基于ASM1042S2S的箭载通信网络抗辐射加固方案研究
服务器·网络·嵌入式硬件·fpga开发·架构·信号处理
棱镜研途14 天前
【EI检索会议 | SPIE出版】 2026年智能计算与多模态信号处理国际学术会议(CIMSP 2026)
图像处理·算法·计算机视觉·信号处理·多模态·学术会议·智能计算
省四收割者15 天前
一文详解信号完整性(1)
python·嵌入式硬件·数学建模·信息与通信·信号处理·智能硬件
传感器与混合集成电路16 天前
深度解析电荷平衡式电流频率转换器:积分器、恒流源与阈值比较的协同设计
信号处理