深度解析黑白棋AI代码原理(蒙特卡洛搜索树MCTS+Roxanne策略)

Posted 程序媛小哨

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了深度解析黑白棋AI代码原理(蒙特卡洛搜索树MCTS+Roxanne策略)相关的知识,希望对你有一定的参考价值。

深度解析黑白棋AI代码原理(蒙特卡洛搜索树MCTS+Roxanne策略)

文章目录

黑白棋规则

黑白棋是通过相互翻转对方的棋子,最后看棋盘上谁的棋子多来判定胜负的游戏。

黑白棋棋盘共有8行8列共64格

开局时,棋盘正中央的4洛先置放黑自相隔的
4枚棋子

通常黑子先行,双方轮流落子

只要落子和棋盘上任意一枚己方的棋子在一条直线上(横,直,斜线皆可)夹着对方棋
就能将对方的这些棋子转变为己方

如果在任意位置落子都不能夹住对方的任意一颗棋子,就要让对手下子。

当双放皆不能下子时,游戏结束,子多的一方胜利

传统黑白棋策略

传统黑白棋策略包括以下几类:

  • a) 贪心策略。
    每一步走子都选择使得棋盘上子最多的一步,而不考虑最终的胜负;
  • b) 确定子策略。
    某些子一旦落子后就再也不会被翻回对方的子,最典型的是四个角上的子,这类子被称为确定子(Stable Discs)。每一步走子都选择使得棋盘上己方的确定子最多的一部。
  • c) 位置优先策略。
    考虑到角点的重要性,把棋盘上的每一个子都赋予一个优先级,每一步从可走子里选择优先级最高的一个子。
  • d) 机动性策略(mobility)。
    黑白棋每一步的可走子都是有限的,机动性策略是指走子使得对手的可走子较少,从而逼迫对手不得不走出差的一步(bad move),使得自己占据先机。
  • e) 消失策略(evaporation, less is more)。
    在棋盘比试的前期,己方的子越少往往意味着局势更优。因此在前期可采用使己方的子更少的走子。
  • f) 奇偶策略(parity)。
    走子尽量走在一行或一列有奇数个空子的位置。 以上只列举了一些常见的黑白棋策略或原则,事实上还有很多更为复杂的策略,此处不进行列举。

蒙特卡洛搜索树

基本概念

蒙特卡洛树搜索(简称 MCTS)是 Rémi Coulom 在 2006 年在它的围棋人机对战引擎 「Crazy Stone」中首次发明并使用的的 ,并且取得了很好的效果。
我们先讲讲它用的原始 MCTS 算法(ALphago 有部分改进)
蒙特卡洛树搜索,首先它肯定是棵搜索树

我们回想一下我们下棋时的思维——并没有在脑海里面把所有可能列出来,而是根据「棋感」在脑海里大致筛选出了几种「最可能」的走法,然后再想走了这几种走法之后对手「最可能」的走法,然后再想自己接下来「最可能」的走法。这其实就是 MCTS 算法的设计思路。

上面这段很重要,可以反复品味

它经历下面 3 个过程(重复千千万万次)

  • 选择(Selection):
    从根结点R开始,选择连续的子结点向下至叶子结点L。下面的结点有更多选择子结点的方法,使游戏树向最优点扩展移动,这是蒙特卡洛树搜索的本质。
  • 扩展 (expansion):
    除非任意一方的输赢导致游戏结束,否则L会创建一个或多个子结点或从结点C中选择。
  • 模拟/仿真(Simluation):在结点C中进行随机布局。
  • 回溯/反向传播(Backpropagation):使用布局结果更新从C到R的路径上的结点信息。

模拟(Simluation)

我们不按顺序,先讲模拟,模拟借鉴了我们上面说的蒙特卡洛方法,快速走子,只走一盘,分出个胜负。
我们每个节点(每个节点代表每个不同的局面)都有两个值,代表这个节点以及它的子节点模拟的次数和赢的次数,比如模拟了 10 次,赢了 4 盘,记为 4/10。
我们再看多一次这幅图,如图,每个节点都会标上这两个值。

