Algorithmes par les Jeux
Résolution de jeux à un joueur
Le Sudoku
On a une manière brutale et naïve de résoudre un sudoku
Exemple
C’est un algorithme de type “backtracking” (retour en arrière)
On parcourt ainsi toutes les possibilités de remplir la grille \rightarrow c’est en fait un parcours en profondeur d’un arbre, dans lequel on cherche la branche qui mène à une solution
- Dans un sudoku moyen : ~50 cases vides, et en moyenne 3 possibilités par case : 3^{50} grilles à explorer, c’est beaucoup !
- Dans un sudoku difficile : peut prendre jusqu’à quelques minutes
Light up
Light up est un puzzle consistant à illuminer une grille formée de carrés noirs et blancs en ajoutant des ampules selon les règles suivantes :
- les ampoules peuvent être placés uniquement dans les carrés blancs, elles allument alors toute leur ligne et colonne jusqu’à un éventuel carré noir
- les carrés noirs peuvent être étiquetés d’un nombre correspondant au nombre d’ampoules qui devraient entourer le carré noir (en 4-connexité, cf. image)
Ce nombre d’ampoules doit être exact, pas intérieur ni supérieur. Si un carré noir n’est pas numéroté le nombre d’ampoules l’entourant peut être quelconque. - Il ne doit pas y avoir d’ampoule sur un carré blanc déjà éclairé par une autre ampoule.
Exemple
Implémentation d’un algorithme de résolution par backtracking
- On va utiliser une structure de board qui comprendra le nombre de lignes, le nombre de colonnes et un tableau de caractères
- On va utiliser une structure d’état pour stocker dans un tableau les positions des lampes dans le board.
typedef struct state {
int nb_light;
int light_pos;
}
- On devra écrire des fonctions :
print_board(board b)
\rightarrow affichage du jeuadd_light
\rightarrow ajoute une lumière supplémentaire dans le jeu et le vecteur détatsfill_board(board b, state s)
\rightarrow éclaire le tableau en fonction des lampes présentescheck(board b, state s)
\rightarrow vérifie si l’état actuel des lampes respecte les contraintes du jeu (0 si non, 1 si oui)is_solved(board b, state s)
\rightarrow détermine si le jeu est résolu (les contraintes sont respectés + tout est éclairé)solve(board b)
\rightarrow résout le jeu par backtracking naïf
while (is_solved(b, s) == 0) { if (check(b, s) == 0) { return 0; } for(int k = 0; k < size; k++) { if (b.game[k] == ' ') { b <- add_light(b, s, k); b <- fill_board(b, s); if (solve(b) == 0) { s.nb_light--; b = fill_board(b, s); solve(b); } } print_board(b); } }
Plus d’infos sur le TP ici et les fichiers ici.
\Rightarrow Sur un niveau difficile, la résolution par backtracking peut être couteuse
Comment améliorer cet algorithme ?
-
Placer les lumières que lumières que l’on peut placer à coup sûr
Exemple
N_{b} = nombre de carrés noirs
i \in \\\{1, ..., N_{b}\\\}, avec :- Need_{i} = nombre dans le carré (dont on a besoin)
- Space_{i} = nombre de carrés blanc pouvant accueillir une ampoule autour de ?
- Have_i = nombre d’ampoules que l’on a autour
Spl_i = { carrés blanc admissibles autour }
Si Space_i + Have_i = Need_i \rightarrow on met une ampoule dans tous les éléments de Spl_i
-
Éliminer les cases qui ne pourront pas accueillir d’ampoule
Exemple
Si Have_i = Need_i \rightarrow toutes les positions restantes de Spl_i peuvent être marquées d’une croix
Exemple : Si on applique ce pattern 1 - 2 deux foix :
Le jeu est presque résolu en 2 étapes !
3. S’il y a une case non allumée isolée, on peut y placer une ampoule
N_w = nombre de carrés blancs
J \in \\\{1, ..., N_w\\\}
Si Source_J = 1, on peut placer une ampoule dans la seule position valable; Source_J correspond au nombre de carrés non éclairés autour de J_i incluent J
Exemple
Il y a d’autres patterns possibles d’améliorations…
Chiu - Chou - Yang - Yen ont publié en 2010 un article “A simple and rapid Lights-Up solver” dans lequel ils présentent un algorithme efficace du puzzle. Ils utilisent notamment 1, 2 et 3 ainsi que d’autres patterns ; ainsi qu’une méthode de parcours arborescente pour résoudre les zones blanches non encore éclairées.
Le fichier lightup.c
contient une implémentation de cet algorithme \rightarrow vous pouvez ainsi tester et comparer l’efficacité de votre algorithme de backtracking par rapport à celui-ci.
Remarque
En fait, la résolution rapide de gros puzzles de Light Up est un problème qui intéresse encore des chercheurs à l’heure actuelle.
\rightarrow jeu qui apparaît comme un problème aux “Computer Olympiads” en 2010
Jeux à deux joueurs et IA
On étudie un jeu à deux joueurs à somme nulle (somme des gains et pertes égale à 0) où :
- chaque joueur joue dans l’objectif de gagner
- les joueurs jouent chacun leur tour
Exemple TicTacToe, Dames, Jeu de Go, Échecs, Puissance 4…
- On veut programmer une IA capable de jouer à un de ces jeux (contre une autre IA ou un joueur humain)
Difficultés :
- Il faut une IA capable de s’adapter à une position de jeu changeante
- Il faut une IA capable de jouer en fonction de la stratégie de son adversaire
Idée (naïve) : Construire l’arborescence des coups réalisables
Exemple avec un TicTacToe
- Évaluer à chaque position en fin de branche
\rightarrow fonction qui nous permet d’évaluer et de mesurer quel joueur à l’avantage à une position de jeu donnée. Il faut donc un moyen de définir une fonction
Exemple : Pour les Échecs,
\begin{aligned}F(p) = &200(N_{k} - N_{k'}) + 9(N_{Q} - N_{Q'}) + S(N_{R} - N_{R'}) \\\ &+ 3(N_{B} + N_{N} - N_{B'} - N_{N'}) + N_{p}- N_{p'} \\\ &-(D-D'+S-S'+I-I')\end{aligned}
où N\cdot désigne le nombre de \cdot chez le joueur blanc (respectivement N\cdot' chez le joueur noir) et K King, Q Queen, R Rook (Tour), B Bishop (Fou), N kNight (Cavalier) et P Pawn (Pion).
D = nombre de pions doublés (cf. image)
S = nombre de pions bloqués
I = nombre de pions isolés
M = mobilité du joueur
Cette fonction renvoie un nombre flottant : plus il est grand (positif), plus le joueur blanc a l’avantage; plus il est petit (négatif); plus le joueur noir a l’avantage
Exemple
\begin{aligned}f(p) = &200(1-1)+9(1-1)+5(0-1)+3(2+0-1-0) \\\ &+3-3-\frac{0-2+0-1+1-1}{2}+\frac{37-46}{10}\\\ &\simeq-1.4\end{aligned}
On peut donc supposer qu’à cette position, le joueur noir a légèrement l’avantage. Cependant, si blanc bouge son fou en H7 (pour mettre en échec le roi), il prend l’avantage en capturant la dame au coup suivant…
Ceci est appelé un effet d’horizon.
- Choisir le chemin permettant d’atteindre la meilleure position finale, et jouer les coups menant à la position souhaitée.
Problème majeur : Complexité spatiale beaucoup trop grande aka arbre de jeu immense
Exemples
- TicTacToe : <3^{9} \approx 20 000
- Échecs : \approx (30\times 30)^{40} \approx 10^{120} \leftarrow ce nombre est appelé nombre de Shannon
- Jeu de Go : \approx 10^{600} \rightarrow impossible à gérer sans amélioration !
- Puissance 4 : Au max 3^{42} \approx 10^{20} parties possible puisque chaque case peut recevoir 3 états : Rouge / Jaune / Vide. Meilleur estimation : 1.3\times 10^{13}, calculée par ordinateur en retirant les configurations impossibles du type : \begin{cases} \text{Jaune} \\\\ \text{Vide} \\\\ \text{Jaune} \end{cases} et en éliminant toutes les cellules vides au dessus d’un cas gagnant.
Pour éviter cette explosion combinatoire, on peut se limiter à parcourir l’arbre jusqu’à une certaine profondeur
Exemple : TicTacToe avec profondeur 2
Principe de résolution :
- on va étudier l’arbre de jeu, et trouver la suite de coups optimale de la position initiale à un état final \rightarrow sélection d’une branche de l’arbre de jeu
- Estimation d’une position p : Soit \mathcal{P} l’ensemble des positions et \mathcal{P}^{\*} l’ensemble des positions légales terminales.
Idéalement : fonction h^{*} : \mathcal{P} \rightarrow \\\{-\infty, 0, +\infty\\\} (avec -\infty : le joueur 2 gagne, 0 match nul et +\infty le joueur 1 gagne) qui détermine quel joueur gagne.
Pour obtenir une telle fonction, on utilise une estimation h : \mathcal{P} \rightarrow \R fonction d’évaluation
Cette fonction devra être d’autant plus fiable que p est proche d’une position terminale
Mais h est seulement une heuristique, et on ne peut pas prévoir les effets d’horizon.
\Rightarrow Il faut donc trouver un bon compromis entre la profondeur de jeu choisie et la fonction d’évaluation
Plus la profondeur est élevée, plus la fonction d’évaluation a des chances d’être efficace en s’approchant des positions terminales; mais plus la taille en mémoire sera importante.
Algorithme Minimax
Hypothèses :
- Les deux adversaires utilisent la même fonction d’évaluation et jouent pour gagner.
- Le joueur 1 cherche à maximiser son évaluation
- Le joueur 2 cherche à minimiser son évaluation
Joueur 1 = MAX
Joueur 2 = MIN
minimax (profondeur n, position p, joueur J)
- si p est terminale
return h*(p)
- si n = 0 # On a atteint la profondeur maximale
return h(p)
- sinon, soit p1, ..., pm les m positions accessibles depuis p
- si J = MAX
return max minimax(n - 1, pi, MIN) # 1 <= i <= m
- si J = MIN
return min minimax(n - 1, pi, MAX) # 1 <= 1 <= m
Inconvénients
- Complexité élevée \RA \O(c^{n}) avec c coups légaux en moyenne par chaque position et profondeur n.
- Plus problématique : effets d’horizon; pour minimax(n, p, J) il se peut que p_{n+1} soit perdante alors que p_{n} est favorable
Amélioration
Première idée : on va éviter de parcourir les positions superflus de l’arbre de jeu \Rightarrow Élagage de l’arbre de recherche
Cet algorithme s’appelle alpha-beta (\alpha-\beta) puisque selon le joueur, on va réaliser deux types d’élagage dans l’arbre
Remarque
Cela n’enlève pas l’effet d’horizon, mais permet d’augmenter la profondeur en réduisant le nombre de positions à explorer.
On donne maintenant deux exemples d’élage de positions superflues : \begin{cases} \text{une coupure } \alpha \\\\ \text{une coupure } \beta \end{cases}
(1) MAX va choisir la valeur maximale renvoyée par l’évaluation de tous les coups possibles de MIN, donc 12
(2) MIN va choisir la valeur minimale renvoyée par l’évaluation de tous les coups possibles de MAX, donc 12
- En remontant ainsi jusqu’à la position initiale, on obtient 12, qui est l’évaluation du deuxième coup (dernier étage) \RA pour arriver à cette position, MAX devra donc jouer le premier coup et suivre le chemin en rouge.
Exemple :
- Une fois déterminée que h(p_{0}) = 14, on sait que MAX va choisir max(14,h(p_{1})) \geq 14, donc la valeur de son parent sera \geq 14 !
MIN va donc ensuite choisir la valeur minimale entre 12 et \geq 14 \ra ce sera 12 !
… peu importe ce que donne h(p_{1}) : on a donc pas besoin d’explorer la position p_{1} et on peut couper la tranchée qui relie p_{1} à son parent \RA c’est une coupe \beta - Selon le même principe, une fois déterminées h(p_{2}) et h(p_{3}), MAX va choisir le coup qui mène à p_{3} (donc 6), puis MIN choisira nécesseraiment une valeur de parent \leq 6
Ainsi, MAX préfèrera le coup qui mène à 12 plutôt que une position \leq 6 : c’est une coupe \alpha
Alpha-Beta (\alpha-\beta)
On considère comme précédemment une fonction d’évaluation h : \mathcal{P} \rightarrow \R et deux joueurs : MIN et MAX.
Pour évaluer p à une profondeur n, pour le joueur J, on conserve :
- \alpha = meilleur évaluation trouvée pour le joueur MAX dans les branches déjà vues
- \beta = meilleur évaluation trouvée pour le joueur MIN dans les branches déjà vues
- au départ, \alpha = -\infty et \beta = +\infty.
[Knuth ‘7s]: Lorsque l’algorithme minimax trouve un coup en parcourant n noeuds, alpha-beta trouve ce même coup en parcourant \approx 1+2\sqrt{n} coups si les coups sont bien ordonnées
\RA L’ordre des coups est important car en fonction de l’ordre, on ne réalise pas le même élagage.
Exemple:
alphabeta (profondeur n, position p, joueur J, alpha, beta)
- si p est terminale, alpha = -INF ; beta = +INF
return h*(p)
- si n = 0
return h(p)
- sinon
- soient p1, ..., pm les m positions accessibles depuis p
- si J = MIN
- e <- alphabeta(n-1, p1, MAX, alpha, beta)
- si e <= alpha
return alpha
- sinon beta <- min(beta, e)
recalculer e pour les autres positions p2, ..., pm
- si J = MAX
- e <- alphabeta(n-1, p1, MIN, alpha, beta)
- si e >= beta
return beta
- sinon alpha <- max(alpha, e)
recalculer e pour les autres positions p2, ..., pm
Schéma résumé :
Remarque
Il existe une variante de l’algorithme minimax, appelée negamax, plus utilisée actuellement. Plutôt que de tester si on est à une profondeur paire ou impaire pour savoir si on cherche à maximiser ou minimiser, on peut inverser le signe des évaluations et toujours chercher à maximiser.
On peut donc toujours procéder à un élagage alpha-beta à partir d’un negamax
Améliorations de l’algorithme alpha-beta
-
Autoriser des coupures plus longues : jusqu’à présent, on a réalisé des coupures alpha ou beta en comparant la valeur d’un noeud avec les valeurs des frères de son parent. On peut aussi regarder les parents des parents, exemple :
Le 20 entouré en rouge ne peut a priori pas donner lieu à un élagage classique.
Mais on peut constater que le frère du parent de son parent a pour valeur 10, et que valeur \geq 20 ne sera jamais choisie :- soit le minimum sera obtenue par le frère du 20 à profondeur 2, auquel cas le 20 n’est pas selectionné.
- soit le 20 remonte jusqu’à 1, mais dans ce cas c’est le 10 qui sera choisi
\ra le 20 ne sera donc jamais choisi, et donc son frère non plus (soit il est \leq 20 et ne remonte pas, soit il est \geq 20 et il sera encore moins choisi pour la même raison) : on peut donc réaliser une coupure “grande-\betaeta / \beta+”
-
Alpha-beta itératif incrémental
L’idée est de commencer par réaliser une recherche à profondeur 1, puis recommencer avec une recherche complète à profondeur 2, puis profondeur 3, etc, jusqu’à ce qu’une solution soit trouvée
Avantage : On trouve toujours la solution la plus courte, puisqu’un noeud n’est jamais engendré tant que tous les noeuds à profondeur inférieur n’ont pas été explorés.
Complexité: \O(d) si l’algo se termine à profondeur d
A priori, on perd beaucoup de temps dans les itérations précédent la solution. Mais ce travail supplémentaire est en général beaucoup plus petit que la dernière itération
Exemple : Si n coups en moyenne par position, à profondeur d, on a n^{d} noeuds.
Le nombre de noeuds à profondeur d-1 est de n^{d-1} mais chaque noeud est engendré 2 fois (une fois par le lancement à profondeur d-1 et une fois pour le lancement à profondeur d)
Au final : nombre de noeuds engendrés : n^{d} + 2n^{d-1} + 3n^{d-2} + \dots + dn = \O(n^{d})
Si n est grand, le premier terme est dominant, et c’est la dernière itération qui prend le plus de temps.
De plus : alpha-beta incrémental permet de :- développer des heuristiques de parcours, en reprenant le parcours de l’arbre à partir de la position qui était jugée la meilleur au lancement à profondeur précédente
- optimiser l’élagage en implémentant un tri rapide pour placer les meilleurs coups dans l’ordre
-
Approfondissement sélectif quiescence
- Pour pallier aux inconveniants des effets d’horizon, Deap Blue a utilisé une meta fonction d’évaluation qui n’évalue pas la position mais plutôt le type de la position
\ra elle évalue si une position est stable (essentiellement, si il reste des pièces en prise) qui donneront grosso-modo la même évaluation à profondeur d ou d+1.
Une position stable a donc une évaluation en laquelle on peut avoir confiance alors qu’une instable non.
Deepblue utilise un mécanisme de quiescence pour continuer de développer les positions basses de l’ordre jusqu’à des positions stables. Aux échecs, la quiescence est couramment utilisée pour des coups de capture ou des coups de promotion (sauf quand le roi est en échec)
Chaque étape de l’algorithme est constituée de 4 phases :- Sélection : Depuis la racine de l’arbre, on sélectionne successivement des enfants jusqu’à atteindre une feuille.
- Expansion : Si cette feuille n’est pas une position terminante, créer un enfant (ou plusieurs) en appliquant un mouvement suivant les règles du jeu, puis ajouté cet enfant marqué (0/0)
- Simulation (“Playout” ou “Roll-out” en anglais) : Simuler une exécution d’une partie au hasard depuis cet enfant, jusqu’à atteindre une position de jeu terminale
- Back propagation : Utiliser le résultat de la partie au hasard et mettre à jour les informations sur la branche en partant de la feuille vers la racine
Exemple :
- Pour pallier aux inconveniants des effets d’horizon, Deap Blue a utilisé une meta fonction d’évaluation qui n’évalue pas la position mais plutôt le type de la position
-
Approfondissement sélectif
Si un coup semble intéressant, on continue à cherche à une profondeur plus grande que la profondeur habituelle.
Si un coup semble mauvais, on arrête de chercher à une profondeur plus petite que la profondeur habituelle.
Exemple : Chinook, IA pour les dames- S’il analyse un coup qui perd 3 pions, plutôt que d’analyser à profondeur 10, il va réduire à seulement 5 coups à l’avance, en considérant que le coup a de bonnes chances d’être mauvais
- S’il analyse un coup qui paraît très bon, il augmentera la profondeur à 12 coups à l’avance
Monte-Carlo Tree Search (MCTS)
L’algorithme MCTS est un autre algorithme d’IA pour les jeux basé sur une recherche arborescente
MCTS conserve en mémoire un arbre qui correspond aux noeuds déjà explorés de l’arbre de jeu.
\ra une feuille de cet arbre est soit une position terminale du jeu, soit un noeud dont aucun enfant n’a encore été exploré.
Dans chaque noeud, on stocke deux nombres : le nombre de simulations gagnantes et le nombre totale de simuations.
Si la partie est perdante pour J1 : on incrémente le nombre de simulation total uniquement pour les noeuds J1 et on incrémente à la fois le nombre de simulations totales et gagnantes pour J2… et inversement si la partie est gagnante pour J1.
-
Pour la phase de sélection, il faut choisir un bon compromis entre l’exploitation des choix qui ont l’air prometteurs et l’exploration des noeuds à partir desquels peu de simulations ont été réalisées
\ra formule UCT (Upper Confidence Bound) introduite par Kocsis et Szepesvári donne un bon compromis
On choisit en chaque noeud de l’arbre le successeur i qui maximise l’expression \frac{w_{i}}{m_{i}} + c \sqrt{\frac{\ln{N_{i}}}{n_{i}}} où :- w_{i} = nombre de parties gagnées marquées dans i
- n_{i} = nombre de fois où i à été visité
- N_{i} = nombre de fois où le noeud de base, père de i, a été visité
- c = constante appelée paramètre d’exploration, en générale égale à \sqrt{2}
-
La partie \frac{w}{n} correspond à l’exploitation : la fraction est grande pour les successeurs qui ont eu beaucoup de succès jusque là
-
La partie \sqrt{\frac{\ln{N_{i}}}{n_{i}}} correspond à l’exploration : elle est grande pour des successeurs impliquées dans peu de simulations
Avantages :
- Pas besoin de fonction d’évaluation en cours de partie, uniquement de savoir jouer aléatoirement selon les règles du jeu et d’évaluer une partie terminée avec une fonction score
- Algorithme dit “anytime” : il est interruptible à tout instant en retournant son meilleur résultat courant. L’interruption peut se faire en nombre d’itérations ou en temps, ou réalisée par la découverte d’une solution.
selection (pos p)
Si p est terminale
return p
N = nombre de visites du père p (fonction getN)
Si N == 0
return p
M <- { Mouvements possibles depuis p }
{ max, best } <- { -1, ∅ }
pour chaque m ∈ M faire
ni = nombre de fois où p à été visité
Si ni == 0
return p
new_eval <- UCT(p, m)
Si new_eval > max
{ max, best } <- { new_eval, m }
p' <- apply_move(p, best)
return selection(p')
expansion (pos p)
Si p est terminale
return p
N <- getN(p)
Si N == 0
make_moves(s)
M <- { Mouvements possibles depuis p }
pour chaque m ∈ M
Ni <- getNi(p, m)
Si Ni == 0
p' = apply_move(p, m)
return p'
playout (pos p)
while not(p position termionale)
M <- next_moves(p)
m <- random(M)
p <- apply_move(p, m)
return score(p)
back_propagate (pos p, score)
if parent(p) == ∅
return
add_score(p, score)
return back_propagate(parent(p), score)
Fonction d’évaluation Puissance 4 :
-
Grille de jeu pondérée
score(J1) = somme des poids des cases sur lesquelles J1 a un jeton -
Compter le nombre d’alignements
Pour chaque joueur, on évalue son score de la façon suivante :- +1 par jeton
- +5 par 2 jetons alignés
- +50 par 3 jetons alignés
- +1000 par 4 jetons alignés
f(p) = score(J1) - score(J2)
Inconvénient : Cette évaluation tient en compte des alignements “bloqués”, par exemple :
donnera un score positif pour J1 avec 2 alignements de 3, alors qu’un seul alignement de 4 n’est encore possible.
Mieux : Ne compter dans ce calcul que les alignements encore envisageables