脑电信号处理实战 02 | 从“看信号“到“做推断“:稳健预处理、条件统计与 Alpha 功能连接网络

上一篇(实战 01)我们用 8 张图走通了 EEG 分析的最小流水线,也亲手挖出了这份 UCI 数据的三个问题:巨型尖峰伪迹、疑似重复的通道、以及被伪迹彻底绑架的"睁眼 vs 闭眼"对比。结尾我们留了三个问题:怎么识别并处理伪迹?怎么把"看图说话"升级成"统计推断"?除了功率,通道之间的协同关系怎么量化?

本篇(实战 02)逐一回答这三个问题。我们将完成一次方法学升级:稳健预处理(去中位数 + 带通 + 陷波 + MAD 伪迹标记与修复)→ 以 2 秒窗为统计单位的条件比较(Welch t 检验 + Cohen's d 效应量)→ 时频动态追踪 → Alpha 相干性网络 ,产出 6 张论文风格图和 4 张可直接复用的结果表格。同样重要的是:本篇会再次证明,更严格的方法不会" magically 变出"教科书结果------但它能让我们精确地说清楚效应有多大、在哪里、可信到什么程度。这才是可发表的科研思维。

读完本篇你将学会:

  1. 一套带质量控制的稳健预处理流程:为什么去中位数优于去均值、陷波器怎么用、MAD 稳健 z 分数为什么比标准 z 分数抗造;
  2. EEG 统计推断的"地基":为什么统计单位必须是时间窗而不是采样点(伪重复问题)、为什么功率要取 log10、Welch t 与 Cohen's d 各回答什么问题;
  3. 如何把通道级统计画到头皮示意图上,并正确解读效应量与 p 值(含多重比较的含义);
  4. 如何用时频图反向质检预处理------本篇图 4 会暴露插值留下的"疤痕";
  5. 功能连接入门:幅值平方相干性(coherence)的定义、阈值网络的构建与读法、以及为什么"相干 ≠ 真实神经连接"。

前置阅读:实战 01(本篇不复述 ARFF 解析、Butterworth 零相位滤波、Welch 原理等已讲过的内容,只讲升级点)。


0. 本篇全景:流水线升级对照

配套脚本 eeg_second_case_advanced.py 依然是单文件、开箱即跑 (在文章的最后给出完整代码):运行后自动生成 figures/(6 张图)、results/(4 张 CSV + 方法摘要 txt)。几个工程细节值得学:

  1. 只读复用上一案例的数据FIRST_CASE_DIR / "data" / "EEG_Eye_State.arff"),找不到才自己下载------多项目共享数据时,"只读"是避免连环污染的好习惯;
  2. matplotlib.use("Agg"):批量出图时用非交互后端,不会弹窗阻塞,服务器上也能跑;
  3. 固定随机种子 np.random.seed(...):可复现性的第一道保险;
  4. 结果落盘一律 encoding="utf-8-sig"------带 BOM 的 UTF-8,Excel 打开 CSV 中文不乱码,写论文的人都懂这个细节有多救命。

1. 稳健预处理:把上一篇的"翻车"堵住

预处理函数 preprocess_eeg() 一共四步,每一步都针对实战 01 暴露的一个具体问题。

1.1 去中位数,而不是去均值

python 复制代码
centered = raw - np.median(raw, axis=0, keepdims=True)

实战 01 我们见过:这份数据的每个通道都悬浮在 4000+ 的直流偏置上。去直流,教科书默认答案是"减均值"------但均值的计算本身会被那 70 万量级的尖峰带偏(一个 567,179 的点就能把 117 秒均值抬高几十)。中位数不受极端值影响 ,因此稳健预处理的第一步是减中位数。这个原则会在本篇反复出现,请记住它的名字:稳健统计(robust statistics)

1.2 1--45 Hz 带通 + 50 Hz 陷波

python 复制代码
bandpass_sos = signal.butter(4, [1, 45], btype="bandpass", fs=128, output="sos")
filtered = signal.sosfiltfilt(bandpass_sos, centered, axis=0)
notch_b, notch_a = signal.iirnotch(w0=50, Q=30, fs=128)
filtered = signal.filtfilt(notch_b, notch_a, filtered, axis=0)

两处与实战 01 不同:

  1. 上限从 40 提到 45 Hz:为了把低 Gamma(30--45 Hz)完整纳入观察------本篇要做五个频带的比较,频带定义里低 γ 写到 45 Hz,滤波器自然要让路;
  2. 新增 50 Hz 陷波 :市电(国内 50 Hz、欧美 60 Hz)会通过市电线路、设备电源耦合进电极,在频谱上形成一根细而高的"钉子"。iirnotch(w0=50, Q=30) 是一只二阶陷波器,Q 值决定"坑"的宽窄:Q=30 意味着只挖掉 50 Hz 附近约 1.7 Hz 的窄缝,尽量不伤及邻近的 Gamma 成分。这份数据里 50 Hz 工频未必明显(采样链不详),但陷波是真实 EEG 预处理的标配动作 ,本篇演示标准做法;同样用 filtfilt 保持零相位。

1.3 MAD 稳健 z 分数:给伪迹"测温"

实战 01 的教训是:用标准 z 分数(减均值、除标准差)找离群值会失灵------均值和标准差本身就被尖峰污染,等于让罪犯参与制定通缉标准。解法是换成两个抗造的统计量:

robust-zi=0.6745×xi−median(x)MAD,MAD=median(∣x−median(x)∣)\text{robust-}z_i = 0.6745 \times \frac{x_i - \text{median}(x)}{\text{MAD}}, \qquad \text{MAD} = \text{median}\left(\left|x - \text{median}(x)\right|\right)robust-zi=0.6745×MADxi−median(x),MAD=median(∣x−median(x)∣)

MAD(中位数绝对偏差)扮演"稳健版标准差"的角色;系数 0.6745 是校准因子------当数据服从正态分布时,MAD × 1/0.6745 恰好等于标准差,因此 robust-z 与标准 z 在"正常数据"上刻度一致,可以沿用"|z| > 几"的直觉。代码里 np.where(mad < 1e-12, 1e-12, mad) 是防平信号除零的保护。

判据:任一通道在某采样点的 |robust-z| > 6,就把该采样点标记为伪迹(跨通道"一票否决",因为巨型尖峰往往多通道齐发)。阈值 6 是个经验值:正态数据里 |z|>6 的概率约十亿分之二,正常脑电几乎不可能触及。

1.4 线性插值修复:把洞补上

标记之后怎么办?两种哲学:剔除 (把坏段整块扔掉)或修复(估计一个合理值填回去)。本篇演示修复路线中最简单的一种------线性插值:

python 复制代码
cleaned[artifact_mask, ch] = np.interp(
    sample_index[artifact_mask],   # 待修复点的位置
    sample_index[good],            # 好点的位置
    cleaned[good, ch])             # 好点的取值

np.interp 对每个坏点,找到它前后最近的两个好点,按比例连线取值。对孤立、短时的坏点(几毫秒的电极尖峰),插值几乎无感;对长段坏数据,插值会留下痕迹------这一点先按住,图 1 和图 4 会让我们亲眼看到。

1.5 【图 1】预处理质量控制三视图

这张图是本篇的"质检报告",三个面板各回答一个问题:

  • A 面板(原始信号):AF3/F7/O1/O2 四个通道去中位数后纵向堆叠(通道间加 180 的偏移量避免重叠)。可见慢漂移(AF3、F7 前 2 秒的"小山包")和 7.1 秒处那根贯穿所有通道的巨型尖峰------老朋友了,实战 01 的图 1 和图 8 里都是它。
  • B 面板(清理后) :同样四通道经过完整预处理。尖峰消失,漂移抹平,波动幅度回到正常量级。但请仔细看 6--8.5 秒附近:几条曲线出现了不自然的"笔直线段" ------那不是信号,是线性插值跨过较长的坏段时留下的痕迹。这就是 1.4 节按住的伏笔:插值能补洞,但补出来的不是脑电
  • C 面板(稳健伪迹评分) :紫色曲线是每个采样点 14 个通道中最大的 |robust-z|,红色虚线是阈值 6,红点是所有被标记的采样点。7.1 秒尖峰的 |z| 冲到三千多------阈值 6 对这种量级的怪物毫无悬念。注意图下脚注:全记录被标记的采样比例约 10.11%

