Aller au contenu

La régression logistique — la gagnante du concours

Le modèle qui a fini premier de la campagne tient en une ligne de mathématiques et en quelques dizaines de nombres. Il n'a pas d'arbres, pas de couches cachées, pas de gradient boosting : c'est une somme pondérée de features, écrasée entre 0 et 1. Ce chapitre explique comment il fonctionne, comment il a été réglé, et pourquoi il a battu des méthodes réputées supérieures.

L'intuition avant les formules

Imaginez que vous notiez chaque match à la main. Vous partez de zéro, puis vous ajoutez et retranchez des points :

   Vitality vs Falcons
   ────────────────────────────────────────────
   écart de rating TrueSkill  +180  →  + 0.42
   écart de forme sur 10      +2    →  + 0.15
   match sur LAN                    →  + 0.02
   Falcons mieux reposés      −3 j  →  − 0.05
   écart de rang HLTV         +4    →  + 0.11
   ────────────────────────────────────────────
   total (le « score »)               + 0.65

Ce total, appelé score ou logit, est un nombre sur toute la droite réelle : il peut valoir −4, 0 ou +2.3. Or on veut une probabilité, entre 0 et 1. Il faut donc une fonction qui prenne n'importe quel réel et rende un nombre dans cet intervalle, en respectant l'ordre (plus le score est grand, plus la probabilité est grande). C'est exactement le rôle de la fonction sigmoïde.

Tout le travail d'apprentissage consiste à trouver les bons coefficients : combien vaut un point d'écart TrueSkill, combien vaut la LAN, combien vaut un jour de repos. Ces coefficients ne sont pas devinés, ils sont estimés sur des milliers de matchs déjà joués.

Le modèle

La régression logistique modélise la probabilité que l'équipe 1 gagne par

\[ p = \sigma(\mathbf{w}^\top \mathbf{x} + b), \qquad \sigma(z) = \frac{1}{1 + e^{-z}} \]

