Aller au contenu

pi-ratings et rang HLTV : changer d'objectif, changer d'univers

Les quatre premiers chapitres ont tous modélisé la même chose : la probabilité qu'une équipe batte l'autre. Ce chapitre en présente deux qui rompent avec cette habitude, chacune à sa manière.

Les pi-ratings changent d'objectif : au lieu de prédire qui gagne, ils prédisent de combien. Le rang mondial HLTV change d'univers : c'est une horloge que nous n'avons pas construite, qui vit ailleurs, avec une mémoire bien plus longue que la nôtre — et qu'il a fallu aller déterrer dans les info-bulles de graphiques JavaScript.

La seconde a rapporté à elle seule tout le gain de l'étape squeeze de la campagne.

pi-ratings : prédire un écart, pas un vainqueur

Les pi-ratings viennent des travaux d'Anthony Constantinou et Norman Fenton sur le football (2013). Leur point de départ est une critique d'Elo qui prolonge celle du chapitre 3 : si la marge porte de l'information, pourquoi la traiter comme une correction du modèle de victoire plutôt que comme la quantité à modéliser ?

D'où le renversement : le rating d'une équipe n'est plus une position sur une échelle abstraite, c'est directement un nombre de manches.

Intuition

Un rating Elo de 1620 ne veut rien dire tout seul — il ne prend sens que par comparaison. Un pi-rating de \(+1{,}4\) veut dire quelque chose de concret : « cette équipe marque en moyenne 1,4 manche de plus que la moyenne ». L'écart entre deux pi-ratings est l'écart de manches attendu. C'est une échelle interprétable, et c'est rare.

La formule

L'écart de manches attendu entre \(A\) et \(B\) est simplement la différence de leurs ratings :

\[ e = \pi_A - \pi_B \]

Après le match, on observe l'écart réel \(g\) et on calcule l'erreur \(\text{err} = g - e\). La mise à jour ne se fait pas proportionnellement à cette erreur, mais à sa version amortie :

\[ \psi(\text{err}) = \operatorname{sign}(\text{err}) \times 3 \log_{10}\big(1 + |\text{err}|\big) \]
\[ \pi_A \leftarrow \pi_A + \lambda\,\psi(\text{err}) \qquad \pi_B \leftarrow \pi_B - \lambda\,\psi(\text{err}) \]

Les symboles :

  • \(\pi_A\), \(\pi_B\) : les pi-ratings, exprimés en manches. Tout le monde part de \(0\) ;
  • \(g\) : l'écart de manches observé, orienté du point de vue de \(A\), et borné à \(\pm 13\) dans notre implémentation ;
  • \(\text{err} = g - e\) : l'erreur de prédiction, en manches ;
  • \(\psi\) : la fonction d'amortissement logarithmique ;
  • \(\lambda\) : la vitesse d'apprentissage, l'équivalent du \(K\) d'Elo ;
  • le facteur \(3\) : une constante d'échelle de Constantinou & Fenton, conservée telle quelle.

L'amortissement joue exactement le rôle du logarithme du chapitre 3 : il empêche qu'un 13-1 aberrant ne détruise un rating construit sur quinze matchs.

| \(|\text{err}|\) (manches) | \(\psi\) | |---|---| | 0,5 | 0.528 | | 2 | 1.431 | | 7 | 2.709 | | 13 | 3.438 |

Une erreur 26 fois plus grande produit une correction 6,5 fois plus grande seulement. Le code :

# pi-ratings : erreur sur l'écart de rounds, amortie en log
g = max(-13.0, min(13.0, float(s1 - s2)))
for k, lam in PI_LAMBDAS.items():
    e = self.pi[k][a] - self.pi[k][b]
    err = g - e
    psi = math.copysign(3.0 * math.log10(1 + abs(err)), err)
    self.pi[k][a] += lam * psi
    self.pi[k][b] -= lam * psi

Exemple numérique

Supposons \(\pi_A = 1{,}2\) et \(\pi_B = -0{,}8\). L'horloge attend donc :

\[ e = 1{,}2 - (-0{,}8) = +2{,}0 \text{ manches en faveur de } A \]

Le match se solde par un 13-4 pour \(A\), donc \(g = +9\) :

\[ \text{err} = 9 - 2 = 7 \qquad \psi = 3 \log_{10}(8) = 3 \times 0{,}9031 = 2{,}709 \]