这里必须做一次诚实的复盘。脚本注释里自己写着:"只修复极少量瞬时异常点;如果整段异常过多,应回到实验设计与采集质量层面排查。"而实际跑出来 10.11% 的标记率------十分之一的数据被打上了伪迹标签,这已经超出"极少量"的范畴 。为什么这么多?因为这份数据里除了 3 个巨型尖峰,还有大量中等幅度的毛刺,滤波后的振荡"余震"也会成串越阈。10.11% 下还要不要插值?教学上我们选择保留修复结果并如实展示副作用 (B 面板的直线段、图 4 的暗条纹),让代价看得见;工程上更稳妥的做法是:阈值结合幅度、时宽双判据收紧标记,或对被标记的整段时间窗 做剔除而不是逐点插值(本篇图 2 之后的分析以 2 秒窗为单位,剔除坏窗完全可行,留给读者作为练习)。预处理没有免费的午餐,每一种处理都要在 QC 图上能交代谢罪------这是本篇第一课。


2. 从连续信号到统计样本:2 秒窗频带功率

2.1 统计单位:为什么必须是"窗",不能是"点"

假设我们直接把 14,980 个采样点按标签分成两组做 t 检验------样本量 8257 vs 6723,任何细微差异都会"p < 0.0001"。这是伪科学:相邻采样点间隔仅 7.8 毫秒,脑电在几十到几百毫秒内高度自相关,它们不是独立样本 。把相关的点当成独立样本,会人为膨胀自由度、严重低估 p 值,这就是统计上臭名昭著的伪重复(pseudoreplication)

正确做法:把信号切成 2 秒窗 (256 点,窗内频谱估计已有意义),窗与窗滑动步进 1 秒(50% 重叠),每个窗是一个统计样本 。每个窗的实验条件取窗内标签的众数(stats.mode,即该窗里占多数的状态)。117 秒记录共切出 116 个窗,其中睁眼 66 个、闭眼 50 个------这才是我们真正"诚实"的样本量。

2.2 窗内频带功率与 log10 变换

每个窗内跑 Welch(nperseg=256,即整窗加 Hann 窗),对 PSD 在五个频带内做梯形积分(np.trapz),得到 5 个频带 × 14 通道 = 70 个功率值。然后取 log10(power)

python 复制代码
"log_power": float(np.log10(value + 1e-12))

为什么统计分析要在对数域做?三个理由:

  1. 正态化:功率是平方量,分布右偏重尾;取对数后接近正态,t 检验的前提才站得住;
  2. 方差稳定:大功率通道的绝对波动天然更大,对数把"乘性噪声"变成"加性噪声",让不同通道、不同条件可比;
  3. 可解释性:0.1 个 log10 单位 ≈ 26% 的功率变化,0.3 ≈ 翻倍------后文所有 "Δ log10 功率" 都可以这样心算回线性比例。

2.3 Welch t 检验与 Cohen's d:两个必须同时出场的指标

对每个(频带 × 通道)组合,我们有两组数:闭眼窗的 log 功率(50 个)和睁眼窗的 log 功率(66 个)。做 Welch t 检验ttest_ind(..., equal_var=False))------它不假设两组方差相等,比经典 Student t 更稳妥,是组间比较的默认推荐。

但 p 值只回答"差异是否不像随机噪声",不回答"差异有多大" 。样本量稍大,微不足道的差异也能显著。所以必须配上效应量 Cohen's d:

d=xˉ闭−xˉ睁s pooledd = \frac{\bar{x}{\text{闭}} - \bar{x}{\text{睁}}}{s_{\text{ pooled}}}d=s pooledxˉ闭−xˉ睁

以"合并标准差"为单位度量两组均值差。经验分级:|d| ≈ 0.2 小、0.5 中、0.8 大。p 值告诉你信不信,d 告诉你值不值得在乎 ------科研报告里两者缺一不可。全部 70 组检验的结果存入 band_power_welch_ttest_effect_size.csv

2.4 【图 2】五频带总体功率比较

把 14 个通道、116 个窗的 log 功率按条件汇总(均值 ± 跨窗跨通道标准误 SEM),得到全脑尺度的第一幅统计画像。柱顶标注了"闭 − 睁"差值:

频带 Δ(闭 − 睁,log10) 心算回线性比例
δ Delta 0.5--4 −0.10 闭眼低约 21%
θ Theta 4--8 +0.03 高 7%
α Alpha 8--13 +0.11 高 29%
β Beta 13--30 +0.09 高 23%
低 γ 30--45 +0.06 高 15%

三个读图要点:

  1. Alpha 方向对了。全通道平均下,闭眼 Alpha 功率高于睁眼,与 Berger 效应方向一致------和实战 01 图 6 那个"睁眼碾压闭眼 4--5 个数量级"的荒谬结果相比,稳健预处理 + 正确的统计单位把我们拉回了生理学的轨道。
  2. Delta 反向 。闭眼 Delta 反而更低,且后文图 6 会显示其中若干通道达到显著。一种合理解释是:睁眼期的眨眼、眼动会在前额和眼周电极产生大幅低频偏转(眼电伪迹的主能量就在 Delta 范围),抬高了"睁眼"的 Delta 功率------这个负差异未必是神经活动,很可能仍是眼电的残留指纹
  3. 误差棒很小、差异也很小 。SEM 小是因为样本被汇总了 14 通道 × 116 窗;但 Δ 本身只有 0.03--0.11 个 log 单位。这幅图告诉我们的是"方向与量级",它不构成任何单一通道的显著性证据------那是图 3 和图 6 的工作。

3. 通道级统计:效应在哪里,有多大

3.1 把统计量画回头皮上

全局平均可能掩盖空间结构(比如枕叶升、额叶降,一平均就抵消了)。接下来对每个(频带 × 通道)分别检验,并把结果画到头皮示意图上。脚本内置了一套 CHANNEL_POSITIONS 近似二维坐标(头顶俯视、鼻子朝上)------注意它只是示意布局,不是 MRI 配准的精确头模 ,画拓扑图、网络图够用,写论文时建议换成标准 10--20 坐标或 mne 的拓扑函数。

3.2 【图 3】Alpha 闭眼增强的头皮分布与效应量

左右两个面板是同一份 Alpha 统计表的两种看法:

  • A 面板(头皮分布) :每个通道一个圆点,颜色 = 闭眼 − 睁眼的 Δ log10 Alpha 功率,红色越深增强越强。最扎眼的是 AF3(左额前,+0.23) ,右侧的 F8、P8、T8 也偏暖;而教科书主角 O1 竟然是浅蓝色(−0.06)------枕叶 Alpha 不升反微降。
  • B 面板(效应量排行) :14 个通道按 Cohen's d 排序。只有 AF3 达到 p < 0.05(d = +0.405,p = 0.026,橙色),其余 13 个通道全部 n.s.(未达显著);O1 垫底(d = −0.106)。

和教科书预期(枕叶最强、全枕区显著)相比,这个结果显然"不够漂亮",但它是这份数据在严格方法下的诚实答案。如何专业地解读?

  1. 效应量口径:AF3 的 d = 0.405 属于"接近中等"的效应,Δ = 0.23 log10 意味着闭眼 Alpha 功率约为睁眼的 1.7 倍------幅度本身并不小;
  2. 空间口径:增强集中在额前而非枕叶,与经典 Berger 效应拓扑不符。可能的原因包括:消费级单设备单被试的个体差异、额区本身存在的 Alpha 节律(frontal alpha,与认知抑制相关)、残余眼电/肌电污染,以及------别忘了实战 01 的发现------这份数据若干通道疑似同源重复,空间分布本就不可全信;
  3. 统计口径(最容易被忽视) :我们对 Alpha 一个频带就做了 14 次检验。即使纯属噪声,期望也有 14 × 5% ≈ 0.7 个"p < 0.05"。若做最严格的 Bonferroni 校正(0.05 / 14 ≈ 0.0036),AF3 的 p = 0.026 也无法通过 。所以规范的说法是:"AF3 显示出未经校正的提示性证据(nominal significance),需独立数据验证"------这不是扫兴,这是论文审稿人要求的语言。图 6 的脚注里脚本也做了同样的声明。

本篇第二课:探索性分析用 p < 0.05(未校正)画星号找线索,确认性结论必须过多重比较校正这一关。 热图、拓扑图负责"提出假设",校正与独立样本负责"确认假设",两者的语言不能混用。


4. 时频视角:动态看 Alpha,顺便质检预处理

4.1 这次的 STFT 有什么不一样