où :

  • \(\mathbf{x} \in \mathbb{R}^d\) est le vecteur des features du match (\(d = 21\) pour le premier modèle du concours — le leaderboard le note « 22 features » en comptant l'intercept — et \(d = 54\) pour le leader final) ;
  • \(\mathbf{w} \in \mathbb{R}^d\) est le vecteur des poids appris, un par feature ;
  • \(b\) est l'intercept (ou biais), un poids constant qui ne dépend d'aucune feature ;
  • \(\mathbf{w}^\top \mathbf{x} = \sum_{j=1}^{d} w_j x_j\) est le produit scalaire, c'est-à-dire la somme pondérée du tableau ci-dessus ;
  • \(z = \mathbf{w}^\top \mathbf{x} + b\) est le score, ou logit ;
  • \(\sigma\) est la sigmoïde, qui envoie \(\mathbb{R}\) dans \(]0, 1[\).

La sigmoïde vaut 0.5 en \(z = 0\), tend vers 1 quand \(z \to +\infty\) et vers 0 quand \(z \to -\infty\). Quelques valeurs à retenir : \(\sigma(0.5) \approx 0.62\), \(\sigma(1) \approx 0.73\), \(\sigma(2) \approx 0.88\).

Intuition — le logit est une échelle de cote

La réciproque de la sigmoïde est le logit : \(z = \ln\frac{p}{1-p}\). La quantité \(\frac{p}{1-p}\) est la cote (odds) : à \(p = 0.75\), la cote vaut 3, donc « 3 contre 1 ». Un modèle logistique dit donc : chaque feature ajoute ou retranche un montant fixe au logarithme de la cote. Une feature qui vaut \(+0.69\) de logit multiplie la cote par \(e^{0.69} \approx 2\) : elle double les chances relatives. C'est la bonne façon de lire les poids.

Un exemple numérique, avec les vrais poids

Le premier modèle logistique du concours (dossier logreg, 22 features) a produit, entre autres, ces poids — exprimés sur des features standardisées, donc directement comparables entre eux :

Feature Poids Lecture
intercept +0.2785 biais d'ordre : l'équipe listée « team1 » gagne un peu plus souvent
experience_diff +0.0911 écart de log(1 + matchs joués)
form10_diff +0.0761 écart de victoires sur 10 matchs
disagree +0.0695 désaccord entre l'Elo à marge et le Glicko de Valve
melo_diff +0.0523 écart d'Elo à marge
tanh_melo_400 +0.0518 le même écart, saturé par une tangente hyperbolique
tanh_melo_200 +0.0504 idem, avec une saturation deux fois plus rapide
logit_glicko +0.0424 la probabilité Glicko, ramenée en logit
rest_diff +0.0499 écart de jours de repos
glicko_diff +0.0424 écart de rating Glicko
lan +0.0241 le match est en LAN
rest_x_melo −0.0273 interaction repos × niveau

Prenons un match où l'équipe 1 est un écart-type au-dessus sur les trois canaux d'Elo à marge et les deux canaux de Glicko, et un demi écart-type au-dessus en forme sur 10 matchs, tout le reste étant neutre. Le score vaut

\[ z = 0.2785 + (0.0523 + 0.0518 + 0.0504) + (0.0424 + 0.0424) + 0.5 \times 0.0761 = 0.556 \]

et la probabilité prédite est \(\sigma(0.556) = 0.636\). Une équipe nettement au-dessus sur toutes les horloges n'est donc annoncée qu'à 64 % — ce qui, on va le voir, est un effet direct de la régularisation.

À retenir

Un modèle logistique, c'est un barème. Chaque feature apporte un nombre fixe de points de logit par écart-type ; la sigmoïde convertit le total en probabilité. Rien de plus.

Comment on apprend les poids : la log-vraisemblance

Il faut un critère qui dise si un jeu de poids est bon. Le critère naturel est la vraisemblance : la probabilité que le modèle attribue à ce qui s'est réellement passé.

Pour un match \(i\), notons \(y_i = 1\) si l'équipe 1 a gagné, \(y_i = 0\) sinon, et \(p_i\) la probabilité prédite. La probabilité que le modèle assigne à l'issue observée est

\[ P(y_i \mid \mathbf{x}_i) = p_i^{\,y_i} \, (1 - p_i)^{\,1 - y_i} \]

Cette écriture compacte dit simplement : si \(y_i = 1\), la probabilité assignée est \(p_i\) ; si \(y_i = 0\), c'est \(1 - p_i\). En supposant les matchs indépendants, la vraisemblance de tout le jeu de données est le produit de ces termes. Comme un produit de milliers de nombres inférieurs à 1 s'écrase numériquement, on prend le logarithme, qui transforme le produit en somme :

\[ \ell(\mathbf{w}, b) = \sum_{i=1}^{n} \Big[ y_i \ln p_i + (1 - y_i) \ln (1 - p_i) \Big] \]

Maximiser \(\ell\) revient à minimiser son opposé divisé par \(n\), qui n'est autre que la log-loss — la métrique déjà utilisée pour juger les modèles :

\[ L(\mathbf{w}, b) = -\frac{1}{n}\sum_{i=1}^{n} \Big[ y_i \ln p_i + (1 - y_i) \ln (1 - p_i) \Big] \]

Le critère d'entraînement et le critère d'évaluation sont donc la même chose. C'est une propriété agréable et assez rare.

Pourquoi c'est facile à résoudre

Le gradient de cette perte a une forme remarquablement simple :

\[ \frac{\partial L}{\partial \mathbf{w}} = \frac{1}{n}\sum_{i=1}^{n} (p_i - y_i)\,\mathbf{x}_i \]

Chaque match pousse les poids proportionnellement à son erreur \(p_i - y_i\) et à ses features. Un match parfaitement prédit ne pousse rien.

Surtout, \(L\) est convexe en \((\mathbf{w}, b)\) : elle n'a qu'un seul minimum, sans vallées parasites. Il n'y a donc ni initialisation à soigner, ni graine aléatoire, ni risque de tomber sur un mauvais optimum — deux entraînements donnent exactement le même modèle. C'est l'opposé d'un réseau de neurones.

Dans le projet, deux implémentations ont coexisté, avec le même résultat :

  • scratch/ml/logreg/train.py code la perte et son gradient à la main (NumPy) et les confie à L-BFGS-B de SciPy, un optimiseur quasi-Newton ;
  • tous les modèles suivants (final, push, assault, lastmile, segmodel) utilisent LogisticRegression de scikit-learn, qui fait la même chose avec les mêmes garanties.

La régularisation L2 : rétrécir pour mieux prédire

Si on minimise \(L\) seule, rien n'empêche les poids de grandir indéfiniment. Quand les features sont nombreuses et corrélées — nos 54 features le sont beaucoup — l'optimiseur trouve toujours des combinaisons alambiquées qui expliquent parfaitement le passé et n'ont aucune valeur pour l'avenir : c'est le sur-ajustement.

La régularisation L2 (ou ridge) ajoute une pénalité proportionnelle au carré des poids :

\[ J(\mathbf{w}, b) = L(\mathbf{w}, b) + \frac{\lambda}{2n} \, \|\mathbf{w}\|^2, \qquad \|\mathbf{w}\|^2 = \sum_{j=1}^{d} w_j^2 \]

où \(\lambda \geq 0\) règle la force de la pénalité. Trois remarques importantes :

  • l'intercept \(b\) n'est pas pénalisé (il ne porte aucune information de feature, le rétrécir ne ferait que biaiser le taux de base) ;
  • \(\|\mathbf{w}\|^2\) est le carré de la norme euclidienne : pénaliser le carré pousse fort les gros poids et laisse tranquilles les petits ;
  • scikit-learn paramètre la même chose à l'envers, par \(C\) : plus \(C\) est petit, plus la régularisation est forte. La correspondance est \(C \propto 1/\lambda\).

L'intuition du rétrécissement

Imaginez deux features presque identiques (chez nous : melo_diff et sa version saturée tanh_melo_400, dont la corrélation est énorme). Sans pénalité, l'optimiseur est indifférent entre \((w_1, w_2) = (+10, -9.5)\) et \((0.5, 0)\) : les deux donnent presque le même score sur le train. Le premier jeu est catastrophique — une petite variation de l'une des deux features fait exploser la prédiction. La pénalité L2 départage : entre deux solutions équivalentes en ajustement, elle choisit celle dont les poids sont les plus petits, donc la plus stable.

Autrement dit, la L2 ne supprime pas de features (contrairement à la L1, qui met des poids exactement à zéro) : elle répartit le crédit entre features corrélées et rétrécit tout le monde vers zéro.

Intuition — rétrécir, c'est douter

Rétrécir les poids vers zéro, c'est rapprocher les prédictions de 50 %. Un modèle fortement régularisé est un modèle prudent : il n'annoncera jamais 95 %. Comme le Brier punit très cher une prédiction confiante et fausse, la prudence n'est pas une faiblesse — elle est souvent optimale.

Le rétrécissement, visible à l'œil nu dans nos chiffres

Le premier modèle logistique a retenu \(\lambda = 1000\) après validation croisée temporelle. En repartant de la formule de la perte codée dans train.py (\(\lambda \, \|\mathbf{w}\|^2 / 2n\) avec \(n = 4\,916\)), cette valeur correspond à un \(C = 1/\lambda = 0.001\) dans la convention scikit-learn : une régularisation extrêmement forte.

L'effet se lit directement dans les poids : le plus gros de tous vaut 0.0911, et la somme des valeurs absolues des 21 poids vaut 0.827. Le logit ne peut donc pratiquement pas sortir de l'intervalle \([0.28 - 1.65,\; 0.28 + 1.65]\), soit des probabilités bornées en pratique autour de \([0.20,\; 0.87]\).

Et c'est exactement ce que montre la table de calibration mesurée sur le holdout : la tranche la plus basse contient un seul match (probabilité moyenne 0.193) et la plus haute 38 matchs à 0.833 de moyenne. Ce modèle n'a jamais annoncé ni 5 % ni 95 %.

Les modèles suivants, avec des features bien plus informatives, ont retenu une régularisation nettement plus douce, \(C = 0.3\), et leurs prédictions s'étalent davantage : la table de calibration du leader final couvre de 0.127 à 0.873 de moyenne par tranche, avec 60 matchs sous 0.2 et 112 au-dessus de 0.8.

Modèle Features Régularisation Étendue observée des prédictions
logreg 22 \(\lambda = 1000\) (\(C \approx 0.001\)) 0.19 → 0.83, une seule tranche extrême peuplée
final / push / assault / lastmile / segmodel 32 → 54 \(C = 0.3\) 0.13 → 0.87, tranches extrêmes bien remplies

Le réglage de \(C\), mesuré

Le modèle final a balayé \(C\) sur la validation temporelle interne (les 42 jours précédant le holdout, jamais le holdout lui-même) :

\(C\) 0.03 0.1 0.3 1.0 3.0
Brier validation 0.2290 0.2287 0.2285 0.2285 0.2285

Le plateau est franc : de 0.3 à 3.0, tout se vaut à 0.0000 près. Le choix de \(C = 0.3\) est celui du plus petit \(C\) (donc du modèle le plus contraint) parmi les ex æquo — la règle du rasoir appliquée aux hyperparamètres. Cette valeur a ensuite été conservée par toutes les approches suivantes, jusqu'au leader.

Erreur fréquente

Régler \(C\) sur le holdout. Le holdout n'a été touché qu'une fois par approche, pour le tableau final. Tout le réglage se fait sur une fenêtre de validation antérieure, elle-même hors du jeu d'ajustement. Sans cette discipline, un plateau comme celui ci-dessus devient un piège : on choisirait le meilleur des cinq chiffres, qui n'est meilleur que par le bruit.

La standardisation

La pénalité L2 traite tous les poids de la même façon : \(w_j^2\) pour tous. Or nos features ont des échelles sans rapport — melo_diff se compte en centaines de points d'Elo, lan vaut 0 ou 1, rest_diff en jours entre −14 et +14. Sans précaution, la pénalité écraserait les features à petite échelle (qui ont besoin de gros poids) et laisserait tranquilles celles à grande échelle.

On standardise donc chaque feature :

\[ \tilde{x}_j = \frac{x_j - \mu_j}{s_j} \]

où \(\mu_j\) et \(s_j\) sont la moyenne et l'écart-type de la feature \(j\). Chaque feature standardisée a alors une moyenne de 0 et un écart-type de 1, et les poids deviennent comparables entre eux — c'est ce qui permet de lire le tableau de poids plus haut comme un classement d'importance.

Deux détails d'implémentation ont leur importance, tous deux visibles dans le code de production (vrs/winprob.py) :

  • \(\mu_j\) et \(s_j\) sont calculés sur le jeu d'entraînement seul, puis appliqués tels quels à la validation et au holdout. Les recalculer sur le holdout serait une fuite du futur, faible mais réelle ;
  • un \(s_j\) nul (feature constante) ferait diverger la division : le code ajoute \(10^{-9}\) au dénominateur.
Pour aller plus loin — que deviennent les valeurs manquantes ?

Beaucoup de nos features n'existent pas pour tous les matchs : pas de données d'économie pour une équipe rarement couverte, pas de rang HLTV daté, pas de page de maps. Le code remplace ces NaN par zéro dans les unités brutes, avant standardisation (np.nan_to_num est appliqué à la matrice brute). Comme presque toutes nos features sont des écarts entre deux équipes, zéro y signifie « pas de différence connue » : c'est la valeur neutre, ce qui rend le choix défendable. Il ne le serait pas pour une feature de niveau absolu, où zéro n'a aucun sens neutre.

La pondération par récence

Le CS2 de janvier n'est pas celui d'août : les rosters changent, les patchs modifient le jeu, les équipes montent et descendent. Un match vieux d'un an contient donc moins d'information sur demain qu'un match de la semaine dernière — mais il en contient encore.

La réponse standard est de pondérer chaque match d'entraînement par une exponentielle décroissante de son âge :

\[ w_i = 2^{-\Delta t_i / H} \]

où \(\Delta t_i\) est l'âge du match \(i\) en jours (mesuré depuis le match le plus récent du jeu d'entraînement) et \(H\) est la demi-vie : le nombre de jours au bout duquel un match ne pèse plus que la moitié d'un match d'aujourd'hui. Ces poids entrent dans la perte :