Avec la version lente, \(\lambda = 0{,}06\) :

\[ \pi_A \leftarrow 1{,}2 + 0{,}06 \times 2{,}709 = 1{,}363 \qquad \pi_B \leftarrow -0{,}8 - 0{,}163 = -0{,}963 \]

Le nouvel écart attendu passe de \(2{,}00\) à \(2{,}325\) manches. Avec la version rapide, \(\lambda = 0{,}15\), il passerait à \(2{,}813\) — l'horloge croit bien plus vite ce qu'elle vient de voir.

Notre implémentation fait tourner les deux vitesses en parallèle, sans trancher a priori :

# pi-ratings : deux vitesses d'apprentissage, le choix se fera en validation interne
PI_LAMBDAS = {"pi_diff_a": 0.06, "pi_diff_b": 0.15}

La mesure

Les pi-ratings sont calculés à la granularité map (chaque map jouée met à jour les ratings) et entrent dans le modèle comme pi_diff_a et pi_diff_b. Résultat en validation interne, par-dessus les 32 features du modèle final/ :

variante Brier (validation)
base, 32 features 0.2285
+ elo_mov_diff (Elo MOV à la 538) 0.2279
+ mapelo (Elo par map, blend 50/50) 0.2280
+ pi_diff_b (\(\lambda = 0{,}15\)) 0.2274
+ pi_diff_a (\(\lambda = 0{,}06\)) 0.2272

Les pi-ratings sont donc la meilleure des trois horloges map-granularité testées, devant l'Elo MOV à la FiveThirtyEight et devant l'Elo par map. pi_diff_a a été retenu dans les 40 features du modèle push/, qui a pris la tête de la campagne à 0.2172 de Brier.

La version lente bat la version rapide. C'est le même enseignement que le \(K = 90\) du chapitre 1 : sur un signal bruité, l'horloge prudente calibre mieux.

À retenir

Les pi-ratings modélisent l'écart de manches attendu plutôt que la victoire, avec une mise à jour amortie en logarithme de l'erreur. Chez nous, \(\lambda = 0{,}06\) en granularité map : 0.2272 en validation contre 0.2285 sans, la meilleure des trois horloges à marge de manches essayées.

Notre implémentation est volontairement une version simplifiée de la méthode publiée. Le lecteur curieux de l'écart trouvera le détail ci-dessous ; les autres peuvent passer directement à la seconde moitié du chapitre.

Pour aller plus loin — ce qu'on n'a pas repris de Constantinou & Fenton

L'article original distingue le rating à domicile et le rating à l'extérieur de chaque équipe, avec un paramètre de diffusion \(\gamma\) qui transfère partiellement l'apprentissage de l'un à l'autre. C'est essentiel au football, où l'avantage du terrain est massif et hétérogène. En CS2, l'équivalent naturel serait LAN contre online — un axe que notre modèle traite ailleurs, par une feature lan et par la segmentation du modèle final. La séparation n'a pas été essayée sur les pi-ratings ; c'est une piste ouverte, non mesurée.

Le rang mondial HLTV : une horloge externe

Changeons complètement de registre.

Toutes les horloges vues jusqu'ici ont un défaut commun, et il est structurel : elles sont construites sur notre fenêtre de données. Le moteur de Valve travaille sur six mois glissants, et nos balayages aussi. Une équipe qui dominait il y a huit mois et qui revient d'une pause n'existe pas, pour nous, autrement que comme une équipe à 1500 points.

Or il existe, à l'extérieur, une horloge qui n'a pas ce problème : le classement mondial HLTV. Il est calculé chaque semaine depuis plus de dix ans, avec sa propre méthodologie, sur l'historique complet du jeu. Si on pouvait le lire à la date de chaque match, on disposerait d'un résumé de tout ce que notre fenêtre ne voit pas.

L'histoire des info-bulles fusioncharts

Le problème est évidemment temporel : lire le classement HLTV d'aujourd'hui pour prédire un match d'il y a trois mois serait une fuite du futur caractérisée, et invaliderait toute l'évaluation.

