【Python】用蒙特卡洛树搜索(MCTS)解决寻路问题

像人类一样思考。

用蒙特卡洛树搜索(MCTS)解决寻路问题

关于蒙特卡洛树搜索

深度优先搜索(Deep First Search, DFS)、广度优先搜索(Breadth First Search, BFS)是最常用、最简单,也最为人所熟知的两种搜索模式。DFS优先探向更深的节点,而BFS则不把当前深度的节点探完绝不向更深迈出一步。

它们的固有特点就是,哪怕(从人——或者说上帝——的角度来看)最优解近在咫尺,它们各自也会老老实实的搜完当前子树(或是当前层)再迈出下一步。这也是层出不穷的搜索剪枝方法试图避免的情况:一些无用的节点明明没有必要花费宝贵的时间去试探。

那么,有没有一种搜索策略可以同时拥有DFS和BFS的优势,又能取长补短避免它们的缺点呢?


蒙特卡洛树搜索(Monte Carlo Tree Search, MCTS)是一种搜索策略,严格来说它和DFS、BFS是相当的。不同之处在于,它通过一个权重表(取决于具体的实现)来决定每次搜索的试探方向:到底是探向更深的一层,还是停留在当前层试探其他节点。

因此,MCTS是一种兼而有之的搜索策略——它估计当前节点和最优解的距离,启发式地决定到底应该采用类DFS的搜索模式,还是采用类BFS的搜索方式。因此,它可以兼具两者的优势并弥补其不足。


最有名的MCTS应用应当就是AlphaGo了。上一个在决策树上胜过人类的前辈“深蓝”还是靠算力和搜索优化和人类硬碰硬,而AlphaGo则是搭乘着ML/AI的快车大步流星地将人类的智力远远地甩在了后面。基于MCTS的试探方式和大数据训练的损失函数,AlphaGo比所有前辈更像“人”——在决策问题上忠实地模拟着“贪心策略”这一人类解决决策问题时最常用的解法。
MCTS解决对弈型问题

寻路问题和寻路算法

游戏业界的AI开发者有一句名言:

寻路已不是问题。1

提到寻路算法,朴素的BFS和Dijsktra固然能解决问题,不过工业上更常用的是A*算法2和NAV导航网格寻路,以及各种预定义路径点的静态寻路。可以说,这已经不是一个亟需解决的“问题”,而是“一题多解”和“算法改进”的舞台。然而,A*和NAV等等工业算法已经足够优秀,很难再上演像“AlphaGo通过变得更像人来超越‘深蓝’”这样为人津津乐道的桥段。

在接下来的内容中,我们将尝试用MCTS解决一个朴素的寻路问题:

n × m n\times m n×m的网格中有若干障碍物,每步可以从某一点的上下左右方向中的一个迈出一步。给出起点 ( f x , f y ) (fx,fy) (fx,fy)和终点 ( t x , t y ) (tx,ty) (tx,ty),求路径。

数据结构与定义

由于起止点已知,基于贪心策略,期望每步尽量能减少和终点的距离是朴素的。然而考虑到解的不确定性,不能通过简单贪心来解决问题,仍然需要对整个网格进行搜索。

定义网格为n*m的矩阵,ft为起止点的坐标,b为障碍物列表。

内部用grid保存可通行情况(是否有障碍物),visited表示地图中的该点是否已被试探过,trails记录当前可供试探的节点列表,path记录节点由哪个节点展开而来,在最后生成路径时反向遍历以得出最终路径。