实战 01 讲过 STFT 的窗长/重叠权衡,本篇的调用多了两个讲究:

python 复制代码
freqs, times, spec = signal.spectrogram(
    x, fs=128, nperseg=256, noverlap=128,
    scaling="density", mode="psd")
spec_db = 10 * np.log10(spec + 1e-12)
alpha_track = spec_db[8:13 Hz].mean(axis=0)   # Alpha 功率轨迹

mode="psd" + scaling="density" 让输出直接是功率谱密度(与 Welch 一致的量纲),而不是原始幅值谱;随后转 dB。然后把 8--13 Hz 这一窄带的功率沿时间抽出来,得到一条"Alpha 功率轨迹",再按每秒标签的众数给时间轴着底色(浅橙=闭眼、浅蓝=睁眼)。时频图负责"看结构",轨迹叠加负责"看条件关系"。

4.2 【图 4】O1 时频谱与 Alpha 轨迹

全 117 秒、1--45 Hz。这张图有三个层次的信息,由浅入深:

  1. A 面板的暗色竖条纹(约 6.5 s、76 s、85 s 附近) ------这是全图最重要的细节。实战 01 里,伪迹尖峰在时频图上是亮的 竖带(宽带能量爆发);本篇里同一批位置变成了暗的 竖带。为什么?因为这些位置的尖峰已被标记并线性插值,插值线段几乎是平直的,高频成分被抽干 ,在时频图上呈现全频段功率塌陷的"疤痕"。这不是新的生理现象,而是预处理自己的指纹 。所以时频图是质检预处理的最佳工具之一:做完任何清理后重画时频图,看伪迹是被"抹平"了还是被"挖空"了------前者是理想修复,后者提示你该换策略(比如干脆剔除这些窗)。
  2. B 面板的 Alpha 轨迹:围绕 0 dB 上下起伏 ±5 dB;两个深谷(约 8 s 的 −20 dB、约 88 s 的 −31 dB)恰好对应插值段的 Alpha 功率塌陷,与 A 面板暗带互为印证。
  3. Alpha 与眼状态的关系 :眯起眼睛对比浅橙(闭眼)与浅蓝(睁眼)区间,轨迹没有肉眼可辨的系统性起伏 ------这与图 3 的统计结果(O1 的 d = −0.106,n.s.)完全自洽:在这份数据、这个通道上,Alpha 阻断效应本来就接近零,时频图不会替我们"看出"一个统计上不存在的效应。可视化与统计检验相互印证,而不是相互替代,这是本图真正想传递的信息。

5. 功能连接:通道之间的 Alpha 相干性网络

5.1 从"单通道功率"到"通道间协同"

到目前为止,所有分析都是"一个通道一个通道地看"。但大脑的工作方式是区域协同,EEG 研究因此发展出功能连接(functional connectivity) :量化两个通道信号的同步程度。最常用的入门指标是幅值平方相干性(magnitude-squared coherence)

Cxy(f)=∣Pxy(f)∣2Pxx(f) Pyy(f)C_{xy}(f) = \frac{|P_{xy}(f)|^2}{P_{xx}(f)\, P_{yy}(f)}Cxy(f)=Pxx(f)Pyy(f)∣Pxy(f)∣2

其中 PxyP_{xy}Pxy 是两信号的互谱,Pxx,PyyP_{xx}, P_{yy}Pxx,Pyy 是各自的自谱。相干性取值 0--1,可以理解为"频域版的相关系数平方 ":在频率 f 上,两通道的相位差与幅度关系是否稳定。与实战 01 的 Pearson 相关相比,相干性的特点是对恒定时间差不敏感------只要两信号在该频率上保持稳定的相位关系(哪怕有固定延迟),相干性就高。这对脑电很重要,因为神经耦合往往伴随传导延迟。

python 复制代码
freqs, coh = signal.coherence(x_chA, x_chB, fs=128, nperseg=256)
alpha_coherence = coh[(freqs >= 8) & (freqs <= 13)].mean()

对 14 个通道的两两组合(14 × 13 / 2 = 91 对)分别计算 8--13 Hz 平均相干性,睁眼、闭眼各算一遍,存入 alpha_coherence_edges.csv(每种条件 91 条边)。

5.2 阈值网络:为什么用"全条件统一阈值"

91 条边全画出来是一团毛线,必须阈值化------只保留相干性最强的边。脚本的做法是:把睁眼、闭眼两种条件的 182 条边混在一起,取第 82 百分位数(0.58)作为统一阈值 ,两种条件用同一把尺子(≥ 0.58)。这一步很关键:如果各画各的阈值(比如各自取前 18%),两张网永远边数相同,"哪张网更密"就永远看不出来;统一阈值之后,边数多少本身就是结果。闭眼网络中各节点的连接度(degree)与连接强度(strength)存入 alpha_network_node_metrics_closed.csv

5.3 【图 5】Alpha 相干网络

左(睁眼)右(闭眼)两张头皮网络:橙色连线 = 相干性 ≥ 0.58 的强边,线越粗越深相干越高;蓝色节点越大,该通道在阈值网络中的连接度越高。对比可见:

  1. 闭眼网络明显更密 ,且新增的边大量集中在额区(AF3--AF4 横贯前额的连线、AF3/AF4 向 F3/F4/F8/FC6 辐射的边)。闭眼条件下的"枢纽"排行:AF4(度 5,强度 3.54)、AF3(度 5,3.29)、FC6(度 4,2.95)、F4(度 4,2.73)------前四个全部是额区节点;
  2. 枕叶几乎不联网:O1 在两种条件下的连接度都是 0,T7、P7 在闭眼网络中也是 0,O2 仅 1 条边。

教科书叙事里,闭眼应该出现"后部 Alpha 同步化增强"。我们又得到了一个反直觉的结果,照例诚实解读:

  • 与功率结果自洽:图 3 已显示这份数据的 Alpha 效应集中在额前,网络的"额区枢纽"与之互为印证------这更像这份数据(或这名被试、这台设备)的个体特征,而非经典群体规律;
  • 与实战 01 的 QC 发现纠缠:01 篇我们发现 AF3 ↔ P8 ↔ F8 相关接近 1,疑似同源。如果某些通道真的共享信号源,它们之间的高相干是"复制"出来的,不是神经耦合;
  • 方法学警告(必须说) :相干性无法区分"两个脑区真的在协同"与"同一个源被两个电极同时看到"------容积传导 (电流经脑脊液、颅骨扩散)和共同参考 都会凭空制造相干。因此工程界常说:头皮 EEG 的相干网络是"电极空间的同步性地图",离"脑区间的真实连接"还隔着源定位、场扩散校正等一整座大山。本图的网络是示意性教学产物,图题和脚注也都如实标注了这一点。

本篇第三课:功能连接指标回答的是"信号间同步性",不是"神经连接"。 解释任何头皮网络的拓扑结论之前,先问三个问题:容积传导排除了吗?共同参考的影响评估了吗?数据里有没有同源通道?


6. 全局汇总:一张热图看尽 70 个检验

6.1 【图 6】全频带 × 全通道差异热图

把 5 频带 × 14 通道 = 70 个 "闭 − 睁" Δ log10 功率排成矩阵:红 = 闭眼更高,蓝 = 睁眼更高,单元格内数值 + 星号(未校正 p < 0.05)。这张图是整篇的"总账本",按行读:

  • δ Delta 行整体偏蓝(闭眼 Delta 更低):14 个通道中 10 个为负,其中 F7(−0.27)、FC5(−0.14)、O1(−0.17)、O2(−0.11)四格带星。呼应图 2 的 Delta 负差异,"睁眼期眼电抬高 Delta"的解释在这里得到空间分布支持(额区 F7、FC5 都在眼周影响范围内);
  • θ Theta 行几乎全白,唯一星号是 P8(+0.18)------孤立显著,谨慎对待;
  • α Alpha 行整体偏红 :14 个通道中 13 个为正,AF3(+0.23)带星;唯一的冷色格子是 O1(−0.06),图 3 的结论再次应验;
  • β Beta 与低 γ 行普遍暖色但无一显著------闭眼时高频活动整体略升,幅度小、一致性弱,不足以确认。

再做一次多重比较的算术:70 个检验,纯噪声下期望 3.5 个假阳性,实际 6 个星号------多于随机期望,但远不到"铁证" 。若做 Benjamini--Hochberg FDR 或 Bonferroni 校正,这批星号大概率所剩无几。因此图 6 的正确用法正如它的定位:探索性总览------它告诉你"Delta 下降、Alpha 额前增强"是接下来值得用独立数据验证的假设,而不是可以写进结论段的发现。

