與游戲AI有關的問題一般開始於被稱作完全信息博弈的游戲。這是一款對弈玩家彼此沒有信息可以隱藏的回合制游戲且在游戲技術里沒有運氣元素(如扔骰子或從洗好的牌中抽牌), 井字過三關,四子棋,跳棋,國際象棋,黑白棋和圍棋用到了這個算法的所有游戲。因為在這個游戲類型中發生的任何事件是能夠用一棵樹完全確定,它能構建所有可能的結果,且能分配一個值用於確定其中一名玩家的贏或輸。盡可能找到最優解,然而在樹上做一個搜索操作,用選擇的方法在每層上交替取最大值和最小值,匹配不同玩家的矛盾沖突目標,順着這顆樹上搜素,這個叫做極小化極大算法。
用這個極小化極大算法解決這個問題,完整搜索這顆博弈樹花費總時間太多且不切實際。考慮到這類游戲在實戰中具有極多的分支因素或每轉一圈可移動的高勝率步數,因為這個極小化極大算法需要搜索樹中所有節點以便找到最優解且必須檢查的節點的數量與分支系數呈指數增長。有解決這個問題的辦法,例如僅搜索向前移動(或層)的有限步數且使用評價函數估算出這個位置的勝率,或者如果它們沒有價值可以用pruning算法分支。許多這些技術,需要游戲計算機領域的相關知識,可能很難收集到有用的信息。這種方法產生的國際象棋程序能夠擊敗特級大師,類似的成功已經難以實現,特別是19X19的圍棋項目。
然而,對於高分支的游戲已經有一項游戲AI技術做得很好且占據游戲程序領域的主導地位。這很容易創建一個僅用較小的分支因子就能給出一個較好的游戲結果的算法基本實現,相對簡單的修改可以建立和改善它,如象棋或圍棋。它能被設置任何游戲規定的時間停止后,用較長的思考時間來學習游戲高手的玩法。因為它不需要游戲的具體知識,它可用於一般的游戲。它甚至可以適應游戲中的隨機性規律,這項技術被稱為蒙特卡洛樹搜索。在這篇文章中我將描述MCTS是如何工作的,特別是一個被稱作UCT博弈樹搜索的變種,然后將告訴你如何在Python中建立一個基本的實現。
試想一下,如果你願意這么做,那么你面臨着laohuji的中獎概率,每一個不同的支付概率和金額。作為一個理性的人(如果你要發揮他們的話),你會更喜歡使用的策略,讓您能夠最大化你的凈收益。但是,你怎么能這樣做呢?無論出於何種原因附近沒有一個人,所以你不能看別人玩一會兒,以獲取最好的機器信息,這是最好的機器通過構建統計的置信區間為每台機器做到這一點.
這里:1.平均花費機器i. 2.ni:第i個機器玩家. 3.n:玩家的總數.
然后,你的策略是每次選擇機器的最高上界。當你這樣做時,因為這台機器所觀察到的平均值將改變且它的置信區間會變窄,但所有其它機器的置信區間將擴大。最終,其他機器中將有一個上限超出你的當前機器,你將切換到這台機器。這種策略有你沮喪的性能,你只將在真正的最好的laohuji上玩的不同且根據該策略你將贏得預期獎金,你僅使用O(ln n)時間復雜度。對於這個問題以相同的大O增長率為理論最適合(被稱為多臂吃角子老虎問題),且具有容易計算的額外好處。
而這里的蒙特卡洛樹是怎么來的,在一個標准的蒙特卡羅代碼程序中,運用了大量的隨機模擬,在這種情況下,從你想找到的最佳移動位置,以起始狀態為每個可能的移動做統計,最佳的移動結果被返回。雖然這種移動方法有缺陷,不過,是在用於仿真中任何給定的回合,可能有很多可能的移動,但只有一個或兩個是良好。如果每回合隨機移動被選擇,他將很難發現最佳前進路線。所以,UCT是一個加強算法。我們的想法是這樣的,如果統計數據適用於所有僅移動一格的位置,棋盤中的任何位置都可以視為多臂吃角子老虎問題。所以代替許多單純隨機模擬,UCT工作用於許多多階段淘汰賽。
第一階段,你有必要持續選擇處理每個位置的統計數據時,用來完成一個多拉桿吃角子laohuji問題。此舉使用了UCB1算法代替隨機選擇,且被認為是應用於獲取下一個位置。然后選擇開始直到你到達一個不是所有的子結點有記錄數據的位置。
選擇:此處通過在每一步UCB1算法所選的位置並移動標記為粗體。注意一些玩家間的對弈記錄已經被統計下來。每個圓圈中包含玩家的勝場數/次數。
第二階段,擴容,發現此時已不再適用於UCB1算法。一個未訪問的子結點被隨機選擇,並且記錄一個新節點被添加到統計樹。
擴張:記錄為1/1的位置位於樹的底部在它之下沒有進一步的統計記錄,所以我們選擇一個隨機移動,並為它添加一個新結點(粗體),初始化為0/0。
擴容后,其余部分的開銷是在第三階段,仿真。這么做是經典的蒙特卡洛模擬,或純隨機或如果選擇一個年輕選手,則用一些簡單的加權探索法,或對於高端玩家,則用一些計算復雜的啟發式和估算。對於較小的分支因子游戲,一個年輕選手能給出好的結果。
仿真:一旦新結點被添加,蒙特卡洛模擬開始,這里用虛線箭頭描述。模擬移動可以是完全隨機的,或可以使用計算加權隨機數來取代移動,可能獲得更好的隨機性。
最后,第四階段是更新和反轉,當比賽結束后,這種情況會發生。所有玩家訪問過的位置,其比賽次數遞增,如果那個位置的玩家贏得比賽,其勝場遞增。
反轉:在仿真結束后,所有結點路徑被更新。每個人玩一次就遞增1,並且每次匹配的獲勝者,其贏得游戲次數遞增1,這里用粗體字表示。
該算法可以被設置任何期望時間后停止,或在某些其他條件。隨着越來越多的比賽進行,博弈樹在內存中成長,這個移動將是最終選擇,此舉將趨近實際的最佳玩法雖然可能需要非常長的時間。
有關UCB1和UCT算法的數學知識,更多詳情請看 Finite-time Analysis of the Multiarmed Bandit Problem and Bandit based Monte-Carlo Planning.
現在讓我們看看這個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將做出合理決策的各種棋盤游戲。我留下了一個合適的模板用於讀者的練習,然而我們留下了給予玩家再次使用AI玩的一種方式。這種游戲框架可以在 jbradberry/boardgame-socketserver and jbradberry/boardgame-socketplayer找到。
這是我們剛剛建立的新手玩家版本。下一步,我們將探索改善AI以供高端玩家使用。通過機器自我學習來訓練一些評估函數並與結果掛鈎。