← Retour aux articles
·8 min de lecture

Apprendre à un agent à jouer au 2048 : du plateau à 512 jusqu'à la tuile 4096

Retour d'expérience sur l'entraînement d'un agent de reinforcement learning : les fausses pistes, les bons outils, et les leçons qu'on n'apprend qu'en pratique.

PythonReinforcement LearningPyTorchStable-Baselines3GymnasiumTensorBoard

Score moyen : 3 500. Tuile max : 512. Dix millions de steps d'entraînement, et mon agent PPO refusait d'aller plus loin. Six runs de comparaison, trois versions du modèle, et un diagnostic TensorBoard plus tard : un changement de 4 lignes a triplé le score et débloqué la tuile 4096.

Le problème : un jeu simple, un espace d'états immense

Traduit en reinforcement learning, le 2048 c'est 4 actions, une observation (4, 4), et environ 3.5 × 10³⁰ états possibles. La difficulté : les récompenses utiles sont rares. Les stratégies gagnantes - garder les grosses tuiles dans un coin, maintenir un gradient - ne paient que des centaines de coups plus tard.

V1 - La baseline qui plafonne

La première chose que j'ai essayée : un MaskablePPO avec une configuration standard.

Pourquoi MaskablePPO ? À chaque état, certaines directions ne déplacent aucune tuile. Plutôt que de laisser l'agent apprendre à les éviter, MaskablePPO fournit un masque booléen des actions valides : les logits invalides passent à -inf avant le softmax.

class ActionMaskWrapper(gym.Wrapper):
    def action_masks(self) -> np.ndarray:
        inner_board = self.unwrapped._board
        masks = np.zeros(4, dtype=bool)
        for action in self._ACTIONS:
            test = Board()
            test._board = inner_board.get_board()
            test._score = inner_board.get_score()
            masks[action] = test.move(action)
        return masks

Chaque action est testée sur une copie du plateau. Le taux d'actions invalides tombe à zéro dès le début de l'entraînement.

L'encodage : voir le plateau autrement

Les valeurs brutes (0, 2, 4, ..., 2048) couvrent une plage énorme. J'ai opté pour un encodage log2 + one-hot : chaque case devient sa puissance de 2, encodée en one-hot sur 16 canaux → tenseur (16, 4, 4).

Le 2048 est spatial et prend en compte l'adjacence des tuiles, les patterns en coin, et les gradients de valeurs. Un CNN s'impose. Deux couches avec un kernel de taille 2, juste assez pour capter les relations entre tuiles voisines :

class CNN2048FeaturesExtractor(BaseFeaturesExtractor):
    def __init__(self, observation_space, features_dim=256):
        super().__init__(observation_space, features_dim)
        self.cnn = nn.Sequential(
            nn.Conv2d(16, 128, kernel_size=2, padding=1),  # (16,4,4) → (128,5,5)
            nn.ReLU(),
            nn.Conv2d(128, 128, kernel_size=2),            # (128,5,5) → (128,4,4)
            nn.ReLU(),
            nn.Flatten(),                                   # → 2048
        )
        self.linear = nn.Sequential(
            nn.Linear(2048, features_dim),                  # 2048 → 256
            nn.ReLU(),
        )

Les 256 features alimentent deux têtes : un acteur [128, 128] → 4 logits et un critique [256, 256] → 1 valeur. Le critique est plus large car prédire les retours à long terme est plus difficile que choisir une direction.

Reward shaping : quatre signaux

La récompense brute du jeu (le score) est trop éparse. J'ai ajouté quatre composantes :

reward = w_merge * r_merge + w_empty * r_empty + w_mono * r_mono + r_survival
  • Merge : log2(score_gained) - récompense logarithmique pour chaque fusion
  • Empty : empty_cells / 16 - encourage à garder le plateau ouvert
  • Monotonie : mesure l'alignement croissant/décroissant des tuiles sur les lignes et colonnes (caractéristique des bons plateaux 2048)
  • Pénalité invalide : -1.0 si le coup ne déplace rien (garde-fou, rarement activé grâce au masquage)

Résultat : plateau à 512

ParamètreValeur
Learning rate3e-4
n_steps2048
n_epochs10
ent_coef0.0
Merge rewardlog2(score) (linéaire)
VecNormalizeNon

Après 10M steps sur 8 environnements parallèles, le score moyen stagnait à ~3 500 et la tuile max restait bloquée à 512. L'agent avait appris les bases : fusionner et garder de l'espace. Mais il ne construisait pas les chaînes de fusions nécessaires pour dépasser 512.

TensorBoard montrait deux problèmes :

  1. Entropy collapse : sans bonus d'entropie (ent_coef=0), la politique convergeait trop vite vers un comportement déterministe. L'agent cessait d'explorer.
  2. Reward trop plat : avec un reward linéaire, l'écart entre fusionner deux 2 et deux 512 n'était pas assez marqué pour orienter l'agent vers les grosses fusions.

V2 - Quatre corrections, toujours bloqué

