{"id":15449,"date":"2026-10-30T16:08:03","date_gmt":"2026-10-30T16:08:03","guid":{"rendered":"https:\/\/www.blopig.com\/blog\/?p=15449"},"modified":"2026-10-30T16:19:50","modified_gmt":"2026-10-30T16:19:50","slug":"stop-using-win-rate-comparing-model-rankings-across-different-benchmark-tasks","status":"publish","type":"post","link":"https:\/\/www.blopig.com\/blog\/2026\/10\/stop-using-win-rate-comparing-model-rankings-across-different-benchmark-tasks\/","title":{"rendered":"Stop using win rate: comparing model rankings across different benchmark tasks"},"content":{"rendered":"\n<p class=\"wp-block-paragraph\">Some recent cheminformatics papers have been using &#8220;win rate&#8221; (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.<br><br>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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Fortunately there are a couple of remedies for these issues:<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li class=\"\">Within each task, compare models with repeated cross-validation results and statistical tests, such as multiple comparison tests [1], like Tukey&#8217;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&#8217;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.<\/li>\n\n\n\n<li class=\"\">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.<\/li>\n<\/ol>\n\n\n\n<p class=\"wp-block-paragraph\">Here&#8217;s a worked example with simulated data:<\/p>\n\n\n\n<!--more-->\n\n\n\n<p class=\"wp-block-paragraph\">First, basic imports and variable definitions:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>import numpy as np, pandas as pd\nfrom itertools import combinations\nfrom scipy.stats import studentized_range, friedmanchisquare, wilcoxon\n\nrng = np.random.default_rng(42)\nmodels = &#091;\"New Model\", \"Old Model\", \"Baseline A\", \"Baseline B\"]\nn_tasks, n_cv = 10, 25  # 5 x 5 repeated CV<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Next, a function for simulating our task data:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>def simulate(task):\n    base = rng.uniform(0.55, 0.75) # a random starting point for our simulated metric\n    means = base + np.array(&#091;0.015, 0.01, -0.005, -0.01]) # some base means for each model\n    if task in (3, 8):\n        means&#091;0] = base - 0.10 # Set `New Model` to fail on two tasks\n    fold = rng.normal(0, 0.04, (n_cv, 1)) # add a shared effect per fold\n    return means + fold + rng.normal(0, 0.02, (n_cv, len(models)))<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Now, we need functions for our within task assessment:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>def paired_cohens_d(group1, group2):\n    \"\"\"Paired version of Cohen's d.\"\"\"\n    diff = group1 - group2\n    diff_mean = np.mean(diff)\n    diff_sd = np.std(diff, ddof=1)\n    return diff_mean \/ diff_sd\n\ndef tukey(scores):\n    \"\"\"\n    Tukey's HSD using repeated measures of ANOVA\n    (to account for fold pairs), plus Cohen's d.\n    \"\"\"\n    n, k = scores.shape\n    resid = scores - scores.mean(0) - scores.mean(1, keepdims=True) + scores.mean()\n    df = (n - 1) * (k - 1)\n    se = np.sqrt((resid**2).sum() \/ df \/ n)\n    rows = &#091;]\n    for i, j in combinations(range(k), 2):\n        diff = scores&#091;:, i].mean() - scores&#091;:, j].mean()\n        p = studentized_range.sf(abs(diff) \/ se, k, df)\n        cohen_d_val = paired_cohens_d(scores&#091;:, i], scores&#091;:, j])\n        rows.append((\n            models&#091;i], models&#091;j], diff,\n            cohen_d_val, p\n        ))\n    return pd.DataFrame(rows, columns=&#091;\"model_1\", \"model_2\", \"diff\", \"paired_cohens_d\", \"p_val\"])<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Let&#8217;s run it and display an example comparison table from the first task:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>wins, sig_ranks, ranks = pd.Series(0, index=models), &#091;], &#091;]\nscore_means = &#091;]\nfor t in range(n_tasks):\n    scores = simulate(t)\n    score_means.append(scores.mean(0))\n    comparison = tukey(scores)\n    if t == 0:\n        display(comparison.round(3))\n    wins&#091;models&#091;scores.mean(0).argmax()]] += 1\n    sig = comparison&#091;comparison.p_val &lt; 0.05]\n    worse = np.where(sig&#091;\"diff\"] &gt; 0, sig.model_2, sig.model_1)\n    sig_ranks.append(1 + pd.Series(worse).value_counts().reindex(models, fill_value=0))\n    sorted_models = &#091;models&#091;i] for i in scores.mean(0).argsort()&#091;::-1]]\n    ranks.append(pd.Series(\n        &#091;1 + i for i in range(len(sorted_models))],\n        index=sorted_models\n    ))\nscore_means = np.array(score_means)\nsig_ranks = pd.DataFrame(sig_ranks, index=&#091;f\"Task {t + 1}\" for t in range(n_tasks)])\nranks = pd.DataFrame(\n    ranks,\n    index=&#091;f\"Task {t + 1}\" for t in range(n_tasks)],\n    columns=models\n)<\/code><\/pre>\n\n\n\n<div>\n<table border=\"1\" class=\"dataframe\">\n  <thead>\n    <tr style=\"text-align: right\">\n      <th><\/th>\n      <th>model_1<\/th>\n      <th>model_2<\/th>\n      <th>diff<\/th>\n      <th>paired_cohens_d<\/th>\n      <th>p_val<\/th>\n    <\/tr>\n  <\/thead>\n  <tbody>\n    <tr>\n      <th>0<\/th>\n      <td>New Model<\/td>\n      <td>Old Model<\/td>\n      <td>0.005<\/td>\n      <td>0.231<\/td>\n      <td>0.701<\/td>\n    <\/tr>\n    <tr>\n      <th>1<\/th>\n      <td>New Model<\/td>\n      <td>Baseline A<\/td>\n      <td>0.024<\/td>\n      <td>1.071<\/td>\n      <td>0.000<\/td>\n    <\/tr>\n    <tr>\n      <th>2<\/th>\n      <td>New Model<\/td>\n      <td>Baseline B<\/td>\n      <td>0.026<\/td>\n      <td>1.097<\/td>\n      <td>0.000<\/td>\n    <\/tr>\n    <tr>\n      <th>3<\/th>\n      <td>Old Model<\/td>\n      <td>Baseline A<\/td>\n      <td>0.020<\/td>\n      <td>0.999<\/td>\n      <td>0.000<\/td>\n    <\/tr>\n    <tr>\n      <th>4<\/th>\n      <td>Old Model<\/td>\n      <td>Baseline B<\/td>\n      <td>0.021<\/td>\n      <td>0.993<\/td>\n      <td>0.000<\/td>\n    <\/tr>\n    <tr>\n      <th>5<\/th>\n      <td>Baseline A<\/td>\n      <td>Baseline B<\/td>\n      <td>0.002<\/td>\n      <td>0.075<\/td>\n      <td>0.983<\/td>\n    <\/tr>\n  <\/tbody>\n<\/table>\n<\/div>\n\n\n\n<p class=\"wp-block-paragraph\">Next, we can investigate the mean rankings:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>summary = pd.DataFrame({\n    \"Win Rate\": wins \/ n_tasks,\n    \"Mean Rank with Tukey\": sig_ranks.mean(),\n    \"Mean Rank\": ranks.mean(),\n}).sort_values(\"Mean Rank with Tukey\")\ndisplay(summary.round(2))<\/code><\/pre>\n\n\n\n<div>\n<table border=\"1\" class=\"dataframe\">\n  <thead>\n    <tr style=\"text-align: right\">\n      <th><\/th>\n      <th>Win Rate<\/th>\n      <th>Mean Rank with Tukey<\/th>\n      <th>Mean Rank<\/th>\n    <\/tr>\n  <\/thead>\n  <tbody>\n    <tr>\n      <th>Old Model<\/th>\n      <td>0.2<\/td>\n      <td>1.0<\/td>\n      <td>1.8<\/td>\n    <\/tr>\n    <tr>\n      <th>New Model<\/th>\n      <td>0.8<\/td>\n      <td>1.6<\/td>\n      <td>1.6<\/td>\n    <\/tr>\n    <tr>\n      <th>Baseline A<\/th>\n      <td>0.0<\/td>\n      <td>2.2<\/td>\n      <td>3.1<\/td>\n    <\/tr>\n    <tr>\n      <th>Baseline B<\/th>\n      <td>0.0<\/td>\n      <td>2.5<\/td>\n      <td>3.5<\/td>\n    <\/tr>\n  <\/tbody>\n<\/table>\n<\/div>\n\n\n\n<p class=\"wp-block-paragraph\">So, are these difference in ranking significant? Let&#8217;s try the Friedman test:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>stat, p = friedmanchisquare(*score_means.T)\nprint(f\"Friedman: chi2 = {stat:.2f}, p = {p:.4f}\")<\/code><\/pre>\n\n\n\nFriedman: chi2 = 15.96, p = 0.0012\n\n\n\n<p class=\"wp-block-paragraph\">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:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>def bonferroni_correction(p_val, m):\n    \"\"\"Apply Bonferroni correction to p-values.\"\"\"\n    return np.minimum(p_val * m, 1.0)\n\nresults = pd.DataFrame(columns=&#091;i for i in models], index=&#091;i for i in models], dtype=float)\nnum_comparisons = len(models) * (len(models) - 1) \/ 2\nfor i, j in combinations(range(len(models)), 2):\n    stat, p = wilcoxon(score_means&#091;:, i], score_means&#091;:, j])\n    p = bonferroni_correction(p, num_comparisons)\n    results.iloc&#091;i, j] = p\n    results.iloc&#091;j, i] = p\nresults = results.round(4)\ndisplay(results)<\/code><\/pre>\n\n\n\n<div>\n<table border=\"1\" class=\"dataframe\">\n  <thead>\n    <tr style=\"text-align: right\">\n      <th><\/th>\n      <th>New Model<\/th>\n      <th>Old Model<\/th>\n      <th>Baseline A<\/th>\n      <th>Baseline B<\/th>\n    <\/tr>\n  <\/thead>\n  <tbody>\n    <tr>\n      <th>New Model<\/th>\n      <td>NaN<\/td>\n      <td>1.0000<\/td>\n      <td>1.0000<\/td>\n      <td>1.0000<\/td>\n    <\/tr>\n    <tr>\n      <th>Old Model<\/th>\n      <td>1.0<\/td>\n      <td>NaN<\/td>\n      <td>0.0117<\/td>\n      <td>0.0117<\/td>\n    <\/tr>\n    <tr>\n      <th>Baseline A<\/th>\n      <td>1.0<\/td>\n      <td>0.0117<\/td>\n      <td>NaN<\/td>\n      <td>0.1641<\/td>\n    <\/tr>\n    <tr>\n      <th>Baseline B<\/th>\n      <td>1.0<\/td>\n      <td>0.0117<\/td>\n      <td>0.1641<\/td>\n      <td>NaN<\/td>\n    <\/tr>\n  <\/tbody>\n<\/table>\n<\/div>\n\n\n\n<p class=\"wp-block-paragraph\">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\u2019t be separated from any other model, despite an 80% win rate and the lowest mean rank (without considering ties by Tukey p-values).<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Together, these address all three issues. Within tasks, effect sizes show whether metric differences are meaningful and statistical tests show whether they\u2019re significant. Across tasks, average ranks reward consistency and the Friedman test shows whether differences in average rank are significant.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">References<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">[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\u20139411. <a href=\"https:\/\/doi.org\/10.1021\/acs.jcim.5c01609\">https:\/\/doi.org\/10.1021\/acs.jcim.5c01609<\/a><br>[2] Friedman, M. The Use of Ranks to Avoid the Assumption of Normality Implicit in the Analysis of Variance. <em>J. Am. Stat. Assoc.<\/em> 1937; 32 (200): 675\u2013701. <a href=\"https:\/\/doi.org\/10.1080\/01621459.1937.10503522\">https:\/\/doi.org\/10.1080\/01621459.1937.10503522<\/a><br>[3] Dem\u0161ar, J. Statistical Comparisons of Classifiers over Multiple Data Sets. <em>J. Mach. Learn. Res.<\/em> 2006; 7: 1\u201330. <a href=\"https:\/\/jmlr.org\/papers\/v7\/demsar06a.html\">https:\/\/jmlr.org\/papers\/v7\/demsar06a.html<\/a><\/p>\n","protected":false},"excerpt":{"rendered":"<p>Some recent cheminformatics papers have been using &#8220;win rate&#8221; (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, [&hellip;]<\/p>\n","protected":false},"author":118,"featured_media":0,"comment_status":"closed","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"nf_dc_page":"","wikipediapreview_detectlinks":true,"_monsterinsights_skip_tracking":false,"ngg_post_thumbnail":0,"_jetpack_newsletter_access":"","_jetpack_dont_email_post_to_subs":false,"_jetpack_newsletter_tier_id":0,"_jetpack_memberships_contains_paywalled_content":false,"_jetpack_memberships_contains_paid_content":false,"nfd_image_url":"","nfd_onboarding_generated":"","footnotes":"","jetpack_post_was_ever_published":false,"_ppma_block_editor_authors":""},"categories":[361,227,278],"tags":[],"ppma_author":[760],"class_list":["post-15449","post","type-post","status-publish","format-standard","hentry","category-data-science","category-python-code","category-statistics"],"jetpack_sharing_enabled":true,"authors":[{"term_id":760,"user_id":118,"is_guest":0,"slug":"sam","display_name":"Sam Money-Kyrle","avatar_url":"https:\/\/secure.gravatar.com\/avatar\/784870e2ed5304f12f11366dad56cbf1c0b9aa63bd80021ae235ba5f30536a12?s=96&d=mm&r=g","avatar_alt":"","avatar_decorative":"","author_category":"","user_url":"","last_name":"Money-Kyrle","first_name":"Sam","job_title":"","description":""}],"jetpack_featured_media_url":"","_links":{"self":[{"href":"https:\/\/www.blopig.com\/blog\/wp-json\/wp\/v2\/posts\/15449","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/www.blopig.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/www.blopig.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/www.blopig.com\/blog\/wp-json\/wp\/v2\/users\/118"}],"replies":[{"embeddable":true,"href":"https:\/\/www.blopig.com\/blog\/wp-json\/wp\/v2\/comments?post=15449"}],"version-history":[{"count":5,"href":"https:\/\/www.blopig.com\/blog\/wp-json\/wp\/v2\/posts\/15449\/revisions"}],"predecessor-version":[{"id":15459,"href":"https:\/\/www.blopig.com\/blog\/wp-json\/wp\/v2\/posts\/15449\/revisions\/15459"}],"wp:attachment":[{"href":"https:\/\/www.blopig.com\/blog\/wp-json\/wp\/v2\/media?parent=15449"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/www.blopig.com\/blog\/wp-json\/wp\/v2\/categories?post=15449"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/www.blopig.com\/blog\/wp-json\/wp\/v2\/tags?post=15449"},{"taxonomy":"author","embeddable":true,"href":"https:\/\/www.blopig.com\/blog\/wp-json\/wp\/v2\/ppma_author?post=15449"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}