AI und 2048. Teil 2: Minimax + Alpha Beta Clipping



Wir haben die Monte-Carlo-Methode untersucht . Heute werden wir sehen, wie der Computergeist im Jahr 2048 mit dem guten alten Minimax mit Alpha-Beta-Clipping spielt.

EDISON Software - Webentwicklung
Der Artikel wurde mit Unterstützung von EDISON verfasst, einem Unternehmen, das mobile Anwendungen entwickelt und Software-Testdienste anbietet .

Vom Benutzer-Stackoverflow ausspionierte Lösung ovolve , der in der Diskussion feststellte , wie man AI das Spiel 2048 beibringt .

Kommentarübersetzung von ovolve
Ich bin der Autor des in diesem Thread erwähnten Programms. Sie können die KI in Aktion sehen oder den Code sehen .

Gegenwärtig gewinnt das Programm in etwa 90% der Fälle, indem es Java-Skripte in einem Browser auf meinem Laptop ausführt. Dabei werden 100 Millisekunden benötigt, um über den Kurs nachzudenken. Es funktioniert zwar nicht perfekt, aber ziemlich gut.

Da das Spiel ein diskreter Zustandsraum mit vollständigen Informationen ist und tatsächlich ein rundenbasiertes Spiel wie Schach und Dame ist, habe ich die gleichen Methoden verwendet, die ihre Leistung in diesen Spielen zeigten, nämlich die Minimax-Suche mit Alpha-Beta-Clipping . Da die Links viele Informationen zu diesem Algorithmus enthalten, werde ich nur auf die beiden wichtigsten Heuristiken eingehen, die ich in der statischen Schätzfunktion verwendet habe, und viele der intuitiven Annahmen formalisieren, die andere Personen hier getroffen haben.


Monotonie


Diese Heuristik versucht sicherzustellen, dass alle Kachelwerte links / rechts und oben / unten entweder erhöht oder verringert werden. Diese Heuristik allein spiegelt die Vermutung wider, dass viele andere erwähnt haben, dass wertvollere Kacheln in einer Ecke gruppiert werden sollten. Dies verhindert in der Regel die Ansammlung von weniger wertvollen Kacheln und hält das Spielfeld organisiert, da kleinere Kacheln in größere übergehen.

Hier ist ein Screenshot eines komplett eintönigen Gitters. Ich habe diese Situation durch Ausführen eines Algorithmus mit der installierten Auswertungsfunktion erhalten, um andere Heuristiken zu ignorieren und nur Monotonie zu berücksichtigen.


Glätte (Glätte, Ebenheit)


Die obige Heuristik an sich neigt dazu, Strukturen zu erzeugen, in denen benachbarte Zellen an Wert verlieren, aber natürlich sollten Nachbarn die gleiche Bedeutung haben, um sie zu kombinieren. Daher misst die Heuristik der Glätte einfach die Wertdifferenz zwischen benachbarten Kacheln und versucht, deren Anzahl zu minimieren.

Ein Kommentator von Hacker News lieferte eine interessante graphentheoretische Formalisierung dieser Idee.

Übersetzung der Formalisierung mit Hacker News
Gestern habe ich dieses Spiel einem Kollegen gezeigt, einem Liebhaber der Graphentheorie, und wir haben uns auch entschlossen, darüber nachzudenken, wie wir dieses Spiel mit KI lösen können.

Die einfachste Lösung ist Minimax, die meiner Meinung nach ziemlich gut implementiert ist. Wenn jemand hier nicht mit minimax vertraut ist, hat OP sehr eleganten und gut kommentierten Code geschrieben, der ein großartiges Tutorial wäre.

Der weniger rechenintensive Ansatz, den wir vorgeschlagen haben, bestand darin, den Spielzustand in Form eines Graphen G (V, E) zu modellieren, wobei V eine Menge von aktiven Kacheln und E eine Menge von Kanten ist, die benachbarte Kacheln verbinden, gewichtet nach Funktion c (v1, v2) , die den absoluten Wert der Differenz zwischen den beiden Kacheln zurückgibt. Für jede Lösung wählt die KI einen Zug, der die Summe der Gewichte aller Kanten im neuen Spielzustand minimiert.

Der Grund dafür ist, dass der einzige Weg, um Fortschritte im Spiel zu erzielen, darin besteht, Kacheln mit denselben Werten nebeneinander zu haben, für die das Gewicht in G 0 beträgt. Daher sollte die KI versuchen, das Gesamtgewicht zu minimieren. Am Ende befindet sich eine große Anzahl an Brettern mit einem großen Gewicht an Kanten zu benachbarten Kacheln. Die KI wird daher versuchen, diese Kacheln neben anderen großen Kacheln zu belassen, um den Unterschied zu minimieren.

Da das Spiel stochastisch ist, funktioniert der von mir beschriebene Ansatz möglicherweise nicht im schlimmsten Fall, kann aber auch als Gewichtsfunktion für jeden Knoten im Baum auf die vorhandene Minimax-Lösung angewendet werden.