Il fallait donc du rang daté. Il se trouve que chaque page d'équipe de HLTV affiche un graphique de l'évolution de son classement. Ce graphique est rendu par la bibliothèque JavaScript FusionCharts, et sa configuration est intégrée dans le HTML sous forme d'un attribut data-fusionchart-config contenant du JSON échappé. Chaque point de la courbe y porte une info-bulle (tooltext) qui contient — et c'est le petit miracle de cette histoire — la date en toutes lettres et les deux rangs, le rang HLTV et le rang Valve :

_HRANK = re.compile(r'h-rank">#(\d+)')
_VRANK = re.compile(r'v-rank-tooltip">#(\d+)')


def team_history(page):
    """[(unix, hltv_rank, valve_rank|None)] dédupliqué par date. Les tooltips des
    charts fusioncharts portent les DEUX rangs (classes h-rank / v-rank-tooltip)."""

Le pipeline complet : parser le JSON de configuration du graphique, en extraire chaque point, lire la date (« 23rd June 2025 ») dans le sous-titre de l'info-bulle, lire les rangs dans les classes CSS, dédupliquer par date.

Aucune requête réseau n'a été faite pour cela — les pages d'équipe étaient déjà dans le cache HTML local du projet, collectées pour d'autres raisons. C'est, comme la marge du chapitre 3, de l'information déjà payée.

La récolte

grandeur valeur
équipes couvertes 439
points de classement datés 42 302
période couverte octobre 2015 → août 2026
matchs de notre pool avec les deux équipes datées 5 088 / 6 062, soit 84 %

Onze ans d'histoire du classement mondial, reconstruits depuis des info-bulles de graphiques. La densité augmente avec le temps — 924 points pour 2016, 8 715 pour les huit premiers mois de 2026 — ce qui est cohérent avec la croissance du nombre d'équipes suivies.

La validation croisée par les archives hebdomadaires

Une donnée arrachée à des info-bulles mérite d'être vérifiée avant qu'on ne construise quoi que ce soit dessus. Une seconde source, indépendante, a été extraite du même cache : les pages d'archives hltv.org/ranking/teams/<année>/<mois>/<jour>, publiées chaque lundi. Trente semaines de février à août 2026, environ 249 équipes par semaine, avec rang et points.

Le croisement des deux sources donne 99,8 % d'accord exact sur les rangs. C'est le genre de contrôle qui ne prend pas longtemps et qui autorise à faire confiance à tout le reste.

Erreur fréquente

Se contenter d'une source unique quand elle a été obtenue par un chemin inhabituel. Une expression régulière sur un JSON échappé dans un attribut HTML peut se tromper de mille façons silencieuses — décalage d'un point, mauvaise série, fuseau horaire. Ici la seconde source coûtait une demi-heure de parsing et a transformé « probablement correct » en « vérifié à 99,8 % ».

La feature

Le rang n'entre pas brut dans le modèle. Il entre en logarithme :

\[ \texttt{hr\_rank\_diff} = \ln(\text{rang}_B) - \ln(\text{rang}_A) \]

Positif quand \(A\) est mieux classée (un rang plus petit est un meilleur rang). Le logarithme est indispensable, et l'exemple le montre :

confrontation écart brut de rangs hr_rank_diff
#3 contre #25 22 2.120
#100 contre #122 22 0.199

Le même écart nominal de 22 places vaut dix fois plus en haut du classement qu'au milieu. C'est exactement ce qu'on veut : la distance entre la 3ᵉ et la 25ᵉ équipe mondiale est un gouffre, celle entre la 100ᵉ et la 122ᵉ un détail.

Une seconde feature capture la tendance sur 90 jours, sur le même principe :

\[ \texttt{hr\_trend\_diff} = \big[\ln(\text{rang}_{A,\,t-90j}) - \ln(\text{rang}_{A,\,t})\big] - \big[\ln(\text{rang}_{B,\,t-90j}) - \ln(\text{rang}_{B,\,t})\big] \]

Positive quand \(A\) progresse plus vite que \(B\). L'intégrité temporelle est assurée par construction : la fonction de lecture prend systématiquement le dernier point antérieur ou égal à la date du match, jamais le suivant.

La mesure : le rang HLTV est tout le gain de squeeze

L'étape squeeze de la campagne a testé quatre familles de features extraites du cache existant. Voici le verdict complet, en validation interne, sur une base de 0.2263 :

