Poly-Y
Transkrypt
Poly-Y
Informatyka studia dzienne Marcin Różański 71357 Bartosz Rybicki 71358 Poly-Y Sprawozdanie do projektu z przedmiotu Sztuczna Inteligencja 1. Opis gry Gra rozgrywana jest na początkowo pustej planszy w kształcie pięciokąta foremnego. Pięciokąt utworzony jest z połączonych ze sobą węzłów, tworzących charakterystyczny graf. Rozgrywka toczy się pomiędzy dwoma graczami – czerwonym i niebieskim. Każdy z graczy dysponuje pionami swojego koloru, które naprzemiennie układane są na pustych polach planszy. Celem każdego z graczy jest ułożenie pionów w taki sposób, by łączyły ze sobą trzy przynajmniej krawędzie pięciokątnej planszy. Gracz, który dokona tego jako pierwszy – wygrywa. Należy pamiętać o tym, iż pola będące wierzchołkami pięciokąta należą do dwóch krawędzi, zatem oba poniższe stany gry reprezentują rozmieszczenie kończące rozgrywkę (dla czytelności widoczne są piony jedynie jednego z graczy): Pierwsze rozwiązanie wydaje się bardziej intuicyjne, jednak jak nietrudno się domyślić zdecydowanie większe korzyści płyną z zajęcia pól umiejscowionych na wierzchołkach pięciokąta – w ten sposób łączymy dwie krawędzie tylko jednym pionem. Gra polega jedynie na rozmieszczaniu pionów swojego koloru – nie istnieje możliwość bicia pionów przeciwnika, ani przemieszczania pionów znajdujących się na planszy, co niesie ze sobą pewne konsekwencje uniemożliwiające zastosowanie algorytmów korzystających z metod porządkowania następników takich jak np. tablica przejść, czy historia ruchów. Powodem jest fakt, iż w trakcie rozgrywki niemożliwa jest sytuacja by stan gry w kolejnych fazach był choćby dwukrotnie taki sam. Wspomniane metody bazują w dużej mierze na założeniu, iż możliwa jest sytuacja kilkukrotnego wystąpienia tego samego stanu gry podczas jednej rozgrywki – w takiej przypadku można bazować na wiedzy nabytej podczas pierwszego procesu przeszukiwania grafu przestrzeni takiego stanu, co zapobiega wielokrotnemu odtwarzaniu identycznych obliczeń. 2. Implementacja • Stan gry Stan gry przechowywany jest w obiektach klasy GameState, która składa się z listy incydencji grafu reprezentującego planszę. W skład listy wchodzą obiekty klasy Field, reprezentujące poszczególne wierzchołki. Każdy z wierzchołków posiada pole col identyfikujące gracza, który jest w jego posiadaniu (można za jego pomocą stwierdzić również fakt, iż z danego pola nie skorzystał jeszcze żaden z graczy). Klasa GameState posiada również pole isTerminal pozwalające stwierdzić, czy dany stan nie jest stanem kończącym rozgrywkę. Obiekt klasy GameState implementuje interfejs Iterable, który po zaimplementowaniu kilku funkcji daje możliwość korzystania z iteratora tejże klasy. Dzięki takiemu podejściu można w bardzo wygodny sposób powoływać się na następniki danego stanu. Filozofię tego podejścia reprezentuje fragment kodu : for (GameState st : s) { int val = -alphaBetaNegMax(st, depth - 1, -beta, -alpha, type == MAX ? MIN : MAX); s.setRate(val); if (val > alpha) alpha = val; if (alpha >= beta) return beta; } return alpha; Możliwość iteracji zwiększa czytelność kodu – jednak w parze z czytelnością nie idzie niestety wydajność. • Ruchy dopuszczalne Podczas generowania ruchów dopuszczalnych (następników aktualnie badanego stanu gry) pod uwagę bierze się kolor gracza, który wykonuje ruch oraz ilość pól niezajętych przez żadnego z graczy. Każde spośród 31 pól planszy posiada unikalny identyfikator (0 – 30). Zatem dowolny ruch może zostać określony poprzez parę wartości : kolor gracza oraz identyfikator wybranego pola. Podczas badania przestrzeni stanów gry dopuszczalne ruchy generowane są w kolejności identyfikatorów wolnych pól. Program częściej operauje jednak nie na dopuszczalnych w danym momencie ruchach, a od razu na stanach (obiektach klasy GameState) powstałych w wyniku tychże ruchów. Wyjątkami są bardziej złożone algorytmy przeprowadzające operacje na zbiorach następników danego stanu (najczęściej sortując owe zbiory według określonego porządku) – wówczas operuje się na obiektach reprezenujących ruch (klasa SmallState) ze względu na nieporównywalnie mniejszą zajętość pamięciową. W razie potrzeby dysponując stanem wyjściowym oraz obiektem reprezentującym ruch można otrzymać obiekt będący stanem powstałym w wyniku badanego ruchu. • Flaga isTerminal Flaga ta, jak wspomniano wcześniej określa czy dany stan, jest stanem kończącym rozgrywkę. Jedynym sposobem pokonania przeciwnika jest połączenie pionami swojego koloru trzech spośród pięciu krawędzi planszy. By stwierdzić, czy w wyniku ruchu gracza otrzymano stan spełniający warunek zakończenia gry podejmowane są następujące czynności: począwszy od aktualnie oznaczonego przez gracza pola, następuje proces przeglądania wgłab grafu reprezentującego stan gry, jednak uwzględniając jedynie wierzchołki koloru gracza wykonującego ruch, – wierzchołki podgrafu powstałego w ten sposób wykorzystane są do utworzenia zbioru. Nazwijmy go zbiorem A. Prócz zbioru A, istnieje również 5 innych zbiorów wierzchołków – do zbiorów tych należą wierzchołki leżące na kolejnych krawędziach planszy. Nazwijmy te zbiory symbolami B, C, D, E, F. – Bazując na powyższych danych przeprowadzane są działania: A∩ B A∩C A∩D A∩E A∩F Jeśli liczność przynajmniej trzech z pięciu zbiorów powstałych w wyniku powyższych działań wynosi conajmniej 1, wtedy można przyjąć iż nowopowstały stan gry jest stanem końcowym. Aby nieco bardziej zobrazować ten proces najlepiej posłużyć się przykładem: – Dany jest stan gry: Rys.1. Przyjmując, iż ruch należy do gracza niebieskiego, możliwe jest zakończenie rozgrywki. Obrazuje to kolejny rysunek. Rys.2. Gracz wykonał ruch kończący rozgrywkę, jednak aby to zweryfikować dokonujemy kilku operacji. W wyniku przejrzenia grafu wgłąb wyodrębniamy grupę wierzchołków koloru niebieskiego i tworzymy zbiór A. Rys.3. Dysponując zefiniowanymi zbiorami B, C, D, E oraz F można dojść do następujących wniosków: – – – – liczność zbioru A∩B wynosi 1 liczność zbioru A∩C wynosi 2 liczność zbioru A∩D wynosi 1 liczności zbiorów A∩E oraz A∩F są równe i wynoszą 0. Jak widać liczności conajmniej trzech zbiorów wynoszą conajmniej 1, co determinuje fakt, iż badany stan jest stanem końcowym gry. Jak widać algorytm jest wystarczająco skomplikowany, by powtarzany cyklicznie spowodować wyraźne opóźnienia w procesie przeglądania drzewa stanów. Drugim czynnikiem jest funkcja heurystycznej oceny stanu. • Funkcje heurystyczne W programie, w celu oceniania poszczególnych stanów zastosowano funkcję o następującym schemacie działania: – – – Korzystając z algorytmu Floyda-Warshalla generowane są dwie tablice zawierające długości najkrótszych ścieżek między każdą parą wierzchołków w grafie, przy czym generując pierwszą tablicę zakłada się wagę między wierzchołkami czerownymi równą 0, natomiast w drugiej tablicy założono iż waga między wierzchołkami niebieskimi jest równa 0. Przy generowaniu obu tablic założono odległości między krawędziami łączącymi wierzchołki niebieskie z czerwonymi równe ∞ . Na bazie powstałych tablic poszukuje się najkrótszych ścieżek łączących 3 dowolne krawędzie planszy gry. Innymi słowy algorytm szuka, ile najmniej pionów musi w danym momencie postawić każdy z obojga graczy by zakończyć rozgrywkę. Po ukończeniu tego procesu dysponujemy dwoma wartościami x i y, gdzie x to minimalna liczba pionów jaką musi postawić gracz niebieski, a y to minimalna liczba pionów jaką musi postawić gracz czerwony. Wartość różnicy x-y jest wynikiem heurystycznej funkcji oceny stanu. Jeśli stan, jest stanem kończącym rozgrywkę, to wartość funkcji oceny takiego stanu wynosi ∞ lub −∞ w zależności, od koloru pionów łączących 3 krawędzie planszy. Powracając do przykładu z rysunku 1: minimalna ilość pionów, jaką musi postawić gracz niebieski by wygrać wynosi 1, natomiast minimalna ilość pionów, jaką musi postawić gracz czerwony wynosi 2, stąd wartość funkcji oceny stanu to 1 – 2, czyli -1. Natomiast dla sytuacji z rysunku 2 (stan terminalny) wartość ta wynosi −∞ . Pierwsze próby implementacji funkcji oceny heurystycznej wykorzystywały algorytm Dijkstry, jednak nie spełnił on do końca wszystkich oczekiwań, dlatego zdecydowano zastąpić go alforytmem Floyda-Warshalla. Niestety wykonanie gotowej funkcji oceny heurystycznej zajmuje bardzo dużo czasu procesora, ze względu na sporą złożoność co jest głównym powodem problemów z maksymalną głębokością przeszukiwania drzewa stanów. • Metody przeszukiwania stanów gry – Manipulowanie głębokością – pogłebianie specyficznych pojedynczych ruchów. W każdym wierzchołku drzewa stanów gry generowane są następniki danego stanu, a następnie są one zapamiętywane na liście. W większości gier słuszne jest założenie, iż najlepszy pierwszy ruch w przeszukiwaniu na głębokość d stanowi dobre przybliżenie najlepszego ruchu w przeszukiwaniu na głębokości d+1. Zatem przy badaniu drzewa stanów na głębokości d następniki aktualnie badanego zapamiętane na liście są następnie sortowane. Na początku listy umieszczone są ruchy najkorzystniejsze z punktu widzenia gracza wykonującego ruch. Następnie dokonuje się ponownego przeszukania drzewa stanów jednak na głębokości d+1 (mając nadzieję, że najlepszy ruch przy głębokości d, będzie niezłym, lub również najlepszym przy głębokości d+1). Proces powtarzany jest aż do osiągnięcia pożądanej głębokości, bądź też wyczerpania się zasobów (pamięci operacyjnej). – – Manipulowanie zakresem alfa-beta – aspiration search Manipulowanie zakresem alfa-beta – nega scout W projekcie zrezygnowano a lgorytmów opierających się na porządkowaniu następników (takich jak alfa-beta z tablicą transpozycji czy tablicą historii ruchów), uzasadnienie zostało podane wcześniej. Dla przypomnienia – korzystając z tablicy przejść, sytuacja, w której przy głębokości przeszukwania d', a znaleziony w tablicy transpozycji stan był już analizowany na głębokości d w grze Poly-Y nie przyniesie oczekiwanych rezultatów, gdyż zawsze d' > d (w grze nie występują cykle), zatem mamy do dyspozycji wynik, na którym nie możemy polegać. Jedynym wyjściem z tej sytuacji jest globalna tablica przejść, ważna nie tylko w trakcie pojedynczej rozgrywki, lecz tworzona na przestrzeni kilku rozgrywek i ciągle zachowująca ważność. Przechowywanie tego typu struktury w pamięci operacyjnej byłoby bardzo kosztowne (drzewo stanów gry o głębokości d=5, którego korzeń odpowiada stanowi rozpoczęcia rozgrywki posiada ponad 2 miliony liści – przy założeniu iż gracze wykonują zawsze najlepsze posunięcie można tą liczbę znacznie zredukować, jednak gdy jednym z graczy jest człowiek założenie to nie zawsze będzie prawdziwe). Próby zaimplementowania globalnej tablicy przejść szybko doprowadzały do sytuacji wyczerpania zasobów, zatem zdecydowano iż wykorzystane zostaną dwa algorytmy, których działanie opiera się na manipulacji zakresem alfa-beta. 3. Pomiary W celu przetestowania wydajności wybranych algorytmów przeprowadzono szereg testów w oparciu o dwa przykładowe stany gry. Tabele przedstawiają wynik w postaci ilości stanów zbadanych przez każdy z algorytmów przy zadanej głębokości przeszukiwania. Uwaga: ilość odwiedzonych stanów algortmu iterative deepening mierzona jest jedynie podczas badania drzewa stanów na zadanej głębokości. Wyniki przedstawiono poniżej: Rys. 4. Przykładowy stan 1. Graczem wykonującym ruch jest gracz niebieski. 2 Alfa-beta 3 4 5 559 8934 133673 Iterative deepening 870 1624 17064 ? 29458 Aspiration Search 582 9385 140424 NegaScout - 3472 25900 ? 107496 Rys. 5. Przykładowy stan 2. Graczem wykonującym ruch jest ponownie gracz niebieski 2 3 4 5 70 854 3346 39353 Iterative deepening 380 756 4097 10576 Aspiration Search 99 941 4160 49244 NegaScout - 1476 4160 39000 Alfa-beta 4. Interpetacja wyników pomiarów. Od razu rzuca się w oczy bardzo dobry wynik algorytmu iterative deepening z porządkowaniem następników. Okazuje się, że teza, iż najlepszy ruch przy przeszukiwaniu na głębokości d stanowi dobre przybliżenie najlepszego ruchu w przeszukiwaniu na głębokości d+1 sprawdza się w Poly-Y bardzo dobrze. Gra jest na tyle nieskomplikowana, że nawet proste uporządkowanie następników względem ich wartości funkcji oceny (czyli przeszukanie na głębokości 1) niesie ze sobą szanse na znaczne zwiększenie odcięć. W grze nie występuje efekt horyzontu (nieoczekiwana zmiana przewagi na korzyść jednego z graczy) skąd poniekąd wynika taki stan rzeczy. Proste sortowanie następników może być kluczem do sukcesu w tej grze (jeszcze jeden powód by nie implementować złożonych w porównaniu z tym rozwiązaniem algorytmów porządkowania następników w stylu tablicy przejść). Zastanawiające mogą wydawać się kiepskie wyniki algorytmu aspiration search. Powodem wydaje się być przeciwdziedzina funkcji oceny stanu, która sama w sobie jest zbiorem dość mało licznym. Zatem algorytm manipulujący zakresem alfa-beta w przypadku funkcji oceny stanu rozwiązanej w ten sposób niekoniecznie będzie działał efektywnie. Jednak jest na to sposób, co dowodzą wyniki algorytmu NegaScout, a sposobem tym jest proste porządkowanie następników (to samo co w iteracyjnym pogłębianiu). Koszty ponownego przeglądania po błędzie są tutaj również redukowane, gdyż mechanizm porządkowania następników zapamiętuje optymalną kolejność następników dla każdego stanu, i w razie ponownego jego przeszukiwania można się na nią powołać ograniczając powtarzanie się obliczeń. Ten prosty mechanizm historii czyszczony jest po wykonaniu każdego ruchu (względy ekonomiczne). Pomiary na głębokości 5 nie są kompletne ze względu na czas potrzebny na ich wykonanie, który w przypadku algorymów alfa-beta oraz aspiration search jest długi. Powodem jest duży stopień złożoności funkcji oceny stanu wykonywanej podczas działania algorytmów kilkaset tysięcy razy. Należałoby się zastanowić nad jej uproszczeniem. Komputer skupia się zarówno na defensywnym jak i ofensywnym aspekcie gry – gra nie daje szans mało istotnych pomyłek, każda pomyłka może doprowadzić do przegranej, stąd takie zachowanie. Zachowanie to można regulować za pomocą zmiany heurystycznej funkcji oceny stanu. Generalnie nie działa ona źle, lecz przydałoby się zastanowić nad jej optymalizacją.