main.py · Panda3D 1.10 · neat-python 0.92

Pandas en évolution, le code

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.

01La grande boucle

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.

  1. initNeat lit config.txt, crée une population de 20 génomes et enregistre les tâches de jeu.
  2. p.run(self.playGame, 10000) lance jusqu'à 10 000 générations.
  3. playGame transforme chaque génome en réseau de neurones et en panda, puis appelle taskMgr.run().
  4. À chaque image : updatePlayerNeat (décider, bouger), updateCollisionsNeat (murs, sols, zones), verifFinPartie (fin ?).
  5. Fin de partie : taskMgr.stop() rend la main à playGame, qui écrit la fitness de chaque génome.
main.py · playGame, ligne 538
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 »

02Le monde

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.

ÉtiquetteEffet sur le panda
WallRetour à la position précédente et vitesse inversée (×−1,3) : il rebondit.
FloorIl ne tombe plus et peut sauter.
LadderIl monte tant qu'il bouge, sans pouvoir sauter.
Score1 à Score9Son score devient le numéro de la zone (s'il est plus grand).
RewardScore 10 : il a gagné, la partie s'arrête pour tout le monde.
BombIl meurt.

Chargement du plan…

Le parcours vu de dessus, dessiné à partir des formes de collision du code. En doré : le trajet du panda qui a atteint la récompense.
main.py · initColliders, ligne 198 (extrait)
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

03La physique

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 :

  • Avancer / reculer ajoute un peu de vitesse dans la direction du regard (cap H).
  • Frottement : les vitesses horizontales sont multipliées par 0,93 à chaque image.
  • Gravité : en l'air, vz perd 0,30 par image.
  • Chute : sous z = −100, le panda est mort.
main.py · updatePlayerNeat, ligne 635 (extrait)
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.

04Les capteurs

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.

panda (vu de dessus) Perception1 avant-gauche · entrée 1 Perception2 avant-droit · entrée 2 Perception3 devant · entrée 3 regard
Position des capteurs dans le repère du panda (à l'échelle). Les deux capteurs latéraux sont 80 unités devant, celui du milieu 50.
main.py · generatePlayerNeat, ligne 576 (extrait)
box = CollisionBox((60, -20, 80), (65, 60, 85))
node = CollisionNode(f"Perception1{i}")   # « 1 » = numéro du capteur, i = numéro du panda

05Le cerveau

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.

12 entrées

  1. Capteur avant-gauche (0 ou 1)
  2. Capteur avant-droit
  3. Capteur devant
  4. A franchi la zone 1
  5. … zone 2 à zone 8
  6.  A franchi la zone 9

5 sorties

  1. Avancer
  2. Reculer
  3. Tourner à gauche
  4. Tourner à droite
  5. Sauter

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.

main.py · updatePlayerNeat, ligne 635 (extrait)
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)

06NEAT

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ètreValeurRôle
pop_size20Nombre de pandas par génération.
fitness_threshold10Fitness qui arrête l'entraînement. Elle valait 11 à l'origine, une valeur impossible à atteindre.
num_inputs / num_outputs12 / 5Doivent correspondre exactement à state et choice.
num_hidden8Neurones cachés au départ (NEAT en ajoute ou en retire ensuite).
initial_connectionfull_directTout est relié au départ, y compris entrées → sorties.
weight_mutate_rate0.8Probabilité de modifier chaque poids à chaque génération.
node_add_prob / conn_add_prob0.2 / 0.5Probabilité d'ajouter un neurone ou une connexion.
elitism1Le meilleur de chaque espèce passe tel quel à la génération suivante.
survival_threshold0.2Seuls les 20 % meilleurs d'une espèce se reproduisent.
max_stagnation100Une 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.

07La fitness

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.

main.py · playGame, ligne 538 (extrait)
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).

08Bugs corrigés

Problème dans le code d'origineConséquenceCorrection
fitness_threshold = 11L'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 imagePlantage aléatoire (setPos sur un objet supprimé).Les collisions d'un panda mort sont ignorées.
Caméra qui suit toujours le panda n°0Plantage s'il est mort.Elle suit le meilleur panda en vie.
Échelle : abs(vx) compté deux foisLa montée dépend de la direction.abs(vx) + abs(vy).
Boîtes de collision jamais retiréesFuite mémoire au fil des générations.removeCollider en fin de partie.
Physique dépendante des images par secondeUn 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.

09Lancer chez soi

terminal
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.