# 用 Monte Carlo Tree Search解决 寻路问题
# by 1mlightyears@gmail.com
# 20210215
class MCTS:
    def setMap(self,
               n: int = 15,
               m: int = 15,
               f: tuple = (0, 0),
               t: tuple = (14, 14),
               b: list = [],
               sleep: float = 0.1,
               nograph: bool = False):
        """
        地图声明部分。
        n,m(int):地图长宽。
        f,t(tuple[int,int]):起点与终点。
        b(list[tuple[int,int]]):地图中不可通行的障碍物。
        sleep(float):控制试探间隔时间。
        nograph(bool):不显示可视化窗口。
        """

        self.grid = np.array([[False for j in range(m)] for i in range(n)])
        self.n = n
        self.m = m
        self.f = f
        self.t = t
        self.sleep = sleep
        self.nograph = nograph
        for i in b:
            self.grid[i[0]][i[1]] = True
        seed = int(time())
        np.random.seed(seed)

		# log
        print(f"地图={n}*{m} 从{f}到{t}\n障碍:{b}")
        print(f"随机种子:{seed}")
        # 可视化部分
        if not self.nograph:
            for i in range(n):
                for j in range(m):
                    self.X.append(i)
                    self.Y.append(j)
            self.C = [[0 for j in range(m)] for i in range(n)]
            se.set()
            plt.ion()

寻路算法的基本假设

因为一条具体的路线是由路线上的每一点各自的决策组成的,每点各自又是一个具体的状态,所以这是一个在状态空间中的搜索。因此选择概率匹配为MCTS的搜索策略。3

不同于复杂的AlphaGo的决策树,寻路问题的搜索树并没有复杂的“交换棋手”的需求,因此没有在决策之间切换的必要,可以暂时不实现反向传播的过程。寻路问题必定会搜索到一个解(把“无解”也当成一个解),因此不需要限定搜索时间。

在当前某一状态下选择展开的新节点时,最常采用的是上限置信度区间算法(Upper Confidence Bound, UCB)。4然而,朴素的UCB算法处理有状态转移(从地图上的一点移动到下一点)的问题时效率相当低下——每个节点初始的被选取概率都相等意味着新节点等概率地被试探,搜索将花费大量时间在“拓宽视野”而不是“向终点前进”上。称这种情况为“算法收敛较慢”。

因此,本实现通过MCTS寻路的遍历策略是:

  1. 优先试探更优的节点。在这个简单的模型中,优先试探离终点更近的节点。
    如果一个节点更优(离终点更近),那么这个节点将具有更高的权重,以在将来的试探中更有可能被选中,反之亦然。
    显然,如果将优秀节点的权重设为无限大,那么这就是一个朴素的贪心算法。在某些复杂的场景下,用来估计节点情况的估计函数可能会很复杂。
  2. 由1.,到终点距离相当的节点应当有相近的权值,到终点距离不同的节点权重应显著不同。
    这是显然的,由于贪心思想的要求,距离终点更近的节点若要被优先试探必须有更高的权值,而由于候选列表的长度随试探次数增加而变化,更优节点需要有显著高的权值。

需要重申的是上述两点并非通过MCTS解决寻路问题的一般要求,更非全部要求。随着地图具体结构和特点的不同可以采取不同的假设,对于本实现所解决的简单地图来说,采用贴近贪心算法的假设是容易收敛的。否则,如果去掉2.的“显著不同”条件,那么算法将更倾向于均等地在候选列表中寻找下一个试探节点——而不是向着“正确的方向”找下一个试探节点,在极端情况下则又变回了UCB。(节点的权值差距不能无限制扩大,见结果分析部分)

因此,搜索算法可按如下思路设计:

  1. 初始化试探节点表trails,标记起点,设置路线记录表path
  2. trails加权随机选取一个节点进行试探;
  3. 从该节点试探其周围节点,按上述两点计算其权重;
  4. 删除该节点(因为它已不能产生新路径)
  5. 重复1,直到试探到终点,或已无节点可以试探。

权值计算

作为蒙特卡洛算法,在搜索算法的具体实现中需要加权随机产生一个试探节点。为满足基本假设的2.条件,最简单直接的实现即是将子节点的权值设为父节点的一定倍数,以在不同代节点中积累出数量级差距,从而实现更优节点的“显著高的权值”。

