ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

EEG脑网络ROI连接分析:体积传导、wPLI与可复现报告

EEG脑网络ROI连接分析:体积传导、wPLI与可复现报告

简介:RoiConnect 是面向脑电研究与神经影像分析者的 ROI 连接分析工具箱,基于 EEGLAB 接口并与主流头模型工具兼容,可在表面或体积图谱上开展 TRGC、CS 等感兴趣区连通性计算,适合从通道分析推进到脑区网络分析的研究生与科研人员。压缩包共 371 个文件,约 46.31MB,以 210 个 .m 源码和 117 个 HTML 帮助文档为主体,辅以 png 示意图、mat 示例数据、mex 平台二进制及配置说明,便于查阅函数实现、示例流程与参数用法。包内目录组织清晰,便于按模块检索、对照学习与二次开发,具有参考价值。现有 655 人学习下载,说明其在脑电分析社区具有一定关注度。读者可获得可运行的连接分析流程、图谱与头模型适配思路、两类连接指标调用及排错线索,用于复现实验或搭建脑区网络分析脚本。

1. 结论先说:ROI 连接矩阵里最粗的那条边,通常最不可信

一份脑电图 ROI 连接分析(region of interest connectivity)的结果,如果最终以单文件 HTML 交付,第一眼看到的往往是热图上对角线附近那一片深色——相邻脑区、甚至同一块皮层被两个标签切开的位置,连接值高得离谱。直觉会说“这两个区真的很同步”,实际上这多半是体积传导留下的特征:同一个电场被相邻电极和相邻源点同时拾取,相位差接近零,任何对零延迟敏感的量都会把它算成强连接。搞懂这条链路上每一步在做什么,比会拖一个阈值滑块重要得多,因为阈值之下的每个数字,都是从电极电位一路投影、对齐、加权推出来的,哪一步偷懒,报告里都看不出来。

2. 电极看到的是混合物:从传感器空间到 ROI 时间序列的三次投影

头皮电极记录的从来不是某个脑区的活动,而是整块皮层和伪迹在那一刻叠加出来的电位。ROI 连接分析要做的第一件事,就是把这个混合物拆成“每个脑区一根时间序列”的形式。这个过程要经过三次投影:正向建模把源映到电极,逆投影把电极映回源,分区归并再把上千个源点压成几十个 ROI。三次投影里任何一次的定义含糊,后面的连接值就只是把误差重新排列了一遍。

2.1 头模型与导联场:源和电极之间的关系是被算出来的

正向问题的形式很简单:V = L·s + n,V是电极电位向量,s是源活动,L就是导联场矩阵,形状为n_ch × n_src。导联场不是配置项,它是几何量:头模型的形状(球模型还是分层的边界元)、电极在头皮上的三维坐标、源点被放在白质边界还是皮层中面,这三件事一旦定下来,L的每一列就确定了——它描述“第 j 个源点放 1 个单位电流时,每个电极上会出现多少电位”。

逆投影是解V = L·s的反问题,而这个方程是欠定的:源点动辄上万个,电极只有几十个。常见的实现思路有两类,最小范数估计和波束形成。前者写成ŝ = W·V,其中W = Lᵀ(LLᵀ + λI)⁻¹,λ是正则化项;后者对每个源点单独求一个空域滤波器w = (LᵀC⁻¹L)⁻¹LᵀC⁻¹,C是数据的协方差矩阵。两者共同的后果是:解出来的源活动在空间上被“抹开”了,一个真实的局灶源会在邻近源点上留下拖尾。

这件事对 ROI 级分析的含义,比大多数人以为的更重。你选的不是电极数量,而是有效空间分辨率。电极越多,L的行数越多,逆问题的约束越强,相邻源点才越可能被分开;电极少的时候,两个相邻 ROI 的源时间序列本来就高度相似,算出来的高连接有相当一部分是算法造成的,不是大脑造成的。

提示:把逆算子缓存下来重复用没问题,但每次换电极坐标或换头模型都要重算,不要拿上一个被试的W直接套。

2.2 分区图谱与时间序列提取:一个 ROI 到底代表哪一根线

分区图谱把皮层划成 N 个标签,每个标签覆盖若干源点。从“若干根源时间序列”到“一根 ROI 时间序列”,中间那一步看着无害,实际是最容易出问题的地方。

问题出在极性上。逆投影得到的每个源点都有自己的偶极子朝向,两个解剖上属于同一脑区、活动完全同步的源点,可能因为朝向相反而解出符号相反的时间序列。直接取平均,两者互相抵消,剩下的是一根幅值很小、相位混乱的线——然后你会看到这个 ROI 跟谁都不连,或者跟谁都乱连。