Hier ist ein Screenshot eines perfekt glatten Netzes, freundlicherweise zur Verfügung gestellt von dieser exzellenten Scheingabel . (Link zum Webarchiv, während Java-Skripte auf der Seite funktionieren und Sie die Tastatur verwenden können, um eine Bewegung in eine beliebige Richtung auszuführen - Anmerkung des Übersetzers).

Lose Fliesen


Und schließlich gibt es eine Strafe für zu wenig freie Steine, da die Optionen schnell enden können, wenn das Spielfeld zu eng wird.

Und das ist alles! Das Durchsuchen des Spielraums bei gleichzeitiger Optimierung dieser Kriterien bietet eine überraschend gute Leistung. Einer der Vorteile eines solchen generischen Ansatzes anstelle einer explizit codierten Verschiebestrategie besteht darin, dass der Algorithmus häufig interessante und unerwartete Lösungen findet. Wenn Sie seinen Fortschritt beobachten, macht er oft erstaunliche, aber effektive Bewegungen, wie zum Beispiel den plötzlichen Wechsel von Wänden oder Ecken, in deren Nähe er sein Spiel baut.


Kleine Änderung


Der Screenshot zeigt die Leistungsfähigkeit dieses Ansatzes. Ich habe das Kachellimit entfernt (damit sie nach Erreichen von 2048 weiter wachsen), und hier ist das beste Ergebnis nach acht Tests.

Ja, das ist 4096 zusammen mit 2048. =) Dies bedeutet, dass er das schwer fassbare 2048-Plättchen auf einem Brett erreicht hat.







Java-Script-Code für minimax mit Alpha-Beta-Clipping und statischer Auswertungsfunktion aus dem Stackoverflow-User-Ovolve ist unten im Artikel angegeben.

Die Minimax-Methode ist mehreren ausgezeichneten Habr-Artikeln gewidmet, daher lassen wir die akademische detaillierte Erklärung dessen, woraus sie besteht, weg. Für diejenigen, die erst kürzlich der IT-Community beigetreten sind, haben sie die schönen Begriffe "Minimax" und "Alpha-Beta-Cut-Off" gehört, aber sie wissen nicht, was dies bedeutet. Versuchen wir, in ein paar Absätzen buchstäblich die allgemeinste Bedeutung zu erklären.

Minimax


In einigen Spielen kann der Prozess eines Spiels zwischen zwei Spielern (die nacheinander einen Zug machen) als sogenannter Optionsbaum dargestellt werden. In jeder bestimmten Position hat jeder Spieler normalerweise die Wahl zwischen verschiedenen Optionen für seinen Zug. Und als Reaktion auf jede dieser Optionen kann ein Gegner auch in vielerlei Hinsicht ähnlich sein.


Fragment eines Baumes von Optionen

Da zu jedem Zeitpunkt des Spiels vollständige Informationen über den Zustand des Spielfelds vorliegen, kann der aktuelle Zustand der Position immer genau geschätzt werden. Eine solche Funktion wird als statische Bewertungsfunktion oder abgekürzter SFO bezeichnet . Je wichtiger diese Funktion bei der Bewertung einer bestimmten Position ist, desto vorteilhafter ist außerdem die Position für einen Spieler (nennen wir sie den maximierenden Spieler ). Je kleiner der numerische Wert dieser Funktion bei der Auswertung einer Position ist, desto vorteilhafter ist die Position für den zweiten Spieler (nennen wir es den minimierenden Spieler ).

Nach jedem Zug ändert sich die Position und damit auch die Punktzahl. Bei der Betrachtung des Optionsbaums muss jeder Spieler nicht nur die Zweige bevorzugen, in denen die Bewertung für ihn am günstigsten ist. Sie sollten auch solche Zweige vermeiden, in denen die Bewertung der Position für den Gegner günstig ist.

Es wird davon ausgegangen, dass der Gegner sich auch von Rationalismus leiten lässt und auch Optionen vermeidet, die ihn zum Verlieren führen könnten. Das heißt, jeder Spieler maximiert bei der Auswahl einer Option seinen eigenen Nutzen und minimiert gleichzeitig den Gewinn des Gegners.

Das ist Minimax.

Alpha Beta Ausschnitt


Es liegt auf der Hand: Wer einen Baum von einer bestimmten Position bis zu einer größeren Tiefe berechnet, hat mehr Gewinnchancen. Aber es gibt ein Ärgernis: Der Baum der Optionen in Spielen hat die unangenehme Angewohnheit, sich mit jeder Verschachtelungsebene zu verzweigen und exponentiell zu wachsen. Die Zählfähigkeiten von Programmen und vor allem die der Menschen sind begrenzt, das Zählen "bis zur Matte" ist bei weitem nicht immer möglich. Es kann sich leicht herausstellen, dass der Spieler zu einer Position gezählt hat, bei der er eine gute Einschätzung des Spielfelds hat, aber auf der nächsten (unlesbaren) Ebene hat der Gegner buchstäblich die Möglichkeit, einen solchen Zug zu machen, der die Positionsschätzung grundlegend zum Gegenteil ändert.

