基于MCTS的黑白棋ai
人工智能课程最近布置了这样的一份作业,完成后把注释还算详细的代码分享出来,供大家借鉴参考。如果你也是左职钟老师的学生,为保证完成地不过于相似,还请结合自己的理解进行修改。欢迎大家来捉虫
1.实验内容与要求
1.1 实验内容
黑白棋(Reversi),也叫苹果棋,翻转棋,是一个经典的策略性游戏。一般棋子双面为黑白两色,故称“黑白棋”。因为行棋之时将对方棋子翻转,变为己方棋子,故又称“翻转棋”(Reversi)。棋子双面为红、绿色的称为“苹果棋”。它使用8x8的棋盘,由两人执黑子和白子轮流下棋,最后子多方为胜方。随着网络的普及,黑白棋作为一种最适合在网上玩的棋类游戏正在逐渐流行起来。中国主要的黑白棋游戏站点有Yahoo游戏、中国游戏网、联众游戏等。
游戏规则:
棋局开始时黑棋位于e4和d5,白棋位于d4和e5。
黑方先行,双方交替下棋。
一步合法的棋步包括:在一个空格新落下一个棋子,并且翻转对手一个或多个棋子。
新落下的棋子与棋盘上已有的同色棋子间,对方被夹住的所有棋子都要翻转过来。可以是横着夹,竖着夹,或是斜着夹。夹住的位置上必须全部是对手的棋子,不能有空格。
一步棋可以在数个(横向,纵向,对角线)方向上翻棋,任何被夹住的棋子都必须被翻转过来,棋手无权选择不去翻某个棋子。
除非至少翻转了对手的一个棋子,否则就不能落子。如果一方没有合法棋步,也就是说不管他下到哪里,都不能至少翻转对手的一个棋子,那他这一轮只能弃权,而由他的对手继续落子直到他有合法棋步可下。
如果一方至少有一步合法棋步可下,他就必须落子,不得弃权。
棋局持续下去,直到棋盘填满或者双方都无合法棋步可下。
如果某一方落子时间超过1分钟,则判该方失败。
1.2. 实验要求
- a)使用MCTS算法实现Mini AlphaGo for Reversi, 有能力的同学可以采用最大最小算法进行对比.
- b)MCTS算法部分需要自己实现,尽量不使用现成的包,工具或者接口.实验报告不能直接复制、粘贴网上内容,请理清思路,转化为自身知识.珍惜每一次训练机会
- c)在博弈过程中,Mini AlphaGo每一步所花费的时间以及总时间需要显示出来.
- d)需要有简单的图形界面用于人机博弈交互.
- e) 可使用Python语言,也可以使用其他编程语言实现.
2.概念理解
在初次接触,并且尝试编写代码时,最大且最直接的问题往往是,MCTS算法是什么?和翻转棋有什么关系?在本文中,将尽力解释这两点。
2.1 核心思想
一位杰出的棋手往往能够根据对手的上一步选择,分析出对手后几步的动向。同样的,若要实现一个具备一定实力的ai,也需要预判对手随后的操作,而后做出最稳妥的选择
2.2 数据结构
落子具有先后顺序,并且一方落完之后,另一方有多种落子可能。因此本程序采用树状结构,每一个节点代表一种个落子可能,“节点间的父子关系”表征“落子的先后顺序“,”一个父节点可能具备多个子节点“表征”一方落子后另一方的多种落子可能“。
2.3 MCTS算法对树的操作
MCTS算法常被分为”选择“、”拓展“、”模拟“、”反向传播“四个部分,接下来将介绍各个部分对节点树的操作。
2.3.1 选择
当我有多个落子位置可选时,我会希望选择胜率最大的位置。而当对手有多个落子位置可选时,他同样会希望选择到他胜率最大,也就是我胜率最小的位置。但我要如何确定这个胜率?
1.遍历棋盘的所有情况,然后计算胜场?算力不够
2.在已有棋盘落子+此处落子的基础上,进行一定场次的随机模拟(2.3.3将会对随机模拟做出更详细的介绍),每次随机模拟中双方都在符合规则的前提下随机落子,然后计算胜率?有一定的参考价值,并且”模拟“阶段就是这么做的,但这显然没有考虑到对局中敌我的主观能动性,参考价值较为有限。
事实上,选择阶段就是在第二种胜率计算方法的基础上增加”主观能动性“的因素。使得胜率更具备参考意义。
由于无法遍历所有情况,所以通过随机落子来计算一个参考概率是很有必要的。但是,只要我在随机落子之前进行越多步的非随机落子——贴近敌我行动可能的非随机落子,那么在此之后通过随机落子计算出的胜率,就包含更多的对”敌我主观能动性“的考量。
我们通过一个例子来讲解
对于这一棵树,A是当前的棋盘。B层是黑方(我们假设此时是黑方回合,假设成白方其实也同理~)在此回合可以落子的位置,此处我们假设只有3处可以落子,命名为B1,B2,B3。编号之下的百分比数字是通过多场随机落子计算出的胜率。C层是在黑方落子后,白方的可落子位置(如黑方落子B1,之后白方符合规则的落点则为C1,C2),名称下的数字仍然是胜率(黑方的胜率),且同样为通过多场随机模拟落子计算出的结果。
可以注意到,到这里仍然全是随机值,接下来我们要往其中添加主观能动性。
首先,白方会希望黑方胜率尽可能小。
这意味着,根据当前的胜率信息,当黑方落子B1时,白方大概率落子C2;黑方B2时,白方C4;黑方B3时,白方C5。
那么,在C2的基础上进行的模拟对局就比C1上更加符合未来可能发生的情况,同理,C4比C3更符合,C5比C6更符合。
而在B1的基础上进行的模拟对局,可能会进入C1,因此通过B1模拟计算得到的胜率也会更多得受一些“未来小概率才会出现的情况”干扰,因此通过B1模拟得出的胜率的现实指导意义要弱于从C2模拟。同理,B2弱于C4 , B3弱于C5。
接下来是判断C2,C4,C6哪个在未来有更大概率发生。
这需要用到黑方的选择偏好:
黑方会希望黑方胜率尽可能大。
因此根据目前的信息,黑方更有可能走B1,即C2在未来有更大概率发生。
初学者看到这里可能会产生一个疑惑:既然都确定走B1了,还有什么模拟下去的必要吗?C层的信息也没用上啊?
答案就在着重标记的”目前“二字里。
由于概率是随机模拟得到的,无论是哪个节点,再多模拟几次可能值都会产生变动。并且目前的B1可能仍在很大程度上受C1影响(当然也可以让B1值直接等于C2,最大最小算法似乎就是直接将子节点的值赋给父节点),因此要进行更多次模拟,而且是更贴近未来可能的模拟,即基于C2的模拟。而后根据新模拟的场数与胜利数重新调整B1与C2的胜率。
在这样的调整后,B1的胜率未必还是B层中的最高者。但无论怎样,在每一次的调整中,总是选择B层中的最大值,以及其对应C子节点中的最小值作为基础,开始新的模拟。直到一定的调整次数之后,选择最终胜率最高的落点位置,作为结果。
以上为ucb公式中常数c为0的情况。uba公式的作用是给节点评分,评分的依据是节点的胜率以及其对父节点的影响比例。当c不为0时,整体的流程仍是相同的,不过是我们做出选择的依据从胜率变成了ucb公式给出的评分
由于”选择“阶段既处理还未被MCTS算法调整过的树,还处理已经被MCTS算法调整过的树。因此在上面的论述中,其实还包括了”模拟“以及”反向传播“两个过程,并且大致介绍了MCTS整个算法的思想
提取并总结一下,选择阶段的作用是:选择出一条未来更可能发生的路径(在例子中,目前该路径为B1C2,但在实际应用时并不一定只有两层,还可以不断向下拓展,甚至穷举完所有可能)。
而拓展、模拟、反向传播三个阶段则是通过这条路径,对第一级子节点的胜率进行调整,参入对敌我动向的分析,使该胜率更可信。
最终就能通过各个可落子位置的较可信的胜率,确定落子方案。这就是MCTS算法在翻转棋上的应用原理。
2.3.2 拓展
这里继续使用上面的例子。
目前而言,其实不只B层的胜率是随机、不完全可信的,C层的胜率同样也是通过随机落子对局计算出来的,也不完全正确。在选择阶段,我们确定了路径[B1-C2] , 现在我们希望C2的胜率也能参入对敌我动向的分析,怎么办呢?
给C2也加上子节点——这就是”拓展“的含义
我们假设C2之后黑方有3个可落子位置,由于3个位置都有可能落子,因此需要将3个位置全都作为子节点接到C2之后,我们将其编号为D1,D2,D3。
2.3.3 模拟
对敌我动向的分析需要使用各个节点的胜率作为依据,因此在得到D1,D2,D3后,首要任务是计算出一个参考胜率。计算方法是进行多局随机模拟。
随机模拟:
在一回合内可选的落子位置往往有多处,随机模拟即为:在目前的基础上(在D1开始的随机模拟的基础即为 棋盘上已经有了棋子+于B1落黑子+于C2落白子+于D1落黑子,回合转至白方)继续对局,之后交战的两方在其回合都是无策略地随机选一处落子,直到游戏结束。
然后将各自多局随机模拟中的胜率作为参考胜率赋值给D1,D2,D3
由于D层可能接着扩展E层,为了调整胜率方便,往往是记录对局数与胜利数,而不是仅记录胜率。
2.3.4 反向传播
D1的模拟既是在D1自己的基础上,同时也是在C2,B1的基础上进行的,因此不仅要将对局数与胜利数赋值给D1自生,还需要修改C2,B1的对局数与胜利数,实现对C2,B1胜率的调整。D2,D3同理。
2.3.5 循环
C1拓展出了D层,D又可以拓展出E层,同理后面还有F,G层。在ai确定落子位置之前,往往不会只执行一次”选择“→”拓展”→”模拟”→”反向传播”,而是会在”反向传播“后再次对修改后的树执行选择操作,不断循环。而循环的具体次数,可根据性能与智能要求做出自己的调整。次数越多ai越强,次数越少ai越快。
3.具体实现
3.1 棋盘逻辑
3.1.1 UI界面
棋盘主要为一个tkinter Canvas,通过监听鼠标左键的点击事件,根据鼠标对于窗口的相对点击位置确认鼠标点击的单元格,接着再判断点击单元格是否可以落子,若可以落子则更新棋盘、画面、计算ai落子位置;若不可落子则直接结束该点击事件,等待下一次点击。
1 | class ReversiBoard(Tk.Canvas): |
3.1.2 数据结构
整个棋盘由一个8*8的数字二维数组board表征,其中board[i][j]表征棋盘第i+1行,j+1列对应的单元格,其可能值有0——表示空,-1——黑子,1——白子。
1 | def getInitialBoard(): # 初始化棋盘数组 |
3.1.3 检测可落子位置
遍历棋盘每一个位置,检测其是否值为0,且相邻对方棋子,且沿着相邻对方棋子方向前进最终会遇到一个自己的棋子
1 | def possible_positions(board, tile): # 返回一个颜色棋子可落子位置 |
3.1.4 翻转
从落点位置向八个方向拓展,检测其是否相邻对方棋子,且沿着相邻对方棋子方向前进最终会遇到一个自己的棋子, 如果是,则将经过的对方棋子翻转。
1 | def updateBoard(board, tile, i, j): |
3.1.5 判断胜负
当双方可落点位置数都为0时,遍历棋盘,计算双方棋子的数量,数量多者胜利。
1 | def eval_board(tep_board): #比较二者的棋子数目,判断胜负 |
3.2 MCTS相关
3.2.1 首次扩展&模拟
本阶段用于初始化mcts树,扩展出第一层子节点,并进行一定场数的模拟,根据模拟结果修改结点的模拟总次数与模拟胜利次数。以此使之后的第一次mcts循环中的选择阶段具有选择的依据,并且可以避免除以0的异常情况。
1 | global PATHROOT #节点树 |
3.2.2 MCTS循环
选择
选择阶段首先根据uba算法计算第一层级各个结点的估值,而后选择估值最高者,遍历其一级子结点以计算各结点的uba估值,选择估值最小的结点。像这样不断深入,交替选择最大或最小值,直到达到叶子结点。记录下这一路每次选择的结果
1 | def find_path(root): |
扩展
拷贝带落点棋盘到一临时棋盘,根据”选择”阶段获得的结点集,在临时棋盘上完成多次落点,完成落点后,检测临时棋盘的下一回合可落点位置,将这些可落点位置更新到结点树中
1 | #扩展结点,返回tep_board的子节点数组 |
单词模拟
对”扩展“阶段得到的每一可落点位置,在临时棋盘的基础上,于该位置落子。随后双方继续在此基础上不断地随机落子(符合规则的随机),直到游戏结束。如此进行多局后,将对局数与胜利数更新到该可落点位置在树中对应的节点。
1 | def find_playout(tep_board, tile, depth=0): #对tep_board进行了系列随机落点后,返回最终结果胜负 |
拓展&模拟&反向传播
根据”模拟”阶段的对局数与胜利数,更新可落点位置在树中对应的节点的所有祖先节点。
1 | #扩展与模拟过程 |
返回结果
遍历树的第一层节点,选择胜率最高的节点对应的落点位置返回。
1 | for n_tuple in PATHROOT: |
4.运行结果
5.完整原码
5.1 game.py
1 | import MCTS as rvs |
5.2 MCTS.py
1 | import random |