合格的实现通常这么做:先在整个 ROI 里挑能量最大的源点作为极性参考,再把每个源点与它做内积,结果为负就整体翻号,对齐之后再平均。下面这段是核心逻辑:

# src_ts: 形状 (n_src_in_roi, n_times),逆投影后属于同一个 ROI 的源时间序列 # 目标:合成一根无极性抵消的 ROI 代表序列 def roi_time_series(src_ts): # 1. 选能量最大的源点当极性参考,避免随机选到噪声主导的源 energy = (src_ts ** 2).sum(axis=1) ref = src_ts[energy.argmax()] aligned = [] for s in src_ts: # 2. 与参考做内积,负相关说明偶极子朝向相反,整体取反 if (s * ref).sum() < 0: s = -s aligned.append(s) # 3. 极性统一之后再做平均,幅值才不会被抵消 return sum(aligned) / len(aligned)

energy.argmax()挑的是幅值最强的源点,理由是这样一根序列的信噪比最高,拿它当符号基准比拿随机源稳定得多;(s * ref).sum() < 0是极性判据,等价于相关系数为负,计算量是 O(n_times),对上千个源点也不慢;最后的sum(...) / len(...)是等权平均,也有人用第一主成分代替,但主成分的符号本身是随机的,还得再做一次对齐,而且它会把强源的权重放大,空间特异性反而下降。

还有两个容易忽略的参数。一是图谱的配准方式:把模板图谱硬套到个体头上,和先做个体化配准再映射,得到的 ROI 边界能差出一大截,尤其是内侧结构和颞叶。二是概率图谱的阈值:阈值调高,ROI 内源点变少,平均后噪声更大;阈值调低,ROI 会溢到邻近区域,空间特异性变差。这两个方向都不“更对”,只有跟你的科学问题匹配不匹配。

2.3 采样与窗口:连接不是瞬时量,它需要一个时间尺度

到这一步,手里是n_roi × n_times的矩阵,采样率还停留在原始数据的几百赫兹。直接拿去算连接是没意义的,因为连接描述的是两段信号在某个时间尺度上的统计关系,必须有窗口。

常见的两条路子。一条是先带通到目标频段(比如 8–12 Hz),再做 Hilbert 变换取包络,把包络降采样到 50–100 Hz,然后在包络上算相关或相干——这条路适合描述“两个区的能量起伏是否同步”。另一条是不取包络,直接在滑动窗口里对原始信号做频谱估计,再算交叉谱——这条路保留了相位信息,wPLI 这类度量必须走它。

窗口长度不是随便定的,它受时频不确定性约束:频带宽度B和窗口长度T大致满足T ≈ 1/B的量级关系。想分析 2 Hz 宽的窄带,窗口就得接近 500 ms;窗口取 200 ms,频率分辨率只剩 5 Hz,你根本分不清 8 Hz 和 12 Hz 的活动。更麻烦的是自由度:一个窗口内的有效独立样本数大约是2·T·B,T=0.5 s、B=2 Hz时只有 2 个自由度,单个窗口的连接估计几乎全是噪声,必须跨 trial 或跨窗口平均。

注意:降采样要在带通之后做,顺序反了会把高频折叠进低频,包络里混进一堆原本不存在的慢波。

3. 零延迟是问题关键:相干、PLI、wPLI 之间的取舍

连接度量有一大类翻车,不是因为代码写错了,而是因为选了一个对零相位差没有抵抗力的量。要理解这件事,得先看清楚体积传导是怎么凭空制造出“强连接”的。

3.1 体积传导如何制造出并不存在的连接

把观测写成V = M·s,M是混合矩阵,s是真实源。假设两个源s1、s2完全独立,但它们在头皮上的投影有重叠,那么电极x记录到a1·s1 + a2·s2,电极y记录到b1·s1 + b2·s2。两个电极的观测信号里都含有s1的成分,相干自然不为零,而且这个虚假的相干有个标志性特征:相位差接近 0。头皮是导体,电磁波传播在这个尺度上的延迟可以忽略,一个源到两个电极几乎是同时到达。

所以判据很直接:如果一个度量对零相位差敏感,它在 EEG 上就不可信。幅值平方相干就是典型,它对零延迟完全无免疫,相邻电极之间的相干常年被抬到 0.6 以上,而真实的功能连接往往只有 0.1 量级。这也是为什么在 EEG 上谈连接,几乎必须在源空间做,而且即便到了源空间,逆问题带来的空间模糊依然会残留一部分零延迟成分。