\[ L = -\frac{1}{\sum_i w_i}\sum_{i=1}^{n} w_i \Big[ y_i \ln p_i + (1 - y_i) \ln (1 - p_i) \Big] \]

Quelques valeurs concrètes :

Âge du match \(H = 180\) j \(H = 365\) j \(H = \infty\) (pas de décroissance)
90 jours 0.71 0.84 1.00
365 jours 0.25 0.50 1.00
730 jours 0.06 0.25 1.00

Notre mesure : la demi-vie a fini par disparaître

C'est un des réglages qui a le plus bougé pendant la campagne, et la trajectoire est instructive.

  • push (40 features, entraînement sur ~4 900 matchs, fenêtre 6 mois) a retenu \(H = 180\) jours : Brier validation 0.2270 contre 0.2272 sans décroissance à \(C = 0.3\). Un gain minuscule, mais un gain.
  • assault a étendu la fenêtre de données à 12 mois (10 287 matchs d'entraînement) et retenu \(H = 365\) jours.
  • lastmile a rebalayé la grille complète \(C \times H\) sur la même fenêtre. Résultat :
\(C\) \(H = 240\) \(H = 365\) \(H = 550\) \(H = 730\) \(H = \infty\)
0.15 0.2172 0.2172 0.2171 0.2171 0.2170
0.3 0.2172 0.2171 0.2170 0.2170 0.2169
0.6 0.2171 0.2171 0.2170 0.2170 0.2169

La tendance est monotone et cohérente sur les trois lignes : plus la demi-vie est longue, mieux c'est, et l'optimum est à l'infini — c'est-à-dire pas de pondération du tout. C'est le réglage adopté par lastmile puis par le leader segmodel (half_life = 0 dans le code, qui signifie « aucune décroissance »).

Intuition — pourquoi la récence a cessé de payer

La pondération temporelle résout un problème : donner plus de poids à ce qui ressemble à demain. Mais elle en crée un autre : elle réduit la taille effective de l'échantillon. Avec \(H = 180\) jours sur une fenêtre de 12 mois, la moitié la plus ancienne des matchs ne pèse presque plus rien — on perd des milliers de lignes.

Or entre push et lastmile, deux choses ont changé. D'abord la fenêtre est passée de 6 à 12 mois, donc l'échantillon perdu est plus gros. Ensuite et surtout, les features elles-mêmes se sont mises à porter la récence : TrueSkill, Glicko-2, l'Elo à marge, la forme sur 5 et 10 matchs, le momentum sur 7 et 21 jours sont déjà des résumés récents de l'état des équipes. Une fois que les features oublient le passé, il devient inutile — et coûteux — que la perte l'oublie une seconde fois.

C'est une hypothèse d'interprétation ; le fait mesuré, lui, est le tableau ci-dessus.

Pourquoi elle a battu les arbres, ici

C'est la question qui structure toute la partie, et la réponse tient en une phrase : parce que nos features avaient déjà fait le travail non linéaire.

Regardez ce que le modèle reçoit en entrée. Ce ne sont pas des données brutes : ce sont des écarts entre deux équipes (ts_diff, melo_diff, hr_rank_diff, round_share_diff), et parfois directement des probabilités transformées (logit_glicko, ts_valve_disagree, qui est une différence de deux probabilités). Or un écart de rating est, par construction, la quantité dont dépend linéairement le logit dans tous les modèles de force : Elo, Glicko et TrueSkill supposent tous que la probabilité de victoire est une fonction sigmoïde de la différence de niveau.

Autrement dit, la sigmoïde du modèle logistique est exactement la forme fonctionnelle que les horloges attendent. Il n'y a plus grand-chose à courber.

   Frontière de décision dans le plan (écart TrueSkill, écart de rang HLTV)

   rang HLTV │                     ╱                 ╱ = frontière apprise
             │        ○  ○       ╱   ×               ○ = équipe 1 perd
             │     ○      ○    ╱  ×   ×              × = équipe 1 gagne
             │  ○   ○   ○    ╱  ×  ×
             │    ○  ○     ╱ ×   ×   ×
             │  ○      ○ ╱  ×  ×
             └───────────────────────────── écart TrueSkill

   Une droite sépare presque aussi bien qu'une frontière en escalier — et une
   droite ne peut pas, elle, apprendre le bruit des 6 000 lignes d'entraînement.

Voir aussi la figure frontiere-lineaire.png, qui compare logistique et XGBoost à features égales.

Le fait mesuré, quatre fois

Ce n'est pas une intuition, c'est une comparaison répétée à features strictement égales.

Comparaison Logistique XGBoost Écart
final, 32 features, validation 0.2285 0.2331 0.0046
final, 32 features, holdout 0.2180 0.2224 0.0044
push, 40 features, validation 0.2263 0.2302 0.0039
stack, 10 features du champion, holdout 0.2310 0.2318 0.0008

La dernière ligne est la plus intéressante : même sur les 10 features brutes du champion, terrain historique de XGBoost, la logistique était déjà très légèrement devant. L'avantage des arbres n'a donc jamais été grand ici ; il a simplement disparu pour de bon quand les features sont devenues riches.

Confirmé deux fois, indépendamment

Le mémoire de l'université de Lund sur la prédiction CS2 rapporte une régression logistique à log-loss 0.62 et 65 % d'accuracy (résultat de littérature, sur un autre pool de matchs que le nôtre). Nos leaders arrivent à log-loss 0.5916 et 67.9 % — sur des matchs plus faciles, puisque notre pool mêle tier 1 et tier 3.

Ce qui compte n'est pas la comparaison des niveaux, impossible à faire proprement, mais la convergence méthodologique : une équipe indépendante, avec ses propres données, a elle aussi trouvé qu'une régression logistique était le bon outil pour ce problème.

Limite importante — ce résultat n'est pas général

« La logistique bat les arbres » est faux en général sur données tabulaires ; la littérature dit plutôt le contraire, et notre propre champion de départ était un XGBoost. Ce qui est vrai, c'est : lorsque les features encodent déjà des différences de force et des probabilités, la frontière optimale est proche d'un hyperplan, et un modèle linéaire régularisé y est difficile à battre.

Le corollaire pratique est important : le mérite du résultat revient au travail sur les features, pas au choix de l'algorithme.

Le modèle final, en une fiche

Voici tout ce qu'il faut pour reconstruire le cœur du leader (le blend par segments est l'objet du chapitre 3) :

