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

Podobne dokumenty