多算法对比如何科学判定?Friedman检验与Holm校正完整指南
最近又帮一个朋友的实验做了多组算法对比对方拿着四张指标的Excel表来问“到底哪个算法赢了”。我一看四个指标Sensitivity、F-measure、G-mean、AUC各有各的排名谁都能说自己第一。我告诉他想回答“哪个算法显著更好”得做非参数统计检验而且要按指标分别做。于是就有了这篇东西——用Friedman检验判断“多算法在多数据集上是否存在显著差异”再用Holm检验做多重比较修正定位差异具体出现在哪两个算法之间。这篇内容是我做科研和工程评估时常用的标准流程适合刚接触算法对比的读者既有原理也有手算示例还有可以直接抄走的Python代码。先说为什么这件事不能偷懒。很多人拿到多个算法、多个数据集时最喜欢干的事是看“平均排名”。比如算法A在15个数据集上平均排名2.1算法B平均排名2.8看起来A赢了。但问题在于2.1和2.8之间的差距是不是只是噪声15个数据集相当于15个样本如果两两间差异波动很大这点平均排名差根本不稳。还有更常见但更危险的做法是对每一对算法做t检验然后看有几个数据集P值小于0.05。t检验本身就要求数据近似正态而算法在数据集上的性能分布经常偏态更关键的是多做几次比较就会放大“假阳性”风险。做10次比较即使所有算法都一样也大概率会出现一两次“显著差异”。所以这个场景下的标准方案就是用Friedman检验做全局判断拒绝之后接一个Holm校正的事后检验。1. 四个指标为什么必须分开做检验1.1 指标混在一起会让结论变成“四不像”像Sensitivity、F-measure、G-mean、AUC这四个指标很多人拿到后会把它们拼成一个大盘子数值一起排进去跑一遍Friedman就完事。这个做法有两个致命伤。第一这四个指标衡量的不是同一个东西Sensitivity是正类召回率F-measure是精度和召回率的调和均值G-mean是正类召回率与负类精度乘积开根号AUC则关注排序能力。一个算法可能Sensitivity很高但F-measure偏低另一个可能AUC突出但G-mean一般。把它们混在一起检验相当于问“苹果、橙子、梨和葡萄哪个质量好”回答必然没有实际参考价值。第二是统计上的问题同一批数据集上算出来的四个指标往往高度相关拼成一个样本后等于变相重复加权了同一批数据违背了独立样本的基本前提。标题里特意写了“分别对每个指标”这一步不是格式要求而是方法论要求每个指标单独构建一个“数据集×算法”的矩阵然后各做一轮FriedmanHolm。1.2 指标矩阵的标准构造方式无论用哪个指标数据在进检验之前都应该整理成一个明确的表格。行是数据集列是算法单元格是“该算法在该数据集上该指标的结果值”。对Sensitivity做检验就只拿出所有算法在所有数据集上的Sensitivity值对AUC做检验就只拿AUC值。以N个数据集、k个算法为例每个指标矩阵都是N行k列。Friedman检验关心的是每一行内部各列的排名是否大致均匀。如果某个算法在多数数据集上都能排到前列那它的平均秩就会很小检验统计量就会变大就越倾向于拒绝“所有算法等价”的原假设。1.3 多次重复实验时的取数方式工程里做评估很少只跑一遍通常会做5折交叉验证或者重复多次取均值。那矩阵里应该填均值还是填每次的值标准做法是把每折或每次重复得到的结果当作独立观测如果数据集和算法相同但重复次数不同就把它们当成不同的“块”或“行”来处理。也就是说一次10折交叉验证可以看作10行样本三次重复5折交叉验证可以看作15行样本。需要注意把同一算法的多次重复结果放在同一行里会让算法内部的方差信息丢失。所以规范做法是要么每次重复各占一行要么对同一数据集上的多次重复先取均值、再以数据集为行。第一种做法能保留更多信息但要求所有算法必须跑同一套划分不能算法A跑一套随机种子、算法B跑另一套。我在实际项目里见过不少人因为随机种子没对齐最后秩的全乱了那个检验结果也就失去了意义。2. Friedman检验的手算过程与Python实现2.1 从“秩”到统计量的直觉Friedman检验的核心是秩。对每一个数据集这一行把k个算法的指标值排序得到第1名到第k名这就是秩。然后对每个算法求平均秩。如果所有算法真的没差别那么每个算法的平均秩都应该约等于(k1)/2也就是所有秩的期望值。偏离这个期望越远说明算法之间差异越大。标准Friedman统计量用的就是这个偏离程度公式定义为R_j是第j个算法的秩和N是数据集个数k是算法个数。分子里的ΣR_j²减去Nk(k1)²/4可以理解为“实际秩分布与完全均匀分布的偏离量”分母(k(k1)/12)作用是把量表标准化。算出的χ²_F近似服从自由度为k-1的卡方分布。当χ²_F超过临界值时拒绝原假设也就是承认“至少有一个算法和其他算法不一样”。这里有一个容易混淆的地方。网络上有两种Friedman统计量写法一种直接叫χ²_F另一种是用Iman和Davenport修正过的F_F。修正版本更适合样本量不特别大的情况公式为实际做检验时推荐用F_F查F分布因为它的第一类错误控制在有限样本下更接近名义水平。不过手算时χ²_F版本更直观和下面要做的Holm检验计算也顺路。2.2 一个可以拿笔算的完整例子我用Sensitivity来演示。假设有5个数据集D1到D54个算法A1到A4数值如下数据集A1A2A3A4D10.950.930.880.91D20.890.950.920.90D30.910.900.940.92D40.880.960.900.93D50.900.920.890.94每行内部从快到慢排秩值最大者给秩1最小者给秩4这步很重要秩越小性能越好D1A1(0.95) A2(0.93) A4(0.91) A3(0.88)秩依次1、2、3、4D2A2(0.95) A3(0.92) A4(0.90) A1(0.89)秩依次4、1、2、3D3A3(0.94) A4(0.92) A1(0.91) A2(0.90)秩依次3、4、1、2D4A2(0.96) A4(0.93) A3(0.90) A1(0.88)秩依次4、1、3、2D5A4(0.94) A2(0.92) A1(0.90) A3(0.89)秩依次3、2、4、1秩和分别为R115R210R314R411。N5k4。代入χ²_F公式ΣR_j²22510019612164212/(5×4×5)0.120.12×64277.04减去3×5×(41)75得到χ²_F2.04。自由度为k-130.05水平下卡方临界值是7.815。2.04远小于7.815所以不能拒绝原假设结论是这4个算法在这5个数据集上的Sensitivity没有统计学上的显著差异。也就是说即使平均值好像有高低波动太大不足以支撑“算法之间有真实差距”的判断。要验证手算对不对Python里可以用现成的函数做。这里敲门砖import numpy as np from scipy.stats import friedmanchisquare # 每个数组是某个算法在所有数据集上的Sensitivity结果 a1 np.array([0.95, 0.89, 0.91, 0.88, 0.90]) a2 np.array([0.93, 0.95, 0.90, 0.96, 0.92]) a3 np.array([0.88, 0.92, 0.94, 0.90, 0.89]) a4 np.array([0.91, 0.90, 0.92, 0.93, 0.94]) stat, p friedmanchisquare(a1, a2, a3, a4) print(stat, p) # 输出结果是 2.04, 0.5642 左右与手算一致p0.564意味着在0.05显著性水平下看不出四个算法有差异。注意这里函数内部没有做Iman-Davenport修正如果想用F_F版本需要手动算N len(a1) k 4 chi2 2.04 F_F (N - 1) * chi2 / (N * (k - 1) - chi2) # 再用 scipy.stats.f 查 p 值2.3 样本量太少时检验功效很弱刚才这个例子里就算A1和A2的秩和看上去差5统计上还是不显著因为N5实在太小。Friedman检验在N较小时功效低这是非参数方法的老毛病。实践里如果只有5个数据集想要检验出差异算法之间的性能差距必须非常大才行。想获得可靠的结论数据集或重复数至少在10到15以上。如果数据集实在少那就别硬上Friedman了老老实实只报表格或者改用配对方法做探索性分析别轻易下“显著更好”的结论。3. Friedman拒绝之后为什么要接Holm检验3.1 多重比较放大了假阳性Friedman检验回答的问题很单一所有算法是否等价。它不会告诉你是A1比A2好还是A3和A4都显著差。实际项目里我们更关心的是算法两两之间到底谁高谁低。于是很自然的想法是对所有算法两两做一遍Wilcoxon符号秩检验。k个算法就会有k(k-1)/2对比较。问题也随之而来每一次比较都有5%的假阳性概率比较次数越多至少犯一次错的概率就越高。两个算法时是5%四个算法六次比较时就差不多是1-(0.95)^6≈26%。这就解释了为什么审稿人要求“多重比较修正”而不是看你挑几个显著的说。3.2 Holm、Bonferroni和Nemenyi的分工事后检验有好几种常见的有Bonferroni校正、Holm逐步校正、Nemenyi检验、Tukey-HSD的非参数版本。简单区分一下Bonferroni最简单也最严格把显著性水平除以比较次数m即α α/m。优点是好解释缺点是太保守很容易把真实差异也滤掉。Holm比Bonferroni宽松一些也是逐步算法但比Bonferroni保留更高的统计功效是很多方法论文本推荐的标准事后检验。Nemenyi直接对平均秩做比较适合在Friedman检验后画CD图Critical Difference但往往比Holm更保守。Holm和Bonferroni调节的是同一组原始p值但Holm按照“从小到大逐步比较”的规则后面的临界值会逐渐放大。这个“放大”不是随便放大而是经过严格推导保证总体错误率不超α的所以在统计功效上优于直接一刀切。3.3 Holm逐步算法的具体步骤假设一共有m个需要比较的假设对应的未校正p值为p1到pm。Holm过程分四步把m个p值从小到大排序记为p(1) ≤ p(2) ≤ ... ≤ p(m)。从最小的p(1)开始计算临界值α/(m-i1)这里的i是当前序号。当i1时临界值为α/mim时临界值为α/1α。如果p(i) ≤ α/(m-i1)拒绝第i小的p值对应的假设然后继续比较i1如果某个p(i)超过临界值则停止从这一位开始以及后面所有更大的p值都无法拒绝。被“拒绝”的对比对才认为存在显著差异。这样的逐步处理相当于前面比较承担了比较高的显著性要求越到后面越宽松是一种既控制全局错误率、又不至于过度保守的做法。4. 一个完整案例G-mean指标下的Friedman加Holm执行过程4.1 构造出能够拒绝Friedman的数据Sensitivity那组数据不显著接下来我用G-mean做演示说明显著时的完整链路。假设还是5个数据集、4个算法但G-mean下算法的秩分布更极端算法B和D在多数数据集上排在前列算法A和C常垫底。经过排序后的秩和如下算法秩和平均秩A183.6B81.6C173.4D71.4代入χ²_F公式ΣR_j²32464289497260.12×72687.12减去75得到χ²_F12.12。自由度3临界值7.81512.127.815p值约0.007拒绝原假设说明算法之间有显著差异。这时候才有资格做Holm检验。4.2 两两比较的p值怎么算Friedman拒绝后下一步用平均秩之间的距离做两两比较。可以用z统计量其中R̄_i和R̄_j是两个算法的平均秩这个公式的分母来自Friedman检验中秩差的方差推导k(k1)/(6N)。本例里分母是sqrt(4×5/(6×5))sqrt(2/3)≈0.8165。z值对应的双侧p值用标准正态分布计算即可。还考虑更稳妥的办法是直接对两个算法在每行数据上的原始G-mean值做Wilcoxon符号秩检验尤其在数据集个数不多时Wilcoxon比正态近似更可靠。两种做法都可以但注意全文统一起见后面结果以z检验为准。计算得到对比对平均秩差z值未校正p值A vs B2.02.4490.0143A vs C0.20.2450.8065A vs D2.22.6940.0071B vs C-1.8-2.2040.0275B vs D0.20.2450.8065C vs D2.02.4490.01434.3 Holm校正后的最终裁决把上述p值从小到大排序0.0071A vs D、0.0143A vs B、0.0143C vs D、0.0275B vs C、0.8065A vs C、0.8065B vs D。m6显著性水平取α0.05。第1位临界值0.05/60.008330.0071≤0.00833拒绝“A与D没有差异”的假设。第2位临界值0.05/50.010.01430.01停止。后续所有对比都不再拒绝。最终结论在G-mean指标上只有算法A与D存在显著差异其余对比均无法在统计上确认差异。这个例子也展示了Holm“逐步放松”的特点如果第1位的小p值是0.009而不是0.007Holm依然能拒绝而如果换用Bonferroni一刀切的0.008330.009就无法通过了。差别虽小但实际实验中经常会出现这种临界情况。4.4 用Python实现Holm校正把上面的过程固化成函数很方便from scipy.stats import norm import numpy as np def holm_adjustment(p_values, alpha0.05): m len(p_values) sorted_idx np.argsort(p_values) sorted_p np.array(p_values)[sorted_idx] decisions [] for i, p in enumerate(sorted_p): threshold alpha / (m - i) if p threshold: decisions.append(True) else: decisions.extend([False] * (m - i)) break else: decisions [True] * m # 还原到原始顺序 result np.empty(m, dtypebool) for original_pos, is_sig in zip(sorted_idx, decisions): result[original_pos] is_sig return result # 两两对比的p值 pvals [0.0143, 0.8065, 0.0071, 0.0275, 0.8065, 0.0143] print(holm_adjustment(pvals)) # [True, False, True, False, False, True]输出里对应A-D、A-B、C-D这三位显示True和手算结论一致。5. 结果汇报与常见坑5.1 报告里需要交代的信息实验部分如果写了Friedman检验至少要写清楚检验针对哪个指标、样本量是多少数据集个数×重复次数、χ²_F或F_F统计量的值、自由度、p值、显著性水平以及事后检验用的方法。比如可以这样描述“在G-mean指标上Friedman检验表明四个算法之间存在显著差异χ²_F12.12df3p0.007。进一步使用Holm多重比较校正α0.05仅算法A与D差异显著。”如果想让读者能复现还应该附上平均秩表因为这是中间结果。只写一句话“p0.05有显著差异”对审稿人来说是不够的因为p值大小、统计量版本、事后检验方式都会影响结论。5.2 可视化CD图和分组条形图两两比较结果适合用Critical Difference图来展示。以平均秩为坐标轴把算法从小到大排列画一条表示最小显著差异的线段被同一根线段覆盖的算法之间无显著差异不重叠的算法才有显著差异。用Python画CD图可以用现成的开源绘图包也可以手动画横轴放倒序排名纵轴放平均秩旁边用粗线把“无显著差异”的算法连接起来。配合一个简单的分组柱状图把各指标下的平均秩并排出来读者一眼就能看出哪个算法在哪项指标上表现稳健。5.3 实操中踩过的几个坑坑一把p值小于0.05但Holm校正后不显著的对比当成结论拿出来讲。这是审稿人最反感的一种“选择性报告”。既然做了多重比较就必须以校正后的结果为准不能用原始p值。坑二不同指标之间完全没有交叉验证。标题强调了“分别对每个指标”实际执行时就是要对四个指标分别构建矩阵、分别统计、分别给结论。有人图省事在某一个指标显著后把结论推广到所有性能维度这很容易被质疑。算法可以在Sensitivity上领先但在AUC上并无优势这两种结论同时成立并不矛盾。坑三数据集个数太少还硬做Friedman。前面说过N5的检验功效很低。有些实验就是数据集数量有限这种情况建议诚实报告“样本量不足仅做描述性统计”或者使用基于排列permutation的非参数检验方案而不是拿一个功效很差的显著结果强行讲故事。坑四秩的方向搞反。如果指标是越小越好比如错误率排名时秩1应该给最小值如果指标是越大越好秩1应该给最大值。把方向搞反平均秩全集就会翻转Holm检验结果完全错误。建议每一种指标进脚本之前先手动对一行数据做一次排序检查。坑五重复实验的随机划分不一致。确保所有算法在同一个数据集上用的是同一套交叉验证划分否则每一行内各个算法的数值比较会受到划分差异污染秩检验的含义就不纯了。6. 我现在的执行习惯把这套流程跑顺之后我现在做多算法对比的标准动作是先把四类指标整理成四个独立矩阵手动核对最大值为优还是最小值为优然后用脚本同时输出Friedman统计量、修正后的F统计量和原始p值如果p值通过再跑两两比较并接Holm校正最后输出一张平均秩表格和CD图。整个流程半天能跑完但对结论的支撑力比单纯列个平均排名强很多。如果你也在做实验对比建议直接把这套代码存成一个函数以后每次只需要传一个N行k列的DataFrame进去省得在Excel和脚本之间反复横跳。统计检验不复杂复杂的是把每一步做对。