6.2 四张结果表:把分析变成可复用资产

本篇的 results/ 目录是实战 01 没有的东西,逐个说明用途:

文件 内容 典型用途
band_power_summary_by_condition.csv 条件 × 通道 × 频带的 log 功率均值、标准差、窗数(睁 66 / 闭 50) 画论文柱状图、写结果段
band_power_welch_ttest_effect_size.csv 70 组检验的均值差、Welch t、p 值、Cohen's d 图 3/图 6 的数据源,写统计表
alpha_coherence_edges.csv 两条件各 91 条通道对的 Alpha 相干性 网络分析、图论指标计算
alpha_network_node_metrics_closed.csv 闭眼网络各节点的度与强度(阈值 0.58) 找枢纽节点、写网络结果

外加一份自动生成的 method_summary_cn.txt------三段式中文方法摘要。它的深层价值在于:让脚本替你写"方法"初稿,参数(窗长、阈值、统计单位)与代码永远一致,不会出现"论文里写 40 Hz、代码里跑 45 Hz"这种事故。


7. 本篇复盘:方法升级带来了什么

维度 实战 01 的做法与结论 实战 02 的做法与结论
预处理 1--40 Hz 带通,伪迹无解 去中位数 + 1--45 Hz + 50 Hz 陷波 + MAD 标记 + 插值;伪迹比例 10.11%,修复副作用如实展示(图 1、图 4 暗带)
统计单位 整段信号直接对比 2 秒窗(睁 66 / 闭 50),杜绝伪重复
比较方式 两条 PSD 曲线目测 log10 功率 + Welch t + Cohen's d,70 组检验
Alpha 结论 "睁眼碾压闭眼"(伪迹绑架,完全反转) 方向恢复(全脑 +0.11 log10),但空间上集中于额前 AF3(d=0.41,未校正 p=0.026),O1 无效应;校正后无确认性显著
通道关系 Pearson 相关(发现疑似重复通道) Alpha 相干性阈值网络(闭眼更密、额区枢纽;并给出容积传导警告)
产出 8 张图 6 张图 + 4 张 CSV + 方法摘要 txt

三条方法论:

  1. 稳健统计贯穿始终:去中位数、MAD z 分数、Welch t(不等方差)------只要数据可能有离群值,先换稳健版本,再谈分析;
  2. 统计单位决定结论生死:把相关样本当独立样本是 EEG 论文被拒稿的常见原因之一。先想清楚"我的一个 n 是什么",再跑检验;
  3. p 值、效应量、多重比较,三位一体:图 3 的 AF3 和图 6 的 6 颗星,都是"未校正的提示性证据"。提出假设与确认假设,必须用不同的统计语言。

仍然存在的局限(也是后续篇目的入口):插值修复留下的时频疤痕提示我们该做坏窗剔除而非逐点修复;单被试、117 秒、消费级设备,样本天花板摆在那里;相干网络未做场扩散校正;所有显著性未经多重比较校正。


配套代码:eeg_second_case_advanced.py(Python 3.9+,依赖 numpy / pandas / scipy / matplotlib)。运行后在项目目录生成 figures/(本篇 6 张图)与 results/(4 张 CSV + 方法摘要)。数据只读复用实战 01 的下载,亦可独立运行自动下载。

python 复制代码
from __future__ import annotations

import gzip
import shutil
import textwrap
import urllib.request
import zipfile
from dataclasses import dataclass
from pathlib import Path

import matplotlib

# 使用非交互式后端,避免批量生成图片时窗口阻塞;PyCharm 中也可以直接运行。
matplotlib.use("Agg")

import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
from scipy import signal, stats


# =========================
# 1. 项目路径与全局参数
# =========================

PROJECT_DIR = Path(__file__).resolve().parent
WORKSPACE_DIR = PROJECT_DIR.parent
FIRST_CASE_DIR = WORKSPACE_DIR / "EEG_First_Case"
FIRST_CASE_ARFF = FIRST_CASE_DIR / "data" / "EEG_Eye_State.arff"

DATA_DIR = PROJECT_DIR / "data"
FIGURE_DIR = PROJECT_DIR / "figures"
RESULT_DIR = PROJECT_DIR / "results"

DATASET_URL = "https://archive.ics.uci.edu/static/public/264/eeg+eye+state.zip"
SECOND_CASE_ZIP = DATA_DIR / "eeg_eye_state.zip"
SECOND_CASE_ARFF_GZ = DATA_DIR / "EEG Eye State.arff.gz"
SECOND_CASE_ARFF = DATA_DIR / "EEG_Eye_State.arff"

EEG_CHANNELS = [
    "AF3",
    "F7",
    "F3",
    "FC5",
    "T7",
    "P7",
    "O1",
    "O2",
    "P8",
    "T8",
    "FC6",
    "F4",
    "F8",
    "AF4",
]

LABEL_COLUMN = "eye_state"
SAMPLING_RATE_HZ = 128
RANDOM_SEED = 20260716

# 频带定义采用 EEG 研究中常见的近似范围;不同论文会有细微差异。
BANDS = {
    "δ Delta 0.5-4 Hz": (0.5, 4),
    "θ Theta 4-8 Hz": (4, 8),
    "α Alpha 8-13 Hz": (8, 13),
    "β Beta 13-30 Hz": (13, 30),
    "低γ Low-gamma 30-45 Hz": (30, 45),
}

# 14 通道近似二维头皮位置,仅用于科研示意图;不是严格 MRI 头模坐标。
CHANNEL_POSITIONS = {
    "AF3": (-0.38, 0.86),
    "AF4": (0.38, 0.86),
    "F7": (-0.82, 0.50),
    "F3": (-0.36, 0.48),
    "F4": (0.36, 0.48),
    "F8": (0.82, 0.50),
    "FC5": (-0.62, 0.18),
    "FC6": (0.62, 0.18),
    "T7": (-0.94, -0.08),
    "T8": (0.94, -0.08),
    "P7": (-0.72, -0.50),
    "P8": (0.72, -0.50),
    "O1": (-0.30, -0.86),
    "O2": (0.30, -0.86),
}


@dataclass
class PreprocessResult:
    """保存预处理后的数据以及质量控制信息。"""

    raw: np.ndarray
    filtered: np.ndarray
    cleaned: np.ndarray
    artifact_mask: np.ndarray
    robust_z: np.ndarray


def setup_chinese_matplotlib_style() -> None:
    """设置中文字体、科研图表风格和随机种子。"""
    np.random.seed(RANDOM_SEED)

    # matplotlib 会按顺序寻找本机存在的字体;Windows 常见中文字体在前。
    plt.rcParams["font.sans-serif"] = [
        "Microsoft YaHei",
        "SimHei",
        "Noto Sans CJK SC",
        "Arial Unicode MS",
        "DejaVu Sans",
    ]
    plt.rcParams["axes.unicode_minus"] = False
    plt.rcParams["figure.dpi"] = 120
    plt.rcParams["savefig.dpi"] = 180
    plt.rcParams["axes.spines.top"] = False
    plt.rcParams["axes.spines.right"] = False
    plt.rcParams["axes.titleweight"] = "bold"
    plt.rcParams["legend.frameon"] = True
    plt.rcParams["legend.framealpha"] = 0.92


def ensure_project_folders() -> None:
    """只创建第二阶段自己的输出目录。"""
    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 get_arff_path() -> Path:
    """
    获取 EEG Eye State ARFF 数据路径。

    优先使用第一阶段已经下载的数据,但只读引用;若不存在,才下载到第二阶段 data/。
    """
    if FIRST_CASE_ARFF.exists():
        print(f"[数据] 只读复用第一阶段数据:{FIRST_CASE_ARFF}")
        return FIRST_CASE_ARFF

    if SECOND_CASE_ARFF.exists():
        print(f"[数据] 使用第二阶段本地数据:{SECOND_CASE_ARFF}")
        return SECOND_CASE_ARFF

    print("[数据] 未发现本地 ARFF,开始下载公开 EEG Eye State 数据到第二阶段目录。")
    urllib.request.urlretrieve(DATASET_URL, SECOND_CASE_ZIP)
    with zipfile.ZipFile(SECOND_CASE_ZIP, "r") as zip_file:
        zip_file.extractall(DATA_DIR)

    if not SECOND_CASE_ARFF_GZ.exists():
        raise FileNotFoundError(f"下载包中未找到预期文件:{SECOND_CASE_ARFF_GZ}")

    with gzip.open(SECOND_CASE_ARFF_GZ, "rb") as compressed_file:
        with open(SECOND_CASE_ARFF, "wb") as output_file:
            shutil.copyfileobj(compressed_file, output_file)

    print(f"[数据] 已生成第二阶段本地数据:{SECOND_CASE_ARFF}")
    return SECOND_CASE_ARFF