J'ai apporté quatre modifications ciblées :

  1. ent_coef = 0.01 - force l'agent à maintenir un minimum d'exploration
  2. Reward superlinéaire - log2(score)² au lieu de log2(score), pour creuser l'écart entre petites et grosses fusions :
FusionScoreReward linéaireReward superlinéaire
2 + 2 → 442.04.0
64 + 64 → 1281287.049.0
1024 + 1024 → 2048204811.0121.0
  1. Critique élargi - tête [256, 256] au lieu de la taille par défaut
  2. LR schedule - learning rate décroissant de 3e-4 à 5e-5

Pour isoler l'impact de chaque changement, j'ai lancé trois runs de comparaison à 3M steps :

RunChangementscore_mean_100max_tilevalue_loss
Aent_coef=0.01 seul3 634256–512137
BSuperlinéaire seul3 735256–5122 153
CTout combiné~2 500128–256~1 000

Aucune des trois configurations ne dépassait 512. Le run C avec toutes les corrections combinées était le pire : le LR schedule conçu pour 10M steps décroissait trop vite sur 3M.

Mais le chiffre révélateur était la value_loss du run B : 2 153. Le reward superlinéaire créait un problème que je n'avais pas anticipé.

V3 - VecNormalize, le game-changer

Le diagnostic

La value_loss mesure à quel point le critique se trompe dans ses prédictions de retours futurs. Une value_loss de 2 153 signifie que le critique est perdu.

La cause : log2(score)² crée des valeurs allant de 4 (fusion de deux 2) à 121 (fusion de deux 1024). Avec γ = 0.99, les retours cumulés atteignent des milliers. Le critique n'arrive pas à régresser sur cette plage dynamique.

La solution

J'ai fini par trouver le facteur manquant : VecNormalize, un wrapper de stable-baselines3 qui maintient des statistiques courantes (moyenne, variance) et normalise les récompenses en temps réel à moyenne nulle et variance unitaire.

if config.use_vec_normalize:
    vec_env = VecNormalize(
        vec_env,
        norm_obs=False,       # l'observation est déjà en [0, 1] (one-hot)
        norm_reward=True,     # normaliser les récompenses
        clip_reward=10.0,     # clipper les extrêmes
        gamma=config.gamma,
    )

Un détail : norm_obs=False. L'observation one-hot est déjà dans [0, 1] donc la normaliser détruirait sa structure binaire.

L'ablation

Pour confirmer que VecNormalize était le facteur décisif, j'ai lancé un run d'ablation : la config V3 complète avec VecNormalize (run D) contre la même config sans (run E).

MétriqueD - avec VecNormalizeE - sans VecNormalizeImpact
score_mean_1009 4742 5333.7x
max_tile512–709~267
value_loss0.032 04468 000x
explained_variance0.860.78

Bien que les ajustements PPO (n_epochs, n_steps, gae_lambda) aient contribué, ils ne suffisaient pas seuls. VecNormalize était le facteur qui brisait le plateau.

Ajustements complémentaires

ParamètreV2V3Motivation
n_epochs104Réduit le sur-apprentissage par batch
n_steps20484096Meilleures estimations de gradient
gae_lambda0.950.9Réduit la variance des avantages
LR schedule3e-4 → 5e-52.5e-4 fixePlus stable sur les longs runs
w_survival0.010.005Le bonus était trop dominant

Résultats : de 512 à 4096

J'ai entraîné le modèle V3 sur 30M steps. La progression ne montre aucun signe de plateau :

Métrique3M10M30M
Score moyen (100 ep.)9 47415 89227 461
Tuile max512–709846–1 0242 048–4 096
Longueur d'épisode~598~923~1 456
Value loss0.030.0240.0185
Explained variance0.860.870.9025

Le explained_variance a franchi 0.90, le critique prédit donc les retours avec une précision élevée. L'entropy reste saine à -0.34, loin du collapse. L'approx_kl diminue (0.058 → 0.035), signe d'une politique qui mature sans diverger.

Évaluation : 5 parties en mode déterministe

ÉpisodeScoreTuile maxReward
160 496409631 452
243 348204824 545
336 272204820 766
428 892204816 633
520 092102412 820

Score moyen : 37 820. Tuile médiane : 2048. 3 parties sur 5 atteignent 2048, une atteint 4096. Par rapport à la V1 : 7.8x sur le score moyen.

Le takeaway

J'ai passé des semaines à ajuster des hyperparamètres : entropy coefficient, learning rate schedule, architecture du critique, reward shaping, le tout pour des gains marginaux. Puis 4 lignes de VecNormalize ont transformé une value_loss de 2 153 en 0.03 et triplé le score.

Sans TensorBoard, je n'aurais jamais identifié le problème. La value_loss explosive du run B était le seul indice pointant vers un problème de scale des récompenses. Sans l'ablation D vs E, je n'aurais pas su que VecNormalize, et non les ajustements PPO, était le facteur décisif.

Ne tuez pas vos rewards. Normalisez-les.


Le code source, le modèle entraîné et les logs TensorBoard sont disponibles sur GitHub.