更新
2017.2.23有更新,见文末。
MCTS与UCT
下面的内容引用自徐心和与徐长明的论文《计算机博弈原理与方法学概述》:
蒙特卡洛模拟对局就是从某一棋局出发,随机走棋。有人形象地比喻,让两个傻子下棋,他们只懂得棋规,不懂得策略,最终总是可以决出胜负。这个胜负是有偶然性的。但是如果让成千上万对傻子下这盘棋,那么结果的统计还是可以给出该棋局的固有胜率和胜率最高的着法。
蒙特卡洛树搜索通过迭代来一步步地扩展博弈树的规模,UCT 树是不对称生长的,其生长顺序也是不能预知的。它是根据子节点的性能指标导引扩展的方向,这一性能指标便是 UCB 值。它表示在搜索过程中既要充分利用已有的知识,给胜率高的节点更多的机会,又要考虑探索那些暂时胜率不高的兄弟节点,这种对于“利用”(Exploitation)和“探索”(Exploration)进行权衡的关系便体现在 UCT 着法选择函数的定义上,即子节点\(N_{i}\) 的 UCB 值按如下公式计算:
\[\frac{W_{i}}{N_{i}} + \sqrt{\frac{C \times lnN}{N_{i}}}
\]
其中:
\(W_{i}\):子节点获胜的次数;
\(N_{i}\):子节点参与模拟的次数;
\(N\):当前节点参与模拟的次数
\(C\):加权系数。
可见 UCB 公式由两部分组成,其中前一部分就是对已有知识的利用,而后一部分则是对未充分模拟节点的探索。C小偏重利用;而 C大则重视探索。需要通过实验设定参数来控制访问节点的次数和扩展节点的阈值。
后面可以看到,在实际编写代码时,当前节点指的并不是具体的着法,而是当前整个棋局,其子节点才是具体的着法,它势必参与了每个子节点所参与的模拟,所以N就等于其所有子节点参与模拟的次数之和。当C取1.96时,置信区间的置信度达到95%,也是实际选择的值。
蒙特卡洛树搜索(MCTS)仅展开根据 UCB 公式所计算过的节点,并且会采用一种自动的方式对性能指标好的节点进行更多的搜索。具体步骤概括如下:
1.由当前局面建立根节点,生成根节点的全部子节点,分别进行模拟对局;
2.从根节点开始,进行最佳优先搜索;
3.利用 UCB 公式计算每个子节点的 UCB 值,选择最大值的子节点;
4.若此节点不是叶节点,则以此节点作为根节点,重复 2;
5.直到遇到叶节点,如果叶节点未曾经被模拟对局过,对这个叶节点模拟对局;否则为这个叶节点随机生成子节点,并进行模拟对局;
6.将模拟对局的收益(一般胜为 1 负为 0)按对应颜色更新该节点及各级祖先节点,同时增加该节点以上所有节点的访问次数;
7.回到 2,除非此轮搜索时间结束或者达到预设循环次数;
8.从当前局面的子节点中挑选平均收益最高的给出最佳着法。
由此可见 UCT 算法就是在设定的时间内不断完成从根节点按照 UCB 的指引最终走到某一个叶节点的过程。而算法的基本流程包括了选择好的分支(Selection)、在叶子节点上扩展一层(Expansion)、模拟对局(Simulation)和结果回馈(Backpropagation)这样四个部分。
UCT 树搜索还有一个显著优点就是可以随时结束搜索并返回结果,在每一时刻,对 UCT 树来说都有一个相对最优的结果。
代码实现
Board 类
Board类用于存储当前棋盘的状态,它实际上也是MCTS算法的根节点。
class Board(object):
"""
board for game
"""
def __init__(self, width=8, height=8, n_in_row=5):
self.width = width
self.height = height
self.states = {} # 记录当前棋盘的状态,键是位置,值是棋子,这里用玩家来表示棋子类型
self.n_in_row = n_in_row # 表示几个相同的棋子连成一线算作胜利
def init_board(self):
if self.width < self.n_in_row or self.height < self.n_in_row:
raise Exception('board width and height can not less than %d' % self.n_in_row) # 棋盘不能过小
self.availables = list(range(self.width * self.height)) # 表示棋盘上所有合法的位置,这里简单的认为空的位置即合法
for m in self.availables:
self.states[m] = -1 # -1表示当前位置为空
def move_to_location(self, move):
h = move // self.width
w = move % self.width
return [h, w]
def location_to_move(self, location):
if(len(location) != 2):
return -1
h = location[0]
w = location[1]
move = h * self.width + w
if(move not in range(self.width * self.height)):
return -1
return move
def update(self, player, move): # player在move处落子,更新棋盘
self.states[move] = player
self.availables.remove(move)
MCTS 类
核心类,用于实现基于UCB的MCTS算法。
class MCTS(object):
"""
AI player, use Monte Carlo Tree Search with UCB
"""
def __init__(self, board, play_turn, n_in_row=5, time=5, max_actions=1000):
self.board = board
self.play_turn = play_turn # 出手顺序
self.calculation_time = float(time) # 最大运算时间
self.max_actions = max_actions # 每次模拟对局最多进行的步数
self.n_in_row = n_in_row
self.player = play_turn[0] # 轮到电脑出手,所以出手顺序中第一个总是电脑
self.confident = 1.96 # UCB中的常数
self.plays = {} # 记录着法参与模拟的次数,键形如(player, move),即(玩家,落子)
self.wins = {} # 记录着法获胜的次数
self.max_depth = 1
def get_action(self): # return move
if len(self.board.availables) == 1:
return self.board.availables[0] # 棋盘只剩最后一个落子位置,直接返回
# 每次计算下一步时都要清空plays和wins表,因为经过AI和玩家的2步棋之后,整个棋盘的局面发生了变化,原来的记录已经不适用了——原先普通的一步现在可能是致胜的一步,如果不清空,会影响现在的结果,导致这一步可能没那么“致胜”了
self.plays = {}
self.wins = {}
simulations = 0
begin = time.time()
while time.time() - begin < self.calculation_time:
board_copy = copy.deepcopy(self.board) # 模拟会修改board的参数,所以必须进行深拷贝,与原board进行隔离
play_turn_copy = copy.deepcopy(self.play_turn) # 每次模拟都必须按照固定的顺序进行,所以进行深拷贝防止顺序被修改
self.run_simulation(board_copy, play_turn_copy) # 进行MCTS
simulations += 1