Intelligence Artificielle et Puissance 4



Introduction

Tout est parti d'une sorte de défi lancé sur le forum de TI-FR, qui se voulait être un concours de programmation d'I.A. Il fut finalement décidé que chacun devrait programmer un moteur de réflexion pour Puissance 4, et qu'on les ferait ensuite combattre pour désigner un vainqueur. J'ai découvert ce topic assez tard et je n'avais pas trop de temps à dépenser, mais j'ai quand même fini par programmer au boulot une macro Excel qui répondrait au problème posé. Enfin, j'ai décidé d'écrire cette page pour tous les newbies qui se demandent comment fonctionne le moteur d'un jeu de réflexion, avec explications et démonstrations. J'espère que ce sera assez clair !

Qu'est-ce qu'est une IA ?

Personnellement, je n'aime pas trop le terme de I.A., c'est beaucoup trop vague.

En effet, tout programme qui répond à un problème donné est à mon avis une IA, une fonction qui effectue une multiplication, un moteur d'échec, ou l'intelligence des adversaires dans un jeu de baston. En gros, tout ce qui traite de l'information à partir d'un ensemble de règles. Comme le cerveau d'ailleurs, dont le but est de traiter de l'information, d'où le nom d'intelligence artificielle.

Moteur de jeu de réflexion

Il existe bien sûr des différences entre ces différentes IA.

La première, la complexité. Il faut bien plus de calculs pour jouer à Puissance 4 que pour faire une division, même si au final les deux programmes utilisent les mêmes fonctions de base. Et c'est cette complexité qui donne tout son intérêt à la programmation d'un moteur d'un jeu de réflexion !

En effet, il n'est pas toujours possible de calculer une solution de manière exacte : même en prenant un jeu très simple comme le Puissance 4, avec un maximum de 7 possibilités à chaque tour, on arrive rapidement à un nombre de positions à examiner énorme (plus de 188 millions pour 10 coups de profondeur et pour la position de départ, sachant qu'il y a 56 coups au maximum dans une partie). Avec un ordinateur actuel (pas avec une TI-89, heureusement pour le défi) et une bonne programmation, résoudre le Puissance 4 complètement (déterminer si la position de départ est ou non gagnante en examinant toutes les possibilités) est à mon avis faisable. Mais en élevant la difficulté d'un cran, par exemple en prenant le jeu Othello, ça devient mathématiquement impossible : en gros, un ordinateur aussi puissant soit-il ne pourra jamais résoudre ce jeu complètement, dut-il y réfléchir jusqu'à la fin de cet univers (jusqu'à preuve du contraire, Othello est ce qu'on appelle un problème intrinsèquement complexe si on oublie la limite de la fin du jeu, autrement dit il ne peut pas se ramener à un problème en temps polynomial). Et si on choisit un jeu encore plus complexe, comme les échecs ou le jeu de Go, alors là...

Bref, à défaut de trouver une solution exacte, le but d'un moteur de réflexion est de trouver la "meilleure" solution possible dans un minimum de temps.

Avant de revenir sur cette notion de "meilleur", nous allons commencer par étudier le principe de base de la réflexion - à savoir la recherche minimax - et quelques améliorations qu'on peut lui apporter - comme l'élagage alpha. La plupart de mes explications peuvent s'appliquer à tous les jeux de réflexion, mais elles seront appuyées pour avoir un exemple sur le code source de la macro Excel (Visual BASIC) qui joue à Puissance 4, tourné vers la simplicité plutôt que vers l'optimisation (de toute façon, ce langage n'est pas fait pour aller vite ; si vous voulez un moteur vraiment plus poussé, attendez que j'ai fini ma version pour TI...)

Préliminaires

