蒙特卡洛樹搜尋介紹
與遊戲AI有關的問題一般開始於被稱作完全資訊博弈的遊戲。這是一款對弈玩家彼此沒有資訊可以隱藏的回合制遊戲且在遊戲技術裡沒有運氣元素(如扔骰子或從洗好的牌中抽牌), 井字過三關,四子棋,跳棋,國際象棋,黑白棋和圍棋用到了這個演算法的所有遊戲。因為在這個遊戲型別中發生的任何事件是能夠用一棵樹完全確定,它能構建所有可能的結果,且能分配一個值用於確定其中一名玩家的贏或輸。儘可能找到最優解,然而在樹上做一個搜尋操作,用選擇的方法在每層上交替取最大值和最小值,匹配不同玩家的矛盾衝突目標,順著這顆樹上搜素,這個叫做極小化極大演算法。
用這個極小化極大演算法解決這個問題,完整搜尋這顆博弈樹花費總時間太多且不切實際。考慮到這類遊戲在實戰中具有極多的分支因素或每轉一圈可移動的高勝率步數,因為這個極小化極大演算法需要搜尋樹中所有節點以便找到最優解且必須檢查的節點的數量與分支系數呈指數增長。有解決這個問題的辦法,例如僅搜尋向前移動(或層)的有限步數且使用評價函式估算出這個位置的勝率,或者如果它們沒有價值可以用pruning演算法分支。許多這些技術,需要遊戲計算機領域的相關知識,可能很難收集到有用的資訊。這種方法產生的國際象棋程式能夠擊敗特級大師,類似的成功已經難以實現,特別是19X19的圍棋專案。
然而,對於高分支的遊戲已經有一項遊戲AI技術做得很好且佔據遊戲程式領域的主導地位。這很容易建立一個僅用較小的分支因子就能給出一個較好的遊戲結果的演算法基本實現,相對簡單的修改可以建立和改善它,如象棋或圍棋。它能被設定任何遊戲規定的時間停止後,用較長的思考時間來學習遊戲高手的玩法。因為它不需要遊戲的具體知識,它可用於一般的遊戲。它甚至可以適應遊戲中的隨機性規律,這項技術被稱為蒙特卡洛樹搜尋。在這篇文章中我將描述MCTS是如何工作的,特別是一個被稱作UCT博弈樹搜尋的變種,然後將告訴你如何在Python中建立一個基本的實現。
試想一下,如果你願意這麼做,那麼你面臨著老虎機的中獎概率,每一個不同的支付概率和金額。作為一個理性的人(如果你要發揮他們的話),你會更喜歡使用的策略,讓您能夠最大化你的淨收益。但是,你怎麼能這樣做呢?無論出於何種原因附近沒有一個人,所以你不能看別人玩一會兒,以獲取最好的機器資訊,這是最好的機器通過構建統計的置信區間為每臺機器做到這一點
這裡:1.平均花費機器i. 2.ni:第i個機器玩家. 3.n:玩家的總數.
然後,你的策略是每次選擇機器的最高上界。當你這樣做時,因為這臺機器所觀察到的平均值將改變且它的置信區間會變窄,但所有其它機器的置信區間將擴大。最終,其他機器中將有一個上限超出你的當前機器,你將切換到這臺機器。這種策略有你沮喪的效能,你只將在真正的最好的老虎機上玩的不同且根據該策略你將贏得預期獎金,你僅使用O(ln n)時間複雜度。對於這個問題以相同的大O增長率為理論最適合(被稱為多臂吃角子老虎問題),且具有容易計算的額外好處。
而這裡的蒙特卡洛樹是怎麼來的,在一個標準的蒙特卡羅程式碼程式中,運用了大量的隨機模擬,在這種情況下,從你想找到的最佳移動位置,以起始狀態為每個可能的移動做統計,最佳的移動結果被返回。雖然這種移動方法有缺陷,不過,是在用於模擬中任何給定的回合,可能有很多可能的移動,但只有一個或兩個是良好。如果每回合隨機移動被選擇,他將很難發現最佳前進路線。所以,UCT是一個加強演算法。我們的想法是這樣的,如果統計資料適用於所有僅移動一格的位置,棋盤中的任何位置都可以視為多臂吃角子老虎問題。所以代替許多單純隨機模擬,UCT工作用於許多多階段淘汰賽。
第一階段,你有必要持續選擇處理每個位置的統計資料時,用來完成一個多拉桿吃角子老虎機問題。此舉使用了UCB1演算法代替隨機選擇,且被認為是應用於獲取下一個位置。然後選擇開始直到你到達一個不是所有的子結點有記錄資料的位置。
選擇:此處通過在每一步UCB1演算法所選的位置並移動標記為粗體。注意一些玩家間的對弈記錄已經被統計下來。每個圓圈中包含玩家的勝場數/次數。
第二階段,擴容,發現此時已不再適用於UCB1演算法。一個未訪問的子結點被隨機選擇,並且記錄一個新節點被新增到統計樹。
擴張:記錄為1/1的位置位於樹的底部在它之下沒有進一步的統計記錄,所以我們選擇一個隨機移動,併為它新增一個新結點(粗體),初始化為0/0。
擴容後,其餘部分的開銷是在第三階段,模擬。這麼做是經典的蒙特卡洛模擬,或純隨機或如果選擇一個年輕選手,則用一些簡單的加權探索法,或對於高階玩家,則用一些計算複雜的啟發式和估算。對於較小的分支因子游戲,一個年輕選手能給出好的結果。
模擬:一旦新結點被新增,蒙特卡洛模擬開始,這裡用虛線箭頭描述。模擬移動可以是完全隨機的,或可以使用計算加權隨機數來取代移動,可能獲得更好的隨機性。
最後,第四階段是更新和反轉,當比賽結束後,這種情況會發生。所有玩家訪問過的位置,其比賽次數遞增,如果那個位置的玩家贏得比賽,其勝場遞增。
反轉:在模擬結束後,所有結點路徑被更新。每個人玩一次就遞增1,並且每次匹配的獲勝者,其贏得遊戲次數遞增1,這裡用粗體字表示。
該演算法可以被設定任何期望時間後停止,或在某些其他條件。隨著越來越多的比賽進行,博弈樹在記憶體中成長,這個移動將是最終選擇,此舉將趨近實際的最佳玩法雖然可能需要非常長的時間。
現在讓我們看看這個AI演算法。要分開考慮,我們將需要一個模板類,其目的是封裝一個比賽規則且不用關心AI,和一個僅注重於AI演算法的蒙特卡洛類,且查詢到模板物件以獲得有關遊戲資訊。讓我們假設一個模板類支援這個介面:
class Board(object):
def start(self):
# 表示返回遊戲的初始狀態。
pass
def current_player(self, state):
# 獲取遊戲狀態並返回當前玩家編號
pass
def next_state(self, state, play):
# 獲取比賽狀態,且這個移動被應用.
# 返回新的遊戲狀態.
pass
def legal_plays(self, state_history):
# 採取代表完整的遊戲歷史記錄的遊戲狀態的序列,且返回當前玩家的合法玩法的完整移動列表。
pass
def winner(self, state_history):
# <span style="font-family: Arial, Helvetica, sans-serif;">採取代表完整的遊戲歷史記錄的遊戲狀態的序列。</span>
# 如果現在遊戲贏了, 返回玩家編號。
# 如果遊戲仍然繼續,返回0。
# 如果遊戲打結, 返回明顯不同的值, 例如: -1.
pass
對於這篇文章的目的,我不會給任何進一步詳細說明,但對於示例,你可以在github上找到實現程式碼。不過,需要注意的是,我們需要狀態資料結構是雜湊表和同等狀態返回相同的雜湊值是非常重要的。我個人使用平板元組作為我的狀態資料結構。
我們將構建能夠支援這個介面的人工智慧類:
class MonteCarlo(object):
def __init__(self, board, **kwargs):
# 取一個模板的例項且任選一些關鍵字引數。
# 初始化遊戲狀態和統計資料表的列表。
pass
def update(self, state):
# 需要比賽狀態,並追加到歷史記錄
pass
def get_play(self):
# 根據當前比賽狀態計算AI的最佳移動並返回。
pass
def run_simulation(self):
# 從當前位置完成一個“隨機”遊戲,
# 然後更新統計結果表.
pass
讓我們從初始化和儲存資料開始。這個AI的模板物件將用於獲取有關這個遊戲在哪裡執行且AI被允許怎麼做的資訊,所以我們需要將它儲存。此外,我們需要保持跟蹤資料狀態,以便我們獲取它。
class MonteCarlo(object):
def __init__(self, board, **kwargs):
self.board = board
self.states = []
def update(self, state):
self.states.append(state)
該UCT演算法依賴於當前狀態執行的多款遊戲,讓我們新增下一個。import datetime
class MonteCarlo(object):
def __init__(self, board, **kwargs):
# ...
seconds = kwargs.get('time', 30)
self.calculation_time = datetime.timedelta(seconds=seconds)
# ...
def get_play(self):
begin = datetime.datetime.utcnow()
while datetime.datetime.utcnow() - begin < self.calculation_time:
self.run_simulation()
這裡我們定義一個時間量的配置選項用於計算消耗,get_play函式將反覆多次呼叫run_simulation函式直到時間消耗殆盡。此程式碼不會特別有用,因為我們沒有定義run_simulation函式,所以我們現在開始寫這個函式。# ...
from random import choice
class MonteCarlo(object):
def __init__(self, board, **kwargs):
# ...
self.max_moves = kwargs.get('max_moves', 100)
# ...
def run_simulation(self):
states_copy = self.states[:]
state = states_copy[-1]
for t in xrange(self.max_moves):
legal = self.board.legal_plays(states_copy)
play = choice(legal)
state = self.board.next_state(state, play)
states_copy.append(state)
winner = self.board.winner(states_copy)
if winner:
break
增加了run_simulation函式埠,無論是選擇UCB1演算法還是選擇設定每回合遵循遊戲規則的隨機移動直到遊戲結束。我們也推出了配置選項,以限制AI的期望移動數目。
你可能注意到我們製作self.states副本的結點,並且給它增加了新的狀態。代替直接新增到self.states。這是因為self.states記錄了到目前為止發生的所有遊戲記錄,在模擬這些探索性移動中我們不想把它做得不盡如人意。
現在,在AI執行run_simulation函式中,我們需要統計這個遊戲狀態。AI應該選擇第一個未知遊戲狀態把它新增到表中。
class MonteCarlo(object):
def __init__(self, board, **kwargs):
# ...
self.wins = {}
self.plays = {}
# ...
def run_simulation(self):
visited_states = set()
states_copy = self.states[:]
state = states_copy[-1]
player = self.board.current_player(state)
expand = True
for t in xrange(self.max_moves):
legal = self.board.legal_plays(states_copy)
play = choice(legal)
state = self.board.next_state(state, play)
states_copy.append(state)
# 這裡的`player`以下指的是進入特定狀態的玩家
if expand and (player, state) not in self.plays:
expand = False
self.plays[(player, state)] = 0
self.wins[(player, state)] = 0
visited_states.add((player, state))
player = self.board.current_player(state)
winner = self.board.winner(states_copy)
if winner:
break
for player, state in visited_states:
if (player, state) not in self.plays:
continue
self.plays[(player, state)] += 1
if player == winner:
self.wins[(player, state)] += 1
在這裡,我們新增兩個字典到AI,wins和plays,其中將包含跟蹤每場比賽狀態的計數器。如果當前狀態是第一個新狀態,該run_simulation函式方法現在檢測到這個呼叫已經被計數,而且,如果沒有,增加宣告palys和wins,同時初始化為零。通過設定它,這種函式方法也增加了每場比賽的狀態,最後更新wins和plays,同時在wins和plays的字典中設定那些狀態。我們現在已經準備好將AI的最終決策放在這些統計上。
from __future__ import division
# ...
class MonteCarlo(object):
# ...
def get_play(self):
self.max_depth = 0
state = self.states[-1]
player = self.board.current_player(state)
legal = self.board.legal_plays(self.states[:])
# 如果沒有真正的選擇,就返回。
if not legal:
return
if len(legal) == 1:
return legal[0]
games = 0
begin = datetime.datetime.utcnow()
while datetime.datetime.utcnow() - begin < self.calculation_time:
self.run_simulation()
games += 1
moves_states = [(p, self.board.next_state(state, p)) for p in legal]
# 顯示函式呼叫的次數和消耗的時間
print games, datetime.datetime.utcnow() - begin
# 挑選勝率最高的移動方式
percent_wins, move = max(
(self.wins.get((player, S), 0) /
self.plays.get((player, S), 1),
p)
for p, S in moves_states
)
# 顯示每種可能統計資訊。
for x in sorted(
((100 * self.wins.get((player, S), 0) /
self.plays.get((player, S), 1),
self.wins.get((player, S), 0),
self.plays.get((player, S), 0), p)
for p, S in moves_states),
reverse=True
):
print "{3}: {0:.2f}% ({1} / {2})".format(*x)
print "Maximum depth searched:", self.max_depth
return move
我們在此步驟中新增三點。首先,我們允許,如果沒有選擇,或者只有一個選擇,使get_play函式提前返回。其次,我們增加輸出了一些除錯資訊,包含每回合移動的統計資訊,且在淘汰賽選擇階段,將保持一種屬性 ,用於根蹤最大深度搜索。最後,我們增加程式碼用來挑選出勝率最高的可能移動,並且返回它。
但是,我們遠還沒有結束。目前,對於淘汰賽我們的AI使用純隨機性。對於在所有資料表中遵守遊戲規則的玩家的位置我們需要UCB1演算法,因此下一個嘗試遊戲的機器是基於這些資訊。
# ...
from math import log, sqrt
class MonteCarlo(object):
def __init__(self, board, **kwargs):
# ...
self.C = kwargs.get('C', 1.4)
# ...
def run_simulation(self):
# 這裡最優化的一點,我們有一個<span style="font-family: Arial, Helvetica, sans-serif;">查詢</span><span style="font-family: Arial, Helvetica, sans-serif;">區域性變數代替每個迴圈中訪問一種屬性</span>
plays, wins = self.plays, self.wins
visited_states = set()
states_copy = self.states[:]
state = states_copy[-1]
player = self.board.current_player(state)
expand = True
for t in xrange(1, self.max_moves + 1):
legal = self.board.legal_plays(states_copy)
moves_states = [(p, self.board.next_state(state, p)) for p in legal]
if all(plays.get((player, S)) for p, S in moves_states):
# 如果我們在這裡統計所有符合規則的移動,且使用它。
log_total = log(
sum(plays[(player, S)] for p, S in moves_states))
value, move, state = max(
((wins[(player, S)] / plays[(player, S)]) +
self.C * sqrt(log_total / plays[(player, S)]), p, S)
for p, S in moves_states
)
else:
# 否則,只做出錯誤的決定
move, state = choice(moves_states)
states_copy.append(state)
# 這裡的`player`以下指移動到特殊狀態的玩家
if expand and (player, state) not in plays:
expand = False
plays[(player, state)] = 0
wins[(player, state)] = 0
if t > self.max_depth:
self.max_depth = t
visited_states.add((player, state))
player = self.board.current_player(state)
winner = self.board.winner(states_copy)
if winner:
break
for player, state in visited_states:
if (player, state) not in plays:
continue
plays[(player, state)] += 1
if player == winner:
wins[(player, state)] += 1
這裡主要增加的是檢查,看看是否所有的遵守遊戲規則的玩法的結果都在plays字典中。如果它們不可用,則預設為原來的隨機選擇。但是,如果統計資訊都可用,根據該置信區間公式選擇具有最高值的移動。這個公式加在一起有兩部分。第一部分是這個勝率,而第二部分是一個叫做被忽略特定的緩慢增長變數名。最後,如果一個勝率差的結點長時間被忽略,那麼就會開始被再次選擇。這個變數名可以用配置引數c新增到__init函式上。c值越大將會觸發更多可能性的探索,且較小的值會導致AI更偏向於專注於已有的較好的移動。還要注意到,當添加了一個新結點且它的深度超出self.max_depth時,從以前程式碼塊中的the
self.max_depth屬性被立即更新。這是我們剛剛建立的新手玩家版本。下一步,我們將探索改善AI以供高階玩家使用。通過機器自我學習來訓練一些評估函式並與結果掛鉤。
更新:該圖已得到糾正,以更準確地反映可能的節點值。
這篇文章是“蒙特卡洛樹搜尋”系列的第1部分: