注:該文章接上一篇文章,同樣是原書第4章內容
4.5 使用蒙特卡洛樹搜索來評估棋盤狀態
在alpha-Beta剪枝中,你使用了一個盤面評估函數來幫助你減少必須考慮的盤面數量。但是圍棋中的盤面評估是非常,非常困難的:你的基於吃子的簡單評估函數不會愚弄很多圍棋玩家。蒙特卡洛樹搜索提供了一種在沒有任何關於遊戲的戰略知識的情況下評估遊戲狀態的方法,該蒙特卡洛算法通過模擬隨機遊戲來評估一個位置好不好。這些隨機遊戲中的一種叫做rollout或playout。在這本書中,我們使用了rollout一詞。
蒙特卡羅樹搜索是蒙特卡羅算法大家族的一部分,它利用隨機性來分析極其複雜的情況,其名字的一個來源就是去摩納哥著名的賭場區。
這可能看起來通過隨機選擇無法制定一個好的策略。一款完全隨機落子的遊戲AI當然非常弱,但當你讓兩各隨機的AI互相對抗時,對手也同樣是很笨的。如果黑棋總是比白棋贏得多,那一定是因爲黑棋一開始取得了優勢,因此,如果你想判斷是否下在這點能夠使你取得優勢,可以採用這種評估方法,而你不需要理解爲什麼這個落子點是好的。
它有可能會得到不平衡的結果。如果你模擬10場隨機遊戲,白贏了7場,你會有多自信覺得白棋取得優勢?不是很好:白棋只比擬預料的多贏了兩場。如果黑棋和白棋是完全平衡的,那麼大約有30%的機會看到7/10的結果。另一方面,如果白棋在100場隨機比賽中贏得70場,你可以幾乎可以肯定的是,評估開始的位置確實對白棋有利。關鍵的是,當你隨機對弈更多時,你的評估會變得更準確
每次蒙特卡洛算法分三步:
1.在蒙特卡洛樹中添加一個新的棋盤盤面。
2.從那個位置模擬一個隨機對局。
3.更新樹的關於隨機遊戲結果的統計數據。
如果你的時間允許,你儘可以重複以上過程多次,然後根據樹頂的統計數據告訴你該選哪一個。
讓我們來嘗試一輪的蒙特卡洛算法。下圖顯示了蒙特卡洛樹。在算法的這一點上,您已經完成了許多rollout,並建立了一個部分樹。每個節點都跟蹤了從該節點開始的任何棋盤盤面的的勝者情況。每個節點的計數都包括其所有子節點的總和。(通常,在這一點上,樹會有更多的節點;在圖中,我們已經爲了節省空間,省略了許多節點。)
在每一輪,你添加一個新的棋盤局面到搜索樹上。首先,在樹的底部選擇一個葉節點,在那裏添加一個新的子節點。這棵樹有五片葉子。爲了得到最好的結果,你需要仔細選擇要選哪片葉子;第4.5.2節涵蓋了這樣做的好策略。現在,假設你沿着最左邊的樹枝一直走下去。在那一點,你隨機選擇下一個落子,評估新的棋盤局面,並將該節點添加到樹中。下圖顯示了經過這個過程後樹的樣子
樹中的新節點是隨機遊戲的起點。你模擬遊戲的其餘部分,實際上只是在每個回合選擇任何合法的遊戲,直到遊戲結束。然後你去數一數得分並找到贏家。在這種情況下,讓我們假設贏家是白棋,而這是根據在新節點中的記錄推出來的。此外,您還可以回到節點的所有祖先並添加他們的計數。圖4.15顯示了此步驟完成後樹的樣子。
整個過程是一輪MCTS。每當你重複一遍,樹就會變大,頂部的估計就會更準確。通常情況下,你會停在一個固定的回合。在這一點上或固定的消耗時間。在這一點上,您選擇的落子有最高的獲勝機率。
4.5.1使用Python實現蒙特卡洛樹搜索
現在您已經走完了一輪MCTS算法過程,讓我們現在來看看實現的細節。首先,您將設計一個數據結構來表示MCTS樹。接下來,您將編寫一個函數來執行MCTS的rollout,如下面的代碼所示,首先定義一個MCTS節點類,以表示樹中的任何節點。每個MCTS節點類將跟蹤以下屬性:
- game_state--當前樹中此節點的遊戲狀態(棋盤局面和當前玩家)。
- parent-導致這一局面的父節點。您可以將父級設置爲None以指示樹的根。
- move-最後一個直接導致產生這個節點的落子。
- children-樹中所有子節點的列表。
- win_counts和num_rollouts-關於從這個節點開始的統計信息。
- unvisited_moves--該局面下合法但不屬於樹中節點的落子位置。每當向樹中添加一個新節點時,就從unvisited_moves中調出一個落子位置,爲它生成一個新的MCTS節點,並且將其添加到children中。
from dlgo.agent.base import Agent
from dlgo.gotypes import Point,Player
# 蒙特卡洛樹節點
class MCTSNode:
def __init__(self,game_state, Parent=None, move = None):
# 當前節點的局面
self.game_state = game_state
self.Parent = Parent
# 表示造成這個節點的上一個落子
self.move = move
# 展示黑白勝利的統計
self.win_counts={
Player.white:0,
Player.black:0
}
# 表示當前的輪數
self.num_rollouts = 0
# 所有的子節點
self.children = []
# 未加入到樹中節點的合法落子
self.unvisited_move = game_state.legal_moves()
一個MCTS樹節點可以通過兩種方式進行修改。您可以將一個新的孩子節點添加到樹中,並更新試驗統計。下面的代碼實現了這兩個功能。
# 隨機增加一個未加入樹的子節點
def add_random_child(self):
# 隨機選一個落子
index = random.randint(0, len(self.unvisited_moves) - 1)
next_move = self.unvisited_moves.pop(index)
next_state = self.game_state.apply_move(next_move)
new_node = MCTSNode(next_state, self, next_move)
# 將新節點加入成爲該節點的子節點
self.children.append(next_node)
return new_node
# 統計勝者
def record_win(self,winner):
self.win_counts[winner] += 1
self.num_rollouts += 1
最後,你要添加三種方便的方法來訪問樹節點的有用屬性:
- can_add_child表明這個局面是否還有任何尚未添加到樹中的合法落子點。
- is_terminal報告遊戲是否在此節點結束;如果是,則無法進一步搜索。
- winning_frac返回給定棋手在一次試驗贏的概率。
# 能否增加葉節點
def can_add_child(self):
return len(self.unvisited_move)>0
# 報告遊戲是否在這個節點結束
def is_terminal(self):
return self.game_state.is_over()
# 該棋手當前局面贏的概率
def winning_frac(self,player):
return float(self.win_counts[player])/float(self.num_rollouts)
定義了樹的數據結構後,現在就可以實現MCTS算法。您首先要創建一棵新樹。根節點爲當前棋局盤面,然後你反覆進行試驗。在這種實現中,您每個回合重複固定的輪數;其他的實現中運行的時間長度是固定的。
每一輪都是從沿着樹走的,直到您可以在其中找到一個可以添加孩子的節點,該孩子是任何尚未加入到樹中的合法落子)。select_move函數隱藏了選擇最佳分支去探索的過程,我們將會在下一節中實現細節。
找到合適的節點後,調用add_random_child來選擇任何後續落子並將其帶到樹中。此時,node是一個新創建的沒有經歷任何試驗的MCTS節點
最後,更新新創建的節點及其所有祖先的勝者計數。整個過程實現如下
def select_move(self,game_state):
# 樹的根節點
root = MCTSNode(game_state)
# 循環執行多次
for i in range(self.num_rounds):
node = root
# 一直搜索到遊戲在節點結束
while (not node.can_add_child()) and (not node.is_terminal()):
node = self.select_child(node) # 後面來實現
# 把新的孩子節點加入到新樹中.
if node.can_add_child():
node = node.add_random_child()
# 從當前局面進行模擬對局,得出勝者
winner = self.simulate_random_game(node.game_state)
#從當前節點進行回溯更新當前及祖先勝者數
while node is not None:
node.record_win(winner)
node = node.Parent
在你完成分配所有這些回合後,你需要選擇一個落子點。要做到這一點,你只需循環所有頂級分支,並選擇一個獲勝機率最高的落子點。以下列出如何實現這一點。
# 從根節點的諸多孩子節點中根據獲勝機率選擇最佳下法
best_move = None
win_pct = -1
for child in root.children:
child_pct = child.winning_frac(game_state.current_player)
if child_pct> win_pct:
best_move = child.move
win_pct = child_pct
return best_move
4.5.2 如何選擇哪棵枝幹去搜索
您的遊戲AI在每個回合上花費的時間是有限的,這就意味着您只能執行固定數量的試驗。每一次試驗都提高了您對一個可能落子的評估準確度。想想看您的試驗作爲一個有限的資源:如果您花費額外的試驗在落子A上,那麼您必須花費一個較少的試驗次數在落子B。你需要一個策略來決定如何分配你有限的預算。這個標準策略被稱爲樹的上限置信區間,或UCT算法。UCT算法在兩個相互衝突的目標之間取得了平衡。
第一個目標是花時間去尋找最好的落子。這個目標被稱爲利用(你想利用你迄今發現的任何優勢)。你會把更多地試驗花在具有最高估計勝率的落子點。現在,其中一些落子點有一個很高的獲勝機率僅僅是偶然情況。但是當你在這些分支中完成更多的試驗時,你的估計就會更準確,這種假的優勢就會在列表裏下降。
另一方面,如果你只試驗過一個節點幾次,你的估計可能會很差。純屬偶然的估計可能會使你對一個很好的落子點估計得很低。在那裏多試驗幾次,就可能會暴露出它的真實品質。因此,你的第二個目標是爲你訪問最少的分支獲得更準確的評估。這個目標叫做探索。
下圖就比較了一棵傾向於利用的搜索樹和一棵傾向於探索的樹。利用與探索的權衡是試錯算法的普遍特徵。當我們在書的後面學到強化學習時,這個也會出現
對於您正在考慮的每個節點,您計算獲勝百分比來表示利用目標。爲了表示探索,您需要計算,其中N是試驗的總數,n是以正在考慮的節點開始的試驗數量。這個具體的公式有一個理論基礎;爲了我們的目的,只需注意,它的價值至少是你訪問過的的最大值。
將這兩個組件組合起來得到UCT公式:
在這裏,c是一個參數,表示您在利用和探索之間的首選平衡。UCT公式給出每個節點的分數,UCT分數最高的節點將是下一次試驗的起點。 w是指你當前節點的勝率
有了更大的c值,您將花費更多的時間去試驗訪問次數最少的節點。使用較小的c值,您將花費更多的時間收集對更好評估的節點。c大小的選擇會影響遊戲玩家的有效性,這通常是通過試錯找到的。我們建議從1.5左右開始,然後從那裏進行實驗。參數c有時被稱爲temperature。當溫度“更熱”時,你的搜索會更不穩定,當溫度“更冷”時,你的搜索會更集中。
下面代碼展示瞭如何實現此策略。在確定要使用的度量方法之後,選擇一個子節點將是一個簡單的問題,即計算每個節點的UCT值並選擇最大UCT值的節點。就像在極小極大搜索中一樣,你需要在每個回合上來回去切換你的視角。你需要從下一個落子的棋手角度去計算獲勝機率,當你沿着樹搜索時,視角就會在黑棋和白棋之間進行切換。
# 計算UCT分值
def get_UCT_score(child_rollout, parent_rollout, win_prc, temperature):
exploration = math.sqrt(math.log(parent_rollout)/child_rollout)
return win_prc + temperaature*exploration
# 選擇最佳孩子節點(即UCT分數最高)
def select_child(self,node):
# 獲得其孩子節點試驗的總數,即爲父節點的試驗次數
total_rollouts = sum(child.num_rollouts for child in node.children)
# 遍歷該節點的孩子節點,獲得最佳分數的孩子
best_score = -1
best_child = None
for child in node.children:
# 獲得該孩子節點的UCT分數
score = get_UCT_score(child.num_rollouts, total_rollouts, child.winning_frac(node.game_state.current_player),self.temperature )
if score > best_score:
best_score = score
best_child = child
return best_child
4.5.3 應用蒙特卡洛樹搜索到圍棋中
在上一節中,您實現了MCTS算法的一般形式。直截了當的MCTS實現的圍棋AI可以達到業餘1段左右的水平,這是一個強大的業餘玩家的水平。將MCTS與其他技術結合將可以產生一個比這更強的AI;今天的許多頂級圍棋AI都使用MCTS和深度學習。如果你有興趣與您的MCTS AI進行對弈,本節將涵蓋一些實際的細節。
MCTS算法在19路圍棋中將開始成爲一種可行的戰略,每回合將達到10,000輪試驗。本章中的實現速度不夠快,無法做到這一點:每一步你將等待幾分鐘,因此你將需要進行優化,以便在合理的時間內完成許多試驗。另一方面,在小棋盤上,甚至你的參照實現也會產生一個有趣的對手。
在所有其他相同的情況下,更多的試驗意味着一個更好的決定。只要加快代碼速度,就可以使你的機器人變得更強大,從而在同樣的時間內做出更多的試驗。這不是MCTS特定的代碼,而是相關的代碼。例如,計算吃子的代碼在每次試驗要進行數百次。所有的基本遊戲邏輯都是公平的優化遊戲。
隨機試驗期間選擇落子的算法稱爲試驗策略。你的試驗政策越真實,你的評估就越準確。在第三章中,你實現了一個隨機落子AI,您可以使用這個隨機落子AI作爲您的試驗策略。但是隨機落子AI在沒有圍棋知識的情況下完全隨機地選擇落子是不完全正確。首先,在棋盤不滿的時候,你的程序不會pass或投降。第二,你編程它不是爲了填滿它自己的眼睛,所以它不會在遊戲結束時殺死自己的石頭。沒有這種邏輯,試驗將不太準確。
一些MCTS的實現更進一步,並在其試驗策略中實現了更多的圍棋特定邏輯。帶有特定遊戲邏輯的試驗有時被稱爲重試驗;相比之下,接近純粹隨機的試驗有時被稱爲輕試驗。實現重試驗的一種方法是建立一個基本的常見的棋形列表,以及一個已知的應對。在圍棋盤上找到任何的已知形狀,您就可以查找到已知的應對方法並提高其被選中的概率。你不想總是把已知的應對方法作爲一條硬性的、快速的規則來選擇,因此你將會從算法中隨機刪除重要的元素。
一個例子如下圖所示..這是一個3×3的局部模式,其中一個黑棋有可能在白棋在下一回合被吃掉。黑棋可以通過逃跑暫時存活。這個並不總是最好的落子;它甚至不總是一個好的落子,但總比棋盤上進行隨機落子要好
建立一個好的這些模式需要一些圍棋戰術的知識。如果您對其他戰術模式感到好奇,您可以在大量試驗中使用這些模式,我們建議查看Fuego(http://fuego.sourceforge.net/)或Pachi(https://github.com/pasky/pachi),兩個開源MCTS 圍棋引擎的源碼。
在實施重試驗時要仔細。如果您的試驗策略中的邏輯計算緩慢,則不能執行那麼多的試驗。你可能會被那些擁有更復雜邏輯的AI所擊敗。
製作一個遊戲AI不僅僅是開發算法的最佳練習,這也是爲了給人類對手創造一個有趣的體驗。其中一部分樂趣來自於讓人類玩家有勝利的滿足感。你在這本書中實現的第一個圍棋機器人,RandomAgent,正在進行瘋狂地對抗。在人類玩家不可避免地要贏棋之後,隨機機器人卻要一直堅持到棋盤全部佔滿。無法阻止人類獲勝,如果能讓你的AI可以投降的話將會是一種更好的體驗。
除了基本的MCTS實現之外,您還可以輕鬆地添加人性化的投降邏輯。在選擇落子的過程中,MCTS算法計算一個估計的獲勝百分比。在一輪中,你比較這些數字來決定要選擇什麼落子。但你也可以比較同一遊戲中不同點的估計獲勝百分比。如果這些數字在下降,說明人類取得了優勢。當最佳選項的獲勝百分比足夠低時,比如10%,你就可以讓你的機器人投降。
4.6 總結
當你沒有一個好的局面評估函數,你有時可以使用蒙特卡羅樹搜索。該算法從特定局面開始模擬隨機遊戲,並跟蹤哪個棋手獲勝更多。
經過試驗,在5*5的棋盤上如果使用原來的RandomBot()進行模擬500次試驗,非常慢,因此換成了之前用佐布里斯特散列做出的隨機落子AI,在5*5的棋盤上進行500次模擬只需10秒多,如下圖
我們加入一些代碼顯示AI模擬對局時的勝率(放在select_move裏)
scored_moves = [
(child.winning_frac(game_state.current_player), child.move, child.num_rollouts)
for child in root.children
]
scored_moves.sort(key=lambda x: x[0], reverse=True)
for s, m, n in scored_moves[::]:
print('%s - %.3f (%d)' % (m, s, n))
結果:我下在C2
AI經過模擬發現(5,4)勝率最高,於是就選擇了(5,4),其中括號內的數字代表該下法的試驗次數
當我把試驗次數調到5000次,棋盤大小改爲7*7,且temperature大小改爲1,同時我把黑的貼目改爲3時,其棋力已經有了進步,如下圖
黑棋下在E3,白棋經過5000盤模擬之後,準確地下在D2
不過AI每下一步棋就要花上4分鐘左右的時間,這要是19*19,那時間可能一小時都頂不住,大家可以根據情況調整模擬次數和temperature