Cliquez ici pour télécharger le fichier Excel avec la macro (ou ici pour récupérer le code-source au format txt, si vous n'avez pas Excel, vous ne pourrez alors pas exécuter le programme). Ne vous inquiétez pas, cette macro ne contient pas de virus.
Une fois le fichier ouvert, vous pouvez jouer en écrivant des 'O' ou des 'X' dans la grille (j'ai limité l'interface au minimum pour simplifier le code-source). Les 'X' commencent. Vous pouvez demander à la macro de jouer en cliquant sur le bouton correspondant et de vider le terrain avec le bouton 'Initialise'.
Pour avoir accès au code-source, allez dans Outils->Macro->Macros, sélectionnez 'Joue' puis cliquez sur 'Modifier'. Et ne vous inquiétez pas si vous n'avez jamais fait de Visual BASIC, c'est lisible par n'importe qui qui se débrouille avec un langage de haut niveau (BASIC, C, Pascal, etc). A la rigueur, sachez juste que la valeur renvoyée par une fonction doit être stockée dans la variable qui a le même nom que la fonction.

Quelques remarques générales :

Tout d'abord, remarquez que la taille du tableau que j'utilise est de (NB_COLONNES+1)*(NB_LIGNES+2)+1. Il faut le voir comme ça :

56 57 58 59 60 61 62 63 64
48 49 50 51 52 53 54 55
40 41 42 43 44 45 46 47
32 33 34 35 36 37 38 39
24 25 26 27 28 29 30 31
16 17 18 19 20 21 22 23
8 9 10 11 12 13 14 15
0 1 2 3 4 5 6 7


J'ai dessiné en gras les cases qui peuvent contenir des pions, soit 7 colonnes pour 6 lignes comme tout Puissance 4 qui se respecte. L'intérêt d'utiliser un tableau par rapport à une matrice est qu'un seul indice permet de s'y déplacer dans toutes les directions : pour aller une case vers le haut à droite, ajouter (NB_COLONNES+1+1) ; pour aller vers la gauche, ajouter -1 ; etc. D'où le tableau Directions, qui recense les 4 directions possibles (il ne faudra pas oublier de regarder aussi les symétriques pour avoir toutes les 8 directions). Enfin, j'ai rajouté des lignes et colonnes inutilisées de chaque côté, comme ça en regardant à côté d'une case valide on ne risquera pas de faire des débordements de tableau (ces cases seront marquées comme DEHORS).

Principe du minimax

Penchons-nous maintenant sur la réflexion en elle-même en examinant la recherche minimax. Pas d'inquiétude, toutes les idées expliquées ici sont en fait assez simple. Je les ai d'ailleurs trouvées (minimax, élagage alpha) moi-même à l'époque où je commençais le moteur de Othello II, et j'ai été un peu dégoûté d'avoir tant réfléchi quand on me les a présentées peu de temps après en cours d'info...

Tout d'abord, on part du principe qu'à chaque position on peut assigner une valeur, d'autant plus grande qu'elle est avantageuse pour le joueur considéré (on y reviendra), et que si n est cette valeur pour un joueur, alors cette valeur est -n pour l'autre joueur.

On peut alors construire l'arbre de recherche, et on utilise notre évaluateur de position pour donner une valeur à toutes les feuilles de l'arbre (qui sont des feuilles soit parce qu'aucun des joueurs ne peut plus joueur, soit parce qu'on a atteint la profondeur maximale de recherche qu'on s'était fixée). Je me suis limité pour l'exemple à trois coups possibles à chaque tour, et à une profondeur de recherche de 3. X joue. Le dessin n'est pas très beau, mais je n'allais pas y passer trois heures, ça suffira largement pour comprendre...



On suppose ensuite que l'adversaire va aussi essayer d'atteindre la position la plus avantageuse pour lui. La question est donc : que jouer pour s'assurer le gain maximal ?

On va partir du bas de l'arbre, et affecter une valeur aux différents noeuds successivement. Au dernier niveau de l'arbre, c'est X qui joue. Il va donc choisir le coup qui lui amènera le meilleur score. Par exemple, il choisira la deuxième feuille parmi les trois premières, le noeud du dessus vaut donc 5, on remplit de même tous les noeuds situés juste avant des feuilles (cf dessin ci-dessous).
Au niveau précédent, c'est O qui joue, il va lui essayer d'atteindre le score le plus faible, car on a supposé que le score pour un joueur était l'inverse de celui pour l'autre joueur. Parmi les trois premiers noeuds auxquels on a affecté un score, il va donc retenir la valeur 4, qu'il va affecter à son noeud. Ici, on affiche tous les scores du point de vue de X, mais on verra que dans le programme il n'y a pas différenciation des deux joueurs : c'est symétrique, les scores sont juste inversés un niveau de profondeur sur deux. Bref, on comprend maintenant le nom de minimax : un niveau sur deux on choisit le minimum, et un niveau sur deux le maximum...
Une fois l'arbre complètement rempli en continuant sur le même principe, on peut repartir de son sommet et suivre le chemin 'optimal' en suivant toujours le même score (ici en rouge).



Je préfère tout de suite répondre à l'habituelle question du newbie : et si l'adversaire ne joue pas de manière optimale, tous nos calculs sont faux ! Bien sûr que non, on ne suivra pas le chemin 'optimal' calculé, mais on arrivera à une position plus avantageuse encore... S'il y a toujours des choses non comprises, un examen approfondi de cet exemple devrait être largement suffisant :-p

Pour terminer, voici les deux fonctions de ma macro qui servent à la réflexion : la première renvoie le numéro de colonne à jouer et est appelée directement par la procédure principale ; la seconde renvoie le score d'une position pour une profondeur et un joueur donnés, elle est appelée par la première et s'appellera elle-même récursivement. Vous pourrez avoir le détail des fonctions annexes dans le code-source de la macro, mais je crois que c'est assez explicite...


Function Reflexion() As Integer

Estimation = -INFINI

For col = 1 To NB_COLONNES

If Hauteur(col) <= NB_LIGNES Then

pion = MetPion(col, JoueurEnCours)

If FaitQuatrePionsAlignes(pion, JoueurEnCours) = True Then

Reflexion = col
Exit Function

End If

valeur = -ReflexionRecursive(-JoueurEnCours, ProfondeurMax - 1)
If valeur > Estimation Then Estimation = valeur: Reflexion = col

RetirePion col

End If

Next col

End Function


Function ReflexionRecursive(joueur, profondeur) As Integer

ReflexionRecursive = -INFINI

For col = 1 To NB_COLONNES

If Hauteur(col) <= NB_LIGNES Then

pion = MetPion(col, joueur)

If FaitQuatrePionsAlignes(pion, joueur) = True Then

ReflexionRecursive = GAGNE
RetirePion col
Exit Function

End If

If profondeur <= 0 Then

valeur = ValeurPosition(joueur)

Else

valeur = -ReflexionRecursive(-joueur, profondeur - 1)

End If

RetirePion col

If valeur > ReflexionRecursive Then ReflexionRecursive = valeur

End If

Next col

End Function


Elagage alpha

L'avantage de cette méthode, c'est son exhaustivité. Son problème, c'est son exhaustivité...

En effet, j'ai déjà mentionné l'explosion combinatoire qui arrive dès qu'on augmente un peu la profondeur de la réflexion. Vient alors la question de comment limiter le nombre de coups à étudier (élagage de l'arbre). C'est un problème très délicat que notre cerveau résoud très bien avec de l'entraînement, ce qui explique pourquoi un ordinateur doive examiner bien plus de positions qu'un humain pour arriver au même niveau dans un quelconque jeu de réflexion !