Élément Valeur
Forme \(p = \sigma(\mathbf{w}^\top \tilde{\mathbf{x}} + b)\)
Features 54 (46 sélectionnées + 8 d'interaction par segment)
Régularisation L2, \(C = 0.3\), intercept non pénalisé
Pondération temporelle aucune (demi-vie infinie)
Standardisation moyenne/écart-type du jeu d'entraînement, NaN → 0 brut
Entraînement 10 287 matchs (fenêtre 12 mois)
Optimiseur L-BFGS via scikit-learn, max_iter = 2000
Résultat holdout Brier 0.2041, log-loss 0.5916, accuracy 67.9 %, 1 146 matchs

À retenir

L'essentiel du chapitre

  • Une régression logistique est un barème : somme pondérée de features, écrasée par une sigmoïde ; ses poids se lisent en logarithme de cote.
  • Elle s'entraîne en maximisant la log-vraisemblance, ce qui revient à minimiser la log-loss — le critère d'entraînement est le critère d'évaluation.
  • Le problème est convexe : pas de graine, pas de minimum local, deux entraînements identiques.
  • La régularisation L2 rétrécit les poids vers zéro, donc les prédictions vers 50 %. Notre premier modèle, très régularisé, n'a jamais annoncé plus de 87 % ni moins de 19 %.
  • La pondération par récence, utile au début (\(H = 180\) j), est devenue nuisible quand la fenêtre est passée à 12 mois et que les features ont pris en charge la récence : l'optimum final est \(H = \infty\).
  • Elle a battu XGBoost ici parce que les features sont des écarts de force et des probabilités — pas parce que le simple gagne toujours.

Le chapitre suivant prend le problème par l'autre bout : le gradient boosting, comment il fonctionne, comment il a été réglé, et pourquoi il a perdu son titre.