资讯中心

Python实现临界图(CD图):算法性能统计比较可视化

📅 2026/8/15 11:53:22
Python实现临界图(CD图):算法性能统计比较可视化
1. 项目概述什么是临界图CD图在数据分析和算法性能评估领域我们常常需要比较多个模型或方法在多个数据集上的表现。简单地罗列平均排名或胜率往往难以直观、严谨地展示它们之间的统计显著性差异。这时临界图Critical Difference Diagram简称CD图就成为了一个非常有力的可视化工具。我第一次接触CD图是在研究一系列分类算法的论文中当时面对十几种算法在几十个数据集上的测试结果表格看得头昏眼花。直到看到那张清晰的CD图哪些算法属于“第一梯队”哪些之间没有显著差异一目了然。CD图的核心思想源于统计学的弗里德曼检验Friedman Test及其后续的Nemenyi事后检验Post-hoc Nemenyi Test。它并不是直接比较算法的“分数”而是比较它们的平均排名。简单来说它的工作流程是这样的首先在每个数据集上为所有参与比较的方法根据其性能如准确率、F1值值越高越好进行排名最好为1次之为2以此类推。然后计算每个方法在所有数据集上的平均排名。最后通过Nemenyi检验来判断哪些方法之间的平均排名差异已经达到了统计意义上的“显著”水平。CD图就是将这个统计检验的结果进行可视化方法按平均排名从左到右排列并用横线将那些没有显著差异的方法连接起来。横线覆盖的范围直观地形成了一个“临界域”——处在这个域内的方法我们认为它们的性能在统计上“不分伯仲”。对于算法工程师、数据科学家或者任何需要进行多方法比较的研究者来说手动计算排名、执行统计检验、再绘制图表不仅繁琐而且容易出错。用Python来自动化这个过程不仅能保证结果的准确性还能一键生成出版级质量的图表极大地提升了分析效率和报告的专业度。接下来我就带你从原理到代码彻底搞懂如何用Python实现CD图的绘制。2. 核心原理与统计基础拆解要正确绘制和解读CD图必须理解其背后的统计学原理。一知半解地调用库函数很可能导致误用和错误的结论。2.1 弗里德曼检验为什么是排名而非原始值我们比较多个方法设数量为k在多个数据集设数量为N上的性能。直接对原始精度值进行方差分析ANOVA存在一个问题不同数据集的难度、数值尺度可能差异巨大导致方差齐性等假设难以满足。弗里德曼检验巧妙地规避了这个问题。它的第一步是在每个数据集内部进行排名。假设我们在一个数据集上比较了A、B、C三个分类器准确率分别为92% 95% 90%。那么它们的排名就是B(1), A(2), C(3)。这个操作消除了数据集间的尺度差异将所有比较都归一化到了“排名”这个相对尺度上。计算每个方法在所有N个数据集上的平均排名 ( R_j )j1...k。如果所有方法性能完全相同那么每个方法在所有数据集上的期望排名应该是 ( (k1)/2 )。弗里德曼检验的统计量就是基于这些平均排名与期望排名的总体偏差来构建的用于检验“所有方法性能相同”这个零假设是否成立。注意当零假设被拒绝即p值小于显著性水平如0.05我们只能得出结论“并非所有方法性能都相同”。但具体是哪些方法之间有差异这就需要“事后检验”。2.2 Nemenyi事后检验与“临界差异”Nemenyi检验是一种用于全对比较all-pairs comparisons的事后检验方法。它告诉我们两个方法的平均排名需要相差多少才能被认为在给定的显著性水平如α0.05下具有统计显著性差异。这个“需要相差的最小值”就是临界差异Critical Difference, CD。其计算公式为 [ CD q_{\alpha} \sqrt{\frac{k(k1)}{6N}} ] 其中( q_{\alpha} ) 是学生化范围统计量Studentized range statistic在显著性水平α和自由度趋于无穷时的临界值。这个值可以通过查表或统计软件获得。( k ) 是待比较的方法数量。( N ) 是数据集或实验重复的数量。这个公式的直观理解非常重要CD值的大小衡量了区分能力的“灵敏度”。方法数量k增加CD值变大。比较的对象越多要从中脱颖而出、证明自己显著优于其他方法就越难。数据集数量N增加CD值变小。用于评估的数据集越多结果越稳定统计检验的威力就越大就越能检测出细微的差异。显著性水平α降低如从0.05变为0.01对应的 ( q_{\alpha} ) 值会增大导致CD值变大。这是因为我们设置了更严格的“显著”标准。2.3 CD图的视觉语言解读一张标准的CD图包含以下元素一条水平数轴代表平均排名通常从左排名好数值小到右排名差数值大排列。一系列带有名称的刻度点每个点代表一个方法其位置由它的平均排名决定。一条贯穿数轴的粗横线这代表了“临界域”。任何被同一条这样的横线连接起来的一组方法它们两两之间的排名差都小于临界差异CD值因此我们认为这些方法在统计上没有显著差异。CD值标注图中会明确标出计算得到的CD值供读者参考。解读黄金法则只比较未被同一条横线直接连接的方法。例如方法A和方法B被横线连接说明A和B无显著差异。方法C没有被任何横线与A连接且C在A的右边排名更差那么我们可以说A显著优于C。横线就像一个“无差异簇”的标识符。3. 工具选型与数据准备在Python生态中有几个库可以辅助绘制CD图但各有优劣。我经过多次实践形成了目前认为最清晰、可控性最强的方案。3.1 核心库选择scipy、statsmodels与matplotlibscipy.stats必选。用于执行弗里德曼检验friedmanchisquare函数。这是整个流程的统计起点。statsmodels.stats.libqsturng关键可选。用于获取Nemenyi检验中关键的 ( q_{\alpha} ) 值。statsmodels库提供了psturng函数我们可以通过它来反查临界值。这是计算CD值最准确的方式之一。如果不想安装statsmodels也可以使用近似值或查表但不够灵活。matplotlib必选。绘图引擎。我们需要它来绘制数轴、点、线和文本。numpy与pandas强烈推荐。用于数据处理、排名计算和平均排名计算比纯Python列表操作高效、清晰得多。为什么不直接用现有的“一键绘图”库确实存在一些像scikit-posthocs这样的库它提供了posthoc_nemenyi_friedman函数和绘图功能。但对于学习和深度定制来说它像一个黑盒。当我们需要调整图的样式如横线样式、字体、颜色、处理特殊情况如排名并列或者想真正理解每一步在做什么时自己动手实现一遍是无可替代的。此外自己实现能确保对统计过程的每一个参数都了然于胸。3.2 输入数据的标准格式你的原始数据应该是一个二维矩阵DataFrame或二维数组这是最自然的格式。行index通常代表不同的数据集、实验重复或测试用例。列columns代表需要比较的不同方法、模型或算法。单元格值代表在该数据集上该方法的表现度量值如**准确率、F1-score、AUC、运行时间注意方向**等。例如一个比较随机森林(RF)、支持向量机(SVM)和逻辑回归(LR)在5个数据集上准确率的数据框可能长这样DatasetRFSVMLRDS10.920.890.85DS20.880.910.87DS30.950.930.90DS40.870.880.82DS50.900.870.88关键准备步骤处理性能度量的方向性CD图基于排名而排名有方向。对于准确率、F1值等数值越大越好因此排名时最高分得第1名。但对于像误差RMSE、运行时间这类指标数值越小越好排名顺序就反过来了。在计算排名前必须统一方向。一个稳妥的做法是在计算排名前对所有“值越小越好”的指标乘以-1将其转化为“值越大越好”的问题。或者在调用pandas.DataFrame.rank()时明确指定ascending参数。4. 分步实现从数据到图形下面我将结合代码详细演示每一个步骤。假设我们已将数据读入一个名为df_performance的Pandas DataFrame其列名为方法名行索引为数据集名单元格值为准确率越大越好。4.1 步骤一计算平均排名import pandas as pd import numpy as np from scipy import stats import matplotlib.pyplot as plt # 假设 df_performance 是我们的数据框 # 例如df_performance pd.DataFrame({‘RF‘: [0.92,0.88,0.95,0.87,0.90], ‘SVM‘: [0.89,0.91,0.93,0.88,0.87], ‘LR‘: [0.85,0.87,0.90,0.82,0.88]}) # 1. 在每个数据集内部计算排名 # axis1 表示按行即每个数据集内部进行排名 # methodmin 表示并列排名时取最小排名常见处理方式 # ascendingFalse 因为准确率是越大越好所以降序排名最高分得第1名 df_ranks df_performance.rank(axis1, methodmin, ascendingFalse) print(排名矩阵) print(df_ranks) # 2. 计算每个方法的平均排名 average_ranks df_ranks.mean() print(\n平均排名) print(average_ranks.sort_values()) # 按平均排名从好到坏排序实操心得rank函数的method参数很重要。‘min‘是常用的如果两个方法在同一个数据集上性能完全相同并列它们会获得相同的、较小的那个排名。例如两个方法并列第一它们都得到排名1下一个方法则得到排名3。这符合弗里德曼检验处理并列排名的常规方式。4.2 步骤二执行弗里德曼检验与Nemenyi检验首先我们需要验证“所有方法性能无差异”这个零假设是否被拒绝。如果弗里德曼检验的p值很大0.05说明连“存在差异”这个前提都不成立后续的成对比较CD图意义就不大了。# 将数据转换为适合 scipy 的格式列向量列表 data_for_friedman [df_performance[col].values for col in df_performance.columns] # 执行弗里德曼检验 friedman_stat, p_value stats.friedmanchisquare(*data_for_friedman) print(f弗里德曼检验统计量: {friedman_stat:.4f}) print(fP值: {p_value:.4f}) if p_value 0.05: print(在显著性水平0.05下拒绝零假设认为并非所有方法性能相同。可以继续绘制CD图。) else: print(在显著性水平0.05下无法拒绝零假设。绘制CD图可能无法显示显著差异。)接下来是最关键的一步计算临界差异CD。我们需要q_alpha值。这里使用statsmodels来获取。# 安装 statsmodels: pip install statsmodels try: from statsmodels.stats.libqsturng import psturng, qsturng USE_STATSMODELS True except ImportError: print(未找到statsmodels将使用近似值计算CD结果可能不精确。) USE_STATSMODELS False def get_critical_difference(k, N, alpha0.05): 计算Nemenyi检验的临界差异(CD)值。 参数: k: 方法数量 N: 数据集数量 alpha: 显著性水平 返回: CD值 if USE_STATSMODELS: # qsturng 需要输入概率 (1-alpha) 和参数 # 对于双尾的Nemenyi检验我们需要的是学生化范围统计量的上alpha分位数 # psturng是CDFqsturng是PPF分位点函数。我们需要找q使得 P(Q q) 1-alpha # 但qsturng函数可能需要直接使用。一个更稳定的方法是利用psturng和根查找但这里用一个简化版本。 # 实际上对于常见的alpha和k有表可查。我们这里采用一个广泛使用的近似公式和statsmodels的查询。 # 更严谨的做法是查表或使用scikit-posthocs库中的函数获取q值。 # 作为演示和大多数情况下的近似我们可以使用以下方法 # 引用自Demsar (2006) 和 相关文献对于alpha0.05, 0.1 q值可以近似查表。 # 这里我们实现一个通过psturng反查q值的简单方法效率较低但概念清晰。 from scipy.optimize import brentq # 定义方程P(Q q) alpha 1 - CDF(q) alpha CDF(q) 1 - alpha def root_func(q): return psturng(q, k, 1000000) - (1 - alpha) # 用一个大数近似自由度无穷大 # q的搜索范围通常q在2到5之间 q_alpha brentq(root_func, 2, 5) else: # 如果没有statsmodels使用一个来自文献的近似值表仅适用于alpha0.05, 0.1 # 注意这是一个非常粗略的近似强烈建议安装statsmodels获取准确值。 # 近似值表 (alpha0.05): k2:2.77, k3:3.31, k4:3.63, k5:3.86, k6:4.03, k7:4.17, k8:4.29, k9:4.39, k10:4.47 # 我们简单线性插值这不严谨仅作演示。 q_table_005 {2:2.77, 3:3.31, 4:3.63, 5:3.86, 6:4.03, 7:4.17, 8:4.29, 9:4.39, 10:4.47} if k in q_table_005: q_alpha q_table_005[k] else: # 如果k超出范围使用一个经验公式极不严谨慎用 print(f警告k{k}超出近似表范围CD值可能不准。) q_alpha 2.77 (k-2)*0.2 # 这是一个非常随意的估计 cd q_alpha * np.sqrt(k*(k1)/(6*N)) return cd, q_alpha if USE_STATSMODELS else None k len(df_performance.columns) # 方法数量 N len(df_performance) # 数据集数量 alpha 0.05 cd_value, q_used get_critical_difference(k, N, alpha) print(f方法数量 k{k}, 数据集数量 N{N}, 显著性水平 alpha{alpha}) print(f临界差异 CD {cd_value:.4f}) if q_used: print(f使用的 q_alpha 值: {q_used:.4f})重要提示获取精确的q_alpha值是CD图统计有效性的核心。上述通过brentq反查的方法在概念上是正确的但在实际生产中更推荐直接使用scikit-posthocs库中的posthoc_nemenyi_friedman函数返回的p值矩阵或者直接使用该库内置的绘图函数来确保统计正确性。自己实现反查主要是为了教学理解。在关键的项目或论文中请务必使用经过验证的统计库。4.3 步骤三可视化绘制CD图有了平均排名和CD值我们就可以开始绘图了。绘图的逻辑是按平均排名对方法进行排序。从左到右在数轴上放置它们。从排名最好的方法开始向右寻找所有与其排名差小于CD的方法用横线连接。重复这个过程直到所有方法都被处理。def plot_critical_difference_diagram(average_ranks, cd, method_namesNone): 绘制临界差异图。 参数: average_ranks: 平均排名序列pd.Series或类似结构 cd: 临界差异值 method_names: 可选的方法名称列表。如果为None则使用average_ranks的索引。 if method_names is None: method_names average_ranks.index.tolist() # 将平均排名和方法名打包并按排名升序排序 ranked_items sorted(zip(average_ranks.values, method_names), keylambda x: x[0]) ranks_sorted, names_sorted zip(*ranked_items) # 创建图形 fig, ax plt.subplots(figsize(10, 4)) # 设置y轴范围将图形主体放在上方 ax.set_ylim(0, 2) ax.set_xlim(0.5, len(ranks_sorted) 0.5) # 隐藏坐标轴框线 for spine in [top, right, left, bottom]: ax.spines[spine].set_visible(False) # 隐藏y轴刻度 ax.yaxis.set_visible(False) # 在y1的高度绘制水平数轴 ax.axhline(y1, colork, linestyle-, linewidth0.5) # 绘制刻度线和刻度标签方法名 for i, (rank, name) in enumerate(zip(ranks_sorted, names_sorted)): x_pos i 1 # 在数轴上等距排列 # 绘制刻度点 ax.plot(x_pos, 1, ko, markersize8) # 添加方法名标签 ax.text(x_pos, 1.05, name, hacenter, vabottom, fontsize11, fontweightbold) # 添加平均排名值可选放在点下方 ax.text(x_pos, 0.93, f{rank:.2f}, hacenter, vatop, fontsize9, colorgray) # 核心逻辑绘制连接无显著差异方法的横线 i 0 while i len(ranks_sorted): j i 1 # 寻找所有与第i个方法无显著差异的方法 while j len(ranks_sorted) and (ranks_sorted[j] - ranks_sorted[i]) cd: j 1 # 如果找到了可以连接的方法j i1 if j i 1: # 绘制连接从i到j-1的横线 x_start i 1 - 0.1 # 稍微向左延伸一点 x_end j # 因为j是第一个不满足条件的索引所以连接线到j-1的位置即j # 在y0.9的高度绘制粗横线 ax.hlines(y0.9, xminx_start, xmaxx_end, colorsred, linewidth3, capstyleround) i 1 # 添加CD值标注 ax.text(len(ranks_sorted)0.1, 0.9, fCD {cd:.3f}, haleft, vacenter, fontsize10, bboxdict(boxstyleround,pad0.3, facecoloryellow, alpha0.5)) # 添加标题 ax.set_title(fCritical Difference Diagram (N{N}, α{alpha}), fontsize14, pad20) # 调整布局 plt.tight_layout() plt.show() # 调用绘图函数 plot_critical_difference_diagram(average_ranks, cd_value)这段代码生成了一个基本的CD图。方法按平均排名从左到右排列红色粗横线连接了那些平均排名差异小于CD值的方法组意味着它们之间没有统计上的显著差异。5. 高级定制与常见问题排查基础的CD图已经能说明问题但在实际论文或报告中使用时我们往往需要对其进行美化并处理一些特殊情况。5.1 样式深度定制你可以像调整任何Matplotlib图表一样调整CD图。以下是一些常见的定制点def plot_cd_diagram_custom(average_ranks, cd, method_namesNone): # ... [前面的排序、数据准备代码与之前相同] ... fig, ax plt.subplots(figsize(12, 5), dpi150) # 提高分辨率和宽度 # 1. 自定义颜色和样式 axis_color #333333 point_color #2E86C1 line_color #E74C3C highlight_color #F1C40F ax.axhline(y1, coloraxis_color, linestyle-, linewidth1.5, alpha0.7) # 2. 绘制更美观的点 for i, (rank, name) in enumerate(zip(ranks_sorted, names_sorted)): x_pos i 1 # 使用散点图可以控制点的大小和边缘 ax.scatter(x_pos, 1, s120, cpoint_color, edgecolorswhite, linewidth2, zorder5) # 使用更漂亮的字体和布局 ax.text(x_pos, 1.08, name, hacenter, vabottom, fontsize12, fontweightsemibold, fontfamilysans-serif) ax.text(x_pos, 0.91, f{rank:.2f}, hacenter, vatop, fontsize10, colordimgray, styleitalic) # 3. 绘制连接线增加样式 i 0 while i len(ranks_sorted): j i 1 while j len(ranks_sorted) and (ranks_sorted[j] - ranks_sorted[i]) cd: j 1 if j i 1: x_start i 0.85 # 调整起始和结束位置让线更贴合点 x_end j - 0.15 # 使用更粗、半透明的线并添加阴影效果 ax.hlines(y0.88, xminx_start, xmaxx_end, colorsline_color, linewidth4.5, alpha0.8, zorder4) # 可以在线下加一个浅色背景突出区域 # ax.axhspan(ymin0.86, ymax0.90, xmin(x_start-0.5)/len(ranks_sorted), # xmax(x_end-0.5)/len(ranks_sorted), colorhighlight_color, alpha0.1) i 1 # 4. 美化标注和标题 cd_text ax.text(len(ranks_sorted)0.2, 0.88, fCD {cd:.3f}, haleft, vacenter, fontsize11, fontweightbold, bboxdict(boxstyleround,pad0.4, facecolorwheat, edgecolorgray, alpha0.9)) ax.set_title(Critical Difference Diagram, fontsize16, fontweightbold, pad25) # 可以添加副标题 # ax.text(0.5, 1.05, fComparison of {k} classifiers on {N} datasets (α{alpha}), # transformax.transAxes, hacenter, fontsize11, colorgray) # 5. 彻底隐藏不需要的轴线 ax.set_xticks([]) ax.set_yticks([]) for spine in ax.spines.values(): spine.set_visible(False) # 6. 设置网格背景可选 # ax.set_facecolor(#f8f9fa) # ax.grid(axisx, linestyle:, linewidth0.5, colorlightgray, zorder0) plt.tight_layout() plt.show()5.2 常见问题与排查技巧所有方法都被一条横线连起来了可能原因CD值计算过大。检查q_alpha值是否正确特别是k方法数量和N数据集数量是否输入正确。N太小会导致CD值巨大难以检测出差异。通常需要足够多的数据集N10CD图才有分辨力。排查打印出k,N,cd_value确认它们符合预期。尝试更严格的显著性水平如α0.1看看横线是否会断开。横线连接看起来“不合理”比如排名第一和倒数第一被连起来了可能原因这恰恰是CD图要揭示的如果它们被连接说明尽管平均排名有差距但考虑到所有数据集上的波动方差这个差距在统计上并不显著。这提醒我们不能只看平均排名定胜负。如果这与领域认知严重不符需要回头检查1数据是否有误2排名计算方向ascending参数是否正确3使用的性能指标是否合适如何处理并列排名Ties影响弗里德曼检验和排名计算本身可以处理并列使用methodmin,‘average‘等。scipy的friedmanchisquare函数能处理平均排名。自己实现CD计算时只要排名矩阵计算正确后续步骤不受影响。建议在df.rank()中明确指定method‘average‘或‘min‘并在报告中注明处理方式。‘average‘会给并列项分配平均排名如并列第1和第2则都排1.5这在一些统计软件中是默认的。想比较的不仅仅是“是否显著”还想知道p值具体是多少方案CD图是基于Nemenyi检验的它是一种较保守的全对比较。如果你只关心某些特定方法对如“新方法” vs “基准方法”可以使用更灵敏的成对Wilcoxon符号秩检验配对的并针对多重比较进行校正如Holm-Bonferroni校正。这需要另一个分析流程CD图不直接提供p值。图形保存与格式使用plt.savefig(‘cd_diagram.pdf‘, dpi300, bbox_inches‘tight‘)保存为矢量PDF用于论文投稿。使用plt.savefig(‘cd_diagram.png‘, dpi300, bbox_inches‘tight‘, transparentTrue)保存为高分辨率PNG背景透明便于插入幻灯片。6. 项目扩展与替代方案掌握了基础实现后你可以根据需求进行扩展集成到自动化分析流水线将CD图生成函数封装成一个模块输入性能DataFrame直接输出统计结果和图表方便在多个实验间复用。支持更多事后检验Nemenyi检验是保守的。可以扩展代码以支持Bonferroni-Dunn检验它通常能给出更短的横线更容易得出显著差异的结论但只适用于与一个控制方法进行比较的场景。交互式CD图使用plotly库创建交互式图表鼠标悬停可以显示方法名、平均排名和具体数值增强可读性。与scikit-posthocs库结合如前所述对于生产环境推荐使用scikit-posthocs进行统计检验获取准确的p值矩阵然后再用自己定制化的绘图代码进行可视化兼顾准确性与灵活性。一个实用的替代方案如果你追求快速出图且对样式要求不高可以直接使用scikit-posthocs的绘图功能import scikit_posthocs as sp import pandas as pd # 假设 df_performance 是性能数据框 # 计算Nemenyi检验的p值矩阵 p_values sp.posthoc_nemenyi_friedman(df_performance.T) # 注意可能需要转置以方法为行数据集为列请查阅文档确认格式。 # 使用库内置函数绘图样式比较基础 sp.critical_difference_diagram(df_performance.T, alpha0.05)不过内置函数的样式通常比较简单且可能不符合特定出版物的要求。这就是为什么理解原理并掌握自定义绘制方法非常有价值。绘制CD图的过程本质上是一次严谨的统计思维训练。它强迫我们超越“A的平均分比B高0.5%”这种简单的比较转而思考“在考虑了所有测试集上的随机波动后这个优势是否依然可靠” 自己动手实现一遍从数据排名、统计检验到可视化不仅能让你在下次需要时游刃有余更能深化你对机器学习模型评估与比较的理解。