Wer ist schuld und was zu tun? Der Rechenaufwand ist für die vollständige Durchquerung der Bäume verantwortlich, und es wird vorgeschlagen, unnötige Äste abzuschneiden, um zu kämpfen. Wenn der Spieler, der die Position bewertet, einen Zweig des Optionsbaums sieht:

oder weniger rentabel als andere Branchen, die bereits analysiert wurden,
oder vorteilhafter für den Gegner als andere Zweige, die bereits analysiert wurden,

dann wirft der Spieler diesen Zweig ab, verschwendet keine Zeit und Ressourcen damit, Unteroptionen aus diesem offensichtlich schlechteren Zweig für ihn in Betracht zu ziehen.

Auf diese Weise können Sie mehr Rechenressourcen für die Berechnung günstigerer Verzweigungen einer größeren Rendering-Tiefe im Optionsbaum zuweisen. Bei der Bewertung des Spielfelds auf verschiedenen Ebenen des Optionsbaums arbeitet der Spieler mit zwei sich dynamisch ändernden Koeffizienten - Alpha (der Wert des SFD, der im Zweig minimal angetroffen wird - d. H. Für den minimierenden Spieler günstiger) und Beta (der Wert des SFD, der im Zweig am häufigsten angetroffen wird - d. H. günstiger für den maximierenden Spieler). Wenn Sie die SFD der aktuellen Position mit den Alpha und Beta Koeffizienten vergleichen, können Sie auf jeder Ebene Zweige fegen (ohne sie vollständig zu berechnen), die für den Spieler, der die Position bewertet, ungünstiger und / oder für seinen Gegner vorteilhafter sind .

Dies ist Alpha-Beta-Clipping.

Rekursive Minimax-Funktion mit Alpha-Beta-Clipping


2048 mit AI ist als Excel-Anwendung mit VBA-Makros implementiert. So sieht der Minimax-Algorithmus mit Alpha-Beta-Clipping wie eine verabscheuungswürdige visuelle Basis aus.
''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''' ''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''' '''''''''''''''''''''''( - )''''''''''''''''''''''''' ''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''' '       -- 'Position -  4  4     'Depth - ,    'Alpha, Beta -         'MaximisingPlayer -      ? Private Function MiniMaxAlpaBeta_Evaluation(Position As Variant, Depth As Long, _ Alpha As Double, Beta As Double, _ MaximisingPlayer As Boolean, _ Optional MainLevel As Boolean = False) As Double Dim MaxEval As Double '  Dim MinEval As Double '  Dim PositionNext As Variant '     Dim PositionTemp As Variant '     Dim Eval As Double '   Dim Way As Long '   -      Dim Row As Long '     Dim Col As Long '     Dim TileNew As Long '      '   (  ,  '     ) If GameOverPosition(Position) Then '    ? '     MiniMaxAlpaBeta_Evaluation = -1000000 + TileMax(Position) '         ElseIf Depth = 0 Then '     MiniMaxAlpaBeta_Evaluation = StaticEvaluation(Position) '  ,    '     () ElseIf MaximisingPlayer Then MaxEval = -1000000 '      For Way = 1 To 4 ' 4   - (, , , ) ChangeCount = 0 ' ,      ',       PositionNext = StepHuman(Position, Way) If ChangeCount > 0 Then '     '      , '    () Eval = MiniMaxAlpaBeta_Evaluation(PositionNext, Depth - 1, _ Alpha, Beta, False) If Eval > MaxEval Then MaxEval = Eval '  '     If Eval > Alpha Then Alpha = Eval '    ,   '   -    If Beta > Alpha Then Exit For End If Next '          MiniMaxAlpaBeta_Evaluation = MaxEval '  ,    '     () Else 'Not MaximisingPlayer MinEval = 1000000 '      For Row = 1 To 4 '     For Col = 1 To 4 '     If Position(Row, Col) = 0 Then '   For TileNew = 2 To 4 Step 2 '    2  4 ',       '    PositionNext = StepComp(Position, Row, Col, TileNew) '     , '    () Eval = MiniMaxAlpaBeta_Evaluation(PositionNext, Depth - 1, _ Alpha, Beta, True) If Eval < MinEval Then MinEval = Eval '  '     If Eval < Beta Then Beta = Eval '    ,   '   -    If Alpha < Beta Then Exit For Next End If Next Next '          MiniMaxAlpaBeta_Evaluation = MinEval End If End Function 