3.2 三种去零延迟的思路与它们的代价

第一类是只取交叉谱的虚部。设Sxy(f)是x、y在频率f上的交叉谱,零延迟成分只贡献实部,虚部对应的是有相位滞后的成分,于是虚部相干天然把体积传导压下去了。代价是方差变大,而且当真实连接的相位差接近 0 或 π 时,虚部本身接近 0,真信号也被一起丢掉。

第二类是相位滞后指数,只看相位差的符号:PLI = |E[sign(Δφ)]|。它把每个样本的相位差压成 ±1,完全无视幅值。好处是对幅值噪声不敏感,坏处同样来自这一点——一段几乎没有连接、只有随机小抖动的数据,符号照样是 ±1,PLI的期望不是一个稳定的零,而是一个随样本数衰减的正偏置,量级大概在√(2/(πN))。trial 数 40 的时候这个偏置约 0.13,和很多真实效应一样大。

第三类是加权相位滞后指数,它保留了虚部的幅值作为权重:

# X, Y: 形状 (n_trials, n_times) 的两根 ROI 时间序列,已带通、已去均值 # fft_band() 返回目标频带内的复系数,形状 (n_trials, n_freq) def wpli(X, Y): zx = fft_band(X) zy = fft_band(Y) # 交叉谱的虚部,对应有相位滞后的那一部分能量 im = (zx * zy.conj()).imag # 分子:先跨 trial 平均再取模,保留符号一致性 num = abs(im.mean(axis=0)) # 分母:先取模再平均,作为总能量归一化 den = abs(im).mean(axis=0) # 平方形式,取值落在 [0, 1],避免分母趋零时的数值抖动 return (num ** 2) / (den ** 2 + 1e-12)

分子分母的顺序是整个度量的灵魂。num先平均再取模,如果各 trial 的相位滞后方向一致,平均之后不会互相抵消,值就大;den先取模再平均,衡量的是“总的虚部能量有多少”。两者的比值本质上是在问:这些有相位滞后的能量里,有多大比例是方向一致的。随机噪声在分子里会互相抵消,所以 wPLI 在无连接数据上的正偏置比 PLI 小得多。

提示:1e-12不是为了好看,分母接近零时(比如某个频点几乎没有能量)会直接给你 inf 或 nan,这个保护必须留。

代价也要说清楚。wPLI 对 trial 数依然敏感,trial 少的时候估计的方差大、系统性偏高;它对相位差接近 0 的真实连接同样不敏感,因为那部分能量被虚部丢掉了。所以它不是“更好的 PLI”,而是“在零延迟污染和统计偏置之间换了一个平衡点”。

度量对零延迟的处理无连接时的偏置对 trial 数的敏感度主要短板
幅值平方相干无很高,体积传导直接抬高低无法区分真假连接
虚部相干只保留虚部低但方差大高相位差接近 0 或 π 时丢真信号
PLI只看相位差符号正偏置约√(2/(πN))中微小幅值也计满,噪声敏感
wPLI按虚部幅值加权明显更小高分母趋零时不稳定

3.3 有向连接:什么时候值得多花这一步

wPLI、PLI、虚部相干都是无向的,它们只知道“这两个区有关系”,不知道谁先谁后。想看方向就得上 Granger 因果或转移熵,前者基于 VAR 模型,后者基于状态转移概率。在 ROI 级 EEG 上做有向分析有两个硬门槛:一是采样率,VAR 模型对时间分辨率敏感,降采样太狠会把滞后结构压没;二是样本量,N个 ROI 的 VAR 模型参数量随N²增长,几十个 ROI、几百个时间点的情况下,过拟合几乎是必然的。

比较务实的做法是分两步:先用 wPLI 这类无向度量在全脑范围筛出候选边,再对少数几对 ROI 单独做有向分析,并配 surrogates 检验。反过来,一上来就对整张矩阵跑有向连接,得到的箭头方向大概率是噪声拟合出来的。

4. 把结果压进一个 HTML:单文件报告的取舍

分析跑完,交付物是一个.html文件,双击就能打开,里面有热图、有网络图、有阈值滑块,断网也能用。这个选择看着像是纯工程问题,实际上它反过来约束了分析阶段的数据结构。

4.1 为什么是单文件,而不是一堆图和 CSV