def load_arff(arff_path: Path) -> pd.DataFrame:
    """读取 ARFF 文本数据,并转换为数值型 DataFrame。"""
    data_lines: list[str] = []
    in_data_section = False

    with open(arff_path, "r", encoding="utf-8") as file:
        for raw_line in file:
            line = raw_line.strip()
            if not line or line.startswith("%"):
                continue
            if line.lower() == "@data":
                in_data_section = True
                continue
            if in_data_section:
                data_lines.append(line)

    df = pd.DataFrame([line.split(",") for line in data_lines], columns=EEG_CHANNELS + [LABEL_COLUMN])
    for column in df.columns:
        df[column] = pd.to_numeric(df[column])
    return df


def robust_zscore(x: np.ndarray, axis: int = 0) -> np.ndarray:
    """基于中位数绝对偏差的稳健 z 分数,对极端伪迹不那么敏感。"""
    median = np.median(x, axis=axis, keepdims=True)
    mad = np.median(np.abs(x - median), axis=axis, keepdims=True)
    mad = np.where(mad < 1e-12, 1e-12, mad)
    return 0.6745 * (x - median) / mad


def preprocess_eeg(df: pd.DataFrame) -> PreprocessResult:
    """
    完成进阶版预处理:
        1. 去均值,削弱通道直流偏置
        2. 1-45 Hz 零相位带通滤波
        3. 50 Hz 陷波,模拟真实 EEG 中常见工频噪声处理
        4. 用稳健 z 分数标记短暂高幅伪迹,并用线性插值修复
    """
    raw = df[EEG_CHANNELS].to_numpy(dtype=float)
    centered = raw - np.median(raw, axis=0, keepdims=True)

    bandpass_sos = signal.butter(
        N=4,
        Wn=[1, 45],
        btype="bandpass",
        fs=SAMPLING_RATE_HZ,
        output="sos",
    )
    filtered = signal.sosfiltfilt(bandpass_sos, centered, axis=0)

    notch_b, notch_a = signal.iirnotch(w0=50, Q=30, fs=SAMPLING_RATE_HZ)
    filtered = signal.filtfilt(notch_b, notch_a, filtered, axis=0)

    rz = robust_zscore(filtered, axis=0)
    artifact_mask = np.any(np.abs(rz) > 6.0, axis=1)
    cleaned = filtered.copy()

    # 只修复极少量瞬时异常点;如果整段异常过多,应回到实验设计与采集质量层面排查。
    sample_index = np.arange(len(cleaned))
    good = ~artifact_mask
    if good.sum() > 2 and artifact_mask.any():
        for ch in range(cleaned.shape[1]):
            cleaned[artifact_mask, ch] = np.interp(sample_index[artifact_mask], sample_index[good], cleaned[good, ch])

    return PreprocessResult(raw=raw, filtered=filtered, cleaned=cleaned, artifact_mask=artifact_mask, robust_z=rz)


def band_power_from_welch(x: np.ndarray) -> tuple[np.ndarray, np.ndarray, dict[str, np.ndarray]]:
    """对每个通道计算 Welch PSD,并积分得到各频带绝对功率。"""
    freqs, psd = signal.welch(
        x,
        fs=SAMPLING_RATE_HZ,
        nperseg=SAMPLING_RATE_HZ * 2,
        noverlap=SAMPLING_RATE_HZ,
        axis=0,
    )

    powers: dict[str, np.ndarray] = {}
    for band_name, (low, high) in BANDS.items():
        idx = (freqs >= low) & (freqs <= high)
        powers[band_name] = np.trapz(psd[idx, :], freqs[idx], axis=0)
    return freqs, psd, powers


def summarize_band_power(cleaned: np.ndarray, labels: np.ndarray) -> tuple[pd.DataFrame, pd.DataFrame]:
    """
    计算睁眼/闭眼条件下的通道频带功率,并进行 Welch t 检验与 Cohen's d 效应量估计。

    这里用 2 秒窗作为统计样本,避免把所有采样点当作独立样本造成伪重复。
    """
    window = SAMPLING_RATE_HZ * 2
    step = SAMPLING_RATE_HZ
    rows: list[dict[str, float | int | str]] = []

    for start in range(0, len(cleaned) - window + 1, step):
        stop = start + window
        segment_labels = labels[start:stop]
        condition = int(stats.mode(segment_labels, keepdims=False).mode)
        segment = cleaned[start:stop]
        _, _, powers = band_power_from_welch(segment)
        for band_name, values in powers.items():
            for channel, value in zip(EEG_CHANNELS, values):
                rows.append(
                    {
                        "window_start_s": start / SAMPLING_RATE_HZ,
                        "eye_state": condition,
                        "condition_cn": "睁眼" if condition == 0 else "闭眼",
                        "channel": channel,
                        "band": band_name,
                        "power": float(value),
                        "log_power": float(np.log10(value + 1e-12)),
                    }
                )

    long_df = pd.DataFrame(rows)
    stats_rows: list[dict[str, float | str]] = []

    for band_name in BANDS:
        for channel in EEG_CHANNELS:
            subset = long_df[(long_df["band"] == band_name) & (long_df["channel"] == channel)]
            open_values = subset.loc[subset["eye_state"] == 0, "log_power"].to_numpy()
            closed_values = subset.loc[subset["eye_state"] == 1, "log_power"].to_numpy()
            if len(open_values) < 2 or len(closed_values) < 2:
                continue

            test = stats.ttest_ind(closed_values, open_values, equal_var=False)
            pooled_sd = np.sqrt((np.var(open_values, ddof=1) + np.var(closed_values, ddof=1)) / 2)
            cohen_d = (np.mean(closed_values) - np.mean(open_values)) / pooled_sd if pooled_sd > 0 else np.nan
            stats_rows.append(
                {
                    "band": band_name,
                    "channel": channel,
                    "open_mean_log_power": float(np.mean(open_values)),
                    "closed_mean_log_power": float(np.mean(closed_values)),
                    "closed_minus_open": float(np.mean(closed_values) - np.mean(open_values)),
                    "welch_t": float(test.statistic),
                    "p_value": float(test.pvalue),
                    "cohen_d": float(cohen_d),
                }
            )

    stats_df = pd.DataFrame(stats_rows)
    return long_df, stats_df


def compute_alpha_coherence(cleaned: np.ndarray, labels: np.ndarray, condition: int) -> pd.DataFrame:
    """计算指定条件下 8-13 Hz Alpha 频带的通道两两相干性。"""
    condition_data = cleaned[labels == condition]
    rows: list[dict[str, float | str]] = []

    for i, ch_a in enumerate(EEG_CHANNELS):
        for j in range(i + 1, len(EEG_CHANNELS)):
            ch_b = EEG_CHANNELS[j]
            freqs, coh = signal.coherence(
                condition_data[:, i],
                condition_data[:, j],
                fs=SAMPLING_RATE_HZ,
                nperseg=SAMPLING_RATE_HZ * 2,
            )
            alpha_idx = (freqs >= 8) & (freqs <= 13)
            rows.append(
                {
                    "channel_a": ch_a,
                    "channel_b": ch_b,
                    "alpha_coherence": float(np.mean(coh[alpha_idx])),
                    "condition": condition,
                    "condition_cn": "睁眼" if condition == 0 else "闭眼",
                }
            )

    return pd.DataFrame(rows)


def save_basic_tables(long_df: pd.DataFrame, stats_df: pd.DataFrame, coherence_df: pd.DataFrame) -> None:
    """保存第二阶段分析结果表,便于论文写作或后续建模复用。"""
    band_summary = (
        long_df.groupby(["condition_cn", "channel", "band"], as_index=False)
        .agg(mean_log_power=("log_power", "mean"), sd_log_power=("log_power", "std"), n_windows=("log_power", "size"))
        .sort_values(["band", "condition_cn", "channel"])
    )

    band_summary.to_csv(RESULT_DIR / "band_power_summary_by_condition.csv", index=False, encoding="utf-8-sig")
    stats_df.to_csv(RESULT_DIR / "band_power_welch_ttest_effect_size.csv", index=False, encoding="utf-8-sig")
    coherence_df.to_csv(RESULT_DIR / "alpha_coherence_edges.csv", index=False, encoding="utf-8-sig")


