Comment une vingtaine de pandas apprennent seuls à traverser un parcours 3D : le monde, les capteurs, le réseau de neurones et l'algorithme NEAT, extrait par extrait.
Le programme mélange deux boucles qui ne sont pas faites pour cohabiter. Panda3D a sa boucle de jeu (taskMgr) qui appelle des tâches à chaque image. NEAT a sa boucle d'évolution (p.run) qui appelle une fonction d'évaluation à chaque génération.
L'astuce du tuto : c'est NEAT qui commande. À chaque génération, playGame crée les pandas puis relance la boucle de Panda3D. Une tâche, verifFinPartie, l'arrête quand la partie est finie. NEAT reprend alors la main, note les pandas et fabrique la génération suivante.
initNeat lit config.txt, crée une population de 20 génomes et enregistre les tâches de jeu.p.run(self.playGame, 10000) lance jusqu'à 10 000 générations.playGame transforme chaque génome en réseau de neurones et en panda, puis appelle taskMgr.run().updatePlayerNeat (décider, bouger), updateCollisionsNeat (murs, sols, zones), verifFinPartie (fin ?).taskMgr.stop() rend la main à playGame, qui écrit la fitness de chaque génome.def playGame(self,genomes,config):
self.alpha = 0.0
self.players = []
for _,g in genomes:
net = neat.nn.FeedForwardNetwork.create(g, config)
self.players.append(self.generatePlayerNeat(idx,net))
g.fitness = 0
self.taskMgr.run() # la partie se joue ici, jusqu'à verifFinPartie
for _, g in genomes:
g.fitness = ... # voir « La fitness »
La carte map_tuto.glb n'est qu'un décor. Le jeu ne la « voit » pas : tout ce qui compte est une liste de formes de collision invisibles, placées à la main dans initColliders. Chaque forme porte une étiquette qui dit quoi faire quand un panda la touche.
| Étiquette | Effet sur le panda |
|---|---|
Wall | Retour à la position précédente et vitesse inversée (×−1,3) : il rebondit. |
Floor | Il ne tombe plus et peut sauter. |
Ladder | Il monte tant qu'il bouge, sans pouvoir sauter. |
Score1 à Score9 | Son score devient le numéro de la zone (s'il est plus grand). |
Reward | Score 10 : il a gagné, la partie s'arrête pour tout le monde. |
Bomb | Il meurt. |
Chargement du plan…
self.collisionNodes.append(addSphere(140,250,24,15,"Bomb"))
self.collisionNodes.append(addCube(136,165,40,1.5,8,50,"Ladder"))
self.collisionNodes.append(addCube(115,410,32,7,2,7,"Reward"))
self.collisionNodes.append(addCube(171,178,16,2,20,25,"Score1"))
self.collisionNodes.append(addCube(136,164,16,3,18,1,"Score2"))
# … jusqu'à Score9
Chaque panda est un dictionnaire : position, vitesses vx vy vz, état (en l'air, sur l'échelle, vivant), score. À chaque image, le réseau décide, puis le code applique une physique très simple :
H).vz perd 0,30 par image.z = −100, le panda est mort.if choice[0] > 0: # avancer
player["vx"] += self.v * sin(degToRad(player["node"].getH()))*.007
player["vy"] -= self.v * cos(degToRad(player["node"].getH()))*.007
if choice[2] > 0: # tourner à gauche
player["node"].setH(player["node"].getH() + self.w)
if choice[4] > 0 and not player["fly"] and not player["inLadder"]:
player["vz"] += self.v*0.08 # sauter
player["vx"] *= 0.93
player["vy"] *= 0.93
player["node"].setPos(
player["node"].getX() + player["vx"]*dt*80,
player["node"].getY() + player["vy"]*dt*80,
player["node"].getZ() + player["vz"]*dt*80)
if player["fly"]:
player["vz"] -= 0.30
Les vitesses changent d'un pas par image, mais la position avance selon dt. Résultat dans le code d'origine : un panda ne se comporte pas pareil à 60 et à 144 images par seconde. La version corrigée fixe le pas de temps à 1/60 s (ClockObject.MNonRealTime), ce qui rend chaque partie reproductible. C'est ce qui permet à la démo de rejouer exactement les parties de l'entraînement.
Un panda ne voit rien. Il porte trois petites boîtes de collision attachées devant lui : Perception1, Perception2 et Perception3. Chacune vaut 1 si elle touche une forme du monde à cette image, 0 sinon.
Ces boîtes descendent sous les pieds du panda. Elles touchent donc le sol tant qu'il y en a devant. Un capteur à 0 veut surtout dire « il y a du vide ici ». C'est ce qui permet au réseau d'apprendre à ne pas tomber.
box = CollisionBox((60, -20, 80), (65, 60, 85))
node = CollisionNode(f"Perception1{i}") # « 1 » = numéro du capteur, i = numéro du panda
Chaque panda est piloté par un réseau de neurones à propagation avant (FeedForwardNetwork). À chaque image, on lui donne 12 nombres et il en rend 5. Une action est faite quand sa sortie est positive. Plusieurs actions peuvent donc se faire en même temps.
Les entrées 4 à 12 codent la dernière zone franchie avec une seule case à 1 (encodage one-hot). Grâce à elles, le même réseau peut se comporter différemment selon l'endroit du parcours : il ne voit presque rien, mais il sait où il en est.
state = [player["p1"],player["p2"],player["p3"]]
for c in range(1,10):
if c == player["score"]:
state.append(1)
else:
state.append(0)
choice = player["net"].activate(state) # 5 valeurs entre -1 et 1 (tanh)
NEAT (NeuroEvolution of Augmenting Topologies) ne fait pas de rétropropagation. Il fait évoluer une population de réseaux comme une espèce vivante : les meilleurs se reproduisent, avec des mutations sur les poids, mais aussi sur la forme du réseau (ajout ou suppression de neurones et de connexions). Les réseaux semblables sont regroupés en espèces pour protéger les nouvelles idées le temps qu'elles mûrissent.
Tout se règle dans config.txt. Les réglages qui comptent le plus ici :
| Paramètre | Valeur | Rôle |
|---|---|---|
pop_size | 20 | Nombre de pandas par génération. |
fitness_threshold | 10 | Fitness qui arrête l'entraînement. Elle valait 11 à l'origine, une valeur impossible à atteindre. |
num_inputs / num_outputs | 12 / 5 | Doivent correspondre exactement à state et choice. |
num_hidden | 8 | Neurones cachés au départ (NEAT en ajoute ou en retire ensuite). |
initial_connection | full_direct | Tout est relié au départ, y compris entrées → sorties. |
weight_mutate_rate | 0.8 | Probabilité de modifier chaque poids à chaque génération. |
node_add_prob / conn_add_prob | 0.2 / 0.5 | Probabilité d'ajouter un neurone ou une connexion. |
elitism | 1 | Le meilleur de chaque espèce passe tel quel à la génération suivante. |
survival_threshold | 0.2 | Seuls les 20 % meilleurs d'une espèce se reproduisent. |
max_stagnation | 100 | Une espèce qui ne progresse pas pendant 100 générations disparaît. |
Avec l'élitisme et le pas de temps fixe, le meilleur panda rejoue exactement la même partie à la génération suivante. C'est pour ça que la courbe « meilleur » de la démo monte en escalier et ne redescend jamais.
La fitness est la note qui décide qui se reproduit. Dans le tuto, c'est simplement le numéro de la dernière zone franchie, de 0 à 10. Le problème : beaucoup de pandas ont la même note, et NEAT ne peut pas savoir lequel était le plus proche de réussir.
La version corrigée ajoute un bonus inférieur à 1, qui grandit quand le panda s'approche de la zone suivante. Il départage les ex-aequo sans jamais faire passer un score de 4 devant un score de 5.
bonus = 0 if player["score"] == 10 else 0.99 / (1 + player["distMin"] / 50)
g.fitness = player["score"] + bonus
distMin est la plus petite distance atteinte pendant la partie entre le panda et le centre de sa prochaine zone. À 0 unité, le bonus vaut 0,99 ; à 50, il vaut 0,5 ; à 150, il vaut environ 0,25.
Une partie s'arrête au bout de 8 secondes, quand tous les pandas sont morts, ou dès que l'un d'eux touche la récompense (verifFinPartie).
| Problème dans le code d'origine | Conséquence | Correction |
|---|---|---|
fitness_threshold = 11 | L'entraînement ne s'arrête jamais, best_model.pkl n'est jamais écrit. | Seuil à 10. |
self.config pour la config NEAT | Écrase la config interne de Panda3D : plantage à la fermeture. | Renommé self.neatConfig. |
| Panda tué par la bombe, autres collisions traitées dans la même image | Plantage aléatoire (setPos sur un objet supprimé). | Les collisions d'un panda mort sont ignorées. |
| Caméra qui suit toujours le panda n°0 | Plantage s'il est mort. | Elle suit le meilleur panda en vie. |
Échelle : abs(vx) compté deux fois | La montée dépend de la direction. | abs(vx) + abs(vy). |
| Boîtes de collision jamais retirées | Fuite mémoire au fil des générations. | removeCollider en fin de partie. |
| Physique dépendante des images par seconde | Un modèle entraîné ne rejoue pas pareil. | Pas de temps fixe de 1/60 s. |
| Dépendances non précisées | .glb illisibles, neat-python 2.0 refuse la config. | requirements.txt : panda3d-gltf, neat-python 0.92. |
python -m venv .venv
.venv/bin/pip install -r requirements.txt # Windows : .venv\Scripts\pip
python main.py # entraînement avec fenêtre, comme dans la vidéo
python main.py --rapide # sans fenêtre, plusieurs fois plus rapide
python main.py --rejouer # rejoue best_model.pkl en boucle
python main.py --manuel # partie 1 : jouer soi-même (ZQSD, espace)
python main.py --rapide --export-web # régénère les données de la démo
Un entraînement en mode --rapide atteint la récompense en 30 à 100 générations, soit une à quatre minutes sur un processeur ordinaire. Le résultat varie d'une fois à l'autre : l'évolution part du hasard.