En fait, cette introduction n'est peut-être pas très bien à sa place dans ce paragraphe, vu qu'il existe un début de solution 'miracle', qui permet de gagner énormément en positions à examiner, et en ne rajoutant qu'une ligne à notre moteur : l'élagage alpha. Ca dépend très fortement du jeu et de la position, mais dans Othello, j'arrive au minimum à un facteur 10 si je ne m'abuse, et bien plus en fin de jeu !

Enfin, quand je dis miracle, le principe est assez simple. Au moment d'optimiser un algorithme, il faut se poser deux questions (d'après mon expérience) : Comment éviter de calculer plusieurs fois la même chose ? Et comme éviter de calculer des choses inutiles ? Par exemple, pour optimiser le moteur de mon Jeu de la Vie, je m'étais principalement concentré sur la première question, vu qu'on doit afficher toutes les cellules du terrain sans exception. Ici, l'élagage alpha se penche plutôt sur la deuxième question, on va voir que notre recherche minimax calcule plein de branches inutiles.

Reprenons l'exemple précédent. Il faut d'abord bien comprendre dans quel ordre le moteur examine les positions, voir dessin ci-dessous.



L'explication se trouve dans le code-source : à chaque appel de ReflexionRecursive(), le programme commence par jouer le coup (ex : de 2 à 3), lance la réflexion au niveau de profondeur suivant (4, 5 et 6), puis annule le coup (3 à 2) et continue avec le coup suivant (2 à 7). Chaque appel de cette fonction ne 'voit' qu'un niveau de profondeur et appelle le niveau suivant, et retourne sa valeur quand tous les niveaux inférieurs sont traités.