Ovolve Code in Java-Skript
 function AI(grid) { this.grid = grid; } //   () AI.prototype.eval = function() { var emptyCells = this.grid.availableCells().length; var smoothWeight = 0.1, //monoWeight = 0.0, //islandWeight = 0.0, mono2Weight = 1.0, emptyWeight = 2.7, maxWeight = 1.0; return this.grid.smoothness() * smoothWeight //+ this.grid.monotonicity() * monoWeight //- this.grid.islands() * islandWeight + this.grid.monotonicity2() * mono2Weight + Math.log(emptyCells) * emptyWeight + this.grid.maxValue() * maxWeight; }; // alpha-beta depth first search AI.prototype.search = function(depth, alpha, beta, positions, cutoffs) { var bestScore; var bestMove = -1; var result; // the maxing player if (this.grid.playerTurn) { bestScore = alpha; for (var direction in [0, 1, 2, 3]) { var newGrid = this.grid.clone(); if (newGrid.move(direction).moved) { positions++; if (newGrid.isWin()) { return { move: direction, score: 10000, positions: positions, cutoffs: cutoffs }; } var newAI = new AI(newGrid); if (depth == 0) { result = { move: direction, score: newAI.eval() }; } else { result = newAI.search(depth-1, bestScore, beta, positions, cutoffs); if (result.score > 9900) { // win result.score--; // to slightly penalize higher depth from win } positions = result.positions; cutoffs = result.cutoffs; } if (result.score > bestScore) { bestScore = result.score; bestMove = direction; } if (bestScore > beta) { cutoffs++ return { move: bestMove, score: beta, positions: positions, cutoffs: cutoffs }; } } } } else { // computer's turn, we'll do heavy pruning to keep the branching factor low bestScore = beta; // try a 2 and 4 in each cell and measure how annoying it is // with metrics from eval var candidates = []; var cells = this.grid.availableCells(); var scores = { 2: [], 4: [] }; for (var value in scores) { for (var i in cells) { scores[value].push(null); var cell = cells[i]; var tile = new Tile(cell, parseInt(value, 10)); this.grid.insertTile(tile); scores[value][i] = -this.grid.smoothness() + this.grid.islands(); this.grid.removeTile(cell); } } // now just pick out the most annoying moves var maxScore = Math.max(Math.max.apply(null, scores[2]), Math.max.apply(null, scores[4])); for (var value in scores) { // 2 and 4 for (var i=0; i<scores[value].length; i++) { if (scores[value][i] == maxScore) { candidates.push( { position: cells[i], value: parseInt(value, 10) } ); } } } // search on each candidate for (var i=0; i<candidates.length; i++) { var position = candidates[i].position; var value = candidates[i].value; var newGrid = this.grid.clone(); var tile = new Tile(position, value); newGrid.insertTile(tile); newGrid.playerTurn = true; positions++; newAI = new AI(newGrid); result = newAI.search(depth, alpha, bestScore, positions, cutoffs); positions = result.positions; cutoffs = result.cutoffs; if (result.score < bestScore) { bestScore = result.score; } if (bestScore < alpha) { cutoffs++; return { move: null, score: alpha, positions: positions, cutoffs: cutoffs }; } } } return { move: bestMove, score: bestScore, positions: positions, cutoffs: cutoffs }; } // performs a search and returns the best move AI.prototype.getBest = function() { return this.iterativeDeep(); } // performs iterative deepening over the alpha-beta search AI.prototype.iterativeDeep = function() { var start = (new Date()).getTime(); var depth = 0; var best; do { var newBest = this.search(depth, -10000, 10000, 0 ,0); if (newBest.move == -1) { break; } else { best = newBest; } depth++; } while ( (new Date()).getTime() - start < minSearchTime); return best } AI.prototype.translate = function(move) { return { 0: 'up', 1: 'right', 2: 'down', 3: 'left' }[move]; } 

Statische Auswertungsfunktion


Da Sie auf jeder Ebene im Optionsbaum das Spielfeld bewerten müssen (um zu entscheiden, für welchen der Spieler die geschätzte Position tatsächlich vorteilhafter ist), müssen Sie entscheiden, welche Kriterien eine gute von einer schlechten Position unterscheiden.

Wir gehen davon aus, dass der maximierende Spieler die Person (oder KI) ist, die entscheidet, in welche der vier Richtungen (oben, links, rechts, unten) alle Steine ​​verschoben werden. Ein minimierender Spieler ist diese heimtückische Unterroutine, die zufällig 2 oder 4 an den unangemessensten Stellen generiert.

SFO wird aus der Sicht eines maximierenden Spielers zusammengestellt. Je höher die SFD-Bewertung für das Spielfeld ist, desto besser ist die Position für den „Maximalisten“. Je niedriger - desto angenehmer ist die Position auf dem Brett für den "Minimalisten".

Im Fall von 2048 - welche Faktoren werden für denjenigen als günstig angesehen, der die Kacheln bewegt?

Monotonie


Erstens ist es wünschenswert, dass die Kacheln in aufsteigender / absteigender Reihenfolge in einigen Richtungen angeordnet sind. Wenn dies nicht erfolgt, wird das Spielfeld beim Generieren neuer Kacheln schnell durch zufällig angeordnete Kacheln unterschiedlicher Größe verstopft, die nicht sofort normal miteinander verbunden werden können.

Im Sibirischen Bundesdistrikt müssen Sie in alle vier Richtungen schauen (von oben nach unten, von links nach rechts, von rechts nach links, von unten nach oben) und berechnen, wo die Kacheln eine abnehmende oder zunehmende Progression aufweisen. Wenn es im Laufe der Zeit Kacheln gibt, die nicht in die allgemeine Reihe passen, verringert dies den numerischen Monotoniekoeffizienten. Dann wird aus den 4 Koeffizienten für alle Richtungen der beste ausgewählt, der im Gesamtwert des Sibirischen Bundesdistrikts berücksichtigt wird.

Glätte


