Aller au contenu

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 :

\[\text{glicko\_diff} = R_a - R_b\]

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 :

\[p_{\text{glicko}} = \text{expected\_score}(R_a, R_b)\]

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 :

\[p_a = \frac{1}{1 + 10^{(m_b - m_a)/400}}, \qquad \Delta = 30 \cdot \mu \cdot \left( y_a - p_a \right)\]

\(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 :

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

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.

\[\text{form5\_diff} = \sum_{i=1}^{5} y_a^{(i)} - \sum_{i=1}^{5} y_b^{(i)}\]

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.

\[\text{h2h\_balance} = (\text{victoires de } a \text{ contre } b) - (\text{victoires de } b \text{ contre } a)\]

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

\[\text{rest\_diff} = \min\!\left(\frac{t - t_a^{\text{dernier}}}{86400}, 14\right) - \min\!\left(\frac{t - t_b^{\text{dernier}}}{86400}, 14\right)\]

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 :

\[\text{overlap}(t) = \frac{\left| \text{line-up actuel} \cap \text{line-up précédent} \right|}{5}, \qquad \text{stability\_diff} = \text{overlap}(a) - \text{overlap}(b)\]

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 :

\[\text{experience\_diff} = \log(1 + n_a) - \log(1 + n_b)\]

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 porte snapshot_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.