Bewertung von Spielzügen

Um gute Spieler zu programmieren, müssen wir statt zufälligen sinnvolle Züge aus den gültigen auswählen. Diese Auswahl ist der Kern der in diesem Kapitel vorgestellten Algorithmen, die wir im folgenden schrittweise entwickeln.

Vollständige Suche im Spielbaum

In einfachen Spielen können wir alle Zugmöglichkeiten systematisch überprüfen, indem wir (ähnlich wie beim Backtracking) alle Folgezüge durchsuchen, bis das Spiel beendet ist. Dadurch entsteht eine Baumstruktur, an deren Blättern beendete Spiele stehen. Jeder innere Knoten verzweigt entsprechend der in diesem Zustand gültigen Züge.

Statt bei jeder Verzweigung Kopien der Spielzustände anzulegen, wollen wir das Spiel-Objekt mutieren. Dazu müssen Spiele neben der Methode make_move eine Methode undo_move definieren, die einen übergebenen Zug rückgängig macht. Dann können Züge probeweise mit make_move ausgeführt und vor dem Ausprobieren weiterer Züge mit undo_move wieder rückgängig gemacht werden.

Für das (vereinfachte) Nim-Spiel definieren wir die Methode undo_move wie folgt.

    def undo_move(self, number):
        self.count = self.count + number

Die im übergebenen Zug herunter genommen Streichhölzer werden hier also wieder auf den Haufen drauf gelegt.

Verglichen mit Backtracking kommt bei Spielbäumen erschwerend hinzu, dass zwei Spieler mit unterschiedlichen Zielen gegeneinander antreten. Was für den einen Spieler ein günstiger Zug ist, ist für den anderen Spieler ein ungünstiger. Beide Spieler versuchen, Züge so auszuwählen, dass sie ein möglichst gutes Ergebnis erzwingen können. Ist kein Sieg erzwingbar, kann möglicherweise zumindest ein Unentschieden gesichert werden, um eine Niederlage zu vermeiden.

Für Spiele im Endzustand können wir diese drei Ergebnisse als Zahl (1 für einen Sieg, 0,5 für ein Unentschieden und 0 für eine Niederlage) ausdrücken. Wir verwenden Zahlen zwischen Null und Eins, um bei Bewertungen einfach die Perspektive wechseln zu können. Ist e die Bewertung einer Spielsituation aus Sicht des einen Spielers, dann ergibt sich eine Bewertung aus Sicht des anderen Spielers als 1-e. Kann der Gegner einen Sieg erzwingen, ist uns eine Niederlage sicher (und umgekehrt). Alternativ könnten wir beliebige Zahlen erlauben und diese beim Perspektivwechsel negieren. Unsere Wahl erlaubt die Interpretation der Bewertung als Gewinnwarscheinlichkeit.

Zur Berechnung dieser Bewertung (aus Sicht des Spielers, der an der Reihe ist) fügen wir der Klasse Game die folgende Methode hinzu.

    def eval_on_end(self):
        if self.winner() == None:  # draw
            return 0.5

        if self.current_player is self.winner():
            return 1.0

        return 0.0

Wir definieren nun eine Klasse SearchingPlayer für Spieler, die den Spielbaum systematisch bis zum Ende durchsuchen. Die vier ersten Methoden können später von Unterklassen überschrieben werden, um die Suche zu beeinflussen.

class SearchingPlayer(Player):
    def make_move(self, move, count):
        self.game.make_move(move)
        self.game.next_turn()

    def undo_move(self, move, count):
        self.game.undo_move(move)
        self.game.next_turn()

    def should_stop(self):
        return self.game.has_ended()

    def eval_on_stop(self):
        return self.game.eval_on_end()

    # weitere Definitionen folgen

In der Klasse SearchingPlayer sind die gezeigten Methoden durch bereits besprochene Methoden auf Spielen implementiert. Die teilweise abweichenden Namen und Parameter klären wir später.

