De la prédiction à la simulation¶
Un modèle qui prédit des matchs ne sert à rien tant qu'on ne lui pose pas la vraie question. La vraie question du projet n'est pas « qui gagne ce Bo3 ? » mais « quelle est la probabilité que cette équipe soit invitée au Major ? ».
Entre les deux, il y a une chaîne : des tournois entiers à jouer, des brackets, des qualifications en cascade, un classement mondial à recalculer, et des slots régionaux à compter. Cette chaîne s'appelle une simulation de Monte Carlo, et elle pose au modèle des problèmes que la prédiction pure ignore complètement.
Le principe : jouer l'avenir des milliers de fois¶
Personne ne sait calculer analytiquement la probabilité qu'une équipe termine dans les 18 premiers d'Europe après six tournois enchaînés. Mais on sait jouer ces six tournois, en tirant chaque match au sort avec la probabilité que donne le modèle, puis recalculer le classement obtenu. En répétant l'opération un grand nombre de fois, la fréquence observée converge vers la probabilité cherchée.
pour chaque tirage (500 par défaut) :
┌──────────────────────────────────────────────────────┐
│ cloner l'état des horloges │
│ pour chaque tournoi à venir, dans l'ordre : │
│ construire le bracket (seedé par la force) │
│ pour chaque match du bracket : │
│ p ← modèle(équipe A, équipe B, date) │
│ tirer un nombre aléatoire u ∈ [0,1[ │
│ A gagne si u < p │
│ faire avancer les horloges avec ce résultat │
│ propager les qualifiés vers le tournoi lié │
│ recalculer le classement mondial complet au cutoff │
│ compter qui est dans les slots de sa région │
└──────────────────────────────────────────────────────┘
P(invitation) = (nombre de tirages où l'équipe est qualifiée) / 500
Chaque tirage est un futur possible et cohérent : si une équipe gagne un tournoi, elle gagne aussi les points de classement correspondants, ce qui change son rang, ce qui change son seeding au tournoi suivant. C'est toute la valeur de la méthode par rapport à un calcul en moyenne.
Le tirage biaisé, et son coût¶
Le cœur tient en une ligne, dans vrs/simulate.py :
first_wins = rng.random() < p
rng.random() tire uniformément dans \([0, 1[\). La probabilité que ce tirage tombe sous
\(p\) vaut exactement \(p\) : la pièce est biaisée comme il faut. Le générateur est
initialisé par une graine fixe, ce qui rend chaque exécution reproductible — deux
lancements sur les mêmes données donnent les mêmes probabilités, ce qui est indispensable
pour distinguer un vrai changement d'un frisson de hasard.
Reste que 500 tirages, c'est fini. La fréquence observée \(\hat{q}\) est une moyenne de variables de Bernoulli, dont l'écart-type vaut
| Nombre de tirages \(N\) | Incertitude à \(q = 0.5\) |
|---|---|
| 500 (défaut) | ± 2.2 points |
| 2 000 | ± 1.1 point |
Erreur fréquente
Lire « 63.4 % de chances d'être invité » comme une valeur à la décimale près. Avec 500 tirages, les deux derniers chiffres n'existent pas : la vraie précision est de l'ordre de deux points, et elle ne s'améliore qu'en \(1/\sqrt{N}\) — pour gagner un facteur 10 de précision, il faut 100 fois plus de tirages.
Cela dit, l'incertitude de Monte Carlo est la moins grave des trois : viennent ensuite l'erreur du modèle de match, puis les hypothèses de format (le Swiss n'est pas reproduit, le bracket est une élimination directe seedée).
Du match à la série : les formules Bo3 et Bo5¶
Un tournoi ne se joue pas en manches isolées mais en séries. Si un modèle prédit la probabilité \(p\) de gagner une manche, quelle est la probabilité de gagner le match ?
En supposant les manches indépendantes et de même probabilité, il suffit d'énumérer les scénarios.
Bo3 (premier à deux manches) :
- gagner 2-0 : les deux premières manches, probabilité \(p^2\) ;
- gagner 2-1 : deux victoires et une défaite, la défaite pouvant être la première ou la deuxième manche — donc 2 arrangements, probabilité \(2p^2(1-p)\).
Bo5 (premier à trois manches), même raisonnement avec 3-0, 3-1 (3 arrangements) et 3-2 (6 arrangements) :
Valeurs concrètes :
| \(p\) (par manche) | Bo1 | Bo3 | Bo5 |
|---|---|---|---|
| 0.55 | 0.550 | 0.575 | 0.593 |
| 0.60 | 0.600 | 0.648 | 0.683 |
| 0.65 | 0.650 | 0.718 | 0.765 |
Intuition — le format est un amplificateur
Un format long amplifie l'avantage du plus fort, parce qu'il laisse moins de place au hasard. On peut chiffrer exactement cette amplification en dérivant les formules en \(p = 0.5\) :
Un avantage de 1 point de probabilité par manche devient 1.5 point en Bo3 et 1.9 point en Bo5. Le Bo1, lui, ne l'amplifie pas du tout : c'est pour cela qu'un Bo1 est la loterie du format, et qu'un outsider a intérêt à en demander un.
La subtilité : notre leader ne prédit pas la manche¶
Ces formules ne s'appliquent que si le modèle prédit une manche. Or c'est vrai pour
le Glicko de Valve et pour le champion XGBoost, mais faux pour le blend segmodel :
il a été entraîné sur des vainqueurs de matchs, donc il prédit déjà la série.
Le code gère les deux cas explicitement :
p = (p_map if getattr(draw, "series_level", False)
else series_win_probability(p_map, best_of))
Le tirage _Draw du blend porte l'attribut series_level = True ; le simulateur ne
recompose donc rien par-dessus. Appliquer la formule Bo3 à une probabilité déjà de série
serait une erreur silencieuse et coûteuse : elle transformerait un 65 % en 72 %, et
rendrait toute la simulation sévèrement surconfiante.
Erreur fréquente
Oublier à quel niveau un modèle a été entraîné. La question « qu'est-ce qu'une ligne de mon jeu d'entraînement ? » — une manche ? une série ? un round ? — doit être répondue avant tout usage en aval. Chez nous elle est portée par un attribut de l'objet de tirage, ce qui la rend impossible à perdre en route.
Le vrai problème : quelles features peuvent vivre ?¶
Voici la difficulté propre à la simulation, et elle est plus profonde qu'il n'y paraît.
Quand le simulateur fait perdre quatre matchs d'affilée à une équipe, cette équipe doit devenir plus faible aux yeux du modèle pour les matchs suivants du même tirage. Sinon, la simulation est incohérente : une équipe pourrait perdre tout un tournoi sans que sa probabilité de gagner le match suivant ne bouge d'un iota.
Il faut donc que les features se mettent à jour à l'intérieur du tirage. Sauf que sur les 54 features détaillées dans Les familles de features, la plupart ne le peuvent pas :
| Feature | Peut-elle vivre en simulation ? |
|---|---|
| TrueSkill (série et par map) | oui — se met à jour avec un simple vainqueur |
| Glicko-2 | oui — idem |
| Glicko de Valve, Elo à marge, forme, H2H | oui techniquement |
| statistiques joueurs (rating, ADR, KAST) | non — il faudrait simuler les performances individuelles |
| maps, veto, part de rounds | non — il faudrait simuler le veto et les scores |
| économie des rounds | non — il faudrait simuler round par round |
| rang HLTV, tendance sur 90 jours | non — c'est une donnée externe, publiée par un tiers |
| warm-up bo3.gg | non — source externe également |
Le modèle a donc été construit avec un mélange : les features non simulables sont gelées à leur valeur du jour de l'entraînement (et mémoïsées par paire d'équipes, pour la vitesse), les autres vivent.
La mesure qui a réduit la liste des vivantes à trois¶
Il restait à décider du sort des features de la troisième ligne : Glicko de Valve, Elo à marge, forme, H2H, momentum. Elles peuvent se mettre à jour. Faut-il les laisser vivre ?
L'expérience est simple : prendre une paire d'équipes, faire perdre quatre fois de suite
la première dans un tirage simulé, et regarder ce que devient sa probabilité de victoire.
Le résultat, consigné dans la documentation de vrs/winprob.py, est brutal :
- avec seulement TrueSkill (série et map) et Glicko-2 vivants, le logit du perdant baisse de −0.18 — le bon sens ;
- si l'on laisse aussi vivre Glicko, l'Elo à marge, la forme et le H2H, le logit du perdant monte de +0.6 : quatre défaites d'affilée le rendent plus favori.
C'est absurde, et pourtant parfaitement explicable.
Pourquoi : des coefficients suppresseurs¶
Dans une régression à 54 features fortement corrélées, un poids ne mesure pas l'effet d'une feature. Il mesure sa contribution une fois les 53 autres tenues fixes. Et cela peut inverser un signe.
Le cas le plus simple, avec deux features standardisées \(x_1, x_2\) corrélées entre elles à \(\rho\), et corrélées à la cible à \(r_1\) et \(r_2\). Les coefficients de la régression valent
Prenons \(r_1 = 0.30\), \(r_2 = 0.35\), \(\rho = 0.90\) — deux horloges qui mesurent presque la même chose, la seconde un peu mieux :
Le coefficient de \(x_1\) est négatif alors que sa corrélation à la cible est franchement positive. Ce n'est pas une erreur : \(x_1\) ne sert plus qu'à corriger \(x_2\), en retirant la petite part où \(x_2\) exagère. C'est ce qu'on appelle une variable suppressive.
Tant que toutes les features bougent ensemble, cette correction est saine et le modèle est excellent — c'est le leader du concours. Mais en simulation, la majorité des features sont gelées : \(x_2\) ne bouge plus. La correction de \(x_1\) n'a plus rien à corriger, et elle s'applique toute seule, avec son signe négatif. Une défaite fait baisser \(x_1\), donc \(\beta_1 x_1\) monte, donc la probabilité du perdant monte.
À retenir
Un modèle prédictif excellent peut être structurellement inutilisable en simulation, sans être faux pour autant. La différence entre les deux usages : en prédiction, on ne touche jamais aux features, on les observe toutes ensemble ; en simulation, on les modifie une par une, et on sort alors du régime où les coefficients ont un sens.
La conséquence pratique est contre-intuitive : le bon réflexe n'est pas de faire vivre le plus de features possible, mais le moins possible — seulement celles dont on a vérifié que le coefficient répond dans le bon sens.
Le garde-fou¶
Cette décision est protégée par un test exécutable (tests/test_winprob.py), qui
reproduit l'expérience à chaque exécution :
- il mesure les features et la probabilité avant ;
- il enregistre quatre défaites d'affilée dans le tirage ;
- il vérifie que les 18 features gelées sont rigoureusement inchangées ;
- il vérifie que
ts_diff,g2_diffettsm_diffont baissé ; - il vérifie que la probabilité du perdant a baissé ;
- il vérifie enfin que l'état de référence n'a pas bougé — le tirage travaille sur un clone, sinon la simulation contaminerait le modèle réel.
C'est la bonne façon de figer une leçon coûteuse : pas un commentaire, une assertion qui casse si quelqu'un fait revivre une feature gelée.
Le ré-entraînement quotidien¶
Le dernier maillon est opérationnel. Le modèle vieillit : chaque jour apporte des résultats nouveaux que les horloges doivent absorber.
Le démon vrs/live.py ré-entraîne au démarrage et à chaque tick quotidien, mais
seulement si les données ont bougé :
def maybe_retrain(self, why):
if not config.RETRAIN_TICK:
return
from vrs import winprob
if not winprob.stale(self.matches):
return
winprob.train()
stale() compare la date du dernier match connu à celle enregistrée dans le modèle : pas
de nouveau match, pas de ré-entraînement. Le cycle complet coûte environ deux minutes
— reconstruction du jeu de données sur 12 mois glissants, balayage chronologique de
toutes les familles de features, ajustement des trois logistiques. L'extraction des pages
de cache est incrémentale : le tout premier passage parse les ~11 500 pages en quatre
minutes, les suivants ne traitent que les nouvelles.
Un contrôle out-of-sample rejoué à chaque entraînement¶
Le détail qui rend le dispositif sérieux : à chaque ré-entraînement, le code refait la mesure du concours en miniature.
cut = tmax - TAIL_DAYS * 86400 # TAIL_DAYS = 42
head = [r for r in rows if r["t"] < cut]
tail = [r for r in rows if r["t"] >= cut]
p = predict_blend(fit_blend(head, thr_head), thr_head, tail)
tail_metrics = predict._metrics(...)
Un modèle est ajusté sur tout sauf les 42 derniers jours, puis évalué sur ces 42 jours, et le Brier obtenu est journalisé dans les métadonnées du modèle sauvegardé. Le modèle livré, lui, est ré-ajusté sur la totalité des données — mais on dispose chaque jour d'une mesure honnête de sa qualité, sur la même durée que le holdout du concours.
C'est le seul moyen de voir venir une dérive : si ce Brier quotidien s'éloigne durablement de 0.20-0.21, quelque chose a changé dans le jeu, dans les données ou dans le pipeline.
La chaîne de repli¶
Le modèle est un fichier ; s'il manque, ou si ses dépendances manquent (l'image Docker allégée n'embarque ni NumPy, ni scikit-learn, ni TrueSkill), la simulation ne doit pas tomber :
blend segmodel ──manquant──▶ XGBoost v2 ──manquant──▶ Glicko de Valve
Brier 0.2041 Brier 0.2330 Brier 0.2422
La dégradation est explicite, chiffrée, et forçable par une variable d'environnement
(VRS_WINPROB=auto|glicko|xgboost|blend). Chaque cran coûte du Brier, aucun ne casse le
produit.
Les hypothèses qu'il faut garder en tête¶
La simulation est le maillon où les approximations s'accumulent. Elles sont déclarées plutôt qu'enfouies, et les voici rassemblées :
| Hypothèse | Conséquence |
|---|---|
| 500 tirages par défaut | ± 2.2 points sur une probabilité proche de 50 % |
| bracket en élimination directe seedée ; le Swiss n'est pas reproduit | les placements intermédiaires sont approximatifs, les extrêmes justes |
| features non simulables gelées au jour de l'entraînement | un tirage long s'éloigne progressivement du réel |
| état du modèle daté du dernier entraînement | les matchs du jour n'entrent qu'au ré-entraînement suivant |
| line-up = le dernier cinq vu pour chaque équipe | un changement de roster non encore joué est invisible |
| une équipe jamais vue ne peut pas être simulée | elle est ignorée et listée comme telle |
Aucune de ces limites n'est bloquante ; toutes sont des points d'entrée pour la suite.
À retenir¶
L'essentiel du chapitre
- Le produit fini n'est pas une probabilité de match mais une probabilité d'invitation, obtenue en rejouant l'avenir 500 fois avec des tirages biaisés par le modèle.
- L'incertitude de Monte Carlo vaut \(\sqrt{q(1-q)/N}\), soit ± 2.2 points à 500 tirages : les décimales d'une probabilité affichée sont décoratives.
- Un Bo3 amplifie un avantage de manche d'un facteur 1.5, un Bo5 d'un facteur 1.875 ; \(P_{\text{Bo3}} = p^2(3-2p)\), \(P_{\text{Bo5}} = p^3(10-15p+6p^2)\) — à n'appliquer que si le modèle prédit la manche, ce que notre leader ne fait pas.
- En simulation, la plupart des features doivent être gelées. Les laisser vivre inverse la dynamique, parce que des coefficients suppresseurs (\(\beta_1 = (r_1 - \rho r_2)/(1-\rho^2)\) peut être négatif) s'appliquent alors sans la majorité qu'ils corrigent.
- Trois horloges seulement vivent : TrueSkill série, TrueSkill par map, Glicko-2. La décision est verrouillée par un test exécutable.
- Le modèle est ré-entraîné quotidiennement sur 12 mois glissants, avec un contrôle out-of-sample de 42 jours rejoué à chaque passage, et une chaîne de repli chiffrée.
Ce chapitre clôt la partie sur les modèles. Pour revenir à la vue d'ensemble et aux autres parties du cours, voir l'introduction de la partie et le glossaire.