def __Ins(self, x, y, fr: node = node()):
        """
        将新试探点插入trails。
        x(int),y(int):新试探点的坐标;
        fr(node):新试探点由哪个点发展而来(父节点)。
        新试探点的具体权重由旧点和它到终点的期望决定。
        """
        if (x, y) == self.t:
            return True
        sign = (abs(self.t[0] - x) + abs(self.t[1] - y)) - \
            (abs(self.t[0] - fr.x) + abs(self.t[1] - fr.y))
        weight = fr.weight * self.factor ** sign
        self.trails.append(node(x, y, weight))

然而,实际使用中该算法存在权值爆炸的问题。


为使得不同代间产生显著差异,子节点的权重是由父节点简单乘除一个因子factor产生的。在路径较长时,靠近终点的权值将比起点附近权值高数十个数量级(取决于具体的factor选取)。

为解决这一问题,采用类似于科学计数法的指数形式存储具体权值:
W = p × b l (1) W=p\times b^{l}\tag{1} W=p×bl(1)
其中 W W W为上文中的原始权重weight p p p为因数; b b b为底数base l l l为指数level

这种存储方法不仅可以解决权重爆炸的问题,还可以利用多引入的参数实现阶段性搜索(见下文)。

改进后的权值计算算法实现:

def __Ins(self, x, y, fr: node = node()):
        """
        将新试探点插入trails。
        x(int),y(int):新试探点的坐标;
        fr(node):新试探点由哪个点发展而来。
        新试探点的具体权重由旧点和它到终点的期望决定。
        """
        if (x, y) == self.t:
            return True
        sign = (abs(self.t[0] - x) + abs(self.t[1] - y)) - \
            (abs(self.t[0] - fr.x) + abs(self.t[1] - fr.y))

        # 权重函数
        # 1. 离终点越近的节点权重越高,离起点越近的函数权值越低
        # 2. 距终点距离相当的节点权重相当,相邻节点需要有明显差距
        # 3. 为防止权重爆炸,使用 p=weight*base^level 的科学计数法模式记录权值,显然weight<base
        # 4. 利用3.,每次只将level最大的那些节点的weight加入权重候选,除非节点数少于threshold个/level==0
        # 5. log(base,p)=ln p/ln base

        ln_weight = np.log(fr.weight)

        level = fr.level
        weight = fr.weight * self.factor ** sign
        if weight>self.base:
            level += 1
            weight /= self.base
        if weight < 1:
            level -= 1
            weight *= self.base

        self.trails.append(node(x, y, weight, level))
        self.visited[x][y] = True
        return False

当然,其实也可以使用简单的双精度实现,而且还可以获得更高的performance;但是哪怕是double ± 10 E 308 \pm10E308 ±10E308数据范围也只能满足大约 25 × 25 25\times25 25×25的地图规模,对于诸如 100 × 100 100\times100 100×100以上的地图采取上述科学计数法更加通用。5

改进后的权值存储和加权随机策略

trails中存储的每个节点如下定义:

class node:
    def __init__(self,
        x: int = 0,
        y: int = 0,
        weight: float = 1,
        level: int = 0
    ):
        """
        trails中的节点的数据结构。
        """
        self.x, self.y, self.weight, self.level = x, y, weight, level

此处以及下文的weight均为原weight的底数部分(即式 ( 1 ) (1) (1)中的 p p p)。
由于指数level(也就是定义式 ( 1 ) (1) (1)中的 l l l)的引入,搜索算法的2.步骤可以不在整个trails中随机选取,这是因为:到终点不同距离的节点,其权值有显著差异,则部分权值较低的节点可以忽略。因为它们实际被取到的概率极低;且若要将它们加入随机选取,则在计算时高权重节点的权值又将变得极大(权值爆炸)。

在最终实现中,每次试探前先构建随机选取表choices,由完整的trailslevel最大的节点组成。对于被选入choices中的节点,每次加权随机选取一个试探,权值为各节点的weight

为防止choices中的元素过少,设置thresholdchoices中节点个数的下限;若choices中的节点数不足threshold个,则扩大选取范围,将trailslevel次大的节点也加入choices,依此类推直到choices中的节点个数超过threshold个,或所有节点均加入choices(此时choices实质上即等于trails)。