Zur Definition der Methode select_move definieren wir zunächst gegenseitig rekursive Hilfsmethoden zur Bewertung von Spielzügen und Spielzuständen. Die Methode eval_move berechnet die Bewertung eines Zuges anhand der aus diesem Zug resultierenden Spielsituation.

    def eval_move(self, moves, index):
        self.make_move(moves[index], len(moves))
        eval = 1 - self.eval_by_search()
        self.undo_move(moves[index], len(moves))

        return eval

Hier werden die oben definierten Methoden make_move und undo_move aufgerufen, denen neben dem Spielzug auch die Anzahl aller verfügbaren Spielzüge übergeben wird, die wir uns später zunutze machen. Die Bewertung der Spielsituation nach Ausführung des Zuges erfolgt mit der Methode eval_by_search, die wie folgt definiert ist. Da ihr Ergebnis aus Sicht des anderen Spielers zu interpretieren ist, berechnen wir eine Bewertung aus der Sicht des ursprünglich ziehenden Spielers mit Hilfe der oben erwähnten Subtraktion von Eins.

    def eval_by_search(self):
        if self.should_stop():
            return self.eval_on_stop()

        moves = self.game.valid_moves()

        best_eval = -1
        for index in range(0, len(moves)):
            eval = self.eval_move(moves, index)
            if eval > best_eval:
                best_eval = eval

        return best_eval

Diese Methode berechnet eine Bewertung mit eval_on_stop, wenn die Suche beendet werden soll und liefert ansonsten die (rekursiv mit eval_move berechnete) bestmögliche Bewertung zurück, die durch gültige Züge erreichbar ist.

Die Definition der Methode select_move ähnelt der von eval_search, liefert aber einen Spielzug zurück und keine Bewertung. Außerdem wird die rekursive Suche nur gestartet, wenn es überhaupt mehrere Züge zur Auswahl gibt.

    def select_move(self):
        moves = self.game.valid_moves()

        if len(moves) == 1:
            return moves[0]

        best_eval = -1
        best_move = None
        for index in range(0, len(moves)):
            eval = self.eval_move(moves, index)
            if eval > best_eval:
                best_eval = eval
                best_move = moves[index]

        return best_move

Falls es nur einen einzigen gültigen Zug gibt, wird dieser zurück gegeben. Ansonsten werden alle gültigen Züge der Reihe nach durchsucht. Für jeden Zug wird eine Bewertung berechnet, und am Ende wird der Zug mit der höchsten Bewertung zurück gegeben.

Der von den Methoden eval_move und eval_by_search implementierte Algorithmus berechnet den bestmöglichen Ausgang unter der Annahme, dass beide Spieler versuchen, ihre eigene Bewertung zu maximieren.

Wenn wir nun eine Instanz der Klasse SearchingPlayer im Nim-Spiel gegen einen zufälligen Spieler antreten lassen, sollte in der Regel der zufällige Spieler verlieren. Hier ist eine entsprechende Beispielausgabe.

>>> alice = SearchingPlayer("Alice")
>>> bob = RandomPlayer("Bob")
>>> SimpleNim(alice, bob, 21).play()
21      matches, Alice's turn
20      matches, Bob's turn
17      matches, Alice's turn
16      matches, Bob's turn
13      matches, Alice's turn
12      matches, Bob's turn
11      matches, Alice's turn
9       matches, Bob's turn
7       matches, Alice's turn
5       matches, Bob's turn
4       matches, Alice's turn
1       matches, Bob's turn
0       matches, Alice won

Für größere Streichholzhaufen können wir beobachten, dass die Suche nach dem besten Zug sehr lange dauert.

Tiefenbeschränkte Suche im Spielbaum

