Some recent cheminformatics papers have been using “win rate” (i.e., how often a model is the best across a set of tasks) to evaluate benchmarking performance. Although it can be a useful proxy for how good a model is, I have some gripes with this metric, particularly if it is presented without additional context.
Firstly, it can hide small differences; a model may achieve the top-ranked performance, but with only a very small margin over the next best model. Secondly, win rate is not a statistical test for ranking; a seemingly high win rate may not translate into clear separation from alternative approaches. Finally, win rate can hide weak points; a model that wins 80% of tasks but performs awfully on the rest appears more trustworthy than it perhaps should be. A more consistent model with a lower win rate may therefore be more useful in practice.
Fortunately there are a couple of remedies for these issues:
- Within each task, compare models with repeated cross-validation results and statistical tests, such as multiple comparison tests [1], like Tukey’s HSD, or pairwise tests with a p-value correction, like Wilcoxon signed-rank tests. Test results should be reported with appropriate effect sizes, such as Cohen’s d for Tukey and rank-biserial correlation for Wilcoxon. These tests enable us to observe mean rankings of the models with significance taken into consideration.
- Subsequently, we can use a Friedman test on model metrics across tasks [2-3]. The Friedman test is for detecting whether average rankings of groups (i.e., models) are significantly different across different settings (i.e., tasks). Because it averages over every task, a model that wins often but fails badly elsewhere is penalised rather than rewarded.
Here’s a worked example with simulated data:
First, basic imports and variable definitions:
import numpy as np, pandas as pd
from itertools import combinations
from scipy.stats import studentized_range, friedmanchisquare, wilcoxon
rng = np.random.default_rng(42)
models = ["New Model", "Old Model", "Baseline A", "Baseline B"]
n_tasks, n_cv = 10, 25 # 5 x 5 repeated CV
Next, a function for simulating our task data:
def simulate(task):
base = rng.uniform(0.55, 0.75) # a random starting point for our simulated metric
means = base + np.array([0.015, 0.01, -0.005, -0.01]) # some base means for each model
if task in (3, 8):
means[0] = base - 0.10 # Set `New Model` to fail on two tasks
fold = rng.normal(0, 0.04, (n_cv, 1)) # add a shared effect per fold
return means + fold + rng.normal(0, 0.02, (n_cv, len(models)))
Now, we need functions for our within task assessment:
def paired_cohens_d(group1, group2):
"""Paired version of Cohen's d."""
diff = group1 - group2
diff_mean = np.mean(diff)
diff_sd = np.std(diff, ddof=1)
return diff_mean / diff_sd
def tukey(scores):
"""
Tukey's HSD using repeated measures of ANOVA
(to account for fold pairs), plus Cohen's d.
"""
n, k = scores.shape
resid = scores - scores.mean(0) - scores.mean(1, keepdims=True) + scores.mean()
df = (n - 1) * (k - 1)
se = np.sqrt((resid**2).sum() / df / n)
rows = []
for i, j in combinations(range(k), 2):
diff = scores[:, i].mean() - scores[:, j].mean()
p = studentized_range.sf(abs(diff) / se, k, df)
cohen_d_val = paired_cohens_d(scores[:, i], scores[:, j])
rows.append((
models[i], models[j], diff,
cohen_d_val, p
))
return pd.DataFrame(rows, columns=["model_1", "model_2", "diff", "paired_cohens_d", "p_val"])
Let’s run it and display an example comparison table from the first task:
wins, sig_ranks, ranks = pd.Series(0, index=models), [], []
score_means = []
for t in range(n_tasks):
scores = simulate(t)
score_means.append(scores.mean(0))
comparison = tukey(scores)
if t == 0:
display(comparison.round(3))
wins[models[scores.mean(0).argmax()]] += 1
sig = comparison[comparison.p_val < 0.05]
worse = np.where(sig["diff"] > 0, sig.model_2, sig.model_1)
sig_ranks.append(1 + pd.Series(worse).value_counts().reindex(models, fill_value=0))
sorted_models = [models[i] for i in scores.mean(0).argsort()[::-1]]
ranks.append(pd.Series(
[1 + i for i in range(len(sorted_models))],
index=sorted_models
))
score_means = np.array(score_means)
sig_ranks = pd.DataFrame(sig_ranks, index=[f"Task {t + 1}" for t in range(n_tasks)])
ranks = pd.DataFrame(
ranks,
index=[f"Task {t + 1}" for t in range(n_tasks)],
columns=models
)
| model_1 | model_2 | diff | paired_cohens_d | p_val | |
|---|---|---|---|---|---|
| 0 | New Model | Old Model | 0.005 | 0.231 | 0.701 |
| 1 | New Model | Baseline A | 0.024 | 1.071 | 0.000 |
| 2 | New Model | Baseline B | 0.026 | 1.097 | 0.000 |
| 3 | Old Model | Baseline A | 0.020 | 0.999 | 0.000 |
| 4 | Old Model | Baseline B | 0.021 | 0.993 | 0.000 |
| 5 | Baseline A | Baseline B | 0.002 | 0.075 | 0.983 |
Next, we can investigate the mean rankings:
summary = pd.DataFrame({
"Win Rate": wins / n_tasks,
"Mean Rank with Tukey": sig_ranks.mean(),
"Mean Rank": ranks.mean(),
}).sort_values("Mean Rank with Tukey")
display(summary.round(2))
| Win Rate | Mean Rank with Tukey | Mean Rank | |
|---|---|---|---|
| Old Model | 0.2 | 1.0 | 1.8 |
| New Model | 0.8 | 1.6 | 1.6 |
| Baseline A | 0.0 | 2.2 | 3.1 |
| Baseline B | 0.0 | 2.5 | 3.5 |
So, are these difference in ranking significant? Let’s try the Friedman test:
stat, p = friedmanchisquare(*score_means.T)
print(f"Friedman: chi2 = {stat:.2f}, p = {p:.4f}")
Friedman: chi2 = 15.96, p = 0.0012
So, the Friedman result is significant, and at least one of the models is separable from the rest. To work out which models are causing this, we can use a non-parametric post-hoc test, such as Wilcoxon or Nemenyi:
def bonferroni_correction(p_val, m):
"""Apply Bonferroni correction to p-values."""
return np.minimum(p_val * m, 1.0)
results = pd.DataFrame(columns=[i for i in models], index=[i for i in models], dtype=float)
num_comparisons = len(models) * (len(models) - 1) / 2
for i, j in combinations(range(len(models)), 2):
stat, p = wilcoxon(score_means[:, i], score_means[:, j])
p = bonferroni_correction(p, num_comparisons)
results.iloc[i, j] = p
results.iloc[j, i] = p
results = results.round(4)
display(results)
| New Model | Old Model | Baseline A | Baseline B | |
|---|---|---|---|---|
| New Model | NaN | 1.0000 | 1.0000 | 1.0000 |
| Old Model | 1.0 | NaN | 0.0117 | 0.0117 |
| Baseline A | 1.0 | 0.0117 | NaN | 0.1641 |
| Baseline B | 1.0 | 0.0117 | 0.1641 | NaN |
So, the only significant differences in model ranking across tasks are between Old Model and the two baselines. Old Model is reliably better than both baselines, whereas New Model can’t be separated from any other model, despite an 80% win rate and the lowest mean rank (without considering ties by Tukey p-values).
Together, these address all three issues. Within tasks, effect sizes show whether metric differences are meaningful and statistical tests show whether they’re significant. Across tasks, average ranks reward consistency and the Friedman test shows whether differences in average rank are significant.
References
[1] Ash, J.R., et al., Practically Significant Method Comparison Protocols for Machine Learning in Small Molecule Drug Discovery. J. Chem. Inf. Model. 22 September 2025; 65 (18): 9398–9411. https://doi.org/10.1021/acs.jcim.5c01609
[2] Friedman, M. The Use of Ranks to Avoid the Assumption of Normality Implicit in the Analysis of Variance. J. Am. Stat. Assoc. 1937; 32 (200): 675–701. https://doi.org/10.1080/01621459.1937.10503522
[3] Demšar, J. Statistical Comparisons of Classifiers over Multiple Data Sets. J. Mach. Learn. Res. 2006; 7: 1–30. https://jmlr.org/papers/v7/demsar06a.html
