Aller au contenu

L'Elo à marge : notre variante gagnante

Voici le chapitre le plus rentable par ligne de code de toute la partie.

Le moteur de Valve, comme l'Elo original, ne regarde qu'un bit par match : qui a gagné. Un 2-0 écrasant et un 2-1 arraché en prolongation sur la troisième map produisent exactement la même mise à jour de rating. Pourtant, tout spectateur sait que ces deux résultats ne disent pas la même chose.

Cette information est déjà dans nos données. Elle ne demande aucune collecte supplémentaire, aucune page web de plus, aucun risque de fuite temporelle : le score d'une série est écrit sur la même ligne que son vainqueur. L'exploiter a produit notre meilleure horloge en prédicteur pur : Brier 0.2398 contre 0.2423 pour la formule de Valve.

L'intuition : la marge est un signal de dominance

Un match de CS2 se joue en manches (rounds). Une série Bo3 se gagne en deux maps ; une map se gagne en 13 manches gagnées (16 avant le changement de format), avec prolongations.

À chaque niveau de granularité, le score porte de l'information que le simple vainqueur ne porte pas :

   série Bo3        2 – 0   ← domination nette, l'adversaire n'a rien pris
                    2 – 1   ← série disputée, pouvait basculer

   map (Bo1)       16 – 2   ← écrasement, 14 manches d'écart
                   16 – 14  ← quasi-égalité, une manche de prolongation décidait

Un 16-14 et un 16-2 comptent tous les deux comme « une victoire ». Mais la probabilité que le résultat s'inverse si on rejouait le match est très différente dans les deux cas. La marge est un estimateur bruité mais non biaisé de l'écart de force réel, là où le vainqueur seul est cet écart passé au travers d'une fonction de seuil qui détruit la moitié de l'information.

Intuition

Prédire une victoire, c'est prédire le signe d'une quantité. Prédire une marge, c'est prédire cette quantité elle-même. Toujours, en statistique, la deuxième tâche est plus informative — à condition que la marge ne soit pas polluée par autre chose que la force. C'est justement là que se trouve tout le débat de la littérature, et on y vient.

Notre facteur de marge

Le code tient en cinq lignes, et il vit à deux endroits : dans le script d'exploration scratch/elo_variants.py et, une fois la variante retenue, dans le code de production vrs/predict.py.

@staticmethod
def margin(match):
    """2-0 -> 1.5, 2-1 -> 1.0 ; en Bo1 la marge de manches, bornée. Sans score : 1.0."""
    hi, lo = max(match.score1, match.score2), min(match.score1, match.score2)
    if hi == 0:
        return 1.0
    if hi <= 3:
        return 1.5 if lo == 0 else 1.0
    return 0.75 + (hi - lo) / max(hi + lo, 1)

Il fait deux choses différentes selon ce que le score représente.

Si le score est un score de série (le vainqueur a 2 ou 3 maps, donc hi <= 3) : un blanchissage vaut \(1{,}5\), une série disputée vaut \(1{,}0\). C'est volontairement grossier — deux valeurs, pas de gradient.

Si le score est un score de manches (un Bo1, donc hi vaut 13, 16 ou plus) :

\[ m = 0{,}75 + \frac{\text{hi} - \text{lo}}{\text{hi} + \text{lo}} \]

où \(\text{hi}\) est le nombre de manches du vainqueur et \(\text{lo}\) celui du perdant. Le rapport \((\text{hi}-\text{lo})/(\text{hi}+\text{lo})\) est la part d'écart : il vaut 0 pour une égalité parfaite et tend vers 1 pour un 16-0. Le \(0{,}75\) est un plancher — même un match ultra-serré compte pour quelque chose.

La mise à jour devient alors :

\[ r_A \leftarrow r_A + K \, m \, (y - E) \]