Für komplexe Spiele ist es nicht praktikabel, den Spielbaum vollständig zu durchsuchen. Es ist daher üblich, Zugfolgen nicht bis zum Ende sondern nur bis zu einer bestimmten Tiefe im Baum zu verfolgen. Die Klasse LimitingPlayer definiert dazu ein Attribut limit für die maximale Anzahl von Verzweigungen, die bei einer durchsuchten Zugfolge durchlaufen werden dürfen.

class LimitingPlayer(SearchingPlayer):
    def __init__(self, name, limit):
        super().__init__(name)
        self.limit = limit

    # weitere Definitionen folgen

Um die Suchtiefe wie beschrieben zu beschränken, überschreiben wir die Methode should_stop wie folgt.

    def should_stop(self):
        return self.limit == 0 or super().should_stop()

Da die Suche nun möglicherweise bei einem Spiel abbricht, das noch nicht beendet ist, müssen wir die Methode eval_on_stop so anpassen, dass sie auch mit nicht beendeten Spielen zurecht kommt.

    def eval_on_stop(self):
        if self.game.has_ended():
            return self.game.eval_on_end()
        return random()

Falls das Spiel beendet ist, rufen wir die dafür ausgelegte Implementierung der Oberklasse auf und geben ihr Ergebnis zurück. Falls nicht, geben wir eine zufällige Bewertung zwischen Null und Eins zurück. Für Spiele, für die wir eine spezialisierte Bewertungsfunktion angeben können, können wir eine Unterklasse von LimitingPlayer definieren, in der wir die Methode eval_on_stop überschreiben.

Die Methoden make_move und undo_move überschreiben wir so, dass das Attribut limit manipuliert wird, wenn es Alternativen zum übergebenen Zug gibt.

    def make_move(self, move, count):
        super().make_move(move, count)
        if count > 1:
            self.limit = self.limit - 1

    def undo_move(self, move, count):
        super().undo_move(move, count)
        if count > 1:
            self.limit = self.limit + 1

Mit diesen Definitionen, implementieren die geerbten Methoden eval_move und eval_by_search die beschriebene tiefenbeschränkte Suche. Wir können damit eine weitere Simulation des Nim-Spiels starten.

>>> alice = LimitingPlayer("Alice", 10)
>>> bob = RandomPlayer("Bob")
>>> SimpleNim(alice, bob, 42).play()
42      matches, Alice's turn
41      matches, Bob's turn
38      matches, Alice's turn
35      matches, Bob's turn
34      matches, Alice's turn
33      matches, Bob's turn
30      matches, Alice's turn
29      matches, Bob's turn
26      matches, Alice's turn
23      matches, Bob's turn
21      matches, Alice's turn
18      matches, Bob's turn
17      matches, Alice's turn
16      matches, Bob's turn
14      matches, Alice's turn
13      matches, Bob's turn
10      matches, Alice's turn
9       matches, Bob's turn
7       matches, Alice's turn
5       matches, Bob's turn
2       matches, Alice's turn
1       matches, Bob's turn
0       matches, Alice won

Trotz der beschränkten Suchtiefe gelingt es Alice am Ende gegen den zufälligen Spieler Bob zu gewinnen.

Beschneidung des Spielbaums

Bei geschickter Protokollierung von Zwischenergebnissen, gibt es noch mehr Potential, die Suche im Spielbaum vorzeitig abzubrechen. Bei der Suche im Spielbaum speichern wir als Zwischenergebnis den Wert best_eval für die beste bisher gefundene Bewertung. Rekursive Aufrufe speichern entsprechende Werte für uns und den Gegner. Aus diesen Werten ergeben sich Grenzen für solche Bewertungen, die für die Suche interessant sind. Bewertungen, die unterhalb dem bisher gefundenen besten Wert liegen, sind uninteressant, weil sie weniger Erfolg versprechen als ein bereits gefundener Zug. Statt dem niedrigeren Wert können wir gefahrlos den bisher besten gefundenen Wert zurückgeben, ohne das Ergebnis der Suche zu beeinflussen, denn der beste Wert wird nur bei einem noch besseren Wert angepasst.