def annotate_panel(ax: plt.Axes, label: str) -> None:
    """给多子图加 A/B/C 风格面板标记。"""
    ax.text(
        -0.08,
        1.06,
        label,
        transform=ax.transAxes,
        fontsize=13,
        fontweight="bold",
        va="top",
        ha="left",
    )


def plot_preprocessing_qc(df: pd.DataFrame, prep: PreprocessResult) -> None:
    """图 1:展示预处理前后波形、伪迹标记和通道质量概览。"""
    seconds = 10
    n = seconds * SAMPLING_RATE_HZ
    t = np.arange(n) / SAMPLING_RATE_HZ
    channels = ["AF3", "F7", "O1", "O2"]

    fig, axes = plt.subplots(3, 1, figsize=(13.5, 9), gridspec_kw={"height_ratios": [2.2, 2.2, 1.2]})
    fig.suptitle("图1  EEG 第二阶段预处理质量控制:滤波、陷波与稳健伪迹标记", fontsize=16, y=0.98)

    offsets = np.arange(len(channels))[::-1] * 180
    for offset, channel in zip(offsets, channels):
        idx = EEG_CHANNELS.index(channel)
        axes[0].plot(t, prep.raw[:n, idx] - np.median(prep.raw[:n, idx]) + offset, lw=0.8, label=channel)
        axes[1].plot(t, prep.cleaned[:n, idx] + offset, lw=1.0, label=channel)

    axes[0].set_title("A. 原始信号片段:含慢漂移与通道幅值差异")
    axes[0].set_ylabel("幅值 + 通道偏移")
    axes[0].legend(ncol=4, loc="upper right", title="通道")
    axes[0].grid(alpha=0.25)

    axes[1].set_title("B. 清理后信号片段:1-45 Hz 带通 + 50 Hz 陷波 + 瞬时伪迹插值")
    axes[1].set_ylabel("滤波幅值 + 通道偏移")
    axes[1].legend(ncol=4, loc="upper right", title="通道")
    axes[1].grid(alpha=0.25)

    artifact_seconds = np.where(prep.artifact_mask[:n])[0] / SAMPLING_RATE_HZ
    axes[2].plot(t, np.max(np.abs(prep.robust_z[:n]), axis=1), color="#7A3E9D", lw=1.1, label="最大稳健 |z|")
    axes[2].axhline(6, color="#B24C3C", ls="--", lw=1.2, label="伪迹阈值 |z|=6")
    if len(artifact_seconds) > 0:
        axes[2].scatter(artifact_seconds, np.full_like(artifact_seconds, 6.2), s=12, color="#B24C3C", label="被标记采样点")
    axes[2].set_title("C. 稳健伪迹评分:用中位数绝对偏差识别短暂异常")
    axes[2].set_xlabel("时间(秒)")
    axes[2].set_ylabel("稳健 |z|")
    axes[2].set_ylim(0, max(8, np.nanpercentile(np.max(np.abs(prep.robust_z[:n]), axis=1), 99.5) * 1.1))
    axes[2].legend(loc="upper right")
    axes[2].grid(alpha=0.25)

    for ax, label in zip(axes, ["A", "B", "C"]):
        annotate_panel(ax, label)

    artifact_rate = prep.artifact_mask.mean() * 100
    fig.text(
        0.01,
        0.01,
        f"注:本图仅展示前 {seconds} 秒;全记录伪迹采样比例约 {artifact_rate:.2f}%。"
        "第二阶段分析保留原始条件标签,并仅在本项目输出目录保存结果。",
        fontsize=10,
    )
    plt.tight_layout(rect=(0, 0.035, 1, 0.955))
    plt.savefig(FIGURE_DIR / "01_预处理质量控制_滤波陷波伪迹.png")
    plt.close(fig)


def plot_band_power_overview(long_df: pd.DataFrame) -> None:
    """图 2:展示不同频带在睁眼/闭眼条件下的整体差异。"""
    summary = (
        long_df.groupby(["condition_cn", "band"], as_index=False)
        .agg(mean=("log_power", "mean"), sem=("log_power", lambda s: s.std(ddof=1) / np.sqrt(len(s))))
        .sort_values(["band", "condition_cn"])
    )

    bands = list(BANDS.keys())
    x = np.arange(len(bands))
    width = 0.36
    open_vals = summary[summary["condition_cn"] == "睁眼"].set_index("band").loc[bands]
    closed_vals = summary[summary["condition_cn"] == "闭眼"].set_index("band").loc[bands]

    fig, ax = plt.subplots(figsize=(13, 6.8))
    ax.bar(x - width / 2, open_vals["mean"], width, yerr=open_vals["sem"], capsize=4, label="睁眼", color="#4C78A8")
    ax.bar(x + width / 2, closed_vals["mean"], width, yerr=closed_vals["sem"], capsize=4, label="闭眼", color="#E07B39")
    ax.set_title("图2  睁眼与闭眼条件下的全通道频带功率比较")
    ax.set_ylabel("log10 频带功率(均值 ± SEM)")
    ax.set_xlabel("EEG 频带")
    ax.set_xticks(x)
    ax.set_xticklabels([b.replace(" ", "\n", 1) for b in bands])
    ax.legend(title="实验条件")
    ax.grid(axis="y", alpha=0.25)

    for i, band in enumerate(bands):
        delta = closed_vals.loc[band, "mean"] - open_vals.loc[band, "mean"]
        ax.text(i, max(open_vals.loc[band, "mean"], closed_vals.loc[band, "mean"]) + 0.04, f"闭-睁={delta:+.2f}", ha="center", fontsize=9)

    fig.text(
        0.01,
        0.01,
        "注:每个 2 秒滑动窗先计算 Welch 频谱,再对频带积分;误差线为跨窗口、跨通道的标准误。",
        fontsize=10,
    )
    plt.tight_layout(rect=(0, 0.04, 1, 1))
    plt.savefig(FIGURE_DIR / "02_睁眼闭眼_频带功率总体比较.png")
    plt.close(fig)


def plot_alpha_effect_topography(stats_df: pd.DataFrame) -> None:
    """图 3:用头皮示意图表达闭眼相对睁眼的 Alpha 增强效应。"""
    alpha_name = "α Alpha 8-13 Hz"
    alpha_stats = stats_df[stats_df["band"] == alpha_name].copy()
    alpha_stats = alpha_stats.set_index("channel").loc[EEG_CHANNELS].reset_index()

    values = alpha_stats["closed_minus_open"].to_numpy()
    vmax = max(abs(values.min()), abs(values.max()))

    fig, axes = plt.subplots(1, 2, figsize=(13.5, 6.8), gridspec_kw={"width_ratios": [1.0, 1.35]})
    fig.suptitle("图3  Alpha 频带闭眼增强:通道效应量与头皮空间分布", fontsize=16)

    ax = axes[0]
    head = plt.Circle((0, 0), 1.02, fill=False, color="#555555", lw=1.5)
    ax.add_patch(head)
    ax.plot([0, -0.08, 0.08, 0], [1.02, 1.14, 1.14, 1.02], color="#555555", lw=1.2)
    ax.plot([-1.02, -1.12, -1.02], [0.12, 0, -0.12], color="#555555", lw=1.2)
    ax.plot([1.02, 1.12, 1.02], [0.12, 0, -0.12], color="#555555", lw=1.2)

    scatter = ax.scatter(
        [CHANNEL_POSITIONS[ch][0] for ch in EEG_CHANNELS],
        [CHANNEL_POSITIONS[ch][1] for ch in EEG_CHANNELS],
        c=values,
        s=520,
        cmap="RdBu_r",
        vmin=-vmax,
        vmax=vmax,
        edgecolor="#333333",
        linewidth=0.8,
    )
    for ch in EEG_CHANNELS:
        x, y = CHANNEL_POSITIONS[ch]
        ax.text(x, y, ch, ha="center", va="center", fontsize=9, color="black")
    ax.set_aspect("equal")
    ax.set_axis_off()
    ax.set_title("A. 闭眼 - 睁眼的 Alpha log功率差")
    cbar = fig.colorbar(scatter, ax=ax, fraction=0.046, pad=0.03)
    cbar.set_label("Δ log10 Alpha 功率")

    ax = axes[1]
    sorted_stats = alpha_stats.sort_values("cohen_d", ascending=True)
    colors = np.where(sorted_stats["p_value"] < 0.05, "#E07B39", "#8E8E8E")
    ax.barh(sorted_stats["channel"], sorted_stats["cohen_d"], color=colors)
    ax.axvline(0, color="#444444", lw=1)
    ax.set_title("B. 通道级 Cohen's d:正值表示闭眼 Alpha 更强")
    ax.set_xlabel("Cohen's d(闭眼相对睁眼)")
    ax.set_ylabel("通道")
    ax.grid(axis="x", alpha=0.25)
    for y, (_, row) in enumerate(sorted_stats.iterrows()):
        sig = "*" if row["p_value"] < 0.05 else "n.s."
        ax.text(row["cohen_d"] + 0.03 * np.sign(row["cohen_d"] if row["cohen_d"] != 0 else 1), y, sig, va="center", fontsize=9)
    ax.legend(
        handles=[
            plt.Line2D([0], [0], color="#E07B39", lw=8, label="p < 0.05"),
            plt.Line2D([0], [0], color="#8E8E8E", lw=8, label="未达显著"),
        ],
        title="Welch t 检验",
        loc="lower right",
    )

    fig.text(
        0.01,
        0.01,
        "注:头皮图为 14 通道近似二维示意;统计单位为 2 秒窗,效应量基于 log10 Alpha 功率。",
        fontsize=10,
    )
    plt.tight_layout(rect=(0, 0.04, 1, 0.94))
    plt.savefig(FIGURE_DIR / "03_Alpha闭眼增强_头皮分布与效应量.png")
    plt.close(fig)


