用Friedman+Holm检验搞定多模型对比的显著性分析(附代码)

发布时间:2026/10/9 8:45:38
用Friedman+Holm检验搞定多模型对比的显著性分析(附代码)
我做完一版多模型对比实验后最怕面对的就是“到底哪个模型更好”这个问题。不是怕跑代码而是怕结论被人一追问就站不住脚。你算完五个模型在十个数据集上的AUC均值发现XGBoost平均排名第一然后呢这个“第一”是真实优势还是某个数据集上天时地利的结果如果审稿人问你p值是多少你怎么回答这时候Friedman检验和Holm检验就必须上场了。这套组合拳是机器学习论文里做多个分类器、多个数据集对比时的标准统计方案分别针对Sensitivity、F-measure、G-mean、AUC这类指标核对性能差异是否显著。这篇把我实际跑通这套流程的经验完整拆出来从原理到代码再到各种容易翻车的细节争取你读完就能直接用。1. 为什么模型对比不能只看平均排名先说你每天大概率在做的事把五个模型在十个数据集上跑一遍每个数据集算出一个AUC然后横向求平均按平均值排名谁高谁就赢。这方法本身没错它有信息量但它没法回答统计意义的问题。平均值差0.002可能只是随机波动平均值差了0.05也可能因为某个数据集特别极端拉高了整体。统计检验存在的意义就是帮你在这些数字差异里分辨出“真实差异”和“噪声”。1.1 平均排名会掩盖什么我见过最典型的一个案例有个同事把六个分类器在八个UCI数据集上做对比最后按F-measure平均值排序两个模型只差0.001他下结论说A显著优于B。但把每个数据集上的排名展开看A在八个数据集上赢了四个、平了一个、输了三个根本没有稳定优势。Friedman检验对这种情况特别敏感因为它不关心你具体数值差多少它关心的是在每一个数据集上各个模型的相对排名是否稳定一致。这里有个容易误解的点平均秩和平均分数不是一回事。举个例子三个数据集上两个模型的F-measure分别如下。数据集1A0.90B0.80数据集2A0.85B0.88数据集3A0.79B0.95。算平均分A是0.847B是0.877B更高。但看排名A的秩分别是1、2、2平均秩1.67B的秩分别是2、1、1平均秩1.33B更好。虽然结论一致但换个例子结论就可能分歧A可能平均分高但在多数数据集上排名第二B平均分稍低但稳定赢。秩检验把这些问题都摆到台面上来。1.2 Friedman检验到底在测什么Friedman检验是一种非参数检验适用场景是你有k个相关样本这里的k个模型在n个区组n个数据集上做了配对观测想判断这k个总体是否存在显著差异。它不像ANOVA那样要求数据正态分布、方差齐性所以非常适合机器学习实验——你那些数据集往往来自不同分布指标分布也千奇百怪。检验逻辑可以理解为如果所有模型水平一样那么每个模型在每个数据集上的排名应该是随机的长期来看平均秩应该差不多大约在(k1)/2附近。如果某些模型的平均秩明显偏离这个中心值Friedman统计量就会变大p值变小。1.3 为什么要“分别对每个指标”做检验因为Sensitivity、F-measure、G-mean、AUC这四个指标描述的是模型的不同侧面。Sensitivity关注正类查全率F-measure权衡精确率和召回率G-mean是正负类召回率的几何平均AUC则考察排序能力。它们之间经常有相关性但结论不一定同步。有的模型AUC很高但G-mean拉胯说明正负类表现不均衡有的模型F-measure领先但Sensitivity一般。把这四个指标统一成一个综合分去检验或者只做一个平均排名容易把关键差异糊弄过去。我在实际处理中就是把每个指标单独拉出来建一张矩阵表分别跑一遍Friedman再分别跑Holm后续检验最后能清楚地看到在AUC上模型间差异显著在Sensitivity上可能没差异这种细粒度信息恰恰是论文里最值钱的部分。2. 方法原理与选型从Friedman到Holm你要和别人解释你用了什么检验至少得能把核心逻辑讲明白。这一节我把Friedman统计量的计算步骤拆开再用一个具体例子带你手算一遍然后讲清楚为什么后续检验用Holm而不是更常见的Bonferroni。2.1 Friedman检验核心逻辑与手算示例常用的统计量表达式有两种先看经典版本[ Q_F \frac{12n}{k(k1)}\left[\sum_{j1}^{k}R_j^2 - \frac{k(k1)^2}{4}\right] ]其中n是数据集个数k是模型个数R_j是第j个模型的平均秩。这个Q_F近似服从自由度为k-1的卡方分布。但很多文献建议用Iman和Davenport修正的F统计量因为卡方近似在n较小时偏保守。[ F_F \frac{(n-1)Q_F}{n(k-1)-Q_F} ]F_F服从自由度为(k-1, (k-1)(n-1))的F分布。我个人的习惯是Friedman检验的p值报告卡方版本但当p值临界时再算一次F版本确认避免误判。举个三段式的小例子。三个模型A、B、C在三个数据集上的某个指标如下数据集ABCD10.90.80.7D20.70.90.8D30.80.70.9按行排名D1序次是A(1)、B(2)、C(3)D2序次是A(3)、B(1)、C(2)D3序次是A(2)、B(3)、C(1)。平均秩分别为A2.0B2.0C2.0。三个模型晃成平手Q_F算出来是0p值接近1结论显然是没有显著差异。这个例子虽然极端但能说明一个要点Friedman只关系相对排名的一致性数值差距多大不重要。2.2 Holm为什么比Bonferroni更合理Friedman检验只会告诉你“这k个模型整体有差异”但它不告诉你具体是哪两个模型之间有显著差异。所以接下来要做的是post-hoc两两比较。最粗暴的做法是Bonferroni校正把所有两两比较的p值乘以对比次数m或等价地把显著性水平除以m。它控制住了整体假阳性率但代价是太保守。假设你有5个模型两两对比就是10次如果某对真实差异存在Bonferroni也可能把它过滤掉。Holm检验的思路是逐步拒绝法先把m个p值从小到大排序从最小的开始逐个检验。第i小的p值如果小于α/(m-i1)就拒绝对应假设然后继续如果某个p值比阈值大就停止后面的也不再拒绝。它不牺牲家族错误率控制但比Bonferroni在功效上更好。一个简单的对比三个比较的原始p值分别为0.01、0.02、0.06α0.05。Bonferroni要求每个p值小于0.0167只有0.01能拒绝Holm先看0.01≤0.0167拒绝再看0.02≤0.025拒绝再看0.06≤0.05不拒绝。多拒绝了一组这就是Holm的优势。2.3 工具链选型常用的Python工具有三个组合scipy.stats.friedmanchisquare算Friedman卡方统计量和p值。statsmodels.stats.multitest.multipletests做Holm校正。scikit-posthocs封装了很多post-hoc方法还能画显著性热力图和critical difference图。我的主力是前两个。scikit-posthocs虽然方便但它有些版本对输入矩阵格式比较挑剔反而更容易踩坑。后面的完整实操我会用最直接的方式写一遍你自己可以照着抄。3. 完整实操四指标逐一跑FriedmanHolm这一节是整篇的核心。我用一个虚拟但合理的实验设置来演示5个分类器10个数据集指标是AUC。然后告诉你Sensitivity、F-measure、G-mean怎么替换着来。所有代码都是可以直接跑的数据你自己填进去就行。3.1 数据准备矩阵结构决定检验有效性你需要的不是一堆散乱的指标结果而是一张规范的矩阵行是数据集列是模型。单元格里填的是该模型在当前数据集上的指标均值。举个例子AUC的10×5矩阵长这样数据集LogRegSVMRFXGBoostLightGBMD10.820.850.830.880.86D20.760.800.810.850.83..................D100.790.820.840.860.87这里有两个关键点要提醒。第一单元格里的数应当是多次重复实验的均值而不是单次五折交叉验证的某个fold值。因为统计检验的配对假设要求每行内的观测相互独立而同一个数据集上的不同fold显然不独立。我一般会在每个数据集上做“5次重复的5折交叉验证”取25个测试结果的平均值填进表格。第二数据集之间不要有依赖。如果你从同一个原始数据集切出两个版本当“两个数据集”它们在分布上高度相关检验的独立性假设就会被破坏。宁可少用几个数据集也不要硬凑相关性过高的数据。3.2 第一步Friedman检验的代码与输出解读先把五个模型在十个数据集上的AUC均值矩阵定义成numpy数组行是数据集列是模型。然后这样调import numpy as np from scipy import stats # 示意数据实际换成自己的矩阵 auc_data np.array([ [0.82, 0.85, 0.83, 0.88, 0.86], [0.76, 0.80, 0.81, 0.85, 0.83], [0.88, 0.84, 0.87, 0.90, 0.89], [0.71, 0.75, 0.78, 0.80, 0.82], [0.85, 0.86, 0.85, 0.89, 0.88], [0.90, 0.88, 0.91, 0.93, 0.92], [0.65, 0.70, 0.72, 0.75, 0.74], [0.78, 0.83, 0.82, 0.86, 0.85], [0.84, 0.81, 0.86, 0.87, 0.85], [0.77, 0.79, 0.80, 0.83, 0.84], ]) # 把每列变成独立参数传给 friedmanchisquare args [auc_data[:, j] for j in range(auc_data.shape[1])] qf_stat, qf_p stats.friedmanchisquare(*args) print(fFriedman Q {qf_stat:.4f}, p {qf_p:.4f})通常你会看到类似Friedman Q 13.5432, p 0.0089的结果。p小于0.05时说明整体上这五个模型的AUC分布存在显著差异接下来才允许做后续两两比较。如果p很大那就到此为止别再硬找哪对显著了那叫砸自己脚。我还会用Iman-Davenport F统计量做二次确认n_datasets auc_data.shape[0] k_models auc_data.shape[1] ff_stat ((n_datasets - 1) * qf_stat) / (n_datasets * (k_models - 1) - qf_stat) ff_p stats.f.sf(ff_stat, k_models - 1, (k_models - 1) * (n_datasets - 1)) print(fIman-Davenport F {ff_stat:.4f}, p {ff_p:.4f})当两种方法得到相同结论时我在论文里会优先报告F版本因为它在小样本下更稳健。3.3 第二步Holm校正的post-hoc比较Friedman只给整体信号现在要定位到具体哪两模型之间差异显著。传统做法是用配对Wilcoxon符号秩检验得到所有两两对比的p值再用Holm校正。from statsmodels.stats.multitest import multipletests from itertools import combinations model_names [LogReg, SVM, RF, XGBoost, LightGBM] pair_list list(combinations(range(len(model_names)), 2)) raw_pvals [] for i, j in pair_list: # 在10个数据集上配对比较两个模型 diff auc_data[:, i] - auc_data[:, j] # 注意diff全为0时 wilcoxon 会报错实际数据一般不会出现 w_stat, w_p stats.wilcoxon(auc_data[:, i], auc_data[:, j], zero_methodwilcox) raw_pvals.append(w_p) # Holm校正 reject, pvals_corrected, _, _ multipletests(raw_pvals, alpha0.05, methodholm) print(两两对比\t原始p值\t校正p值\t是否显著) for pair, raw, corr, flag in zip(pair_list, raw_pvals, pvals_corrected, reject): print(f{model_names[pair[0]]} vs {model_names[pair[1]]}\t{raw:.4f}\t{corr:.4f}\t{flag})这里要解释清楚Holm的校正过程。刚才的代码里multipletests已经把逐步比较的逻辑做掉了。输出的pvals_corrected是校正后的p值reject为True表示在0.05水平上仍然显著。比如XGBoost vs LogReg校正后p0.012那你就可以放心地说XGBoost显著优于LogReg。关于配对比较我踩过一个小坑Wilcoxon符号秩检验要求每对数值的差不为零差值为零会被忽略如果零太多检验功效会下降。有的数据集上两个模型指标完全一样这种情况直接删掉那个数据集会破坏配对结构更合理的做法是用zero_methodwilcox保留零值的处理方式。这句话写在这里等你真遇到diff全为0的报错时就知道去哪儿找了。3.4 第三步结果可视化与显著性标注命令行里看到p值不算完论文或报告里得让读者一眼看到差异。我常用的方式有两个。第一个是平均秩柱状图加显著性标注。先算出每个模型的平均秩然后画成柱状图在柱子上方标注哪些模型之间差异显著。代码大致如下import matplotlib.pyplot as plt # 计算平均秩 ranks np.argsort(np.argsort(-auc_data, axis1), axis1) 1 avg_rank ranks.mean(axis0) plt.bar(model_names, avg_rank, colorsteelblue) plt.ylabel(Average Rank) plt.title(Average Rank of AUC across 10 datasets) # 显著对比用星号或横线标注这里简化只标星号 for idx in range(len(model_names)): plt.text(idx, avg_rank[idx] 0.05, f{avg_rank[idx]:.2f}, hacenter) plt.ylim(0, max(avg_rank) 1) plt.show()第二个是scikit-posthocs的显著性热力图能直观展示所有两两比较的校正p值。这个图我一般在组会里自证用论文里少见。画法也很短import scikit_posthocs as sp # 要求每行是同一个数据集下各模型的观测值 df_auc pd.DataFrame(auc_data, columnsmodel_names) sp.sign_plot(sp.posthoc_conover(df_auc, p_adjustholm))不要被posthoc_conover这个名字劝退它本质上是多组配对数据的post-hoc检验方法之一搭配Holm校正使用很常见。同样的代码把auc_data换成Sensitivity矩阵、F-measure矩阵、G-mean矩阵就是四个指标各自的完整分析流程。代码一次写好后面只管换数据改文件名这是最舒服的推进方式。4. 实战中会踩的坑与排查笔记这一节是干货中的干货。跑Friedman和Holm看起来就几行代码但用错场景、填错数据、写错结论的情况非常多。我把这些年见过和踩过的问题整理成速查表。4.1 交叉验证的标准差要不要参与检验很多人喜欢在表格里填“0.85±0.03”这种形式然后问我要不要把标准差也喂给Friedman检验。答案很明确不要。Friedman检验的输入是一个区组内的一次观测值不支持也不理解均值±标准差的复合结构。你要做的是在“多次重复交叉验证”基础上把当前数据集-当前模型的指标均值作为一个点放进矩阵。标准差的作用是写在描述统计表里展示实验稳定性统计检验就该用均值矩阵。如果你觉得标准差信息丢了很可惜那说明你真正需要的可能是分层检验框架比如用“每个数据集上先跑5次重复用重复值组成小样本再检验”——但那样数据量不够通常做不出现显著性。实际经验是能有10个以上数据集时均值矩阵检验就是可靠方案。4.2 论文里怎么写不会挨批审稿人看到“p0.05”一句话就放行的情况越来越少了。我习惯的写法是给出完整统计细节“使用Friedman检验判断五个模型在AUC上的整体差异结果显示差异显著Q_F13.54p0.009Iman-Davenport F4.42p0.012。随后采用Wilcoxon配对符号秩检验结合Holm校正进行两两比较XGBoost显著优于LogReg校正后p0.012、SVM校正后p0.025……”为什么提“校正后”因为直接写原始p值很容易被质疑多重比较的风险。为什么写Q_F和F两个值因为小样本下F更稳展示严谨性。4.3 数据集只有五六个怎么办Friedman检验在区组数n太少时统计功效有限往往整体差异没跑显著但你看平均秩差距已经挺大的了。这种情况下我的缓解方案有两个。第一个是使用Iman-Davenport F统计量替代卡方近似前面代码已经给了它对小样本的表现更稳定。第二个是考虑排列检验permutation test固定每个数据集上模型的排名结构随机打乱模型标签模拟零分布来算p值。这个方案更费算力但能避开卡方近似的偏差。如果你只是日常做个模型选型数据集少就老老实实承认统计功效不足别硬下结论比什么都重要。4.4 四个指标结果冲突怎么处理这种情况非常常见AUC检验显示有显著差异但G-mean同一个数据矩阵跑出来p0.15根本不显著。很多人第一反应是我哪步写错了其实没有错。AUC对排序敏感G-mean对类别平衡敏感两者衡量维度不同模型之间在某一个维度区分度大在另一个维度可能确实拉不开。处理办法是分维度报告不要强行协调。你可以说“在AUC指标下模型间存在显著差异而在G-mean指标下未检测到整体差异”这比硬把G-mean也写成显著要诚实得多。表格把四个指标的Friedman结果汇总放一起谁显著谁不显著一目了然审稿人反而觉得你处理细致。4.5 临界值表和手动计算要不要了解虽然代码几秒钟出结果但我建议你至少手动算一次小规模的Friedman统计量加深理解。临界值表在传统教材里查k个模型、n个数据集对应的临界值现代做法看p值就够。手动算的另一个价值在于核对代码当你怀疑库函数结果时用前面小节的三段式例子手推一遍能快速定位是自己数据问题还是调用问题。我推荐所有第一次接触这套流程的人都做一遍这个动作。5. 最后的实操建议回到这一整套流程我最想给的经验是把统计检验前置到实验设计阶段而不是放到结果出来以后。我每次准备跑多模型对比时实验开始前就会把四张矩阵表建好行是数据集列是模型四张表分别对应Sensitivity、F-measure、G-mean、AUC。每跑完一个数据集就填一格实验结束时表也齐了直接跑检验代码不用再回头从文件堆里翻结果。填表时顺手记下每次交叉验证的随机种子这个习惯救过我很多次。模型跑出来的指标值如果不稳定差距在0.001级别时换个随机种子结论可能就变了。那时候用Friedman检验跑出来不显著你别惊讶先去检查重复实验的方差是不是太大了。最后一件事如果有人问起“为什么选了Holm而不是Nemenyi”我的回答是Nemenyi是Friedman检验之后基于平均秩差值的经典后续检验方法也不是不行只是Holm在两两比较上更灵活既可以做全量两两也能做“一行算法vs其余算法”这种定制化对比。你只需要在方法描述里把Holm的校准逻辑写明白审稿人一般不会挑刺。我个人在这几年的使用习惯里Full pairwise Holm是最省心也是最稳的组合。