Interessanterweise lässt sich auch eine Obergrenze für interessante Bewertungen angeben. Bewertungen, die oberhalb der Obergrenze liegen, sind uninteressant, wenn der Gegner bereits eine Möglichkeit gefunden hat, uns eine niedrigere Bewertung aufzuzwingen. Die Obergrenze ergibt sich also aus der bisherigen besten Bewertung des Gegners. Sobald uns eine Zugmöglichkeit zur Verfügung steht, die die Obergrenze überschreitet, können wir die Suche abbrechen, weil wir davon ausgehen können, dass der Gegner den vorherigen Zug, der uns diese Möglichkeiten bescherte, nicht auswählen wird. Statt des größeren Wertes können wir gefahrlos die übergebene Obergrenze zurück liefern, ohne das Ergebnis der Suche zu verändern, weil der Gegner einen bisher gefundenen Wert nur anpasst, wenn er uns eine noch niedrigere Bewertung aufzwingen kann.

Die Klasse PruningPlayer überschreibt die Methoden eval_move und eval_by_search unter Verwendung zusätzlicher Parameter für die besprochenen Grenzen.

class PruningPlayer(LimitingPlayer):
    def eval_move(self, moves, index, min, max):
        self.make_move(moves[index], len(moves))
        eval = 1 - self.eval_by_search(min, max)
        self.undo_move(moves[index], len(moves))

        return eval

    def eval_by_search(self, min, max):
        if self.should_stop():
            return self.eval_on_stop()

        moves = self.game.valid_moves()

        best_eval = min
        for index in range(0, len(moves)):
            if best_eval >= max:
                return best_eval

            eval = self.eval_move(moves, index, 1 - max, 1 - best_eval)
            if eval > best_eval:
                best_eval = eval

        return best_eval

    # weitere Definition folgt

In der Definition von eval_move wurden die Parameter min und max hinzugefügt, um sie an eval_by_search weiterreichen zu können. Die Definition von eval_by_search enthält neben den zusätzlichen Parametern eine bedingte Rückgabeanweisung im Schleifenrumpf, die die Suche wie oben diskutiert vorzeitig beendet. Im rekursiven Aufruf wird als Obergrenze für den Gegner die umgekehrte bisher beste eigene Bewertung übergeben. Analog dazu wird als Untergrenze die umgekehrte Obergrenze verwendet, die dem bisher besten gefundenen Zug des Gegners entspricht.

Die neue Implementierung von select_move unterscheidet sich von der ursprünglichen nur durch den Aufruf von eval_move mit zusätzlichen Argumenten.

    def select_move(self):
        moves = self.game.valid_moves()

        if len(moves) == 1:
            return moves[0]

        best_eval = -1
        best_move = None
        for index in range(0, len(moves)):
            eval = self.eval_move(moves, index, -1, 1 - best_eval)
            if eval > best_eval:
                best_eval = eval
                best_move = moves[index]

        return best_move

Als Bereichsgrenzen übergeben wir solche außerhalb der berechneten Bewertungen. Die Untergrenze ist so klein, dass sie durch den ersten gefundenen Zug angehoben wird. Die Obergrenze ist so groß, dass sie durch den ersten gefundenen Zug des Gegners abgesenkt wird.

Nach diesen Anpassungen liefert die neue Implementierung von select_move den gleichen Zug wie die ursprüngliche. Dabei werden weniger Zugfolgen betrachtet als mit der ursprünglichen Implementierung, weil Teile des Spielbaums, die das Ergebnis nicht beeinflussen, nicht durchlaufen werden. Instanzen der Klasse PruningPlayer verhalten sich also wie Instanzen von LimitingPlayer, berechnen ihren Zug aber schneller. Die Suche ist weiterhin tiefenbeschränkt, und spezialisierte Implementierungen von eval_on_stop können in Unterklassen definiert werden.