L'intégrité temporelle¶
Il n'y a qu'une règle dans ce chapitre, et elle tient en une phrase que le code de production porte en commentaire :
Les features d'un match ne contiennent que ce qui était connu avant lui. Aucune fuite du futur, sinon l'évaluation est un mensonge.
Tout le reste — le balayage chronologique, les curseurs, les snapshots datés, les sentinelles de cache — n'est que de la plomberie au service de cette phrase.
Pourquoi c'est le principe cardinal¶
Intuition
Imaginez qu'on vous demande de prédire le résultat d'un match qui a lieu ce soir à 20 h. Vous avez le droit de tout savoir de ce qui s'est passé jusqu'à 19 h 59. Une seconde de plus, et vous trichez. Le problème, c'est qu'une base de données ne connaît pas l'heure qu'il est : elle vous sert la ligne « KAST moyen de ce joueur » sans vous dire que cette moyenne inclut le match de ce soir.
Une fuite du futur ne provoque aucune erreur. Le programme tourne, les métriques s'affichent, et elles sont excellentes. C'est précisément ce qui rend le défaut dangereux : le signal d'alarme d'une fuite ressemble exactement au signal d'un succès.
Prenons un exemple concret tiré de nos données. La feature rshare_diff est l'écart entre
les deux équipes de leur part de rounds gagnés sur les dix dernières manches. Si l'on
oublie d'exclure le match courant de cette moyenne, l'équipe qui a gagné 13-4 ce soir voit
sa part de rounds monter avant qu'on prédise ce même 13-4. Le modèle apprendrait alors une
relation triviale — « l'équipe dont la part de rounds vient de monter gagne » — qui
fonctionne à 90 % en test et à 50 % en production, parce qu'en production le score du soir
n'existe pas encore.
L'ordre de grandeur du dommage se lit dans nos chiffres. Le leader final atteint 67,9 % d'exactitude. Une seule feature contaminée peut afficher 85 %. La différence entre les deux n'est pas une amélioration de modèle : c'est la mesure de ce qu'on a laissé fuir.
Limite importante
L'intégrité temporelle ne se teste pas facilement. Il n'existe pas d'assertion générale qui dirait « ce tableau ne fuit pas ». La seule défense est structurelle : construire les features par un balayage chronologique unique où la mise à jour de l'état arrive après la featurisation, et n'autoriser aucune source de données qui ne porte pas de date. Tout le chapitre découle de cette contrainte d'architecture.
Le balayage chronologique¶
Le mécanisme est simple à décrire et facile à casser. On trie tous les matchs par heure de
début. On maintient un objet state — la mémoire de ce qu'on sait de chaque équipe. Pour
chaque match, dans l'ordre : on lit l'état pour fabriquer les features, puis on
écrit dans l'état le résultat du match.
Jamais l'inverse. Jamais les deux en même temps.
BALAYAGE CORRECT
temps ────────────────────────────────────────────────────────────►
M1 M2 M3 M4 M5 M6
│ │ │ │ │ │
│ ┌────────┴───────────┴───────────┴────────┐ │ │
│ │ état S : glicko, melo, forme 5/10, │ │ │
│ │ H2H, repos, stabilité, expérience │ │ │
│ └──────────────────┬──────────────────────┘ │ │
│ │ │ │
│ ① lire : features(M4) = g(S) │ │
│ │ │ │
│ ▼ │ │
│ [ ligne du dataset ] │ │
│ │ │
│ ② écrire : S ← update(S, résultat M4) │ │
│ │ │
└───────────────────────────────────────────────┘
Le résultat de M4 n'entre dans S qu'APRÈS que la ligne de M4 soit écrite.
Il servira donc à M5, M6, … et jamais à M4 lui-même.
BALAYAGE FAUX (fuite)
② écrire d'abord : S ← update(S, résultat M4)
① lire ensuite : features(M4) = g(S) ← S contient déjà le résultat de M4
Symptôme : accuracy anormalement haute, calibration parfaite, modèle inutile.
Dans le code de production, cela donne littéralement ceci, dans vrs/predict.py :
for match in playable:
feats, glicko_p = state.features(match, ...) # ① lecture
rows.append((match.start_time, feats, glicko_p, label))
state.update(match) # ② écriture
Deux lignes séparées par une troisième. C'est tout le dispositif. Sa fragilité vient de ce qu'aucun test unitaire ne les distingue : inverser les deux lignes produit un programme qui marche mieux.
Le même motif se retrouve dans chaque approche du concours. maps/run.py le documente en
commentaire — « état par équipe mis à jour APRÈS le calcul des features du match, H2H
filtré strictement avant le match ». assault/build_dataset.py aussi, sur la classe
d'économie : « Rolling par équipe, mis à jour APRÈS featurisation du match (pas de
fuite) ». Et le module industrialisé vrs/winprob.py regroupe toutes les familles sous un
seul balayage, avec une section de code explicitement intitulée -- mises à jour APRÈS
featurisation (pas de fuite).
À retenir
Un balayage unique pour toutes les familles de features vaut mieux que dix balayages séparés joints après coup. Une seule boucle, un seul ordre lecture/écriture à vérifier, et la garantie que toutes les colonnes d'une ligne parlent du même instant.
L'objet _State, pas à pas¶
_State est la mémoire du balayage. Sa docstring dit exactement ce qu'il est : « L'état
"connu avant le match" de chaque équipe, par team_id HLTV. » Il contient sept dictionnaires,
et chacun donne naissance à une ou deux features. Voyons-les un par un.
Le Glicko courant¶
self.glicko associe à chaque équipe un objet GlickoTeam initialisé à 1500. C'est
l'horloge de Valve, celle du classement officiel, reproduite à l'identique. La feature est
l'écart brut :
où \(R_a\) et \(R_b\) sont les ratings courants des équipes \(a\) et \(b\) avant le match. L'état renvoie aussi, gratuitement, la probabilité de la baseline :
C'est le repère contre lequel tout le concours s'est mesuré : Brier 0,2422, exactitude 56,0 %.
Le Elo à marge¶
self.melo est une horloge maison, mise à jour avec un facteur \(K = 30\) pondéré par la
marge de victoire :
\(m_a, m_b\) sont les ratings Elo courants, \(y_a\) vaut 1 si \(a\) gagne et 0 sinon, et \(\mu\) est la marge. Puis \(m_a \leftarrow m_a + \Delta\) et \(m_b \leftarrow m_b - \Delta\).
La marge est calculée par une petite fonction dédiée : un 2-0 en Bo3 vaut \(\mu = 1{,}5\), un 2-1 vaut \(\mu = 1{,}0\), et en Bo1 c'est la marge de manches, bornée :
où hi et lo sont le plus grand et le plus petit des deux scores. En l'absence de score, \(\mu = 1\).
Exemple numérique. Deux équipes à 1500 chacune. \(p_a = 0{,}5\). L'équipe \(a\) gagne 2-0, donc \(\mu = 1{,}5\) : \(\Delta = 30 \times 1{,}5 \times (1 - 0{,}5) = 22{,}5\). Les nouveaux ratings sont 1522,5 et 1477,5. Si elle avait gagné 2-1, \(\Delta = 15\) et l'écart créé aurait été de 30 points au lieu de 45.
Le commentaire du code justifie ce choix en une ligne : c'est « la seule variante qui bat
la formule de Valve en calibration ». En prédicteur pur, melo_diff donne un Brier de
0,2398 contre 0,2423 pour l'horloge de Valve. Et dans les modèles entraînés, c'est
systématiquement la feature n°1 ou n°2 en importance.
La forme 5 et 10¶
self.results est, par équipe, la liste des dix derniers résultats, en 0/1. Le code la
tronque à chaque écriture (del self.results[team][:-10]), ce qui la transforme en fenêtre
glissante de longueur fixe sans jamais allouer de structure sophistiquée.
et de même sur 10. Ces deux features sont bornées : form5_diff vit dans \([-5, +5]\) et
form10_diff dans \([-10, +10]\). Une équipe qui a gagné ses dix derniers matchs contre une
qui a perdu ses dix derniers donne form10_diff = 10.
Dans le champion à 10 features, form10_diff arrive 2ᵉ en importance (16,5) juste derrière
melo_diff (21,7). form5_diff arrive dernière (3,8) : la fenêtre courte est trop bruitée,
mais elle ne coûte rien de la garder.
Le head-to-head¶
self.h2h est un dictionnaire indexé par paire d'équipes triée — la clé est
(a, b) if a <= b else (b, a). C'est un détail d'implémentation qui évite le bug classique
de la paire comptée deux fois dans les deux sens. La valeur stockée est le bilan signé du
point de vue de min(a, b), et le signe est rétabli à la lecture.
Cette feature a une particularité : elle est presque toujours nulle. Deux équipes tirées au hasard ne se sont jamais rencontrées dans la fenêtre. Elle ne porte de l'information que sur les paires qui se croisent souvent — les rivalités régionales, les qualifications à élimination directe.
Le repos¶
Le nombre de jours depuis le dernier match, borné à 14. Le bornage n'est pas cosmétique : sans lui, une équipe qui n'a pas joué depuis huit mois produirait une valeur de 240 qui écraserait toute l'échelle de la feature. Au-delà de deux semaines, l'information « cette équipe est fraîche » sature de toute façon.
Exemple. Une équipe a joué hier (\(1\) jour), l'autre il y a trois semaines (bornée à
\(14\)). rest_diff \(= 1 - 14 = -13\) : l'équipe 1 est nettement plus fatiguée, ou nettement
plus en rythme, selon l'interprétation que le modèle apprendra. Dans le champion,
rest_diff pèse 7,8 — modeste mais réel.
La stabilité du line-up¶
self.last_lineup mémorise les cinq identifiants de joueurs alignés au match précédent. La
feature mesure le recouvrement avec le line-up d'aujourd'hui :
Une équipe qui aligne son cinq habituel a un overlap de 1,0. Une équipe avec un stand-in a 0,8. Une équipe qui a changé trois joueurs a 0,4. La feature capte les stand-ins et les transferts sans avoir besoin d'une base de transferts — l'information est dans la succession des feuilles de match.
L'expérience et le contexte¶
Deux dernières entrées, plus simples :
où \(n\) est le nombre de matchs joués dans la fenêtre. Le logarithme compresse : la différence entre 5 et 10 matchs compte plus que celle entre 100 et 105. Cette feature pèse 9,7 dans le champion — quatrième position, ce qui surprend jusqu'à ce qu'on réalise qu'elle sépare les équipes établies des équipes de qualification.
Enfin lan (booléen) et stakes (le poids de l'événement, dotation liée comprise) viennent
non pas de l'état mais du contexte du tournoi. Elles sont détaillées au
chapitre suivant.
Les pièges réels rencontrés¶
Voici les anachronismes qui ont effectivement été trouvés, et corrigés, pendant la campagne. Aucun n'est théorique.
Les stats d'un match ne sont utilisables qu'après lui¶
Les tables de statistiques individuelles (KAST, ADR, rating 3.0) sont extraites de la page du match lui-même. Une page de match contient donc, dans le même fichier HTML, ce qu'on veut prédire et ce qu'on veut utiliser pour prédire.
La parade est un curseur. Dans stats/run.py :
while cursor < len(parsed) and parsed[cursor]["unix"] < match.start_time:
pstate.apply(parsed[cursor])
cursor += 1
L'inégalité est stricte. Le commentaire l'explicite : « Toutes les stats de matchs STRICTEMENT antérieurs entrent dans l'état joueur ; le match courant (même timestamp) n'y est pas — ses stats servent aux suivants. »
Le cas limite est celui des matchs simultanés. HLTV horodate un match à l'heure de début du
tournoi pour toute une série de rencontres du même tour : plusieurs matchs partagent
exactement le même start_time. Avec une inégalité large (<=), les stats du match A
entreraient dans les features du match B joué à la même minute — et réciproquement. C'est
une fuite latérale, plus subtile que la fuite temporelle classique, et le < strict la
ferme.
Le même curseur strict est utilisé pour le réchauffage bo3.gg dans winprob.py
(while cur_bo3 < len(bo3) and bo3[cur_bo3][0] < t).
Les snapshots Wayback sont datés, et c'est tout leur intérêt¶
La collecte de carrières via la Wayback Machine ramène 966 snapshots de pages de statistiques
HLTV, couvrant 629 joueurs et 131 équipes. Chaque snapshot porte un snapshot_date.
Le README de la moisson est parfaitement explicite sur la responsabilité :
career_stats.pkl: chaque snapshot portesnapshot_date— n'utiliser que pour les matchs postérieurs à cette date. Rien n'est filtré ici.
Autrement dit, la couche de collecte livre une matière brute anachronique par construction — un snapshot du 15 juin contient les statistiques carrière au 15 juin, donc des matchs de mai et juin — et c'est à la couche de modélisation de ne l'utiliser qu'à droite de sa date. Sur 6 056 matchs, seuls 1 145 ont leurs deux équipes couvertes par un snapshot antérieur au match. Le filtre coûte 81 % de la couverture. C'est le prix de l'honnêteté.
Certaines pages ne sont pas datables du tout¶
Le fichier team_pages.pkl contient trois choses différentes, et une seule est utilisable :
| contenu | nature temporelle | utilisable comme feature ? |
|---|---|---|
ranking_history |
daté point par point (66 860 points) | oui |
map_stats |
instantané à la date de fetch | non |
player_ratings |
instantané à la date de fetch | non |
Le rapport d'exploration le formule ainsi : « Attention : hors ranking, c'est un instantané à la date de fetch de la page. » Le winrate par map affiché aujourd'hui sur la page d'une équipe agrège toute son histoire, y compris le match qu'on essaie de prédire. Il n'y a aucun moyen de le désagréger.
C'est pour cette raison qu'une page riche en chiffres n'a donné que deux features
retenues, hr_rank_diff et hr_trend_diff, toutes deux tirées du seul historique daté. Les
winrates par map, pourtant plus directement liés au jeu, ont été jetés.
Les pages figées par le cache : l'anachronisme inversé¶
Les quatre défauts les plus coûteux de la campagne du moteur VRS sont des anachronismes de cache. Ils méritent d'être listés, parce qu'ils forment une famille cohérente.
Le drapeau finished lu au présent. Le fait qu'un tournoi soit terminé est lu sur la
page HLTV telle qu'elle est aujourd'hui, où tout tournoi passé est évidemment terminé.
Or le calcul rejoué doit savoir si le tournoi était terminé à la date rejouée. Conséquence
mesurée : des matchs comptés qui n'auraient pas dû l'être, un joueur franchissant le seuil
des cinq apparitions, et l'identité même de l'entité « Liquid » qui change. Correction :
un champ ends_at et un comptage paramétré par une date de coupure.
La page d'événement capturée pendant le tournoi. Le cache disque n'expire jamais — ce qui est correct pour une page de match, immuable une fois le match joué, et faux pour la page d'un tournoi en cours, qui fige alors une dotation partielle définitivement. Symptôme : une marche d'escalier dans le suivi quotidien, 0,4 point d'écart jusqu'au 11 août, 7 points le 13, 51 points le 23. Les 32 participants du tournoi concerné portaient un résidu médian de 7,20 contre 1,20 pour les 359 autres équipes. Correction : refuser toute page mise en cache avant la fin annoncée du tournoi.
Les listings /results figés sur le bord du présent. Même défaut, autre page. /results
est trié du plus récent au plus ancien : sa première page change à chaque match terminé, et
le cache la servait telle quelle. Correction : une page n'est acquise que quand son match le
plus récent a plus d'un jour.
Les archives re-rendues au moment de la consultation. Le plus retors des quatre. La page HLTV d'un jour passé est re-rendue avec la connaissance du moment du rendu — placements des tournois en cours compris. Se comparer à elle pendant qu'un tournoi courait mesurait l'âge de nos captures, pas la qualité du moteur. Deux épisodes entiers de « dégradation du moteur » se sont révélés être des artefacts de mesure.
Erreur fréquente
Un cache sans expiration est correct exactement pour les contenus immuables. Le critère retenu au terme de la campagne est net : une ressource est figée quand son élément le plus récent a plus d'un jour. Pages de match : oui. Événements en cours, listings triés par date, classements du jour : non.
Huit lignes jetées pour cause de fuite¶
Un dernier exemple, minuscule mais instructif. L'approche squeeze a tenté d'exploiter la
boîte « Past matches » affichée sur chaque page d'équipe. Le rapport enregistre un champ
pb_leak_dropped: 8 : huit lignes du jeu de données ont été supprimées parce que la boîte
contenait le match lui-même.
Huit sur 6 062 — statistiquement négligeable, et pourtant elles ont été comptées et supprimées. C'est la bonne réaction : une fuite ne se négocie pas au pourcentage.
Où l'intégrité temporelle s'arrête (et pourquoi ce n'est pas une fuite)¶
Trois pratiques de la campagne ressemblent à des fuites sans en être. La distinction vaut d'être posée.
Le réchauffage des horloges. L'approche assault fait tourner les horloges Elo, Glicko
et TrueSkill sur des matchs bien antérieurs à la fenêtre d'entraînement — jusqu'à janvier
2024 via bo3.gg. Ce n'est pas une fuite : ces matchs sont dans le passé de tous les matchs
featurisés. C'est même l'inverse d'une fuite, c'est de l'information passée qu'on cessait
d'ignorer. Gain mesuré : −0,0072 de Brier, le plus gros gain unitaire de la campagne.
Le choix des hyperparamètres. La régularisation \(C = 0{,}3\), la demi-vie de 365 jours, la
fenêtre de 12 mois, le seuil de segment à la médiane : tous choisis sur un jeu de
validation de 808 matchs, distinct du holdout. Le holdout de 1 146 matchs n'a été touché
qu'une fois par approche — les rapports enregistrent explicitement "holdout_passes": 1.
Sans cette discipline, on ne fuit pas le futur d'un match, on fuit le futur de l'évaluation,
ce qui revient au même.
Les valeurs manquantes. Quand une feature n'est pas calculable — une équipe sans
historique éco, un rang HLTV absent — le code produit NaN, jamais une valeur par défaut
optimiste. Les NaN sont ensuite ramenés à zéro après standardisation, ce qui les place
à la moyenne du jeu de données. Une équipe inconnue est donc traitée comme une équipe
moyenne, pas comme une équipe faible : le modèle n'apprend pas « absence de donnée =
défaite », ce qui serait une fuite déguisée par la corrélation entre obscurité et faiblesse.
À retenir
Trois questions à poser à toute nouvelle source de données, dans cet ordre. 1. Cette information porte-t-elle une date ? Si non, elle n'est pas utilisable comme feature. 2. Que disait cette page à la date que je rejoue ? Si la page est re-rendue au présent, la réponse est « je n'en sais rien ». 3. Le contenu que je mets en cache peut-il encore changer ? Si oui, le cache doit expirer.
Ce que ce chapitre prépare¶
Le balayage chronologique est le squelette. Il ne dit rien sur les muscles : quelles colonnes on y accroche, comment on les construit, ce qu'elles rapportent.
C'est l'objet du catalogue des familles de features, où les 54 colonnes du leader sont reprises une par une — avec, pour chacune, la construction exacte et le chiffre qu'elle a rapporté. Le chapitre sur l'ingénierie d'acquisition décrit ensuite d'où viennent physiquement ces pages, et pourquoi certaines ont mis 209 minutes à être téléchargées.