最近我用Python做了一個國際象棋程式並把程式碼釋出在Github上了。這個程式碼不到1000行,大概20%用來實現AI。在這篇文章中我會介紹這個AI如何工作,每一個部分做什麼,它為什麼能那樣工作起來。你可以直接通讀本文,或者去下載程式碼,邊讀邊看程式碼。雖然去看看其他檔案中有什麼AI依賴的類也可能有幫助,但是AI部分全都在AI.py檔案中。
AI 部分總述
AI在做出決策前經過三個不同的步驟。首先,他找到所有規則允許的棋步(通常在開局時會有20-30種,隨後會降低到幾種)。其次,它生成一個棋步樹用來隨後決定最佳決策。雖然樹的大小隨深度指數增長,但是樹的深度可以是任意的。假設每次決策有平均20個可選的棋步,那深度為1對應20棋步,深度為2對應400棋步,深度為3對應8000棋步。最後,它遍歷這個樹,採取x步後結果最佳的那個棋步,x是我們選擇的樹的深度。後面的文章為了簡單起見,我會假設樹深為2。
生成棋步樹
棋步樹是這個AI的核心。構成這個樹的類是MoveNode.py檔案中的MoveNode。他的初始化方法如下:
1 2 3 4 5 6 |
def __init__(self, move, children, parent) : self.move = move self.children = children self.parent = parent pointAdvantage = None depth = 1 |
這個類有五個屬性。首先是move,即它包含的棋步,它是個Move類,在這不是很重要,只需要知道它是一個告訴一個起子往哪走的棋步,可以吃什麼子,等等。然後是children,它也是個MoveNode類。第三個屬性是parent,所以通過它可以知道上一層有哪些MoveNode。pointAdvantage屬性是AI用來決定這一棋步是好是壞用的。depth屬性指明這一結點在第幾層,也就是說該節點上面有多少節點。生成棋步樹的程式碼如下:
1 2 3 4 5 6 7 8 9 10 |
def generateMoveTree(self) : moveTree = [] for move in self.board.getAllMovesLegal(self.side) : moveTree.append(MoveNode(move, [], None)) for node in moveTree : self.board.makeMove(node.move) self.populateNodeChildren(node) self.board.undoLastMove() return moveTree |
變數moveTree一開始是個空list,隨後它裝入MoveNode類的例項。第一個迴圈後,它只是一個擁有沒有父結點、子結點的MoveNode的陣列,也就是一些根節點。第二個迴圈遍歷moveTree,用populateNodeChildren函式給每個節點新增子節點:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
def populateNodeChildren(self, node) : node.pointAdvantage = self.board.getPointAdvantageOfSide(self.side) node.depth = node.getDepth() if node.depth == self.depth : return side = self.board.currentSide legalMoves = self.board.getAllMovesLegal(side) if not legalMoves : if self.board.isCheckmate() : node.move.checkmate = True return elif self.board.isStalemate() : node.move.stalemate = True node.pointAdvantage = 0 return for move in legalMoves : node.children.append(MoveNode(move, [], node)) self.board.makeMove(move) self.populateNodeChildren(node.children[-1]) self.board.undoLastMove() |
這個函式是遞迴的,並且它有點難用影象表達出來。一開始給它傳遞了個MoveNode物件。這個MoveNode物件會有為1的深度,因為它沒有父節點。我們還是假設這個AI被設定為深度為2。因此率先傳給這個函式的結點會跳過第一個if語句。
然後,決定出所有規則允許的棋步。不過這在這篇文章討論的範圍之外,如果你想看的話程式碼都在Github上。下一個if語句檢查是否有符合規則的棋步。如果一個都沒有,要麼被將死了,要麼和棋了。如果是被將死了,由於沒有其他可以走的棋步,把node.move.checkmate屬性設為True並return。和棋也是相似的,不過由於哪一方都沒有優勢,我們把node.pointAdvantage設為0。
如果不是將死或者和棋,那麼legalMoves變數中的所有棋步都被加入當前結點的子節點中作為MoveNode,然後函式被呼叫來給這些子節點新增他們自己的MoveNode。
當結點的深度等於self.depth(這個例子中是2)時,什麼也不做,當前節點的子節點保留為空陣列。
遍歷樹
假設/我們有了一個MoveNode的樹,我們需要遍歷他,找到最佳棋步。這個邏輯有些微妙,需要花一點時間想明白它(在明白這是個很好的演算法之前,我應該更多地去用Google)。所以我會盡可能充分解釋它。比方說這是我們的棋步樹:
如果這個AI很笨,只有深度1,他會選擇拿“象”吃“車”,導致它得到5分並且總優勢為+7。然後下一步“兵”會吃掉它的“後”,現在優勢從+7變為-2,因為它沒有提前想到下一步。
在假設它的深度為2。將會看到它用“後”吃“馬”導致分數-4,移動“後”導致分數+1,“象”吃“車”導致分數-2。因此,他選擇移動後。這是設計AI時的通用技巧,你可以在這找到更多資料(極小化極大演算法)。
所以我們輪到AI時讓它選擇最佳棋步,並且假設AI的對手會選擇對AI來說最不利的棋步。下面展示這一點是如何實現的:
1 2 3 4 5 6 7 8 9 10 11 12 |
def getOptimalPointAdvantageForNode(self, node) : if node.children: for child in node.children : child.pointAdvantage = self.getOptimalPointAdvantageForNode(child) #If the depth is divisible by 2, it's a move for the AI's side, so return max if node.children[0].depth % 2 == 1 : return(max(node.children).pointAdvantage) else : return(min(node.children).pointAdvantage) else : return node.pointAdvantage |
這也是個遞迴函式,所以一眼很難看出它在幹什麼。有兩種情況:當前結點有子節點或者沒有子節點。假設棋步樹正好是前面圖中的樣子(實際中每個樹枝上會有更多結點)。
第一種情況中,當前節點有子節點。拿第一步舉例,Q吃掉N。它子節點的深度為2,所以2除2取餘不是1。這意味著子節點包含對手的一步棋,所以返回最小步數(假設對手會走出對AI最不利的棋步)。
該節點的子節點不會有他們自己的節點,因為我們假設深度為2。因此,他們但會他們真實的分值(-4和+5)。他們中最小的是-4,所以第一步,Q吃N,被給為分值-4。
其他兩步也重複這個步驟,移動“後”的分數給為+1,“象”吃“車”的分數給為-2。
選擇最佳棋步
最難的部分已經完成了,現在這個AI要做的事就是從最高分值的棋步中做選擇。
1 2 3 4 5 6 7 8 9 10 11 12 13 |
def bestMovesWithMoveTree(self, moveTree) : bestMoveNodes = [] for moveNode in moveTree : moveNode.pointAdvantage = self.getOptimalPointAdvantageForNode(moveNode) if not bestMoveNodes : bestMoveNodes.append(moveNode) elif moveNode > bestMoveNodes[0] : bestMoveNodes = [] bestMoveNodes.append(moveNode) elif moveNode == bestMoveNodes[0] : bestMoveNodes.append(moveNode) return [node.move for node in bestMoveNodes] |
此時有三種情況。如果變數bestMoveNodes為空,那麼moveNode的值是多少,都新增到這個list中。如果moveNode的值高於bestMoveNodes的第一個元素,清空這個list然後新增該moveNode。如果moveNode的值是一樣的,那麼新增到list中。
最後一步是從最佳棋步中隨機選擇一個(AI能被預測是很糟糕的)
1 2 |
bestMoves = self.bestMovesWithMoveTree(moveTree) randomBestMove = random.choice(bestMoves) |
這就是所有的內容。AI生成一個樹,用子節點填充到任意深度,遍歷這個樹找到每個棋步的分值,然後隨機選擇最好的。這有各種可以優化的地方,剪枝,剃刀,靜止搜尋等等,但是希望這篇文章很好地解釋了基礎的暴力演算法的象棋AI是如何工作的。