基于 FieldTrip 的置换检验与统计绘图
脑电统计的终极难题不是算出 t 值,而是"一次实验做了几万次检验"。 这一课把论文里最常见的 cluster 置换检验,拆到能讲给师弟师妹听的程度。
🎯 本节学习目标
- 算得出自己数据的检验次数,说清为什么逐点检验会让假阳性失控
- 能用"洗牌发牌"的比喻向别人讲明白置换检验与零分布
- 拆解 cluster-based 置换检验的四个步骤,解释为什么校正发生在 cluster 而不是单个点上
- 独立配置
ft_timelockstatistics的完整 cfg:design、ivar/uvar、neighbours、numrandomization 一项不落 - 会用
ft_freqstatistics做时频统计、用ft_clusterplot与显著蒙版出图,并按规范报告结果
一、脑电统计的核心矛盾:一次实验,上万次检验
模块 3 你画过两个条件的 ERP 差值图,肉眼看到 P3 更负;模块 4 你看过两块时频图,肉眼觉得 alpha 能量不同。但"肉眼显著"到"统计显著"之间隔着一道鸿沟,而且这道鸿沟比多数新手想象的深得多。
算一笔账:64 通道、500 Hz 采样、分析窗口 -0.2 到 1.0 秒(601 个时间点)。 如果你逐个"通道 × 时间点"做检验,一共要做 64 × 601 = 38464 次。 就算两个条件的数据完全来自同一个分布(真实差异为零),按每次 α = 0.05 算, 期望也会有约 1923 个"显著"点——它们全是噪声,却会画满你的显著图。 加上时频的频率维度,检验次数轻松冲上十几万。
至少犯一次假阳性错误的概率。单次检验 α=0.05 不可怕;可怕的是四万次里"至少错一次"的概率——它几乎等于 1。脑电统计的全部目标,就是在做完这四万次比较之后,把 FWER 依然压在 0.05 以内。
四种常见应对方式放在一起看,就知道为什么脑电界最后集体选了最后一种:
| 方法 | 怎么做 | 结果 | 脑电适用性 |
|---|---|---|---|
| 不校正 | 逐点 α = 0.05 | 假阳性失控(数千个假显著点) | 只能探索,不能进论文 |
| Bonferroni | α 除以检验次数 | 过度保守,真实效应也几乎必死 | 把相邻点当独立,不推荐 |
| FDR | 控制错误发现的比例 | 介于两者之间 | 可用,但同样忽视时空相关 |
| cluster-based | 按时空邻接聚类 + 置换检验 | FWER 控制好、检验力高 | 脑电/MEG 论文的标准配置 |
Bonferroni 的问题在于它假设四万次检验彼此独立——但脑电相邻时间点的相关高达 0.9 以上, 相邻通道也高度相关。真正"独立"的检验远没有四万次。cluster-based 方法干脆承认 这种相关结构,把它变成优势:显著的效应从来不是孤立的一个点,而是连成片的时空区域。
二、置换检验的直觉:洗牌发牌
先把 cluster 放一边,讲它的地基——置换检验。想象你怀疑牌局有鬼: 某位玩家这几局赢得太多。怎么判断是实力还是运气?把整副牌重新洗 10000 次、重新发 10000 次, 数一数"纯运气"下能出现多大的领先——这就得到了零分布。 如果真实牌局的领先幅度超过了其中 99% 的洗牌结果,那基本不是运气能解释的。
换到脑电上完全一样:零假设是"条件标签与数据无关"——同一个被试的两组数据, 标签换一下同样说得通。于是:
- 按真实标签算一次统计量(如配对 t);
- 随机打乱标签,重新算一次;
- 重复 1000 次,得到零分布;
- p 值 = 零分布中比真实值更极端的比例。数据自己告诉你什么是显著,不需要任何正态分布假设。
% 手工置换检验:20 个被试的条件差值(想象成 B-A 的 alpha 功率)
rng(1); % 固定随机种子,结果可复现
diff = 0.5 + randn(20, 1); % 真实均值 0.5
t_obs = mean(diff) / (std(diff) / sqrt(numel(diff))); % 单样本 t
% 零假设:条件无差别 → 每个差值的正负号可以随便换
nPerm = 1000;
t_null = zeros(nPerm, 1);
for p = 1:nPerm
flip = randi([0 1], numel(diff), 1) * 2 - 1; % 随机生成 +1/-1
d = diff .* flip;
t_null(p) = mean(d) / (std(d) / sqrt(numel(d)));
end
% p 值 = 零分布中与观察值同样极端的比例
pval = sum(abs(t_null) >= abs(t_obs)) / nPerm;
fprintf('观察 t = %.2f,置换 p = %.3f\n', t_obs, pval);
% 画出零分布,标出真实值——显著 = 真实值落在分布的尾巴上
figure; histogram(t_null, 40); hold on;
xline(t_obs, 'r', 'LineWidth', 2); % 需要 R2018b+
这个一维小例子就是 5.2 全部内容的缩影:脑电的真实场景只是把"一个数"换成 "通道 × 时间(× 频率)的矩阵",把"打乱标签"交给 FieldTrip,其余逻辑分毫未变。 注意 p 值的分辨率由置换次数决定:1000 次置换时最小 p 值约为 1/1001, 想报告 p < 0.001 就得加码(见第八节)。
三、cluster-based 置换检验:四步拆解
现在把"多重比较"和"置换检验"组装起来,就是论文里最常见的 cluster-based permutation test:
① 逐点检验:每个 (通道, 时间点) 上算一次 t 值 ② 阈值聚类:t 值超过 cfg.clusteralpha 的点,按"时间相邻 + 空间相邻"连成 cluster ③ 取统计量:对每个 cluster 求 t 值之和,只保留所有 cluster 中的最大值 ④ 置换比较:打乱标签重复 ①-③,用"最大 cluster 统计量"的零分布给真实值算 p
可以把聚类想成把散落的珠子串成项链:过阈值的数据点是珠子,邻接关系是线, 一整条项链的"重量"(t 值之和)才是被检验的对象。一条又长又重的项链, 远比一颗孤零零的大珠子更难用噪声解释。
Bonferroni 把四万次检验当独立的来罚,真实效应被一起处决。cluster 法只做一次推断——"数据里是否存在一个这么大的 cluster"——用置换分布直接控制这一次推断的 FWER,因此既压得住假阳性,又不丢检验力。代价见第九节:它能告诉你"有效应",但不太能告诉你"效应恰好在这几个电极、这几毫秒"。
两个容易误解的参数,先划重点:
cfg.clusteralpha(默认 0.05)只是珠子入串的门槛:阈值松则 cluster 大而少、紧则小而碎,它不影响最终的假阳性率——假阳性由置换次数和第④步的比较控制;cfg.minnbchan(常用 2)要求 cluster 至少含 2 个邻接通道,防止单个噪声通道自成一簇。
这套方法的完整理论出处是 Maris & Oostenveld(2007, Journal of Neuroscience Methods),也是 FieldTrip 官方教程反复引用的文献——写论文时值得放进参考文献列表。
四、邻接结构:ft_prepare_neighbours
第②步"空间相邻"需要一个邻接表:谁和谁算邻居。FieldTrip 用 ft_prepare_neighbours 生成,
常用两种方法:
% 方法一:模板法——用官方内置的标准邻接表(推荐,最省心)
cfg = [];
cfg.method = 'template';
cfg.template = 'elec1020_neighb.mat'; % 10-20 系统;10-10 帽子用 elec1010_neighb.mat
cfg.channel = 'EEG';
neighbours = ft_prepare_neighbours(cfg);
% 方法二:距离法——按电极物理距离定义(非标准电极帽时用)
cfg = [];
cfg.method = 'distance';
cfg.neighbourdist = 0.04; % 40 mm 以内算邻居(默认值)
neighbours = ft_prepare_neighbours(cfg, data); % data 自带电极坐标
% 一定要画出来检查:孤岛通道进不了任何 cluster,统计力白白流失
cfg = [];
cfg.neighbours = neighbours;
cfg.layout = 'elec1005';
ft_neighbourplot(cfg, data); % enableedit 设为 yes 还能交互增删邻接边
模板法的前提是电极名规范(又是模块 2.2 强调过的那件事:Fp1、Cz、O2……);
距离法需要数据里带电极坐标(data.elec),40 mm 这个默认值对多数脑电帽都合理。
无论用哪种,都请用 ft_neighbourplot 画出来看一眼——邻接结构错了,cluster 就错了。
五、ft_timelockstatistics 完整实战
万事俱备。输入是 5.1 批处理保存的每被试、每条件 timelock 结构——先把它们合并成 保留个体值的组结构(统计需要每个被试的 ERP,不能只要组平均),再配统计:
%% 第 0 步:准备输入——保留每个被试个体值的组结构
cfg = [];
cfg.keepindividual = 'yes';
gaA = ft_timelockgrandaverage(cfg, tlkA{:}); % tlkA 来自 5.1 的批处理
gaB = ft_timelockgrandaverage(cfg, tlkB{:});
%% 第 1 步:设计矩阵——统计的"户口本"
subj = numel(tlkA);
design = zeros(2, 2*subj);
design(1,:) = [ones(1,subj) 2*ones(1,subj)]; % 第 1 行:条件标签(1=A,2=B)
design(2,:) = [1:subj 1:subj]; % 第 2 行:被试编号
%% 第 2 步:完整统计 cfg
cfg = [];
cfg.channel = 'EEG';
cfg.latency = [0 1]; % 检验空间限定在刺激后 0-1 s(按先验假设圈,别圈全段)
cfg.method = 'montecarlo'; % 蒙特卡洛置换检验
cfg.statistic = 'ft_statfun_depsamplesT'; % 被试内配对 t
cfg.correctm = 'cluster'; % cluster 校正解决多重比较
cfg.clusteralpha = 0.05; % 聚类阈值(只影响 cluster 形状)
cfg.clusterstatistic = 'maxsum'; % cluster 统计量:簇内 t 值之和的最大值
cfg.minnbchan = 2; % cluster 至少含 2 个邻接通道
cfg.neighbours = neighbours; % 空间邻接结构(上一节准备的)
cfg.numrandomization = 1000; % 置换次数:论文级 1000 起步
cfg.tail = 0; % 双侧检验:不预设方向
cfg.design = design;
cfg.ivar = 1; % 条件在第 1 行
cfg.uvar = 2; % 被试在第 2 行
stat = ft_timelockstatistics(cfg, gaA, gaB);
%% 第 3 步:看看输出里有什么
stat.stat % (通道 x 时间) 的 t 值
stat.prob % 每个数据点的置换 p 值
stat.mask % 显著性蒙版(0/1 逻辑矩阵),绘图直接用
stat.posclusters % 正向 cluster 列表,每个元素的 .prob 是它的 p 值
stat.negclusters % 负向 cluster 列表
cfg 字段一多容易晕,按"四件事"归类记忆:
| cfg 字段 | 示例值 | 管什么事 |
|---|---|---|
cfg.method | 'montecarlo' | 用蒙特卡洛近似置换分布 |
cfg.statistic | 'ft_statfun_depsamplesT' | 逐点统计量:被试内配对 t(被试间设计换 ft_statfun_indepsamplesT) |
cfg.correctm | 'cluster' | 多重比较校正方式(也可 no / max / bonferroni / fdr) |
cfg.clusteralpha / clusterstatistic / minnbchan | 0.05 / 'maxsum' / 2 | cluster 怎么形成、怎么算统计量 |
cfg.neighbours | 结构体 | 空间邻接关系,聚类的骨架 |
cfg.numrandomization / tail / alpha | 1000 / 0 / 0.05 | 置换次数与检验方向、显著性水平 |
cfg.design + ivar + uvar | 见上面代码 | 设计矩阵:条件在哪行、被试在哪行 |
设计矩阵是最常见的翻车点:ivar 指条件所在行,uvar 指被试所在行; 条件标签必须是 1 和 2,被试编号是 1 到 N 的整数。报错"design 与数据不匹配"时, 先数 design 的列数是否等于"被试数 × 条件数"。
六、时频统计:ft_freqstatistics 同套路
时频数据的统计零新知识:同一个 cfg 换个函数名,输入换成频域的组结构,
再多一个频率窗 cfg.frequency:
% 输入:5.1 批处理算好的每被试 TFR,先合并成保留个体值的组结构
cfg = [];
cfg.keepindividual = 'yes';
gaTFR_A = ft_freqgrandaverage(cfg, tfrA{:});
gaTFR_B = ft_freqgrandaverage(cfg, tfrB{:});
% 统计 cfg 与 ERP 版完全同一套路
cfg = [];
cfg.method = 'montecarlo';
cfg.statistic = 'ft_statfun_depsamplesT';
cfg.correctm = 'cluster';
cfg.clusteralpha = 0.05;
cfg.clusterstatistic = 'maxsum';
cfg.minnbchan = 2;
cfg.neighbours = neighbours;
cfg.numrandomization = 1000;
cfg.tail = 0;
cfg.design = design; % 与 ERP 统计用的是同一个 design
cfg.ivar = 1;
cfg.uvar = 2;
cfg.channel = 'EEG';
cfg.frequency = [4 30]; % 时频统计专属:限定检验的频率窗 (Hz)
cfg.latency = [0 1]; % 限定时间窗 (s)
stat_tfr = ft_freqstatistics(cfg, gaTFR_A, gaTFR_B);
唯一的新东西是聚类多了一个频率维度:相邻频率bin的点也会被串进同一条项链
(时空谱聚类)。检验空间从"通道 × 时间"变成"通道 × 频率 × 时间"——这也是第一节那笔账
涨到十几万次的原因,也是为什么 cfg.frequency 和 cfg.latency
要按先验假设圈定、而不是全段全频扫。
七、统计绘图:ft_clusterplot 与显著蒙版
统计结果有两类画法。ft_clusterplot 一键出图:按时间点(或频率bin)画一排地形图,
把显著 cluster 高亮标号,适合快速浏览和组会汇报;显著蒙版叠加则把
stat.mask 作为透明度蒙版画在自己指定的图上,是论文配图的主力:
%% 方法一:ft_clusterplot 一键出图
cfg = [];
cfg.layout = 'elec1005';
cfg.alpha = 0.05; % 只画 p 小于 0.05 的 cluster(上限 0.3)
ft_clusterplot(cfg, stat); % 时频结果同理:ft_clusterplot(cfg, stat_tfr)
%% 方法二:差值地形图 + 显著蒙版(论文常用)
% 1) 算两条件的组平均差:x1 - x2
cfg = [];
cfg.operation = 'x1-x2';
cfg.parameter = 'avg';
gaDiff = ft_math(cfg, gaA, gaB);
% 2) 把置换检验的显著蒙版挂到差值结构上(0=不显著,1=显著)
gaDiff.mask = stat.mask;
% 3) 画图:不显著的电极自动变透明,一眼看出"效应长在哪里"
cfg = [];
cfg.layout = 'elec1005';
cfg.parameter = 'avg';
cfg.maskparameter = 'mask'; % 引用刚才挂上的蒙版字段
cfg.xlim = [0.3 0.5]; % 显示 300-500 ms 窗口
cfg.zlim = 'maxabs';
cfg.colorbar = 'yes';
ft_topoplotER(cfg, gaDiff);
时频版同理:用 ft_math 算两条件的 TFR 差值,把 stat_tfr.mask 挂上去,
ft_singleplotTFR(单通道时频图)和 ft_topoplotTFR(频窗地形图)
都支持 cfg.maskparameter。图注务必写清蒙版的含义(如"透明区域未通过 cluster
校正的置换检验,α = 0.05")。源空间的统计绘图(ft_sourceplot)属于溯源,模块 7 再讲。
八、结果怎么报告:置换次数、cluster 统计量、校正逻辑
审稿人最常追问的就是这几项,投稿前对照自查:
- 统计类型与设计:依赖样本 t(被试内)还是独立样本 t(被试间);检验方向(双侧/单侧);
- cluster 参数:cluster 形成阈值(clusteralpha)、cluster 统计量(maxsum)、最小邻接通道数;
- 置换次数:numrandomization = 多少次。p 值分辨率 = 1/(次数+1),报告 p < 0.001 至少要 1000 次;
- 邻接定义:模板法(哪个模板)或距离法(多少 mm);
- 检验空间:纳入的时间窗、频率窗、通道集合——它必须由先验假设决定;
- 结果表述:显著 cluster 的 p 值(保留三位小数)、簇的时空范围用"描述"口吻给出。
"采用基于 cluster 的非参数置换检验(依赖样本 t 统计量;cluster 形成阈值 α = 0.05;cluster 统计量为簇内 t 值之和的最大值 maxsum;1000 次随机置换;双侧检验,α = 0.05;通道邻接关系采用标准 10-20 系统模板)。在 0–1 s 的检验空间内,条件 A 相对条件 B 在约 300–500 ms 出现一个显著的正向 cluster(p = 0.003,峰值位于中央顶区)。"
九、常见误区:cluster 检验不是免检金牌
- 先看数据再定检验方向:画完图挑个方向明显的效应再跑单侧检验——这是变相的两次检验,假阳性翻倍。没有预注册的把握,一律双侧;
- 把 cluster 当定位证据:检验的结论是"存在效应",不是"恰好这几个电极、这几毫秒显著"。显著 cluster 的边界受噪声影响很大,描述可以,推断不行;
- 置换次数太少:100 次置换时 p 值分辨率只有 0.01,"p = 0"其实只是小于 0.01。论文级一律 1000 次起步,谨慎者 5000;
- 用错统计函数或指错行:被试内设计必须用 depsamplesT(被试间用 indepsamplesT),ivar/uvar 指错行会直接报错或悄悄算错;
- 邻接结构不检查就开跑:改名后的电极进不了模板、孤岛通道进不了 cluster——统计力在你看不见的地方流失。
置换检验对"分析自由度"没有免疫力:看完结果再换时间窗、换频率范围、换参考,每改一次就多挖了一次假阳性的机会。把分析方案(窗口、方向、参数)写在跑统计之前,是比任何统计方法都强的防身术。
下一模块(模块 6)我们从"每个通道自己的活动"走向"通道之间的对话"——功能连接与相位同步。
你会发现本课的 cfg 哲学一路通用:连接指标用 ft_connectivityanalysis 算,
统计照旧用置换检验。
📌 本节小结
- 多重比较是脑电统计的核心矛盾:64 通道 × 601 时间点约 3.8 万次检验,零假设下也会有近两千个假显著点
- 置换检验不依赖分布假设:打乱标签构造零分布,让数据自己定义显著性
- cluster 校正一次推断管住全部检验空间:clusteralpha 只影响聚类形状,不改变假阳性率
- 邻接结构是聚类的骨架:
ft_prepare_neighbours模板法或距离法,用ft_neighbourplot检查 - 置换次数、检验方向、cluster 统计量都要事先定好并如实报告;cluster 位置只能描述、不能当定位证据
✏️ 课后练习
- 用 20 行以内的 Matlab 手工做一次置换检验:两组各 20 个随机数,打乱标签 1000 次,画零分布直方图并标出真实 t 值的位置。
- 对 5.1 批处理保存的多被试数据跑一次完整的 cluster-based 置换检验,把全部 cfg 抄进脚本注释,保存 stat 结构并核对
stat.mask的尺寸。 - 把
numrandomization分别设成 100 和 1000 各跑一遍,对比同一数据 p 值的波动,用两三句话总结"置换次数应该报多少"。