IA de jeux, Implémenter une IA basique, en Haskell
Voir aussi : video youtube - video peertube - code source
Cet article, de la série “IA de jeux”, aborde la mise en place d’une intelligence artificielle, en Haskell : bots simples, comparaison, interface utilisateur. Pour cela, on réutilisera le jeu de Puissance 4 présenté dans l’article précédent. Une IA plus évoluée sera présentée dans le prochain article.
Articles de la séries IA de jeux :
- Introduction
- Implémenter un Puissance 4, en C++
- Implémenter une IA basique, en C++
- Implémenter une IA arborescente, en C++
- Performances de code, en C++
- Implémenter un Puissance 4, en Haskell
- Implémenter une IA basique, en Haskell
- Implémenter une IA arborescente, en Haskell
- Performances de code, en Haskell
- Conclusion
Architecture de Bot
Rappel sur le module Game
Pour implémenter le jeu de Puissance 4, on avait écrit le type Game s
et les
fonctions de signatures suivantes.
-- Game.hs
data Game s = ...
mkGame :: Player -> ST s (Game s)
cloneGame :: Game s -> ST s (Game s)
nextGame :: Game s -> ST s (Game s)
isRunning :: Game s -> Bool
nMovesGame :: Game s -> Int
playK :: Int -> Game s -> ST s (Game s)
Pour rappel, la monade ST permet d’utiliser de la mémoire “en-place” (sans
copie). Le paramètre de type s
correspond au thread qui effectue le calcul.
Classe de types Bot
On veut pouvoir utiliser différents types de bots (aléatoire, Monte-Carlo,
joueur humain, etc) mais de façon homogène. Pour cela, on définit une classe de
types Bot
, qui contient une fonction genmove
permettant de choisir le coup
à jouer pour un jeu donné.
-- Bot.hs
class Bot s b where
genmove :: b -> Game s -> ST s Int
On notera qu’il s’agit d’une classe à deux paramètres. Le paramètre s
permet
d’accéder au jeu, avec la monade ST. Le paramètre b
est le type qui va
implémenter le bot proprement dit.
Bot de test
Écrivons un bot très simple, qui choisit toujours le premier coups possible.
Pour cela, il suffit de définir un type BotZero
qui instancie la classe de
types Bot
.
-- Bot.hs
data BotZero = BotZero
instance Bot s BotZero where
= return 0 genmove _bot _game
Pour dérouler une partie de jeu avec deux bots donnés, on écrit la fonction
playoutBots
suivante. Il s’agit simplement de demander au bot concerné de
choisir un coup, de jouer ce coup et de recommencer jusqu’à ce que le jeu soit
terminé.
-- Bot.hs
playoutBots :: (Bot s b1, Bot s b2) => b1 -> b2 -> Game s -> ST s Status
playoutBots botR botY g0 | isRunning g0 =
let moveFunc = if _currentPlayer g0 == PlayerR then genmove botR
else genmove botY
in moveFunc g0 >>= (`playK` g0) >>= playoutBots botR botY
| otherwise = return (_status g0)
On peut alors faire jouer deux BotZero
et vérifier les résultats dans des
tests unitaires.
-- tests.hs
"BotZero" $ do
describe
"genmove 1" $ do
it <- stToIO $ do
(g, s) <- mkGame PlayerR
g0 <- playoutBots BotZero BotZero g0
s0 return (g0, s0)
`shouldBe` WinR
s <- stToIO $ toLists2 <$> freezeS (_cells g)
b `shouldBe`
b CellR, CellR, CellR, CellR, CellE, CellE, CellE]
[[CellY, CellY, CellY, CellE, CellE, CellE, CellE]
,[CellR, CellR, CellR, CellE, CellE, CellE, CellE]
,[CellY, CellY, CellY, CellE, CellE, CellE, CellE]
,[CellR, CellR, CellR, CellE, CellE, CellE, CellE]
,[CellY, CellY, CellY, CellE, CellE, CellE, CellE]] ,[
Pour rappel, on utilise la bibliothèque Massiv pour gérer le plateau de jeu.
Ceci permet d’avoir des fonctionnalités intéressantes comme les tableaux 2D
mutables et la fonction toLists2
pour les convertir en listes faciles à
tester.
Interface utilisateur
Afficher et dérouler des jeux
On veut implémenter une interface texte permettant d’afficher le jeu et de faire jouer des IA et des humains. Pour afficher le jeu, on écrit les fonctions suivantes.
-- cli.hs
formatCell :: Cell -> String
CellE = "."
formatCell CellR = "R"
formatCell CellY = "Y"
formatCell
showGame :: Game s -> ST s String
= do
showGame g <- reverse . toLists2 <$> freezeS (_cells g)
cs let bb = unlines $ map (concatMap formatCell) cs
return $ "\n0123456\n" ++ bb
++ "moves: " ++ unwords (map show $ U.toList $ _moves g)
++ "\nstatus: " ++ show (_status g) ++ "\n"
La fonction toLists2
permet d’afficher le plateau de jeu facilement (le
reverse
permet d’avoir les lignes de bas en haut). De même, les coups
possibles sont dans un “unboxed vector”, qu’on peut formater facilement,
avec U.toList
, map
et unwords
.
Comme les joueurs humains vont saisir leur coup au clavier, la fonction pour
choisir le coup à jouer doit permettre de faire des entrées-sorties (IO). On
peut implémenter ça simplement en définissant une classe BotIO
, similaire à
la classe Bot
mais fonctionnant dans la monade IO
plutôt que ST s
.
-- cli.hs
class BotIO b where
genmoveIO :: b -> Game RealWorld -> IO Int
On peut voir BotIO
comme un Bot
restreint au contexte IO
. Ainsi, comme le
type BotZero
instancie déjà la classe Bot
, il peut également instancier la
classe BotIO
simplement.
-- cli.hs
instance BotIO BotZero where
= stToIO (genmove b g) genmoveIO b g
On peut alors écrire une fonction run
pour dérouler des parties successives
entre deux bots, ainsi qu’un programme principal qui lance cette fonction sur
des bots particuliers.
-- cli.hs
run :: (BotIO b1, BotIO b2) => b1 -> b2 -> Game RealWorld -> IO ()
run botR botY g0| isRunning g0 = do
<- if _currentPlayer g0 == PlayerR then genmoveIO botR g0
k else genmoveIO botY g0
>>= run botR botY
stToIO (playK k g0) |otherwise = do
>>= putStrLn
stToIO (showGame g0) putStrLn "new game (y/n) ? ";
<- getLine
r == "y") $ stToIO (nextGame g0) >>= run botR botY
when (r
main :: IO ()
= do
main <- stToIO (mkGame PlayerR)
game let botR = BotZero
let botY = BotZero
run botR botY game
Exemple d’exécution :
$ runghc cli.hs
0123456
YYY....
RRR....
YYY....
RRR....
YYY....
RRRR...
moves: 3 4 5 6
status: WinR
new game (y/n) ?
y
0123456
RRR....
YYY....
RRR....
YYY....
RRR....
YYYY...
moves: 3 4 5 6
status: WinY
new game (y/n) ? n
Joueur humain
Pour implémenter un joueur humain utilisable avec l’interface utilisateur, il
suffit de définir un type BotHuman
et d’instancier la classe BotIO
. Ici la
fonction genmoveIO
affiche le jeu, saisit au clavier la colonne à jouer et
trouve son indice dans le tableau des coups possibles (ou recommence si la
saisie n’est pas valide).
-- cli.hs
data BotHuman = BotHuman
instance BotIO BotHuman where
= do
genmoveIO b g >>= putStrLn
stToIO (showGame g) putStr "j ? "
hFlush stdout<- getLine
line let mK = do j <- readMaybe line
U.elemIndex j (_moves g)case mK of
Just k -> return k
Nothing -> genmoveIO b g
Exemple d’exécution (BotZero
contre BotHuman
) :
$ runghc cli.hs
0123456
.......
.......
.......
.......
.......
R......
moves: 0 1 2 3 4 5 6
status: PlayY
j ? 2
0123456
.......
.......
.......
.......
R......
R.Y....
moves: 0 1 2 3 4 5 6
status: PlayY
j ?
Bot aléatoire
Gestion de l’aléatoire
Comme expliqué dans l’article précédent, la monade ST permet de manipuler
facilement des générateurs de nombres aléatoires, par exemple grâce au type
GenST
et à la fonction uniformR
. Ainsi, on peut écrire une fonction
randomMove
qui choisit aléatoirement un coup possible pour un jeu donné.
-- Bot.hs
randomMove :: GenST s -> Game s -> ST s Int
= uniformR (0, nMovesGame game - 1) gen randomMove gen game
Éventuellement, on peut écrire des tests unitaires pour vérifier la validité, l’espérance et la distribution d’une variable aléatoire.
-- tests.hs
"BotRandom" $ do
describe
"random 1" $ do
it <- createSystemRandom
gen let n = 100
<- stToIO $ replicateM n $ uniformR (0, 10::Int) gen
xs `shouldSatisfy` all (\x -> x>=0 && x<=10)
xs
"random 2" $ do
it <- createSystemRandom
gen let n = 10000
<- stToIO $ replicateM n $ uniformR (0, 10::Int) gen
xs let m = fromIntegral (sum xs) / fromIntegral n
abs (m - 5.0) `shouldSatisfy` (<0.1)
"random 3" $ do
it <- createSystemRandom
gen let n = 10000
<- stToIO $ replicateM n $ uniformR (0, 10::Int) gen
xs let nD = fromIntegral n
= [ fromIntegral (length (filter (==i) xs)) / nD | i <- [0 .. 10] ]
h `shouldSatisfy`
h all (\hi -> abs (hi - 0.1) < 0.05)
Bot random
Pour implémenter un bot aléatoire, on écrit un type BotRandom
contenant un
générateur pseudo-aléatoire et on instancie la classe Bot
grâce à la fonction
randomMove
précédente.
-- Bot.hs
newtype BotRandom s = BotRandom { randomGen :: GenST s }
instance Bot s (BotRandom s) where
BotRandom gen) = randomMove gen genmove (
Pour tester notre bot aléatoire, on peut faire jouer deux BotRandom
et
vérifier que chacun gagne à peu près la moitié des parties.
-- tests.hs
"genmove 1" $ do
it <- BotRandom <$> createSystemRandom
br <- BotRandom <$> createSystemRandom
by let n = 1000
let go :: Int -> Game RealWorld -> ST RealWorld [Status]
0 _ = return []
go = do
go i g <- nextGame g
gi <- playoutBots br by gi
si <- go (i-1) gi
ss return (si : ss)
<- stToIO (mkGame PlayerY >>= go n)
xs let ratio status = fromIntegral (length $ filter (==status) xs) / fromIntegral n
abs (ratio WinR - 0.5) `shouldSatisfy` (<0.05)
abs (ratio WinY - 0.5) `shouldSatisfy` (<0.05)
abs (ratio Tie - 0.0) `shouldSatisfy` (<0.05)
Et comme pour BotZero
, on peut utiliser BotRandom
dans l’interface
utilisateur en instanciant BotIO
et en appelant la fonction genmove
.
Bot Monte-Carlo
Pour rappel, la méthode de Monte-Carlo consiste à évaluer chaque coup possible en jouant de nombreuses parties aléatoires. On écrit donc une fonction qui joue aléatoirement un jeu jusqu’à la fin.
-- Bot.hs
playoutRandom :: GenST s -> Game s -> ST s Status
playoutRandom gen g0 | isRunning g0 = randomMove gen g0 >>= (`playK` g0) >>= playoutRandom gen
| otherwise = return (_status g0)
On a également besoin d’une fonction qui associe un score au résultat d’une partie (et en fonction du joueur considéré).
-- Bot.hs
computeScore :: Player -> Status -> Double
PlayerR WinR = 1.0
computeScore PlayerY WinY = 1.0
computeScore Tie = 0.5
computeScore _ = 0.0 computeScore _ _
On peut alors écrire une fonction qui évalue un coup donné, selon la méthode de Monte-Carlo. Pour cela, on joue le coup à évaluer puis on effectue des simulations (parties aléatoires) à partir de ce jeu, en accumulant les scores obtenus. On notera qu’on doit cloner manuellement les jeux avant les simulations car notre implémentation utilise un tableau mutable, qui serait donc partagé par toutes les simulations si on ne clonait pas.
-- Bot.hs
evalMove :: Game s -> GenST s -> Int -> Int -> ST s Double
= do
evalMove game0 gen nsims k let player0 = _currentPlayer game0
<- cloneGame game0 >>= playK k
game1 let aux 0 s = return s
= do status2 <- cloneGame game1 >>= playoutRandom gen
aux i s - 1) (s + computeScore player0 status2)
aux (i 0 aux nsims
On a désormais tout ce qu’il faut pour écrire notre bot. On écrit donc un type
BotMc
contenant le nombre de simulations Monte-Carlo à effectuer pour évaluer
un coup possible et le générateur pseudo-aléatoire à utiliser. Enfin, on
instancie la classe Bot
où la fonction pour choisir un coup se résume à
évaluer tous les coups possibles et à choisir celui qui a le meilleur score.
-- Bot.hs
data BotMc s = BotMc
mcNsims :: Int
{ mcGen :: GenST s
,
}
instance Bot s (BotMc s) where
BotMc nsims gen) game =
genmove (let aux ki k s = if ki == nMovesGame game then return k else do
<- evalMove game gen nsims ki
si if si>s then aux (ki+1) ki si else aux (ki+1) k s
in aux 0 0 (-1)
Comparer des bots
Pour comparer des bots, on écrit une fonction qui fait jouer deux bots donnés
sur un nombre de parties donné. Pour cela, on utilise la fonction playoutBots
et on calcule la part de victoires pour chacun des bots et la part d’égalités.
-- Cmp.hs
run :: (Bot s b1, Bot s b2)
=> b1 -> b2 -> Int -> ST s (Double, Double, Double)
=
run botR botY nGames let aux 0 r y t _g0 =
let nGamesD = fromIntegral nGames
= fromIntegral r
rD = fromIntegral y
yD = fromIntegral t
tD in return (rD/nGamesD, yD/nGamesD, tD/nGamesD)
= do
aux n r y t g0 <- nextGame g0
g1 <- playoutBots botR botY g1
s1 case s1 of
WinR -> aux (n-1) (r+1) y t g1
WinY -> aux (n-1) r (y+1) t g1
Tie -> aux (n-1) r y (t+1) g1
-> error "game not terminated"
_ in mkGame PlayerR >>= aux nGames (0::Int) (0::Int) (0::Int)
On écrit également une fonction qui permet de tester l’influence d’un réglage
d’un bot (par exemple le nombre de simulations pour BotMc
). Pour cela, on
prend en paramètre le bot de comparaison, un constructeur de bot et une liste
de valeurs permettant de construire les bots à tester. On notera qu’on utilise
la fonction timeIt
pour mesurer le temps d’exécution d’un test, et qu’on
écrit les résultats, par ligne, dans un fichier de type CSV.
-- cmp2.hs
test1 :: (Bot RealWorld b1, Bot RealWorld b2)
=> String -> Int -> (Int -> Gen RealWorld -> b1) -> b2 -> [Int] -> IO ()
=
test1 name nGames mkBotR botY values let filename = "out-test1-" ++ name ++ ".csv"
in withFile filename WriteMode $ \h -> do
putStrLn filename
"winR WinY tie ry ryt dt nGames value"
hPutStrLn h $ \v -> do
forM_ values <- mkBotR v <$> createSystemRandom
botR <- timeItT $ stToIO (run botR botY nGames)
(dt, (r, y, t)) $ unwords (map show [r, y, t, r+y, r+y+t, dt]
hPutStrLn h ++ [show nGames, show v])
Exemple de programme principal comparant différents réglages de BotMc
avec
BotRandom
et avec BotMc
à 32 simulations par coup possible :
-- cmp2.hs
main :: IO ()
= do
main <- BotRandom <$> createSystemRandom
botRandom <- BotMc 32 <$> createSystemRandom
botMc32 let nGames = 300
"McX-Random" nGames BotMc botRandom [1, 2, 4, 8, 16, 32, 64]
test1 "McX-Mc32" nGames BotMc botMc32 [4, 8, 16, 32, 64] test1
Si on génère les graphiques correspondant aux fichiers résultats, on vérifie
que BotMc
est beaucoup plus performant que BotRandom
.
Et on vérifie également l’évolution caractéristique des performances de Monte-Carlo lorsqu’on augmente le nombre de simulations.
Conclusion
Dans cet article, on a vu comment implémenter des bots avec des types algébriques et comment définir des classes de types permettant d’utiliser différents bots de façon homogène, par exemple pour les comparer ou dans une interface utilisateur.
On remarquera qu’on a écrit ici beaucoup de fonctions récursives, alors qu’on aurait parfois pu utiliser des fonctions Haskell de plus haut-niveau. On aurait alors gagner un peu en concision mais au détriment d’un peu de performances. Ceci sera détaillé dans un prochain article, après l’article sur l’IA arborescente.