Les symboles :

  • \(m\) : le facteur de marge défini ci-dessus, typiquement entre \(0{,}75\) et \(1{,}75\) ;
  • \(K\) : le pas de base, fixé à \(30\) — la valeur du Glicko de Valve, démontrée au chapitre 2 ;
  • \(y\), \(E\) : le résultat et l'espérance, comme au chapitre 1.

Autrement dit : la marge module \(K\), match par match.

Exemple numérique

Reprenons les deux équipes du chapitre 1, à 1650 et 1500, donc \(E = 0{,}7034\), avec \(K = 30\).

résultat \(m\) si la favorite gagne si la favorite perd
2-1 (série disputée) 1.000 +8,90 −21,10
2-0 (blanchissage) 1.500 +13,35 −31,65
Bo1 16-14 0.817 +7,27 −17,23
Bo1 16-9 1.030 +9,16 −21,74
Bo1 16-2 1.528 +13,59 −32,24

Deux lectures à retenir.

D'abord, un 2-0 déplace 1,5 fois plus qu'un 2-1. L'horloge à marge accumule donc plus vite de l'écart entre une équipe qui domine et une équipe qui gagne à l'arraché.

Ensuite — et c'est un point qu'on oublie souvent — la marge amplifie aussi les pertes. Une favorite à 70 % qui se fait blanchir 0-2 lâche 31,65 points, contre 21,10 pour une défaite 1-2. Le facteur multiplie la surprise, quel que soit son signe. C'est cohérent : perdre sans prendre une map est plus surprenant que perdre de justesse.

Erreur fréquente

Croire qu'un facteur de marge « récompense » les grosses victoires. Il ne récompense rien : il pondère la confiance qu'on accorde à l'observation. Un facteur de marge qui ne s'appliquerait qu'aux gains créerait de l'inflation de rating et casserait la propriété de somme nulle d'Elo.

Notre mesure : 0.2398 contre 0.2423