选择(Selection)

我们将节点分成三类:

未访问:还没有评估过当前局面
未完全展开:被评估过至少一次,但是子节点(下一步的局面)没有被全部访问过,可以进一步扩展
完全展开:子节点被全部访问过
我们找到目前认为「最有可能会走到的」一个未被评估的局面(双方都很聪明的情况下),并且选择它。
什么节点最有可能走到呢?最直观的想法是直接看节点的胜率(赢的次数/访问次数),哪个节点最大选择哪个,但是这样是不行的!因为如果一开始在某个节点进行模拟的时候,尽管这个节点不怎么好,但是一开始随机走子的时候赢了一盘,就会一直走这个节点了。
因此人们造了一个函数

Q(v) 是该节点赢的次数,N(v) 是该节点模拟的次数,C 是一个常数。
因此我们每次选择的过程如下——从根节点出发,遵循最大最小原则,每次选择己方 UCT 值最优的一个节点,向下搜索,直到找到一个「未完全展开的节点」,根据我们上面的定义,未完全展开的节点一定有未访问的子节点,随便选一个进行扩展。
这个公式虽然我们造不出来,但是我们可以观赏它的巧妙之处,首先加号的前面部分就是我们刚刚说的胜率,然后加号的后面部分函数长这样:


随着访问次数的增加,加号后面的值越来越小,因此我们的选择会更加倾向于选择那些还没怎么被统计过的节点,避免了我们刚刚说的蒙特卡洛树搜索会碰到的陷阱——一开始走了歪路。

扩展(expansion)

将刚刚选择的节点加上一个统计信息为「0/0」的节点,然后进入下一步模拟(Simluation)

回溯(Backpropagation)

Backpropagation 很多资料翻译成反向传播,不过我觉得其实极其类似于递归里的回溯,就是从子节点开始,沿着刚刚向下的路径往回走,沿途更新各个父节点的统计信息。


再放一次这个图,可以观察一下在模拟过后,新的 0/0 节点,比如这里模拟输了,变成了 0/1,然后它的到根节点上的节点的统计信息的访问次数全部加 1,赢的次数不变。

算法什么时候可以终止

取决于你什么时候想让他停止,比如说你可以设定一个时间,比如五秒后停止计算。
一般来说最佳走法就是具有最高访问次数的节点,这点可能稍微有点反直觉。这样评估的原因是因为蒙特卡洛树搜索算法的核心就是,越优秀的节点,越有可能走,反过来就是,走得越多的节点,越优秀。

蒙特卡洛黑白棋

经过策略的实践发现,单一的贪心策略和消失策略效果并不理想,确定子策略的算法比较复杂,程序效率较低,而机动性策略难以用程序体现,奇偶策略的效果也不佳,而位置优先策略在UCT算法中表现优异。 程序中采用两种优先级策略。

第一种优先级如下图:

这个优先级表的首要策略是占据角点,所有优先级的设置都为此服务

第二种优先级如下图:

这个优先级表由Roxanne提出,结合了多种策略,同时也结合了Mobility的特性,因为中间子的优先级较高,会提高自己的Mobility而限制对手的可走步数。

Roxanne 策略 详见 《Analysis of Monte Carlo Techniques in Othello》
提出者:Canosa, R. Roxanne canosa homepage. https://www.cs.rit.edu/~rlc/

代码:

# -*- coding = utf-8 -*-
# @Time : 2021/4/1 20:06
# @Author : Leokadia 
# @File : Chess.py
# @Software : PyCharm