Reprenons maintenant notre arbre avec les valeurs des positions, sans oublier que chaque noeud est initialisé avec la valeur -INFINI.



La réflexion en est au moment où elle vient de remplacer le coefficient du noeud rouge (-INFINI) par 6. Or, on sait que X essaie de maximiser le score, donc que ce noeud aura au moins 6 comme valeur finale. De plus, comme O essaie de minimiser le score et qu'il a déjà la possibilité de jouer un coup valant 5, il ne va pas de toute évidence choisir un coup valant au minimum 6. Bref, il ne sert à rien de calculer les deux feuilles restantes à côté du 6, elles ne seront de toute manière pas retenues !

Ici, on élague deux feuilles, mais l'élagage peut concerner des pans entiers de l'arbre. Plus l'arbre est déséquilibré (certaines branches ne menant que vers de scores très hauts ou très faibles), plus l'élagage va être important, ce qui est habituel dans un jeu de réflexion (sacrifier sa dame en début de partie aux échecs risque d'entraîner une majorité de scores négatifs par exemple ; un champion d'échec se contentera lui de suivre seulement quelques lignes qui lui semblent intéressantes, un élagage énorme). Malheureusement, ce n'est pas vraiment le cas ici, donc voici au final tout ce qui aura été calculé, soit 16 feuilles et 10 noeuds au lieu de 27 feuilles et 13 noeuds :



Bref, voici les lignes à modifier réécrites :


valeur = -ReflexionRecursive(-JoueurEnCours, ProfondeurMax - 1, -Estimation)


Function ReflexionRecursive(joueur, profondeur, alpha) As Integer

valeur = -ReflexionRecursive(-joueur, profondeur - 1, -ReflexionRecursive)

If valeur > ReflexionRecursive Then

ReflexionRecursive = valeur
If ReflexionRecursive >= alpha Then Exit Function ' seule ligne ajoutée

End If