Protocole identique à celui du chapitre 1 : prédicteur pur (la probabilité est directement la logistique de l'écart de rating), balayage chronologique complet, holdout des 42 derniers jours — 1 146 matchs.

Voici la mesure de la campagne, datée du 2026-08-24 (scratch/elo_variants.py, commit fbe621d) :

variante accuracy Brier log-loss
Glicko de Valve (baseline) 56,0 % 0.2423 0.6773
Elo \(K = 90\), sans marge 57,8 % 0.2478 —
Elo \(K = 30\) + marge — 0.2398 0.6725

Ces valeurs — 0.2398 contre 0.2423 — sont celles qui font foi dans tout le cours : ce sont elles qui ont motivé la décision, et elles sont citées telles quelles dans le code de production (vrs/predict.py). Relancé en fin de campagne sur le pool élargi (6 080 matchs, holdout de 1 176), le même script donne 0.2404 contre 0.2428 : les décimales bougent, l'écart persiste, la conclusion est stable.

Le gain de 0,0025 de Brier peut sembler petit. Remettons-le à l'échelle : l'écart total entre la baseline de Valve (0.2422) et le leader final de la campagne (segmodel, 0.2041) est de 0,038. Ce chapitre en prend 6,5 % — pour cinq lignes de code et zéro donnée supplémentaire.

Et surtout, contrairement au \(K = 90\) du chapitre 1, le gain est propre : le Brier ET le log-loss s'améliorent en même temps. Ce n'est pas un modèle qui se crispe, c'est un modèle qui apprend.

Pour aller plus loin — l'ordre est-il robuste ?

Le détail de la relance du 2026-08-25 reproduit l'ordre exactement : Valve 0.2428, \(K=30\) + marge 0.2404, \(K=45\) + marge 0.2417, \(K=60\) + marge 0.2443. Le \(K\) optimal avec marge reste 30 : au-delà, le facteur de marge et un \(K\) trop grand se cumulent et l'horloge redevient surconfiante — exactement le piège du \(K = 90\) du chapitre 1, atteint par un autre chemin.

Pourquoi la marge est de l'information gratuite

Le mot « gratuite » n'est pas rhétorique. Il désigne une propriété technique précise, et c'est la vraie leçon de ce chapitre.

Elle est déjà collectée. Les pages de résultats HLTV que nous parsions déjà pour connaître le vainqueur portent le score sur la même ligne. Ajouter la marge a consisté à faire porter deux champs de plus à l'objet Match :

# Score de la serie en manches (2-0, 2-1, 16-12 en Bo1...) : porte la marge, que le
# moteur de Valve ignore mais que les modeles de victoire peuvent exploiter.
score1: int = 0
score2: int = 0

Elle ne crée aucun risque de fuite temporelle. C'est le point critique. Une feature construite après coup — les stats d'un joueur sur les six derniers mois, par exemple — demande une vigilance permanente pour ne jamais inclure du futur. La marge d'un match, elle, est utilisée après ce match, pour mettre à jour un état qui servira au match suivant. Le balayage chronologique la rend automatiquement sûre.

Elle ne coûte rien en complexité de modèle. L'Elo à marge produit un seul nombre, comme l'Elo ordinaire. Il entre dans le modèle final comme une feature de plus (melo_diff), sans augmenter la dimension d'apprentissage de façon significative.

À retenir

Avant d'aller collecter des données nouvelles, épuisez celles que vous avez. Le score d'un match était dans notre cache HTML depuis le premier jour ; il a fallu la question « et si on essayait une formule d'Elo ? » pour aller le chercher. Cinq lignes plus tard, la meilleure horloge en prédicteur pur de tout le projet.

melo_diff devient la feature numéro un

L'Elo à marge n'est pas resté un exercice de style. Il est entré dans le modèle de production sous le nom melo_diff (l'écart d'Elo-marge entre les deux équipes) :

FEATURES = [
    "glicko_diff",        # écart de rating courant (le signal de la baseline)
    "melo_diff",          # Elo à marge (2-0 pèse plus qu'un 2-1) : meilleure horloge mesurée
                          # en prédicteur pur — Brier 0.2398 contre 0.2423 pour Valve
    ...
]

Et il est immédiatement devenu la feature la plus importante du XGBoost, avec un gain de 18,6 — devant la forme récente (15,7), l'expérience (9,8) et le Glicko de Valve lui-même (8,8). Le modèle est passé de 0.2340 à 0.2330 de Brier et de 58,5 % à 60,4 % d'accuracy sur le même holdout.

modèle accuracy Brier log-loss
Glicko (baseline) 56,0 % 0.242 0.677
XGBoost v1 (sans melo_diff) 58,5 % 0.234 0.659
XGBoost v2 (avec melo_diff) 60,4 % 0.233 0.657

Ce XGBoost v2 est le champion de départ du concours de modèles : le repère que les six agents du concours ont eu pour mission de battre. Une horloge à marge, autrement dit, est le point de départ de toute la seconde moitié du projet.

Le lien avec la littérature : les modèles MOV

L'idée d'incorporer la marge de victoire — margin of victory, ou MOV — dans un Elo n'est pas de nous. Elle est bien documentée, principalement par FiveThirtyEight (NFL, NBA) et par la littérature sur le hockey (NHL). Elle vient avec un avertissement sérieux qu'il faut comprendre.

La forme logarithmique

Toutes les implémentations sérieuses écrasent la marge par un logarithme :

\[ \text{multiplicateur} \propto \ln(1 + \text{marge}) \]

La raison est simple : la marge est hétéroscédastique et à queue lourde. Gagner de 20 points au basket au lieu de 10 ne veut pas dire « deux fois plus dominant » — souvent, cela veut dire que l'adversaire a abandonné dans les dernières minutes. Le logarithme sature : la différence entre une marge de 1 et de 3 compte beaucoup, celle entre 15 et 20 presque pas.

Quelques valeurs :

marge (manches) \(\ln(1+\text{marge})\)
1 0.693
3 1.386
9 2.303
13 2.639

La correction d'autocorrélation

C'est l'ingrédient le moins évident, et le plus important. Il corrige un biais mécanique : les grosses victoires arrivent surtout aux équipes déjà bien classées, tout simplement parce qu'elles jouent souvent contre plus faible. Sans correction, l'Elo à marge s'emballe : un favori qui écrase gagne beaucoup de points, ce qui le rend plus favori, ce qui lui fait écraser davantage. La série des ratings devient autocorrélée et l'échelle se dilate sans fin.

FiveThirtyEight corrige par un facteur qui atténue le multiplicateur quand le vainqueur était déjà très supérieur :

\[ \text{mult} = \ln(1 + \text{marge}) \times \frac{2{,}2}{0{,}001 \times \Delta_{\text{gagnant}} + 2{,}2} \]

où \(\Delta_{\text{gagnant}}\) est l'écart de rating du point de vue du vainqueur : positif s'il était favori, négatif s'il était outsider. Notre implémentation la transcrit telle quelle dans scratch/ml/push/mapfeats.py :

# Elo MOV (538) : mult = ln(1+marge) * 2.2/(0.001*diff_gagnant + 2.2)
mult = math.log1p(margin) * 2.2 / (0.001 * diff_w + 2.2)

Exemple numérique, avec une marge de 3 manches :

situation \(\Delta_{\text{gagnant}}\) multiplicateur
équipes à égalité 0 1.386
le favori (+200) écrase +200 1.271
l'outsider (−200) écrase −200 1.525

Le même 3 manches d'écart vaut 20 % de plus quand c'est l'outsider qui l'inflige. C'est exactement l'effet recherché.

Ce que la version savante a donné chez nous

Voici la partie honnête. Nous avons implémenté la version FiveThirtyEight complète — logarithme, correction d'autocorrélation, granularité map plutôt que série (14 963 maps contre 6 062 matchs, donc bien plus d'observations) — sous le nom elo_mov_diff, dans la campagne push/.