def plot_time_frequency(cleaned: np.ndarray, labels: np.ndarray) -> None:
    """图 4:以 O1 通道展示时频谱和随时间变化的 Alpha 功率。"""
    channel = "O1"
    idx = EEG_CHANNELS.index(channel)
    x = cleaned[:, idx]

    freqs, times, spec = signal.spectrogram(
        x,
        fs=SAMPLING_RATE_HZ,
        nperseg=SAMPLING_RATE_HZ * 2,
        noverlap=SAMPLING_RATE_HZ,
        scaling="density",
        mode="psd",
    )
    keep = (freqs >= 1) & (freqs <= 45)
    freqs = freqs[keep]
    spec = spec[keep]
    spec_db = 10 * np.log10(spec + 1e-12)

    alpha_keep = (freqs >= 8) & (freqs <= 13)
    alpha_track = spec_db[alpha_keep].mean(axis=0)
    label_track = np.array([stats.mode(labels[int(t * SAMPLING_RATE_HZ) : int(t * SAMPLING_RATE_HZ) + SAMPLING_RATE_HZ], keepdims=False).mode for t in times])

    fig, axes = plt.subplots(2, 1, figsize=(13.5, 8.2), gridspec_kw={"height_ratios": [2.4, 1.0]}, sharex=True)
    fig.suptitle(f"图4  {channel} 通道时频分析:Alpha 活动随眼状态变化", fontsize=16)

    im = axes[0].pcolormesh(times, freqs, spec_db, shading="auto", cmap="magma")
    axes[0].axhspan(8, 13, color="#7AD7F0", alpha=0.18, label="Alpha 8-13 Hz")
    axes[0].set_ylabel("频率(Hz)")
    axes[0].set_title("A. 短时傅里叶时频谱(功率谱密度 dB)")
    axes[0].legend(loc="upper right")
    cbar = fig.colorbar(im, ax=axes[0], fraction=0.022, pad=0.012)
    cbar.set_label("PSD(dB)")

    axes[1].plot(times, alpha_track, color="#4C78A8", lw=1.4, label="O1 Alpha 平均功率")
    axes[1].fill_between(times, alpha_track.min(), alpha_track.max(), where=label_track == 1, color="#E07B39", alpha=0.18, label="闭眼区间")
    axes[1].fill_between(times, alpha_track.min(), alpha_track.max(), where=label_track == 0, color="#4C78A8", alpha=0.10, label="睁眼区间")
    axes[1].set_title("B. Alpha 轨迹与眼状态标签叠加")
    axes[1].set_xlabel("时间(秒)")
    axes[1].set_ylabel("Alpha PSD(dB)")
    axes[1].legend(ncol=3, loc="upper right")
    axes[1].grid(alpha=0.25)

    for ax, label in zip(axes, ["A", "B"]):
        annotate_panel(ax, label)

    fig.text(
        0.01,
        0.01,
        "注:颜色越亮表示对应频率在该时间窗内功率越高;浅橙背景为闭眼主导时间窗。",
        fontsize=10,
    )
    plt.tight_layout(rect=(0, 0.04, 1, 0.94))
    plt.savefig(FIGURE_DIR / "04_O1通道_时频谱与Alpha轨迹.png")
    plt.close(fig)


def plot_alpha_network(coherence_open: pd.DataFrame, coherence_closed: pd.DataFrame) -> pd.DataFrame:
    """图 5:展示 Alpha 相干性网络,并返回闭眼条件下的节点指标。"""
    coherence_all = pd.concat([coherence_open, coherence_closed], ignore_index=True)
    threshold = coherence_all["alpha_coherence"].quantile(0.82)

    fig, axes = plt.subplots(1, 2, figsize=(13.5, 6.8))
    fig.suptitle("图5  Alpha 频带功能连接网络:通道间相干性比较", fontsize=16)

    node_metrics: list[dict[str, float | str]] = []

    for ax, data, title in zip(axes, [coherence_open, coherence_closed], ["A. 睁眼 Alpha 相干网络", "B. 闭眼 Alpha 相干网络"]):
        strong = data[data["alpha_coherence"] >= threshold].copy()
        degree = {ch: 0 for ch in EEG_CHANNELS}
        strength = {ch: 0.0 for ch in EEG_CHANNELS}

        ax.add_patch(plt.Circle((0, 0), 1.02, fill=False, color="#555555", lw=1.5))
        ax.plot([0, -0.08, 0.08, 0], [1.02, 1.14, 1.14, 1.02], color="#555555", lw=1.2)

        for _, row in strong.iterrows():
            ch_a = str(row["channel_a"])
            ch_b = str(row["channel_b"])
            coh = float(row["alpha_coherence"])
            xa, ya = CHANNEL_POSITIONS[ch_a]
            xb, yb = CHANNEL_POSITIONS[ch_b]
            degree[ch_a] += 1
            degree[ch_b] += 1
            strength[ch_a] += coh
            strength[ch_b] += coh
            ax.plot([xa, xb], [ya, yb], color="#E07B39", alpha=0.18 + 0.55 * coh, lw=0.6 + 3.0 * coh)

        node_sizes = np.array([160 + 70 * degree[ch] for ch in EEG_CHANNELS])
        ax.scatter(
            [CHANNEL_POSITIONS[ch][0] for ch in EEG_CHANNELS],
            [CHANNEL_POSITIONS[ch][1] for ch in EEG_CHANNELS],
            s=node_sizes,
            color="#4C78A8",
            edgecolor="#333333",
            linewidth=0.8,
            zorder=3,
        )
        for ch in EEG_CHANNELS:
            x, y = CHANNEL_POSITIONS[ch]
            ax.text(x, y, ch, ha="center", va="center", fontsize=8.5, color="white", zorder=4)
            if "闭眼" in title:
                node_metrics.append(
                    {
                        "channel": ch,
                        "alpha_network_degree": degree[ch],
                        "alpha_network_strength": strength[ch],
                        "threshold": threshold,
                    }
                )

        ax.set_title(f"{title}\n边阈值:全条件相干性前 18%(≥{threshold:.2f})")
        ax.set_aspect("equal")
        ax.set_axis_off()
        annotate_panel(ax, title[0])

    handles = [
        plt.Line2D([0], [0], color="#E07B39", lw=1.5, alpha=0.45, label="强 Alpha 相干边"),
        plt.Line2D([0], [0], marker="o", color="w", markerfacecolor="#4C78A8", markeredgecolor="#333333", markersize=10, label="EEG 通道节点"),
    ]
    fig.legend(handles=handles, loc="lower center", ncol=2, title="网络图例")
    fig.text(
        0.01,
        0.01,
        "注:线越粗、越深表示 Alpha 相干性越高;节点越大表示在阈值网络中的连接度越高。",
        fontsize=10,
    )
    plt.tight_layout(rect=(0, 0.08, 1, 0.94))
    plt.savefig(FIGURE_DIR / "05_Alpha功能连接_相干性网络.png")
    plt.close(fig)

    node_df = pd.DataFrame(node_metrics).sort_values(["alpha_network_degree", "alpha_network_strength"], ascending=False)
    node_df.to_csv(RESULT_DIR / "alpha_network_node_metrics_closed.csv", index=False, encoding="utf-8-sig")
    return node_df