理由不是“好看”,是可复核。连接分析的报告要给不装环境的人看,要给几个月后的自己看,还要能被别人拿着原始数据重跑一遍做对照。散装交付(一张 PNG 热图、一份 CSV 矩阵、一份 README 记录参数)在这三件事上都会掉链子:图片不能查数值,CSV 不知道对应哪次运行,README 里的参数和实际跑的那次未必一致。

单文件的好处是把数据和视图绑死:矩阵、ROI 名称、频带、窗口长度、trial 数、正则化参数、生成时间,全在同一个文件里,改了任何一个都能看出来。代价也很实在:文件体积随 ROI 数的平方增长,浏览器渲染能力有限,为了压体积必然要做精度或分辨率上的让步。这些让步必须有记录,否则报告就变成了新的黑匣子。

4.2 数据载荷:矩阵怎么塞进 HTML 而不把它撑大

ROI 数 60 的时候,一个 60×60 的 float64 矩阵是 28 KB,压缩后更小;但真实报告里往往不止一张矩阵——多个频段、多个被试、多组对比,加起来的体积是乘法关系。三个常规手段:只存上三角(对称矩阵省一半)、定点量化(float32 转 int16,精度 1e-4 足够显示)、按需下采样(视图用低分辨率,原始矩阵单独存)。

import base64 import numpy as np def build_payload(conn, labels, meta, q=4): # 连接矩阵对称,只取严格上三角,体积直接减半 iu = np.triu_indices_from(conn, k=1) vals = conn[iu] # 定点量化:float 乘 10^q 取整存成 int16,精度 1e-q scale = 10 ** q qvals = np.round(vals * scale).astype(np.int16) return { "meta": meta, # 频带、窗口、trial 数、逆算子参数等 "labels": labels, # ROI 名称,顺序即矩阵下标 "edges": base64.b64encode(qvals.tobytes()).decode(), "scale": scale, # 解码时用来还原浮点值 "n": len(labels), # 前端重建方阵需要 }

np.triu_indices_from(conn, k=1)拿到上三角的行列索引,k=1表示排除对角线——自连接恒为 1,存了也是浪费。10 ** q里q=4表示保留四位小数,对 0–1 范围的连接值绰绰有余;超过 32767 会溢出,所以q不能随便往 5、6 调。base64编码会让体积涨约三分之一,但换来的是纯文本可嵌入、不会被 HTML 转义破坏,比直接写二进制安全。

解码端做三件事:atob拿到二进制串、Int16Array视图还原整数、除以scale还原浮点,再按上三角索引填回方阵。整个过程在前端几十毫秒内完成,不需要任何后端。

4.3 渲染层:canvas 画矩阵,SVG 画网络

60×60 的热图是 3600 个格子,用 DOM 元素画会直接卡死,必须用 canvas:一次性把矩阵画到离屏画布,缩放和 hover 只做坐标映射,不重绘。网络图反过来,节点通常只有几十个,边上要挂 hover 事件、要拖动、要高亮邻居,用 SVG 更省事,每个<circle>和<line>都是独立元素,事件委托一行就能写。

视图参数和分析参数必须分开,这是单文件报告里最容易被混淆的边界。阈值滑块调的是“显示哪些边”,它不应该改变矩阵里的任何数值,也不应该影响报告里记录的统计结论。可行的做法是在meta里写死两套字段:analysis下面放不可变的参数(频带、窗口、trial 数、置换次数、显著性校正方式),view下面放可变的(当前阈值、颜色映射范围、选中的 ROI)。导出截图或者分享时,只带analysis。

注意:滑块拖动时不要重算矩阵。把阈值当过滤条件在渲染层做,一次遍历边列表就够,重算会把交互拖成幻灯片。

5. 踩坑记录:五条来自真实排查现场

5.1 配准与标签类

现象:同一批被试,左半球某 ROI 的连接值系统性偏高,右半球对应区域几乎为零,跨被试一致得像真的效应。

原因:电极坐标和模板空间用了两套不同的坐标约定(一套是影像学方向,一套是头表方向),配准矩阵里少了一次左右翻转。ROI 标签本身没错,错的是电极被映射到了镜像位置。

解决:在流水线里加一步独立的坐标一致性检查——把配准后的电极位置画到模板头皮上,人工看一眼鼻尖和左右耳前点是否落在预期位置。这一步花两分钟,能省掉两周的返工。

现象:个别被试的某些 ROI 时间序列接近常数,方差几乎为零,连接值全是 nan。