Résultat en validation interne, sur la base des 32 features du modèle final/ :

variante Brier (validation)
base 32 features 0.2285
+ elo_mov_diff (538 complet, par map) 0.2279
+ mapelo (Elo par map, blend 50/50) 0.2280
+ pi_diff_a (pi-ratings, cf. chapitre 5) 0.2272

Le raffinement à la 538 apporte 0,0006 — réel, mais trois fois moins que notre facteur grossier « 2-0 vaut 1,5 » n'avait apporté au niveau série. Et il perd contre les pi-ratings, qui sont une autre façon d'attaquer le même problème.

Limite importante

Il ne faut pas en conclure « la version 538 est mauvaise ». Il faut en conclure que la marge de série et la marge de manches ne sont pas la même information, et que la seconde était déjà largement captée par les autres features map-granularité du modèle (round_share_diff, rshare_diff, les pi-ratings). Un signal qui n'apporte rien en plus n'est pas un signal faible : c'est un signal redondant. La distinction est cruciale et revient plusieurs fois dans ce cours.

Le mot de la fin appartient au journal de la campagne push/ : « Le signal map-granularité passe, mais par les HORLOGES, pas par la recomposition. »

Résumé

À retenir

L'Elo à marge multiplie le pas \(K\) par un facteur \(m\) dépendant du score : \(1{,}5\) pour un blanchissage de série, \(0{,}75 + \text{part d'écart}\) pour un Bo1. Mesure sur notre holdout de 1 146 matchs : Brier 0.2398 et log-loss 0.6725, contre 0.2423 et 0.6773 pour Valve — le meilleur prédicteur pur simple du projet. En feature (melo_diff), il devient la variable la plus importante du XGBoost champion (gain 18,6). La leçon générale : la marge était de l'information déjà collectée, sans risque de fuite, exploitable en cinq lignes.

Chapitre suivant : Glicko-2 et TrueSkill, où l'on ressuscite l'incertitude que Valve avait figée — et où l'on change carrément d'objet suivi.