Critical Difference (CD) diagrams: the standard way to report a benchmark of many algorithms over many datasets.
Averaging accuracy across datasets is misleading — the scale of a metric differs per dataset, and one easy dataset can dominate the mean. Demšar (2006) instead recommends ranking the algorithms within each dataset, averaging those ranks, running a Friedman test for "are any of them different at all?", and following up with a Nemenyi post-hoc test that yields a single critical difference (CD): the smallest gap between two average ranks that counts as significant.
This module provides the three pieces of that recipe plus the plot:
-
compute_ranks— per-dataset ranks with ties averaged. -
critical_difference— the CD value forkalgorithms overn
-
plot_critical_difference— the diagram itself, returning a
CDDiagramResult with ranks, CD, cliques, and the Friedman statistic.
Reading the diagram. Algorithms sit on a horizontal rank axis running from 1 (best) on the left to k (worst) on the right; each one is connected by a line to its average rank. A CD-wide reference bar is drawn at the top for scale. Thick horizontal bars underneath join algorithms whose average ranks differ by less than the CD — those are the groups that are not statistically distinguishable. So the claim "A beats B" is only supported when A is to the left of B and no thick bar joins them.
Notes
critical_difference).See Also
References
Classes
Numbers behind a Critical Difference diagram.
plot_critical_difference so the statistics shown in the figure can be reported in text or asserted on in tests.Attributes
avg_ranks
plot_critical_difference. Lower is better; 1.0 means the algorithm won on every dataset. Keys follow the input order, not the sorted order drawn in the figure.
critical_difference
groups
p_value
test_statistic
n_algorithms - 1 degrees of freedom.
See Also
>>> from tuiml.evaluation.visualization import CDDiagramResult
>>> res = CDDiagramResult(
... avg_ranks={'A': 1.0, 'B': 2.0, 'C': 3.0},
... critical_difference=1.5,
... groups=[['A', 'B'], ['B', 'C']],
... p_value=0.03,
... test_statistic=6.0,
... )
>>> min(res.avg_ranks, key=res.avg_ranks.get)
'A'
Functions
Rank algorithms within each dataset, averaging tied ranks.
This is the first step of the Demšar (2006) comparison protocol: ranking per dataset removes the effect of datasets having wildly different score scales, which is what makes a plain average across datasets untrustworthy.
Each row of scores is ranked independently. The best algorithm on a row gets rank 1, the worst gets rank n_algorithms. Ties share the mean of the ranks they span, so two algorithms tying for first both get 1.5 and the next one gets 3 — rank sums stay comparable across rows.
Parameters
scores
lower_better
Returns
ranks
scores; 1 is best. Fractional values indicate ties.
Notes
j of the result always refers to the same algorithm as column j of the input — nothing is sorted. The dtype follows scores (via np.zeros_like), so pass a float array: an integer score matrix cannot represent the fractional ranks produced by ties.>>> import numpy as np
>>> from tuiml.evaluation.visualization import compute_ranks
>>> scores = np.array([[0.90, 0.85, 0.80],
... [0.70, 0.70, 0.65]])
>>> compute_ranks(scores)
array([[1. , 2. , 3. ],
[1.5, 1.5, 3. ]])
With an error metric, flip the direction:
>>> compute_ranks(np.array([[0.10, 0.15, 0.20]]), lower_better=True)
array([[1., 2., 3.]])
Smallest gap between two average ranks that counts as significant.
Implements the Nemenyi critical difference of Demšar (2006). For k algorithms compared over N datasets,
where q_{\alpha} is the Studentised range statistic at level \alpha divided by \sqrt{2}, tabulated below. Two algorithms whose average ranks differ by at least CD are declared significantly different; anything closer is joined by a bar in the diagram.
Two consequences worth internalising: the CD shrinks as you add datasets (\propto 1/\sqrt{N}) and grows as you add algorithms — throwing extra baselines into a benchmark makes every comparison harder to call.
Parameters
n_datasets
n_algorithms
alpha
alpha <= 0.05 selects the 0.05 critical values, anything larger selects the 0.10 values. Values such as 0.01 therefore behave exactly like 0.05.
test
Returns
cd
Notes
n_algorithms from 2 to 20. Above 20 the value is approximated by a linear extrapolation, and an unlisted count falls back to 3.5 — treat results for very large k as indicative only.See Also
References
>>> from tuiml.evaluation.visualization import critical_difference
>>> round(float(critical_difference(n_datasets=20, n_algorithms=5)), 3)
1.364
More datasets tighten the threshold:
>>> round(float(critical_difference(n_datasets=80, n_algorithms=5)), 3)
0.682
Draw a Critical Difference diagram comparing algorithms over many datasets.
The plot answers one question: which of these algorithms can I actually claim are different? Each algorithm is ranked within every dataset (see compute_ranks), the ranks are averaged, and a Nemenyi critical difference is computed from the number of algorithms and datasets.
How to read it. The horizontal axis is average rank, best (1) on the left, worst (k) on the right. Every algorithm hangs off the axis by a connector line ending at its average rank, with names printed on the left for the better half and on the right for the worse half. The short bar labelled CD at the top is a ruler showing how wide the critical difference is in rank units. The thick horizontal bars underneath join algorithms that are NOT significantly different — if a bar spans two algorithms, the data does not support preferring one over the other, no matter how their average ranks are ordered:
|------ CD ------|
1 2 3 4 5 6
|--------|--------|--------|--------|--------|
| | | | |
SVM Forest Boosting kNN NaiveBayes
|________| |________|
(tied) (tied)Here SVM is significantly better than everything to the right of Forest, Forest and Boosting are indistinguishable, and so are kNN and NaiveBayes.
Parameters
scores
names.
names
scores is an array.
lower_better
alpha
<= 0.05 from larger values — see critical_difference.
test
'wilcoxon' does not currently change the figure.
correction
title
figsize
save_path
Returns
result
result.p_value first: if the Friedman test is not significant, the ordering shown carries no statistical weight.
Raises
ImportError
ValueError
names is None while scores is an array, or if len(names) does not match the number of columns in scores.
Notes
Side effects: this function mutates the global matplotlib style (see apply_style), calls matplotlib.pyplot.show() before returning — so it blocks in a GUI backend and renders inline in a notebook — and writes a file when save_path is given. Use a non-interactive backend such as Agg to render headlessly. The figure object itself is not returned; grab it with matplotlib.pyplot.gcf() before show() clears the display if you need to post-process it.
Bars are computed from the pairwise rank gaps directly, so with many algorithms the bars can overlap; each one is drawn on its own row between the rank axis and the labels. The method connector lines pass through the bars, making group membership explicit.
See Also
compute_ranks
The per-dataset ranking step.
critical_difference
The CD value that sets the width of the bars.
friedman_test
The omnibus test whose statistic and p-value are reported in the result.
nemenyi_post_hoc
Pairwise p-values for a numeric write-up of the same comparison.
plot_boxplot_comparison
Score spread per algorithm, a useful companion figure.
References
>>> import numpy as np
>>> from tuiml.evaluation.visualization import plot_critical_difference
>>> scores = np.array([
... [0.85, 0.82, 0.78],
... [0.87, 0.84, 0.80],
... [0.83, 0.81, 0.79],
... ])
>>> names = ['Algorithm A', 'Algorithm B', 'Algorithm C']
>>> result = plot_critical_difference(scores, names) # doctest: +SKIP
>>> result.avg_ranks # doctest: +SKIP
{'Algorithm A': 1.0, 'Algorithm B': 2.0, 'Algorithm C': 3.0}
Passing a dict names the algorithms for you, and saves the figure:
>>> results = {
... 'SVM': np.array([0.91, 0.88, 0.93]),
... 'Forest': np.array([0.89, 0.90, 0.88]),
... 'kNN': np.array([0.80, 0.79, 0.84]),
... }
>>> res = plot_critical_difference(
... results, title='Accuracy over 3 datasets',
... save_path='cd.png') # doctest: +SKIP
For an error metric, flip the direction so rank 1 is the smallest value:
>>> errors = np.array([[0.15, 0.18, 0.22], [0.13, 0.16, 0.21]])
>>> res = plot_critical_difference(
... errors, names=['A', 'B', 'C'], lower_better=True) # doctest: +SKIP