原因:概率图谱的阈值定得太严,加上个体配准偏差,该标签下没有落进任何源点,提取环节返回了空集或者全零。

解决:提取时对每个标签检查源点数量,低于阈值就退化到“取最近源点”而不是静默返回零;同时在报告meta里记录每个 ROI 的源点数,让这类问题在交付时就暴露出来。

5.2 度量与统计类

现象:两组被试的 PLI 做独立样本 t 检验,多处“显著”,但把两组标签打乱重跑,显著性几乎不变。

原因:PLI 在无连接数据上有正偏置,而且这个偏置随 trial 数变化。两组 trial 数不一致时,偏置差异直接被当成了组间差异。

解决:不要对原始 PLI 做参数检验。用置换检验,并且用相位随机化的 surrogates 估计每个被试自己的零分布,报告里给出“观测值减零分布均值”的校正后指标,而不是原始值。

现象:trial 数从 80 降到 30 之后,全脑平均连接强度涨了一截,看起来像是“任务难度提高了”。

原因:wPLI 是小样本偏高的估计量,trial 少的时候方差大、期望上移。

解决:把 trial 数作为协变量记录进meta,跨条件比较时保持 trial 数一致(随机下采样到最小值),并在报告里显式标注这一处理。

5.3 报告与交付类

现象:HTML 报告在生成机上打开正常,发给合作方之后浏览器直接无响应,文件 200 MB 出头。

原因:把每个被试、每个频段、每个窗口的完整连接矩阵都以 float64 塞进了 payload,还带上了所有 trial 级别的中间结果。

解决:payload 只保留可视图层需要的数据(上三角 + 定点量化),trial 级中间结果单独存成二进制附件;单文件超过 20 MB 就该拆分了。

6. 设计取舍:可复现和可交互之间的那条线

回到最初那个问题:一份 ROI 连接报告要可信,靠的到底是什么。做过几轮之后我的判断是,靠的不是度量选得有多新,而是每一个数字都能被追溯到一个明确的参数组合。wPLI 比 PLI 抗噪,但如果报告里没写清窗口长度、trial 数、图谱阈值、逆算子的正则化系数,换个人重跑一遍照样得到不一样的结果,这时候度量再讲究也没用。

所以设计上有一条我越来越坚持的线:凡是影响数值的东西,一律固化进 payload 的analysis字段,且渲染层只读不写;凡是只影响看的方式,全部归到view,随便拖。这条线的好处是,一份报告同时满足两个互相拉扯的需求——合作方能拖着阈值滑块自己探索,你自己能在三个月后拿着同一份文件复现出完全相同的矩阵,因为矩阵本身根本没被交互碰过。

验证这件事有个笨办法,但很有效:同一份原始数据,跑两次流水线,把两次生成的 payload 里的edges字段解码出来逐元素比对。差异应该是严格为零。如果出现了 1e-6 量级的漂移,多半是某处用了未固定种子的随机过程(比如 trial 级的下采样、或者 surrogates 的抽样顺序),这类不确定性必须在meta里显式声明,而不是让它悄悄混进结果。

再往上一层是精度和体积的取舍。定点量化到四位小数,对连接值的显示完全够用,但如果有人想拿这份 HTML 里的数据去做二次统计,四位小数可能不够。我的处理是分层:HTML 里存量化后的可视图层,同时用meta里的一个字段指向原始精度数据的校验值(比如哈希),需要精确复算的人拿原始数据和参数自己重跑,报告只负责自证“我这次跑的是什么”。这样既不把文件撑爆,也不给“报告里的数和论文里的数对不上”留后门。

进阶一点的延伸,是把 surrogates 的零分布也塞进报告。现在大多数报告只给观测值和 p 值,读者没法判断这个 p 值是置换 200 次得到的还是 5000 次得到的,也看不出观测值离零分布有多远。把零分布的直方图和观测值标在同一条数轴上,一眼就能看出效应是“稳稳在分布之外”还是“擦着尾巴过去的”。代价是 payload 又要大一圈,但如果只对显著的边存零分布的分位数(比如 5%、50%、95%),体积增加很有限,信息量却翻倍。

踩过的坑里,最贵的一次是拿一份没记录图谱阈值的报告去做了组间比较,两组用了不同阈值,差异显著得离谱,查了两周才发现问题出在提取环节而不是统计环节。从那以后我每次生成报告,都会先打开 HTML 检查analysis字段是否齐全——频带、窗口、trial 数、图谱阈值、逆算子参数、随机种子,缺一项就重新跑,不管结果看起来多合理。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进