J'ajouterai trois commentaires :

  • Avec une programmation un peu intelligente, il est possible d'optimiser l'élagage, par exemple en commençant par examiner les coups qui donnent statistiquement les meilleurs résultats ou qui avaient les meilleurs scores au tour précédent (peut-être pas terrible pour le Puissance 4).

  • Il est aussi possible d'augmenter drastiquement le taux d'élagage en limitant les scores à {-1,0,1} : par exemple, le programme aurait rencontré en temps normal les scores de -9 et -8, il n'aurait pas élagué, là il aurait rencontré -1 et -1 et il aurait élagué (on a bien écrit If ReflexionRecursive >= alpha). On perd bien sûr toute la finesse de notre fonction d'évaluation, mais c'est intéressant en fin de partie quand on cherche juste à trouver une position gagnante, pas une position avantageuse pour gagner... Pour donner des ordres de grandeur, je redonne le nombre de 188 millions d'un début de partie pour une profondeur de 10, on descend à 89 millions avec l'élagage alpha et des scores tirés au hasard entre -1000 et 1000, et à à peine plus de 2 millions pour tous les scores entre -1 et 1. Vous avez dit drastique ?...

  • Il existe bien sûr d'autres moyens de parcourir l'arbre et de l'élaguer. Par exemple, au lieu de parcourir l'arbre avec cette méthode appelée je-sais-plus-quoi, toutes les profondeurs à la fois, on peut le faire profondeur par profondeur en stockant tous les coups qui nous paraissent intéressants dans un niveau avant de passer au suivant. C'est nettement plus compliqué à programmer et on ne peut plus calculer autant de coups dans le même temps, mais ça présente plusieurs avantages : on peut élaguer beaucoup plus et assez facilement, et on peut s'arrêter au bout d'un nombre de coups ou d'un temps donné (cf défi) sans risquer de laisser une partie de l'arbre inexplorée.

Evaluation d'une position

Dernier point délicat (en fait la programmation de toute l'intelligence du moteur, pour l'instant ce n'était que des généralités) : l'appel de la fonction ValeurPosition(joueur), qui calcule le score d'une feuille. C'est là qu'intervient notre connaissance du jeu : c'est vraiment important, vu qu'il faut que la fonction donne à la fois une évalutation précise de la position (ça ne sert à rien de regarder 50 coups en avance si c'est pour se diriger vers des positions qui ne sont en fait pas bonnes), mais il ne faut pas non plus qu'elle soit trop lourde, sinon on va se limiter à un coup de profondeur, et même une fonction parfaite ne l'empêchera pas de tomber dans le premier piège venu par manque de visibilité...

Pour l'instant, le moteur est pitoyablement mauvais. Non seulement il est programmé en visant la pire simplicité, mais en plus j'ai l'impression que le Visual BASIC tourne plus lentement sur mon Duron 700 que le C sur ma TI-89... En tout cas, je vous conseille de jouer avec au moins le niveau 5 pour que ce soit intéressant, mais il vaut mieux avoir un ordi puissant (du genre 15s par coup au début chez moi)...

Quelques suggestions pour de futures améliorations, en attendant que j'ai le temps de les implémenter :
  • Bien sûr, augmenter l'intelligence la fonction ValeurPosition. Par exemple, deux cases vides l'une au-dessus de l'autre qui permettent toutes deux à un joueur d'aligner 4 pions forment un piège imparable si l'adversaire ne détient aucune de ces deux cases. Bref, repérer ces positions et leur donner de très forts scores.

  • Accélérer le moteur pour augmenter la profondeur : par exemple, faire une première passe qui déterminera quelles sont les colonnes piégées dans lesquelles ça ne sert à rien de vouloir jouer, ce sera toujours ça de moins à calculer ("éviter les calculs inutiles"). Autre exemple, l'évaluation de deux positions proches a une majorité de calculs en commun, par exemple mettre un pion dans la première ou la deuxième colonne ne peut pas modifier les scores des cases vides à droite. S'arranger pour ne faire ces calculs qu'une seule fois ("éviter de calculer deux fois la même chose") ; à mon avis, c'est le point le plus délicat si on veut supprimer le maximum de calculs...

  • Faire une fonction différente pour la fin de jeu. Ramener la fenêtre de recherche à +/-1 en examinant seulement si une position est perdante ou non comme je l'ai expliqué dans le paragraphe sur l'élagage, pour pouvoir augmenter la profondeur de recherche (en gros, remplacer le corps de ValeurPosition par ValeurPosition = 0, on se contente de dire qu'on ne sait pas ce que ça vaut). Pour les colonnes non piégées, se contenter de regarder la parité des cases vides pour savoir qui sera forcé de prendre l'initiative ensuite.



Retour au sommet de la page