Darüber hinaus wäre es vorzuziehen, wenn der Fortschritt vom Stehen in einer Reihe von Kacheln nicht nur zunimmt, sondern nicht abnimmt (oder statt der Reihe zu verringern, ist es vorzuziehen, nicht zuzunehmen), das heißt, es ist gut, wenn dieselben Kacheln in der Nähe sind, was es ihnen ermöglicht, zu einer zusammenzufallen, Punkte zu gewinnen und Erhöhung des freien Platzes auf dem Spielfeld.

Daher sucht der Sibirische Bundesdistrikt auf dem Spielfeld nach denselben benachbarten Kacheln und berücksichtigt die Anzahl solcher Paare in einem speziellen Koeffizienten.

Leere Zellen


Je mehr Freiraum vorhanden ist, desto mehr Spielraum ist vorhanden und desto geringer ist die Wahrscheinlichkeit, schnell zu verlieren.

SFO betrachtet leere Zellen auf dem Spielfeld und je mehr davon, desto profitabler wird die Position für den maximierenden Spieler.

Maximale Kachel


Da die Hauptsache in diesem Spiel darin besteht, ein großes Plättchen auf das Spielfeld zu bringen, sollten die Optionen, bei denen der maximale Plättchenwert höher ist, als das rentabelste SFD angesehen werden, je mehr desto besser - 2048, 4096, 8192 (oder was auch immer Sie die Stärke und Geduld dafür haben).

Sibirischer Bundesdistrikt für 2048


