1 背景
本篇介绍MCTS(蒙特卡洛搜索树)算法,它被广泛应用在复杂决策和游戏中,在最近大火的大语言模型的推理模型中也有应用。2016年,AlphaGo战胜了韩国9段棋手李在石,MCTS就是其重要的组成部分,有兴趣读者可观看NetFlix的纪录片《阿尔法狗 AlphaGo (2017)》,B站就可以搜到。在后来的Alpha Zero中,MCTS继续成为其重要的模型基础。
本篇用Python代码搭建了一套五子棋游戏框架,并实现了3种AI策略:随机策略、规则策略和MCTS策略。代码将游戏逻辑、UI展现、AI策略这三部分做了解耦,并采用面向对象的设计原则,如有读者对代码结构设计感兴趣也可参考和讨论。
2 MCTS原理
个人认为MCTS的原理有如下特点:(1)简单,符合人的直觉;(2)巧妙,发挥了数据结构中树的优势,非常适合部署到计算机中,实现人机结合解决实际问题。因为模型比较简单,笔者将做简要介绍,不会涉及到规范的数学语言描述,以五子棋为例拆解该算法。
2.1 算法简介
从算法名称可以看出,MCTS算法有2个关键元素,即蒙特卡洛和搜索树。
蒙特卡洛,指使用随机模拟逼近结果。人在下棋的时候也会往后推演,思考我方棋子下到某个位置时,对方应如何应对。计算机就是在仿照这个过程,通过自身的高算力,向后模拟多个回合,并选择胜率最高的动作。
搜索树,指算法主体是用“树”这一数据结构实现。树是计算机中重要的数据结构,这里不展开说明,仅以形象化例子解释:想象一棵树,从根部向上,分叉出若干枝条,每个枝条又可以分叉,到了叶子处不再分叉。在数据结构中,树是倒过来的,最上层是根节点,往下分叉出若干子节点。每个子节点又会往后分叉,直到叶子节点。
每个棋盘局面可以认为是一个节点,新一轮落子后的棋盘是一个子节点。假设棋盘是19*19,那么最初空白棋盘是根节点,其子节点指后续可能的下法,一共19*19=361个,继续向下的子节点是361-1=360个,那么游戏进行5轮时,可能的下法有361*360*359*358*357。可以想象到计算量是非常大的,计算机穷举所有下法不现实,所以需要用蒙特卡洛方法模拟。只要模拟次数足够多,也可以达到和穷举类似的效果。
2.2 选择Selection
给定一个棋盘局面,下一步应该选择往哪个位置模拟?当然是有利于自己的位置了。标准是什么呢?
有2个标准:“经由此节点”模拟的胜率以及被探索的次数。胜率越高越好,这很好理解——哪一步胜率高就选择哪一步;探索次数越小越好,因为选择探索次数少的下法能够让模拟覆盖尽量多的路径,挖掘潜力。
比如之前模拟了50次,有如下2个子节点可以选择。
子节点 | 获胜次数 | 经过此节点总数 |
A | 12 | 20 |
B | 2 | 30 |
显然A节点胜率高,而且探索次数少,应该选择A。
胜率和探索次数怎么组合成一个指标呢?可以使用UCB公式计算
加号左侧指该节点胜率,右侧的c是一个常数,一般取2.N表示父节点探索次数,n表示子节点探索次数。对于同一个父节点而言,其所有子节点的N是固定的值。从此式可知,胜率越大,子节点UCB越大,探索次数越小,子节点UCB越大,常数c就是在“利用”与“探索”之间发挥调节作用。
当然,如果当前局面是一个新的局面,即节点是叶子节点,暂无子节点,那么跳过这一步骤,直接进行扩展。如果当前局面已经分出胜负,那么也无模拟的必要了。
2.3 扩展Expansion
如果模拟到达了一个叶子节点,即一个新的局面,那么就把子节点全部罗列出来。即从当前节点扩展出子节点。
2.4 模拟Simulation
从扩展出的子节点往后模拟下棋,直到分出胜负或者步数超过给定的限额(比如30步,防止模拟消耗时间过长),注意模拟时经过的节点都是虚拟的节点,并没有被扩展,本次模拟结束后,这些虚拟的节点都会删除,他们唯一的作用就是帮助此次模拟分出胜负。
2.5 反向传播Backpropagation
分出胜负后,将沿途的节点(不包括虚拟节点)胜利次数加1,被探索次数也加1。
最后,根据所有子节点的胜率、探索次数,选择最佳的子节点作为下一步动作。
3 代码解析
代码已上传到Github中,欢迎大家clone或star。项目地址:GitHub - cufewxy/gobang: MCTS implement for gobang game
代码写得仓促,不完善的地方欢迎大家探讨。所需安装模块有:
tkinter,numpy
3.1 整体结构
文件名/文件夹 | 作用 |
ai_strategy | 所有AI的策略都在此实现,每个AI策略是一个py文件 |
data | 对局结果,按日期存储,便于以后AI训练 |
ai_base.py | AI策略的基类,所有AI策略需要继承此基类 |
gobang.py | 游戏后台逻辑处理,包括判断游戏胜利条件,封装了落子接口 |
ui.py | 将游戏后台棋盘展示成窗口,方便玩家对局,或者重放某局游戏(暂未实现) |
3.2 后台逻辑的实现
后台逻辑是在gobang.py中实现的,这里包含了游戏进行所需要的全部元素。
主要的代码对象如下
GoBangBoard类
类名 | GoBangBoard | |
作用 | 棋盘类 | |
主要属性 | position | 棋子位置,1和2表示玩家1和玩家2落子位置,0表示空位置 |
主要函数 | put | 落子 输入玩家编号、位置,将棋盘的position置为相应玩家的编码 |
将棋盘和游戏逻辑解耦,因为棋盘有可能被用于围棋等其他棋类,具有通用性。
GoBangGame类
类名 | GoBangGame | |
作用 | 实现了五子棋游戏下棋逻辑、获胜条件判断 | |
主要属性 | cur_player | 当前落子的玩家 |
result | 游戏结果 | |
board | 棋盘,即GoBangBoard的对象 | |
主要函数 | proceed | 游戏进行一轮下棋动作 输入位置,在棋盘落子后,检查获胜条件 |
check_winner | 判断玩家是否获胜 输入为棋盘、玩家 输出获胜结果以及获胜连子的位置 |
3.3 UI的实现
ui.py负责渲染棋盘页面,将用户的点击动作发送到gobang.py封装的下棋接口中,并且会调用AI策略,实现与玩家对弈。
这里是参考了另一篇文章,略做修改改造成面向对象的形式,并适配AI策略和gobang.py后台算法,因此不展开叙述。参考的文章找不到了,如果涉及版权请联系我。
3.4 AI策略
策略基类
策略基类是在ai_base.py中实现的,定义了AIStrategy抽象基类,AI策略必须继承此类,并实现model函数。model是AI策略最重要的计算函数,输入当前的棋盘和玩家编号,输出落子的位置。
策略一:随机策略
import random
from ai_base import AIStrategy
class RandomStrategy(AIStrategy):
"""
随机模型
"""
def model(self, cur_board, ai_player):
empty_list = []
for i in range(len(cur_board)):
for j in range(len(cur_board)):
if cur_board[i][j] == 0:
empty_list.append([i, j])
res = random.choice(empty_list)
return res
随机策略是最简单的策略,在棋盘中所有的空位置中随机落子。
策略二:规则策略
基于经验构造出5条规则,AI根据规则做出决策。
情形 | 动作 |
当我方有4个棋子相连且一头为空 | 落到其中一头,获胜 |
当对手有4个棋子相连且一头为空 | 堵住连珠 |
当我方有3个棋子相连且两头为空 | 落到其中一头。优先落到连续空位置大于1的一头(例如OOXXXO,X表示棋子,O表示空位置,应该落到左侧),如果都是1个空位置则任选一头 |
当对手有3个棋子相连且两头为空 | 落到其中一头。其中至少有一头的空位置大于1,否则没必要堵住(例如OXXXO) |
不符合上述所有情况 | 调用随机策略,随机选择位置落子 |
策略三:MCTS策略
说实话,由于计算量过大,此策略的实际效果一般,实战意义小于算法示范的意义。单进程运行速度比较慢(可考虑拓展成多进程甚至多机器运行),AI要卡顿很久才能出招。若减小模拟次数,可能覆盖不了所有搜寻的空间,导致很多节点未被探索;若减小对局深度,则很多对局无法区分胜负,失去了模拟的意义。读者在运行程序时,可尝试缩减棋盘大小。
树的表示
使用class Tree表示
class Tree:
def __init__(self, cur_player):
self.root_node = Node(cur_player)
def trim(self, node):
self.root_node = node
self.root_node.parent_node = None
属性包含root_node,表示根节点。
函数包含trim,即传入一个节点,裁剪使得此节点为根节点。
此树的遍历是通过节点完成的,因此Tree的属性和函数较少。
节点的表示
使用class Node表示。
class Node:
def __init__(self, player):
self.player = player
self.child_node = []
self.action = [] # 表示下棋的动作,没有在每个节点都保存棋盘,而是从根节点开始往下推演棋盘,否则内存过大
self.parent_node = None
self.visit_num = 0 # 访问次数
self.win_num = 0 # 获胜次数
self.if_win = None # 1表示胜利,0表示未胜利,None表示无法判断
属性包括:
属性 | 含义 |
player | 此节点是由哪个玩家走棋得到的。每个节点都对应下棋的玩家,同一层的所有节点对应的玩家是一致的 |
child_node | 子节点列表 |
action | 该节点的下棋动作。没有在每个节点都保存完整棋盘,而是从根节点开始往下推演棋盘,以减小内存开销 |
parent_node | 父节点 |
visit_num | 探索次数 |
win_num | 胜利次数 |
if_win | 表示此节点下玩家是否胜利。1表示胜利,0表示未胜利,None表示暂未判断 |
注意下棋是对局双方交替进行的,因此在设计算法时(尤其是模拟阶段和反向传播阶段)需要考虑以下方面:(1)每一步是哪个玩家出手;(2)结束时是哪个玩家胜利,反向传播时应该只更新胜利玩家的节点;(3)要考虑人类的走棋,AI要接收人类的走棋,从而更新自己棋盘。具体而言,需要注意以下逻辑。
内嵌规则策略AI
先查看当前棋面是否符合规则策略,如果符合则直接调用规则,不再应用MCTS算法。
更新棋盘树
AI和人类对局是交替进行的,因此AI需要识别出人类下了哪一步棋,更新棋盘。同时,对当前棋盘树做剪枝,将根节点替换为当前局面的节点。之所以没有重新初始化一棵树,是希望复用之前扩展得到的子节点胜利信息、探索信息。
模拟
模拟阶段是调用了“规则策略AI”进行对弈。
反向传播
注意反向传播时,所有途径节点的探索次数加1,但只有胜利的玩家对应的节点才会更新胜利次数。因此胜利次数加1的操作是隔一层应用一次的。
3.5 引申讨论——AlphaZero
程序运行起来较慢,事实上,AlphaGo/AlphaZero也并非单纯使用了MCTS算法,而是引入了其他模型,例如策略网络,负责预测下一步走棋;价值网络,负责评判胜率。
这样做有2个好处,第一,从速度上,这2个网络可以预先训练好,加快了AI决策的速度;第二,从效果上,这2个网络能够使用到棋谱数据或者是AI自对弈得到的数据,使得模型能够不断迭代,反观MCTS,每次开启新的对局,旧的对局数据就没有作用了。