def plot_statistical_summary(stats_df: pd.DataFrame) -> None:
    """图 6:用热图汇总所有通道、所有频带的条件差异。"""
    pivot = stats_df.pivot(index="band", columns="channel", values="closed_minus_open").loc[list(BANDS.keys()), EEG_CHANNELS]
    pvals = stats_df.pivot(index="band", columns="channel", values="p_value").loc[list(BANDS.keys()), EEG_CHANNELS]
    values = pivot.to_numpy()
    vmax = max(abs(np.nanmin(values)), abs(np.nanmax(values)))

    fig, ax = plt.subplots(figsize=(13.5, 6.8))
    im = ax.imshow(values, cmap="RdBu_r", vmin=-vmax, vmax=vmax, aspect="auto")
    ax.set_title("图6  全频带 × 全通道条件差异热图:闭眼减睁眼")
    ax.set_xlabel("EEG 通道")
    ax.set_ylabel("频带")
    ax.set_xticks(np.arange(len(EEG_CHANNELS)))
    ax.set_xticklabels(EEG_CHANNELS, rotation=45, ha="right")
    ax.set_yticks(np.arange(len(BANDS)))
    ax.set_yticklabels(list(BANDS.keys()))

    for r in range(values.shape[0]):
        for c in range(values.shape[1]):
            value = values[r, c]
            p = pvals.iloc[r, c]
            star = "*" if p < 0.05 else ""
            ax.text(c, r, f"{value:+.2f}{star}", ha="center", va="center", fontsize=8, color="black")

    cbar = fig.colorbar(im, ax=ax, fraction=0.026, pad=0.018)
    cbar.set_label("Δ log10 功率(闭眼 - 睁眼)")
    fig.text(
        0.01,
        0.01,
        "注:星号表示未校正 Welch t 检验 p < 0.05;该图用于探索性教学展示,正式研究需进行多重比较校正。",
        fontsize=10,
    )
    plt.tight_layout(rect=(0, 0.04, 1, 1))
    plt.savefig(FIGURE_DIR / "06_全频带全通道_统计差异热图.png")
    plt.close(fig)


def print_research_summary(df: pd.DataFrame, prep: PreprocessResult, stats_df: pd.DataFrame, node_df: pd.DataFrame) -> None:
    """在终端输出类似研究报告摘要的关键信息。"""
    duration = len(df) / SAMPLING_RATE_HZ
    label_counts = df[LABEL_COLUMN].value_counts().sort_index()
    alpha = stats_df[stats_df["band"] == "α Alpha 8-13 Hz"].sort_values("cohen_d", ascending=False)
    top_alpha = alpha.head(3)[["channel", "closed_minus_open", "cohen_d", "p_value"]]
    top_nodes = node_df.head(5)[["channel", "alpha_network_degree", "alpha_network_strength"]]

    print("\n" + "=" * 78)
    print("EEG 第二阶段进阶案例完成:研究式摘要")
    print("=" * 78)
    print(f"样本量:{len(df)} 个采样点,约 {duration:.1f} 秒,采样率 {SAMPLING_RATE_HZ} Hz,通道数 {len(EEG_CHANNELS)}")
    print(f"标签分布:睁眼={int(label_counts.get(0, 0))},闭眼={int(label_counts.get(1, 0))}")
    print(f"稳健伪迹采样比例:{prep.artifact_mask.mean() * 100:.2f}%")
    print("\nAlpha 闭眼增强最明显的 3 个通道:")
    print(top_alpha.to_string(index=False, formatters={"closed_minus_open": "{:+.3f}".format, "cohen_d": "{:+.3f}".format, "p_value": "{:.3g}".format}))
    print("\n闭眼 Alpha 网络中连接度较高的节点:")
    print(top_nodes.to_string(index=False, formatters={"alpha_network_strength": "{:.3f}".format}))
    print(f"\n图片输出目录:{FIGURE_DIR}")
    print(f"表格输出目录:{RESULT_DIR}")
    print("=" * 78)


def main() -> None:
    """执行完整第二阶段进阶 EEG 分析流程。"""
    setup_chinese_matplotlib_style()
    ensure_project_folders()

    arff_path = get_arff_path()
    df = load_arff(arff_path)
    labels = df[LABEL_COLUMN].to_numpy(dtype=int)

    prep = preprocess_eeg(df)
    long_df, stats_df = summarize_band_power(prep.cleaned, labels)
    coherence_open = compute_alpha_coherence(prep.cleaned, labels, condition=0)
    coherence_closed = compute_alpha_coherence(prep.cleaned, labels, condition=1)
    coherence_df = pd.concat([coherence_open, coherence_closed], ignore_index=True)

    save_basic_tables(long_df, stats_df, coherence_df)

    plot_preprocessing_qc(df, prep)
    plot_band_power_overview(long_df)
    plot_alpha_effect_topography(stats_df)
    plot_time_frequency(prep.cleaned, labels)
    node_df = plot_alpha_network(coherence_open, coherence_closed)
    plot_statistical_summary(stats_df)

    # 保存一份简短方法说明,仍由本单文件脚本自动生成,便于课程报告引用。
    method_text = textwrap.dedent(
        f"""
        EEG 第二阶段进阶案例方法摘要
        ============================
        数据来源:UCI EEG Eye State,采样率 {SAMPLING_RATE_HZ} Hz,14 个 EEG 通道。
        预处理:通道中位数去中心化、1-45 Hz Butterworth 零相位带通、50 Hz 陷波、稳健 z 分数伪迹标记与线性插值。
        频域分析:2 秒窗 Welch PSD,计算 Delta/Theta/Alpha/Beta/Low-gamma 频带功率。
        条件比较:以 2 秒窗为统计单位,比较闭眼与睁眼 log10 频带功率,输出 Welch t 检验、p 值与 Cohen's d。
        时频分析:O1 通道短时傅里叶谱,并叠加 Alpha 功率轨迹与眼状态标签。
        功能连接:计算通道两两 Alpha 频带相干性,并以全条件前 18% 相干边构建示意网络。

        注意:本案例用于教学与科研流程演示。公开数据标签可用于方法学习,但正式论文仍需根据实验设计进行更严格的伪迹剔除、独立样本定义和多重比较校正。
        """
    ).strip()
    (RESULT_DIR / "method_summary_cn.txt").write_text(method_text, encoding="utf-8")

    print_research_summary(df, prep, stats_df, node_df)


if __name__ == "__main__":
    main()
相关推荐
科恒盛远7 天前
【无标题】
fpga开发·硬件工程·信号处理
省四收割者10 天前
一文详解信号完整性(3)--规避SI问题的具体实现方法
嵌入式硬件·硬件架构·信号处理·智能硬件·pcb工艺·altium designer
国科安芯10 天前
基于ASM1042S2S的箭载通信网络抗辐射加固方案研究
服务器·网络·嵌入式硬件·fpga开发·架构·信号处理
棱镜研途11 天前
【EI检索会议 | SPIE出版】 2026年智能计算与多模态信号处理国际学术会议(CIMSP 2026)
图像处理·算法·计算机视觉·信号处理·多模态·学术会议·智能计算
省四收割者12 天前
一文详解信号完整性(1)
python·嵌入式硬件·数学建模·信息与通信·信号处理·智能硬件
传感器与混合集成电路13 天前
深度解析电荷平衡式电流频率转换器:积分器、恒流源与阈值比较的协同设计
信号处理
奔袭的算法工程师1 个月前
论文解读--Sparse4D v3: Advancing End-to-End 3D Detection and Tracking
人工智能·目标检测·计算机视觉·自动驾驶·信号处理
江畔柳前堤1 个月前
github实战指南01-账号配置与 SSH 密钥
运维·人工智能·深度学习·ssh·github·pyqt·信号处理
江畔柳前堤1 个月前
github实战指南02-仓库管理与 Issue
人工智能·深度学习·github·信号处理·caffe·wps·issue