实战 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)。
读完本篇你将学会:
- 为什么"随机打乱后交叉验证"在脑电数据上必然泄漏------以及泄漏值多少钱(本篇实测:+0.319);
- 被试分组交叉验证(GroupKFold)与留一被试法(LOGO)的正确用法与读法;
- 置换检验的完整实现:为什么要在被试内部打乱标签,零假设分布怎么读,"观测值低于机会水平"意味着什么;
- 两种特征重要性口径的对照:训练集内 MDI(偏倚) vs 验证折置换重要性(诚实)------实战 06 欠账的正式偿还;
- 本专栏最重要的一课:统计显著性 ≠ 可解码性,以及被试间变异如何吃掉一个真实的生理效应。
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 数据形态学的两个纪律
专栏的老规矩,先摸清数据形态再动手:
- 频带上限压到 28 Hz :采集端低通约 30 Hz,Gamma 波段在记录时已被滤除------分析频带因此定为 Delta 1--4 / Theta 4--8 / Alpha 8--13 / Beta 13--28 Hz。尊重记录滤波器,是数据素养的一部分(对比实战 05:那里的天花板是 90 毫秒窗长,这里是 30 Hz 低通);
- 窗口取 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)。左右两图分别是额区与枕区,四个频带用背景色标出:
- Alpha 峰清晰可见 (约 10--11 Hz 处的隆起),且静息(蓝)高于心算(橙)------额区、枕区皆然。心算时的 alpha 抑制,预期兑现;
- 额区 Theta 段(4--8 Hz)心算略高于静息------认知负荷的额 theta 效应,预期兑现;
- 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。
两个读图要点:
- 全员贴地:8 名被试无一超过 0.6,最好成绩 0.58 也只是略高于机会。模型在新个体身上基本没有可用信息;
- 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。
两个层面都要读懂:
- 判决层面:分组 CV 的 0.471 不具备任何统计显著性------跨被试解码不成立,正式结论写死;
- 细节层面 :观测值不但不显著,还显著地低于机会水平 (零分布最左端都有 0.492)。这再次指向图 6 的机制:当若干被试(如 S13)的规律方向相反时,模型学到的"平均规律"在这些人身上是系统性反向的,把整体成绩拖到机会线以下。低于机会不是"倒霉",是结构性异质性的指纹。
至此,实战 06 留下的前两问有了完整答案:跨被试泛化还剩多少?------零(甚至为负)。高于运气吗?------不,p = 1.0。
6. 【图 7】特征重要性双口径:06 欠账的正式偿还

实战 06 里置换重要性因为在训练集上计算而全军覆没(67 个零)。本篇用正确姿势重赛:模型在训练折上拟合,重要性在留出的验证折上计算,五个分组折取平均;并与随机森林自带的 MDI(训练集内不纯度下降)并排对照。前 12 名特征的对照结果耐人寻味:
- MDI(黄)一片繁荣 :顶区|Alpha 高达 0.085,前排挤满各区域 Beta/Alpha 功率------但 MDI 天然偏爱取值范围大、与其他特征相关的高方差特征,且它在训练集上评估,天然乐观。图 2 告诉我们 Beta 段两条 PSD 几乎重合,MDI 却把 Beta 特征排在前列------与无模型证据直接矛盾;
- 置换重要性(蓝)一片荒芜:几乎全部贴近 0 且误差棒巨大------这与第 5 节的判决完全一致:模型本来就没能泛化,"哪个特征帮它泛化"自然无从谈起;
- 唯一的例外恰好是最生理的那个 :
额区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"之间,隔着被试间变异这座大山。翻过它的工程手段也都是现成的,按从轻到重排列:
- 被试内归一化:按每名被试自己的静息分布做 z-score,把"绝对功率"换成"相对自己的偏移"------直接对因下药(下篇实测);
- 个体化校准:BCI 产品标配------每个新用户先录几分钟基线,模型在他自己的数据上微调;
- 域自适应/迁移学习:把每名被试当作一个域,学域不变的表征;
- 更多被试:8 人的训练集覆盖不了人群变异的分布,36 人全量运行会让结论更稳(脚本改一行即可)。
最后如实交代本数据自带的局限:静息与心算是先后两段记录 ,状态与时间顺序混杂(疲劳、适应都可能贡献差异);8 名被试的默认子集不宜外推群体------这些已写入 run_metadata.json 的 important_limitation 字段。
三条新方法笔记:
- 评估协议决定结论,模型只是配角:0.790 与 0.471 之间差了一整个"论文故事",而它们共享同一个模型同一批特征;
- 置换检验是显著性的终审法官:任何"高于机会"的声称都应附上零分布,被试内置换是保结构的正确零假设;
- 显著性与可解码性是两个物种:配对检验对基线差异免疫,分类器没有这种免疫------报效应时想清楚你的读者需要哪一种证据。
配套代码: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()