MCTS搜索部分实现如下:

def Search(self,
           threshold: float = -float("inf"),
           factor: 
  • 14
    点赞
  • 76
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
好的,以下是一个基于Python实现的基于MCTS和UCT的五子棋游戏AI的代码示例: ``` python import random import math class TreeNode: def __init__(self, state, parent=None): self.state = state self.parent = parent self.children = [] self.visits = 0 self.score = 0 def UCT(node): C = 1.4 if node.visits == 0: return float('inf') return (node.score / node.visits) + C * math.sqrt(math.log(node.parent.visits) / node.visits) def MCTS(state, iterations): root = TreeNode(state) for i in range(iterations): node = root # selection while node.children: node = max(node.children, key=UCT) # expansion if node.visits > 0: moves = node.state.get_moves() for move in moves: if move not in [c.state.last_move for c in node.children]: child_state = node.state.apply_move(move) child_node = TreeNode(child_state, node) node.children.append(child_node) # simulation sim_node = node while sim_node.children: sim_node = random.choice(sim_node.children) score = simulate(sim_node.state) # backpropagation while node: node.visits += 1 node.score += score node = node.parent return max(root.children, key=lambda c: c.visits).state.last_move def simulate(state): player = state.get_current_player() while not state.is_terminal(): move = random.choice(state.get_moves()) state = state.apply_move(move) player = state.get_current_player() if state.get_winner() == player: return 1 elif state.get_winner() == None: return 0.5 else: return 0 class Board: def __init__(self, width=15, height=15, win_length=5): self.width = width self.height = height self.win_length = win_length self.board = [[None for y in range(height)] for x in range(width)] self.last_move = None def get_moves(self): moves = [] for x in range(self.width): for y in range(self.height): if self.board[x][y] == None: moves.append((x, y)) return moves def apply_move(self, move): x, y = move player = self.get_current_player() new_board = Board(self.width, self.height, self.win_length) new_board.board = [row[:] for row in self.board] new_board.board[x][y] = player new_board.last_move = move return new_board def get_current_player(self): if sum(row.count(None) for row in self.board) % 2 == 0: return "X" else: return "O" def is_terminal(self): if self.get_winner() != None: return True for x in range(self.width): for y in range(self.height): if self.board[x][y] == None: return False return True def get_winner(self): for x in range(self.width): for y in range(self.height): if self.board[x][y] == None: continue if x + self.win_length <= self.width: if all(self.board[x+i][y] == self.board[x][y] for i in range(self.win_length)): return self.board[x][y] if y + self.win_length <= self.height: if all(self.board[x][y+i] == self.board[x][y] for i in range(self.win_length)): return self.board[x][y] if x + self.win_length <= self.width and y + self.win_length <= self.height: if all(self.board[x+i][y+i] == self.board[x][y] for i in range(self.win_length)): return self.board[x][y] if x + self.win_length <= self.width and y - self.win_length >= -1: if all(self.board[x+i][y-i] == self.board[x][y] for i in range(self.win_length)): return self.board[x][y] return None def __str__(self): return "\n".join(" ".join(self.board[x][y] or "-" for x in range(self.width)) for y in range(self.height)) if __name__ == "__main__": board = Board() while not board.is_terminal(): if board.get_current_player() == "X": x, y = map(int, input("Enter move (x y): ").split()) board = board.apply_move((x, y)) else: move = MCTS(board, 1000) print("AI move:", move) board = board.apply_move(move) print(board) print("Winner:", board.get_winner()) ``` 该代码定义了一个 `TreeNode` 类来保存节点的状态和统计信息,实现了基于UCB公式的UCT算法和基于MCTS和UCT的五子棋AI。同时,代码还定义了一个 `Board` 类来表示五子棋游戏的状态和规则,并实现了判断胜负、获取可行落子位置等方法。在 `__main__` 函数中,代码通过交替输入玩家落子位置和调用AI选择落子位置的方式,实现了人机对战的功能。 希望这个代码对你有所帮助!

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值