famille contenu Brier (validation)
A H2H enrichi, pondéré par le recouvrement de roster 0.2264
B boxes « Past matches » des pages équipe 0.2261
C rang mondial HLTV daté + tendance 90 j 0.2250
D archives hebdomadaires (rang + points) 0.2258
C + D 0.2255
toutes 0.2256

Et le détail par feature, qui est encore plus net :

feature seule Brier (validation)
+ hr_rank_diff 0.2251
+ hr_trend_diff 0.2264
+ wk_rank_diff 0.2258
+ wk_pts_share 0.2260

La sélection gloutonne, lancée sur l'ensemble des candidates, n'en retient qu'une seule : hr_rank_diff. La tendance 90 jours n'apporte rien. Les archives hebdomadaires n'apportent rien en plus de l'historique long — elles sont redondantes, ce qui est rassurant puisqu'elles mesurent la même chose sur une fenêtre plus courte.

Sur le holdout, en une seule passe :

modèle accuracy Brier log-loss
push (leader précédent, re-run) 64,0 % 0.2172 0.6210
push + famille A 63,7 % 0.2172 0.6208
push + famille B 64,1 % 0.2176 0.6216
squeeze = push + famille C 64,6 % 0.2154 0.6192

Toute l'étape squeeze tient dans une seule feature. Une expression régulière sur des info-bulles de graphiques a produit 0,0018 de Brier — plus que le raffinement FiveThirtyEight du chapitre 3, plus que TrueSkill par map du chapitre 4.

Pourquoi ça marche : la mémoire longue

L'explication est dans le journal de la campagne, et elle est nette :

L'horloge externe longue durée (le rang HLTV perce notre fenêtre de 6 mois) est le seul vrai gisement restant du cache actuel.

Nos horloges connaissent six mois. Le classement HLTV en connaît onze ans. Pour une équipe stable, la différence est nulle — nos six mois suffisent à la situer. Pour une équipe qui revient, qui vient de monter d'un tier, qui a raté une saison sur blessure ou visa, le rang HLTV contient une information que rien dans nos données ne peut reconstruire.

   notre fenêtre                          le rang HLTV
   ├──────── 6 mois ────────┤    ├──────────── 11 ans ─────────────┤

   équipe stable      : suffisant           redondant
   équipe qui revient : aveugle             ← la seule source
   équipe promue      : partiel             ← le contexte manquant

C'est aussi, mot pour mot, l'annonce du chapitre 6 : le problème n'est pas la formule de l'horloge, c'est la longueur de sa mémoire.

Limite importante

Une horloge externe est une boîte noire. Nous ne savons pas exactement comment HLTV calcule son classement, ni s'il a changé de méthodologie sur onze ans (c'est arrivé au moins une fois publiquement). Nous ne pouvons pas la recalculer, la déboguer, ni garantir qu'elle sera disponible demain dans le même format. La dépendance est réelle, et c'est le prix du gain. Un modèle de production qui repose sur du parsing d'info-bulles JavaScript doit prévoir le jour où le graphique change de bibliothèque.

Une seconde limite, moins visible : la couverture est de 84 %. Pour 16 % des matchs, hr_rank_diff est manquant. Le pipeline remplace alors la valeur absente par zéro (np.nan_to_num avant la standardisation), ce qui revient à dire « les deux équipes sont au même rang » — une valeur neutre, donc inoffensive, mais qui n'apporte rien. Ces matchs sont typiquement ceux d'équipes obscures de tier 3 sans page d'équipe fournie. Le gain de 0,0018 est donc un gain moyen qui cache un gain plus fort sur les 84 % couverts et zéro sur le reste.

À retenir

Le rang mondial HLTV daté, extrait des info-bulles FusionCharts des pages d'équipe (439 équipes, 42 302 points datés d'octobre 2015 à août 2026, validés à 99,8 % par les archives hebdomadaires), entre dans le modèle sous forme de \(\ln(\text{rang}_B) - \ln(\text{rang}_A)\). C'est la seule feature retenue de l'étape squeeze, et elle en constitue tout le gain : holdout 0.2172 → 0.2154. Sa valeur vient de sa mémoire longue : elle perce la fenêtre de six mois qui aveugle toutes nos horloges internes.

Chapitre suivant : le démarrage à froid, qui prend ce constat au sérieux et le transforme en le plus gros gain mesuré de la campagne.