'''
a) 选择(Selection):从根结点R开始,选择连续的子结点向下至叶子结点L。
下面的结点有更多选择子结点的方法,使游戏树向最优点扩展移动,这是蒙特卡洛树搜索的本质。
b) 扩展(Expansion):除非任意一方的输赢导致游戏结束,否则L会创建一个或多个子结点或从结点C中选择。
c) 仿真(Simulation):在结点C中进行随机布局。
d) 反向传播(Backup):使用布局结果更新从C到R的路径上的结点信息。

'''
from func_timeout import func_timeout, FunctionTimedOut
import datetime                # datetime模块用于操作时间
import random                  # random模块用于生成随机数
from math import log, sqrt    #  math数学计算库导入对数函数log,开方函数sqrt
from time import time         # 时间模块用于时间的设置
#
from copy import deepcopy     # 从copy模块导入深度拷贝方法


# 棋盘类
class ReversiBoard(object):
    def __init__(self):
        """
        初始化棋盘,棋盘大小为8*8,黑棋用 X 表示,白棋用 O 表示,未落子时用 . 表示
        """
        self.board_init()

    def board_init(self):
        """
        重置棋盘
        """
        self.empty = '.'  # 未落子状态
        self._board = [[self.empty for _ in range(8)] for _ in range(8)]  # 规格:8*8,构造8*8个.
        self._board[3][4], self._board[4][3] = 'X', 'X'  # 黑棋棋子初始状态
        self._board[3][3], self._board[4][4] = 'O', 'O'  # 白棋棋子初始状态
        # 棋局开始时黑棋位于E4和D5,白棋位于D4和E5

    def display(self, step_time=None, total_time=None):
        """
        打印棋盘
        :param step_time: 每一步的耗时, 比如:"X":1,"O":0,默认值是None
        :param total_time: 总耗时, 比如:"X":1,"O":0,默认值是None
        :return:
        """
        board = self._board

        print(' ', ' '.join(list('ABCDEFGH')))  # 打印列名,开头空一格
        for i in range(8):  # 打印行名和棋盘
            # print(board)
            print(str(i + 1), ' '.join(board[i]))

        ''' Display time. '''
        if (not step_time) or (not total_time): # 如果step_time=None, total_time=None
            # 棋盘初始化时展示的时间
            step_time = "X": 0, "O": 0
            total_time = "X": 0, "O": 0
            print("统计棋局: 棋子总数 / 每一步耗时 / 总时间 ")
            print("黑   棋: " + str(self.count('X')) + ' / ' + str(step_time['X']) + ' / ' + str(total_time['X']))
            print("白   棋: " + str(self.count('O')) + ' / ' + str(step_time['O']) + ' / ' + str(total_time['O']) + '\\n')
        else:
            # 比赛时展示时间
            print("统计棋局: 棋子总数 / 每一步耗时 / 总时间 ")
            print("黑   棋: " + str(self.count('X')) + ' / ' + str(step_time['X']) + ' / ' + str(total_time['X']))
            print("白   棋: " + str(self.count('O')) + ' / ' + str(step_time['O']) + ' / ' + str(total_time['O']) + '\\n')

    def count(self, color):
        """
        统计 color 一方棋子的数量。(O:白棋, X:黑棋, .:未落子状态)
        :param color: [O,X,.] 表示棋盘上不同的棋子
        :return: 返回 color 棋子在棋盘上的总数
        """

        count = 0
        for y in range(8):
            for x in range(8):
                if self._board[x][y] == color:
                    count += 1
        return count

    def get_winner(self):
        """
        判断黑棋和白旗的输赢,通过棋子的个数进行判断
        :return: 0-黑棋赢,1-白旗赢,2-表示平局,黑棋个数和白旗个数相等
        """

        # 定义黑白棋子初始的个数
        black_count, white_count = 0, 0
        for i in range(8):
            for j in range(8):
                # 统计黑棋棋子的个数
                if self._board[i][j] == 'X':
                    black_count += 1
                # 统计白旗棋子的个数
                if self._board[i][j] == 'O':
                    white_count += 1
        if black_count > white_count:
            # 黑棋胜
            return 0, black_count - white_count
        elif black_count < white_count:
            # 白棋胜
            return 1, white_count - black_count
        elif black_count == white_count:
            # 表示平局,黑棋个数和白旗个数相等
            return 2, 0

    def _move(self, action, color):
        """
        落子并获取反转棋子的坐标
        :param action: 落子的坐标 可以是 D3 也可以是(2,3)
        :param color: [O,X,.] 表示棋盘上不同的棋子
        :return: 返回反转棋子的坐标列表,落子失败则返回False
        """

        # 判断action 是不是字符串,如果是则转化为数字坐标
        if isinstance(action, str):
            action = self.board_num(action)

        fliped = self._can_fliped(action, color)

        if fliped:
            # 有就反转对方棋子坐标
            for flip in fliped:
                x, y = self.board_num(flip)
                self._board[x][y] = color      # 将待翻转列表全部变成自己的color

            # 落子坐标
            x, y = action                      # 落子坐标改为我要下的地方坐标
            # 更改棋盘上 action 坐标处的状态,修改之后该位置属于 color[X,O,.]等三状态
            self._board[x][y] = color
            return fliped
        else:
            # 没有反转子则落子失败
            return False

    # 从子节点开始,沿着刚刚向下的路径往回走
    # 沿途更新各个父节点的统计信息。
    def backpropagation(self, action, flipped_pos, color):
        """
        回溯
        :param action: 落子点的坐标
        :param flipped_pos: 反转棋子坐标列表
        :param color: 棋子的属性,[X,0,.]三种情况
        :return:
        """
        # 判断action 是不是字符串,如果是则转化为数字坐标
        if isinstance(action, str):
            action = self.board_num(action)

        # 还原棋盘
        self._board[action[0]][action[1]] = self.empty

        # 如果 color == 'X',则 op_color = 'O';否则 op_color = 'X'
        op_color = "O" if color == "X" else "X"

        for p in flipped_pos:
            # 判断action 是不是字符串,如果是则转化为数字坐标
            if isinstance(p, str):
                p = self.board_num(p)
            self._board[p[0]][p[1]] = op_color

    def is_on_board(self, x, y):
        """
        判断坐标是否出界
        :param x: row 行坐标
        :param y: col 列坐标
        :return: True or False
        """

        return x >= 0 and x <= 7 and y >= 0 and y <= 7

    def _can_fliped(self, action, color):
        """
        检测落子是否合法,如果不合法,返回 False,否则返回反转子的坐标列表
        :param action: 下子位置
        :param color: [X,0,.] 棋子状态
        :return: False or 反转对方棋子的坐标列表
        """

        # 判断action 是不是字符串,如果是则转化为数字坐标
        if isinstance(action, str):
            action = self.board_num(action)
        xstart, ystart = action

        # 如果该位置已经有棋子或者出界,返回 False
        if not self.is_on_board(xstart, ystart) or self._board[xstart][ystart] != self.empty:
            return False

        # 临时将color放到指定位置
        self._board[xstart][ystart] = color

        # 棋手
        op_color = "O" if color == "X" else "X"

        # 要被翻转的棋子
        flipped_pos = []        # 数字坐标
        flipped_pos_board = []  # 棋盘坐标

        for xdirection, ydirection in [[0, 1], [1, 1], [1, 0], [1, -1], [0, -1], [-1, -1], [-1, 0], [-1, 1]]:  #八个方向依次遍历
            x, y = xstart, ystart
            x += xdirection
            y += ydirection
            # 如果(x,y)在棋盘上,而且为对方棋子,则在这个方向上继续前进,否则循环下一个角度。
            if self.is_on_board(x, y) and self._board[x][y] == op_color:
                x += xdirection
                y += ydirection
                # 进一步判断点(x,y)是否在棋盘上,如果不在棋盘上,继续循环下一个角度,如果在棋盘上,则进行while循环。
                if not self.is_on_board(x, y):
                    continue
                # 一直走到出界或不是对方棋子的位置
                while self._board[x][y] == op_color:
                    # 如果一直是对方的棋子,则点(x,y)一直循环,直至点(x,y)出界或者不是对方的棋子。
                    x += xdirection
                    y += ydirection
                    # 点(x,y)出界了和不是对方棋子
                    if not self.is_on_board(x, y):
                        break
                # 出界了,则没有棋子要翻转OXXXXX
                if not self.is_on_board(x, y):
                    continue

                # 是自己的棋子OXXXXXXO
                if self._board[x][y] == color:
                    while True:
                        x -= xdirection
                        y -= ydirection
                        # 回到了起点则结束
                        if x == xstart and y == ystart:
                            break
                        # 需要翻转的棋子
                        flipped_pos.append([x, y])

        # 将前面临时放上的棋子去掉,即还原棋盘(可以下的棋子已经在要翻转的列表中了)
        self._board[xstart][ystart] = self.empty  # restore the empty space

        # 没有要被翻转的棋子,则走法非法。返回 False
        if len(flipped_pos) == 0:
            return False

        for fp in flipped_pos:
            flipped_pos_board.append(self.num_board(fp))
        # 走法正常,返回翻转棋子的棋盘坐标
        return flipped_pos_board

    def get_legal_actions(self, color): #我的字的颜色
        """
        按照黑白棋的规则获取棋子的合法走法
        :param color: 不同颜色的棋子,X-黑棋,O-白棋
        :return: 生成合法的落子坐标,用list()方法可以获取所有的合法坐标
        """

        # 表示棋盘坐标点的8个不同方向坐标,比如方向坐标[0][1]则表示坐标点的正上方。
        direction = [(-1, 0), (-1, 1), (0, 1), (1, 1), (1, 0), (1, -1), (0, -1), (-1, -1)]

        op_color = "O" if color == "X" else "X"
        # 统计 op_color 一方邻近的未落子状态的位置
        op_color_near_points = []

        board = self._board
        for i in range(8):
            # i 是行数,从0开始,j是列数,也是从0开始
            for j in range(8):
                # 判断棋盘[i][j]位子棋子的属性,如果是op_color,则继续进行下一步操作,
                # 否则继续循环获取下一个坐标棋子的属性
                if board[i][j] == op_color:
                    # dx,dy 分别表示[i][j]坐标在行、列方向上的步长,direction 表示方向坐标
                    for dx, dy in direction:
                        x, y = i + dx, j + dy
                        # 表示x、y坐标值在合理范围,棋盘坐标点board[x][y]为未落子状态,
                        # 而且(x,y)不在op_color_near_points 中,统计对方未落子状态位置的列表才可以添加该坐标点
                        if 0 <= x <= 7 and 0 <= y <= 7 and board[x][y] == self.empty and (
                                x, y) not in op_color_near_points:
                            op_color_near_points.append((x, y))
        l = [0, 1, 2, 3, 4, 5, 6, 7]
        for p in op_color_near_points:
            # 判断落位是否合法,合法则进行下一步
            if self._can_fliped(p, color):
                # 判断p是不是数字坐标,如果是则返回棋盘坐标
                # p = self.board_num(p)
                if p[0] in l and p[1] in l:  #判断p是不是数字坐标,如果是则返回棋盘坐标
                    p = self.num_board(p)
                yield p

    def board_num(self, action):
        """
        棋盘坐标转化为数字坐标
        :param action:棋盘坐标,比如A1
        :return:数字坐标,比如 A1 --->(0,0)
        """

        row, col = str(action[1]).upper(),以上是关于深度解析黑白棋AI代码原理(蒙特卡洛搜索树MCTS+Roxanne策略)的主要内容,如果未能解决你的问题,请参考以下文章

深度解析黑白棋AI代码原理(蒙特卡洛搜索树MCTS+Roxanne策略)

蒙特卡洛树搜索:井字游戏的实现

强化学习笔记:AlphaGo(AlphaZero) ,蒙特卡洛树搜索(MCTS)

DQN蒙特卡洛树搜索(MCTS)

为啥蒙特卡洛树搜索会重置树

蒙特卡洛树搜索介绍