Implementierung des Sibirischen Bundesdistrikts als VBA-Makro
 ''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''' '''''''''''''''''''''''  '''''''''''''''''''''''' ''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''' '     'Position -  4  4     Private Function StaticEvaluation(Position As Variant) As Double Dim Smoothness As Double ' Dim Monotonicity As Double ' Dim EmptyCount As Double '  Dim MaxValue As Long '  '   Const SmoothWeight = 0.1 Const MonoWeight = 1 Const EmptyWeight = 2.7 Const MaxWeight = 1 Dim k As Long '   Dim i As Long '  Dim j As Long '  Dim x As Long '  Dim y As Long '  ' Dim Value As Double '       '         Dim TargetValue As Double Smoothness = 0 '    For i = 1 To 4 '     For j = 1 To 4 '     If Position(i, j) > 0 Then '   Value = Log(Position(i, j)) / Log(2) If i < 4 Then '       For x = i + 1 To 4 '    If Position(x, j) > 0 Then '    '    TargetValue = Log(Position(x, j)) / Log(2) ',   Smoothness = Abs(Value - TargetValue) '       Exit For End If Next End If If j < 4 Then '       For y = j + 1 To 4 '    If Position(i, y) > 0 Then '    '    TargetValue = Log(Position(i, y)) / Log(2) ',   Smoothness = Abs(Value - TargetValue) '        Exit For End If Next End If End If Next Next ' Dim arrTotals(1 To 4) As Double '     Dim Current As Long '   Dim Next_ As Long '      Dim CurrentValue As Double '      Dim NextValue As Double '        Monotonicity = 0 '    '      For k = 1 To 4 arrTotals(k) = 0 Next ' -  - For x = 1 To 4 '   Current = 1 '      '   (     )  Next_ = Current + 1 Do While Next_ <= 4 '       '      '(       ) Do While Next_ <= 4 '       If Position(x, Next_) = 0 Then '     Next_ = Next_ + 1 '   Else ' -    Exit Do ' ,  ,   End If Loop '         If Next_ > 4 Then Next_ = 4 '          If Position(x, Current) > 0 Then CurrentValue = Log(Position(x, Current)) / Log(2) Else CurrentValue = 0 End If ' MsgBox "Position[" & x & ", " & Next_ & "]=" & Position(x, Next_) If Position(x, Next_) > 0 Then NextValue = Log(Position(x, Next_)) / Log(2) Else NextValue = 0 End If If CurrentValue > NextValue Then '   ? arrTotals(Up) = arrTotals(Up) + NextValue - CurrentValue ElseIf NextValue > CurrentValue Then '   ? arrTotals(Down) = arrTotals(Down) + CurrentValue - NextValue End If Current = Next_ '       Next_ = Next_ + 1 '    Loop Next '      -  - Monotonicity = IIf(arrTotals(Up) >= arrTotals(Down), _ Monotonicity + arrTotals(Up), _ Monotonicity + arrTotals(Down)) ' -  - For y = 1 To 4 '   Current = 1 '      '   (     )  Next_ = Current + 1 Do While Next_ <= 4 '       '      '(       ) Do While Next_ <= 4 '       If Position(Next_, y) = 0 Then '     Next_ = Next_ + 1 '   Else ' -    Exit Do ' ,  ,   End If Loop '         If Next_ > 4 Then Next_ = 4 '          If Position(Current, y) > 0 Then CurrentValue = Log(Position(Current, y)) / Log(2) Else CurrentValue = 0 End If If Position(Next_, y) > 0 Then NextValue = Log(Position(Next_, y)) / Log(2) Else NextValue = 0 End If If CurrentValue > NextValue Then '   ? arrTotals(Left) = arrTotals(Left) + NextValue - CurrentValue ElseIf NextValue > CurrentValue Then '   ? arrTotals(Right) = arrTotals(Right) + CurrentValue - NextValue End If Current = Next_ '       Next_ = Next_ + 1 '    Loop Next '      -  - Monotonicity = IIf(arrTotals(Left) >= arrTotals(Right), _ Monotonicity + arrTotals(Left), _ Monotonicity + arrTotals(Right)) '     EmptyCount = 0 '      MaxValue = 0 '    For i = 1 To 4 '     For j = 1 To 4 '     If Position(i, j) = 0 Then '  ... '...     EmptyCount = EmptyCount + 1 '     ... ElseIf Position(i, j) > MaxValue Then MaxValue = Position(i, j) '...    End If Next Next '   StaticEvaluation = Smoothness * SmoothWeight + _ Monotonicity * MonoWeight + _ Log_Base_Arg(EmptyCount) * EmptyWeight + _ MaxValue * MaxWeight End Function 

Ovolve-Code im Java-Skript
 function Grid(size) { this.size = size; this.startTiles = 2; this.cells = []; this.build(); this.playerTurn = true; } // pre-allocate these objects (for speed) Grid.prototype.indexes = []; for (var x=0; x<4; x++) { Grid.prototype.indexes.push([]); for (var y=0; y<4; y++) { Grid.prototype.indexes[x].push( {x:x, y:y} ); } } // Build a grid of the specified size Grid.prototype.build = function () { for (var x = 0; x < this.size; x++) { var row = this.cells[x] = []; for (var y = 0; y < this.size; y++) { row.push(null); } } }; // Find the first available random position Grid.prototype.randomAvailableCell = function () { var cells = this.availableCells(); if (cells.length) { return cells[Math.floor(Math.random() * cells.length)]; } }; Grid.prototype.availableCells = function () { var cells = []; var self = this; this.eachCell(function (x, y, tile) { if (!tile) { //cells.push(self.indexes[x][y]); cells.push( {x:x, y:y} ); } }); return cells; }; // Call callback for every cell Grid.prototype.eachCell = function (callback) { for (var x = 0; x < this.size; x++) { for (var y = 0; y < this.size; y++) { callback(x, y, this.cells[x][y]); } } }; // Check if there are any cells available Grid.prototype.cellsAvailable = function () { return !!this.availableCells().length; }; // Check if the specified cell is taken Grid.prototype.cellAvailable = function (cell) { return !this.cellOccupied(cell); }; Grid.prototype.cellOccupied = function (cell) { return !!this.cellContent(cell); }; Grid.prototype.cellContent = function (cell) { if (this.withinBounds(cell)) { return this.cells[cell.x][cell.y]; } else { return null; } }; // Inserts a tile at its position Grid.prototype.insertTile = function (tile) { this.cells[tile.x][tile.y] = tile; }; Grid.prototype.removeTile = function (tile) { this.cells[tile.x][tile.y] = null; }; Grid.prototype.withinBounds = function (position) { return position.x >= 0 && position.x < this.size && position.y >= 0 && position.y < this.size; }; Grid.prototype.clone = function() { newGrid = new Grid(this.size); newGrid.playerTurn = this.playerTurn; for (var x = 0; x < this.size; x++) { for (var y = 0; y < this.size; y++) { if (this.cells[x][y]) { newGrid.insertTile(this.cells[x][y].clone()); } } } return newGrid; }; // Set up the initial tiles to start the game with Grid.prototype.addStartTiles = function () { for (var i=0; i<this.startTiles; i++) { this.addRandomTile(); } }; // Adds a tile in a random position Grid.prototype.addRandomTile = function () { if (this.cellsAvailable()) { var value = Math.random() < 0.9 ? 2 : 4; //var value = Math.random() < 0.9 ? 256 : 512; var tile = new Tile(this.randomAvailableCell(), value); this.insertTile(tile); } }; // Save all tile positions and remove merger info Grid.prototype.prepareTiles = function () { this.eachCell(function (x, y, tile) { if (tile) { tile.mergedFrom = null; tile.savePosition(); } }); }; // Move a tile and its representation Grid.prototype.moveTile = function (tile, cell) { this.cells[tile.x][tile.y] = null; this.cells[cell.x][cell.y] = tile; tile.updatePosition(cell); }; Grid.prototype.vectors = { 0: { x: 0, y: -1 }, // up 1: { x: 1, y: 0 }, // right 2: { x: 0, y: 1 }, // down 3: { x: -1, y: 0 } // left } // Get the vector representing the chosen direction Grid.prototype.getVector = function (direction) { // Vectors representing tile movement return this.vectors[direction]; }; // Move tiles on the grid in the specified direction // returns true if move was successful Grid.prototype.move = function (direction) { // 0: up, 1: right, 2:down, 3: left var self = this; var cell, tile; var vector = this.getVector(direction); var traversals = this.buildTraversals(vector); var moved = false; var score = 0; var won = false; // Save the current tile positions and remove merger information this.prepareTiles(); // Traverse the grid in the right direction and move tiles traversals.x.forEach(function (x) { traversals.y.forEach(function (y) { cell = self.indexes[x][y]; tile = self.cellContent(cell); if (tile) { //if (debug) { //console.log('tile @', x, y); //} var positions = self.findFarthestPosition(cell, vector); var next = self.cellContent(positions.next); // Only one merger per row traversal? if (next && next.value === tile.value && !next.mergedFrom) { var merged = new Tile(positions.next, tile.value * 2); merged.mergedFrom = [tile, next]; self.insertTile(merged); self.removeTile(tile); // Converge the two tiles' positions tile.updatePosition(positions.next); // Update the score score += merged.value; // The mighty 2048 tile if (merged.value === 2048) { won = true; } } else { //if (debug) { //console.log(cell); //console.log(tile); //} self.moveTile(tile, positions.farthest); } if (!self.positionsEqual(cell, tile)) { self.playerTurn = false; //console.log('setting player turn to ', self.playerTurn); moved = true; // The tile moved from its original cell! } } }); }); //console.log('returning, playerturn is', self.playerTurn); //if (!moved) { //console.log('cell', cell); //console.log('tile', tile); //console.log('direction', direction); //console.log(this.toString()); //} return {moved: moved, score: score, won: won}; }; Grid.prototype.computerMove = function() { this.addRandomTile(); this.playerTurn = true; } // Build a list of positions to traverse in the right order Grid.prototype.buildTraversals = function (vector) { var traversals = { x: [], y: [] }; for (var pos = 0; pos < this.size; pos++) { traversals.x.push(pos); traversals.y.push(pos); } // Always traverse from the farthest cell in the chosen direction if (vector.x === 1) traversals.x = traversals.x.reverse(); if (vector.y === 1) traversals.y = traversals.y.reverse(); return traversals; }; Grid.prototype.findFarthestPosition = function (cell, vector) { var previous; // Progress towards the vector direction until an obstacle is found do { previous = cell; cell = { x: previous.x + vector.x, y: previous.y + vector.y }; } while (this.withinBounds(cell) && this.cellAvailable(cell)); return { farthest: previous, next: cell // Used to check if a merge is required }; }; Grid.prototype.movesAvailable = function () { return this.cellsAvailable() || this.tileMatchesAvailable(); }; // Check for available matches between tiles (more expensive check) // returns the number of matches Grid.prototype.tileMatchesAvailable = function () { var self = this; //var matches = 0; var tile; for (var x = 0; x < this.size; x++) { for (var y = 0; y < this.size; y++) { tile = this.cellContent({ x: x, y: y }); if (tile) { for (var direction = 0; direction < 4; direction++) { var vector = self.getVector(direction); var cell = { x: x + vector.x, y: y + vector.y }; var other = self.cellContent(cell); if (other && other.value === tile.value) { return true; //matches++; // These two tiles can be merged } } } } } //console.log(matches); return false; //matches; }; Grid.prototype.positionsEqual = function (first, second) { return first.x === second.x && first.y === second.y; }; Grid.prototype.toString = function() { string = ''; for (var i=0; i<4; i++) { for (var j=0; j<4; j++) { if (this.cells[j][i]) { string += this.cells[j][i].value + ' '; } else { string += '_ '; } } string += '\n'; } return string; } // counts the number of isolated groups. Grid.prototype.islands = function() { var self = this; var mark = function(x, y, value) { if (x >= 0 && x <= 3 && y >= 0 && y <= 3 && self.cells[x][y] && self.cells[x][y].value == value && !self.cells[x][y].marked ) { self.cells[x][y].marked = true; for (direction in [0,1,2,3]) { var vector = self.getVector(direction); mark(x + vector.x, y + vector.y, value); } } } var islands = 0; for (var x=0; x<4; x++) { for (var y=0; y<4; y++) { if (this.cells[x][y]) { this.cells[x][y].marked = false } } } for (var x=0; x<4; x++) { for (var y=0; y<4; y++) { if (this.cells[x][y] && !this.cells[x][y].marked) { islands++; mark(x, y , this.cells[x][y].value); } } } return islands; } // measures how smooth the grid is (as if the values of the pieces // were interpreted as elevations). Sums of the pairwise difference // between neighboring tiles (in log space, so it represents the // number of merges that need to happen before they can merge). // Note that the pieces can be distant Grid.prototype.smoothness = function() { var smoothness = 0; for (var x=0; x<4; x++) { for (var y=0; y<4; y++) { if ( this.cellOccupied( this.indexes[x][y] )) { var value = Math.log(this.cellContent( this.indexes[x][y] ).value) / Math.log(2); for (var direction=1; direction<=2; direction++) { var vector = this.getVector(direction); var targetCell = this.findFarthestPosition(this.indexes[x][y], vector).next; if (this.cellOccupied(targetCell)) { var target = this.cellContent(targetCell); var targetValue = Math.log(target.value) / Math.log(2); smoothness -= Math.abs(value - targetValue); } } } } } return smoothness; } Grid.prototype.monotonicity = function() { var self = this; var marked = []; var queued = []; var highestValue = 0; var highestCell = {x:0, y:0}; for (var x=0; x<4; x++) { marked.push([]); queued.push([]); for (var y=0; y<4; y++) { marked[x].push(false); queued[x].push(false); if (this.cells[x][y] && this.cells[x][y].value > highestValue) { highestValue = this.cells[x][y].value; highestCell.x = x; highestCell.y = y; } } } increases = 0; cellQueue = [highestCell]; queued[highestCell.x][highestCell.y] = true; markList = [highestCell]; markAfter = 1; // only mark after all queued moves are done, as if searching in parallel var markAndScore = function(cell) { markList.push(cell); var value; if (self.cellOccupied(cell)) { value = Math.log(self.cellContent(cell).value) / Math.log(2); } else { value = 0; } for (direction in [0,1,2,3]) { var vector = self.getVector(direction); var target = { x: cell.x + vector.x, y: cell.y+vector.y } if (self.withinBounds(target) && !marked[target.x][target.y]) { if ( self.cellOccupied(target) ) { targetValue = Math.log(self.cellContent(target).value ) / Math.log(2); if ( targetValue > value ) { //console.log(cell, value, target, targetValue); increases += targetValue - value; } } if (!queued[target.x][target.y]) { cellQueue.push(target); queued[target.x][target.y] = true; } } } if (markAfter == 0) { while (markList.length > 0) { var cel = markList.pop(); marked[cel.x][cel.y] = true; } markAfter = cellQueue.length; } } while (cellQueue.length > 0) { markAfter--; markAndScore(cellQueue.shift()) } return -increases; } // measures how monotonic the grid is. This means the values of the tiles are strictly increasing // or decreasing in both the left/right and up/down directions Grid.prototype.monotonicity2 = function() { // scores for all four directions var totals = [0, 0, 0, 0]; // up/down direction for (var x=0; x<4; x++) { var current = 0; var next = current+1; while ( next<4 ) { while ( next<4 && !this.cellOccupied( this.indexes[x][next] )) { next++; } if (next>=4) { next--; } var currentValue = this.cellOccupied({x:x, y:current}) ? Math.log(this.cellContent( this.indexes[x][current] ).value) / Math.log(2) : 0; var nextValue = this.cellOccupied({x:x, y:next}) ? Math.log(this.cellContent( this.indexes[x][next] ).value) / Math.log(2) : 0; if (currentValue > nextValue) { totals[0] += nextValue - currentValue; } else if (nextValue > currentValue) { totals[1] += currentValue - nextValue; } current = next; next++; } } // left/right direction for (var y=0; y<4; y++) { var current = 0; var next = current+1; while ( next<4 ) { while ( next<4 && !this.cellOccupied( this.indexes[next][y] )) { next++; } if (next>=4) { next--; } var currentValue = this.cellOccupied({x:current, y:y}) ? Math.log(this.cellContent( this.indexes[current][y] ).value) / Math.log(2) : 0; var nextValue = this.cellOccupied({x:next, y:y}) ? Math.log(this.cellContent( this.indexes[next][y] ).value) / Math.log(2) : 0; if (currentValue > nextValue) { totals[2] += nextValue - currentValue; } else if (nextValue > currentValue) { totals[3] += currentValue - nextValue; } current = next; next++; } } return Math.max(totals[0], totals[1]) + Math.max(totals[2], totals[3]); } Grid.prototype.maxValue = function() { var max = 0; for (var x=0; x<4; x++) { for (var y=0; y<4; y++) { if (this.cellOccupied(this.indexes[x][y])) { var value = this.cellContent(this.indexes[x][y]).value; if (value > max) { max = value; } } } } return Math.log(max) / Math.log(2); } // WIP. trying to favor top-heavy distributions (force consolidation of higher value tiles) /* Grid.prototype.valueSum = function() { var valueCount = []; for (var i=0; i<11; i++) { valueCount.push(0); } for (var x=0; x<4; x++) { for (var y=0; y<4; y++) { if (this.cellOccupied(this.indexes[x][y])) { valueCount[Math.log(this.cellContent(this.indexes[x][y]).value) / Math.log(2)]++; } } } var sum = 0; for (var i=1; i<11; i++) { sum += valueCount[i] * Math.pow(2, i) + i; } return sum; } */ // check for win Grid.prototype.isWin = function() { var self = this; for (var x=0; x<4; x++) { for (var y=0; y<4; y++) { if (self.cellOccupied(this.indexes[x][y])) { if (self.cellContent(this.indexes[x][y]).value == 2048) { return true; } } } } return false; } //Grid.prototype.zobristTable = {} //for //Grid.prototype.hash = function() { //} 

2048.xlsm


Die Excel-Anwendung selbst kann von Google heruntergeladen werden .

Die Funktionalität der Anwendung wurde in einem früheren Artikel beschrieben, in dem AI nach der Monte-Carlo-Methode spielt . Die heutige Lösung wurde dem bestehenden Monte Carlo hinzugefügt.

Alle Artikel der AI- und 2048-Serie


  • Monte Carlo
  • Minimax + Alpha-Beta-Ausschnitt
  • Warten auf Maximum
  • Neuronales Netz

Source: https://habr.com/ru/post/de474680/


All Articles