如何使用Python编写树形搜索代码

208 阅读6分钟

在这部分中,我将分享如何编写蒙特卡洛树搜索的代码,以便在下面玩四国联军。

首先,我们将导入我们的库,定义我们的类对象和它的变量。

其次,我们将编写一个循环,第一步是探索树,通过模拟玩游戏来学习最好的招数,下一步是实际走棋。当再次轮到我们时,我们将再次探索树,并做出另一步行动。

这个循环将继续下去,直到决定 "四通八达 "的赢家。

我将在以后写一篇最后的博文来解释代码的深度学习部分--我们可以利用从以前的游戏中学习到的知识,在未来的游戏中做出更好的决定。

蒙特卡洛树搜索

让我们对树状图做一个简单的回顾。

🌲 定义:树是一种数据结构,通过父子关系连接多个节点,每个节点代表棋盘的不同状态。一个节点将有一个父节点和N个子节点,其中N是可用的合法棋步的数量。

树状搜索帮助人工智能决定在给定状态下可用棋步的效用。然后,人工智能会选择最优化的棋步来帮助它获胜。

第一步:导入库

首先,我们导入以下库。TensorflowNumPy[math](https://blog.finxter.com/python-math-module-5-combinatorial-functions-ever-coder-ought-to-know/),以及 [random](https://blog.finxter.com/python-random-module/).

Tensorflow和NumPy将被用来创建变量,这些变量以后将被传递给深度神经网络mathrandom 模块用于一些数值运算。C 是一个全局变量,我们将在后面进行讨论。

import tensorflow as tf
import numpy as np
import math
import random

C = 1.0

第二步:定义节点对象

接下来,我们定义我们的Node 类。这些是我们树形图上的节点,通过父子关系连接起来。

我们设置一个game 对象(连接四)作为参数,我们可以把另一个Node 对象作为母亲。

我们将设置以下属性。

  • self.game- 这持有代表当前游戏状态的游戏对象,该节点应该代表的是当前的游戏状态
  • self.mother - 这指向当前节点的母亲,如果存在的话
  • self.children- 我们将在这个字典中跟踪所有的子节点
  • self.U- 状态的效用。这是由树状搜索计算出来的
  • self.prob- 当前状态的所有可用行动中的概率分布,用于决定下一个行动
  • self.V- 状态的价值(你获胜的可能性有多大)。这是由神经网络模型计算的
  • self.N - 访问次数。它记录了我们在同一状态下的次数。
  • self.outcome- 这记录了游戏是否结束的情况

在你阅读代码时参考上面的定义,以记住这些属性的作用。

class Node:
    def __init__(self, game, mother=None):
        super(Node, self).__init__()
        self.game = game
        self.mother = mother
        self.children = {}
        self.U = 0
        self.prob = None
        self.V = 0
        self.N = 0
        self.outcome = self.game.score

        if self.game.score is not None:
            self.V = self.game.score * self.game.player
            self.U = 0 if self.game.score is 0 else self.V * float('inf')

在创建每个Node 类时,我们也要检查游戏是否已经结束。如果已经结束,我们将V 更新为1或-1,U 更新为无穷大或负无穷大。

Node 类包含两个主要函数。第一个函数是 "explore" ,另一个是 "next" 。

在 "explore" 中,我们将导航我们的树,以学习最佳的移动,在 "next" 中,我们实际进行移动。

第三步:探索树

现在让我们编写代码,开始我们的树搜索。

探索功能

"explore" 负责导航树,学习如何下给定的棋。在explore 中,我们从来没有实际走过一步棋--我们只是在模拟下棋,这样我们就可以确定要下的最佳棋步。

在调用 "explore"时,我们需要传递一个模型,该树将用它来评估不同的游戏状态。

为了启动 "explore" ,我们首先检查以确保游戏有一个有效的分数(游戏还没有结束)。

接下来,我们将 "node" 设置为当前节点并开始向下遍历树。我们开始遍历,首先检查所有子节点的U 值(效用)。然后我们采取与具有最高U 值的节点相对应的行动。如果最高的U 值存在并列,我们从并列的行动中随机选择一个行动来打破它。

    def explore(self, policy):
        if self.game.score is not None:
            raise ValueError('Game has ended with score {o:d}'.format(self.game.score))

        node = self

        while len(node.children) > 0 and node.outcome is None:
            max_U = max(c.U for c in node.children.values())
            actions = [a for a, c in node.children.items() if c.U==max_U]

            if len(actions) == 0:
                raise ValueError("error zero length ", max_U)

            action = random.choice(actions)

检查游戏是否结束

在我们移动到下一个节点之前,我们检查max_U (在上面的代码中定义)是否为无穷大或-无穷大。

  • 赢得游戏的U 值为无穷大,因为不可能有比赢得游戏更好的行动。
  • 同样地,输掉一盘棋的U值为-无穷大,因为不可能有比输掉更糟糕的棋。

如果max_U 是-无穷大,node.U 就会被设为无穷大,node.V 则为1。另一方面,如果max_U 是无穷大,node.U 被设置为-无穷大,node.V 为-1。

max_U 的值是由节点的子节点计算出来的,当我们从父节点到子节点时,会有一个转折。

            if max_U == -float('inf'):
                node.U = float('inf')
                node.V = 1.
                break

            elif max_U == float('inf'):
                node.U = -float('inf')
                node.V = -1.
                break

在检查了max_U 的值之后,我们将"node"设置为与具有最高U 值的行动相对应的子节点。然后,我们重复这个循环,直到我们到达一个没有孩子的节点或一个游戏结束的节点。

            node = node.children[action]

扩展树

一旦我们到达一个不拥有任何子节点的节点,就是扩展树的时候了。我们通过创建更多的子节点来做到这一点。

这要从调用process_policy ,并向该函数传递以下三个参数开始。

  • policy- 用来评估游戏状态的模型
  • node.game- 当前节点的游戏对象(Connect Four)。
  • node.game.player- 当前节点的活动玩家

process_policy 函数返回子节点的概率分布,这有助于我们选择下一步行动。它还评估了当前游戏状态的值(主动玩家获胜的可能性有多大)。

我们将在后面详细介绍process_policy 函数的工作原理。

在将这两个值分别存储在node.probnode.V 中后,下一步是为当前节点创建子节点。

        if len(node.children < 1):
            probs, v = process_policy(policy, node.game, node.game.player)
            node.prob = probs
            node.V = float(v)

为了创建子节点,我们通过查询node.game.available_moves ,得到可用的移动列表。

这给了我们一个整数的列表,对应于仍有空间容纳 "四通八达 "棋子的那几列。

例如,如果所有的列都有空间,那么将返回以下列表:[0,1,2,3,4,5,6] 。然而,如果左起第二列已满,那么将返回以下列表[0,2,3,...,6]

然后我们列举我们从查询中得到的列表。对于每个迭代,我们调用mk_child ,它返回一个node 对象。通过新创建的节点,我们将其添加到当前节点的children 字典中。我们使用对应于行动的整数作为子节点的键。

            for i in node.game.available_moves:
                ch = node.mk_child(i)
                node.children[i] = ch

        node.N += 1

在增加当前节点的访问次数 (node.N)后,我们准备通过反向传播来更新树。

通过反向传播为以前的动作赋值

在本文中,反向传播指的是调整节点值的过程,以提高树搜索的预测精度。我们将调整UV 的值。

因为我们探索了树,我们有更多的信息。我们知道我们所走的棋的结果,以及某一步棋是让我们赢还是输。因此,我们可以根据我们的预测,更新之前每个节点的V 值,它有多好。

我们通过使用一个while 循环来进行反向传播。首先,我们更新所有的值。接下来,我们检查当前节点是否有一个母节点。如果有,我们就把当前节点改为其母节点。

我们一直这样做,直到我们到达一个没有母节点的节点。None 一旦我们到达一个没有母节点的节点,我们将node ,然后退出循环。

        while node.mother:

我们在循环中更新的第一个值是访问次数 (N)。

我们将母节点的访问次数增加1,因为到目前为止,我们唯一更新的节点的访问次数是我们访问的最后一个节点。之后,我们更新母节点的V 值。

重要的是要记住,每当我们在搜索树上或下移动时,一个回合就过去了,这意味着活跃的玩家会改变。因此,我们需要取node.V 的负值,以便使两个值都是从同一个玩家的角度出发。

为了进行更新,我们把node.V 的负值和母亲的V 的值除以母亲的访问次数之间的差。这使得我们随着时间的推移进行较小的更新。

            node.mother.N += 1
            node.mother.V += (-node.V - node.mother.V) / node.mother.N

在更新母亲的V 值之后,我们需要更新当前节点及其所有兄弟姐妹的U 值。

请记住,U 值是在探索阶段用来决定采取哪种行动的。为了得到一个既包括当前节点又包括其所有兄弟姐妹的列表,我们遍历当前节点的母亲的子女的字典,并得到同时给我们提供行动和对应节点的项目列表。

接下来,对于每个节点,我们更新U 值,方法是取其V 值,并将其与到达所述节点的概率相结合,该概率由访问母节点的次数的平方根除以1再加上访问所述节点的次数。

这使得算法更有可能选择神经网络认为好的节点和探索时很少被访问的节点。

            for i, n in node.mother.children.items():
                if n.U != float('inf') and n.U != -float('inf'):
                    n.U = n.V + C * n.mother.prob[i] * math.sqrt(n.mother.N) / (1+n.N)

最后,我们用当前节点的母体更新当前节点,并再次进行循环,直到节点被设置为无,这发生在我们到达树顶的时候。

            node = node.mother

这就涵盖了探索函数的工作方式。

探索的辅助函数

现在让我们来看看由explore()mk_child()process_policy() 所调用的两个函数。

首先,mk_child() 的工作方式是,从game 对象的新副本中创建一个新节点,并应用选定的移动。然后我们返回新创建的节点,该节点将被添加到children 的字典中。

    def mk_child(self, i):
        child = Node(self.game.sim_move(i), self)

        return child

第二,process_policy()explore() 用于树状搜索的函数,能够使用神经网络来评估不同的游戏状态。

它需要三个参数。

  1. policy which是要使用的模型。
  2. game 是当前游戏状态的游戏对象,以及
  3. 活跃的棋手。
def process_policy(policy, game, player):

该函数首先将当前棋盘状态乘以活动棋手。这使得模型总是从同一个棋手的角度来分析棋局,因为棋手要么是1要么是-1。

接下来,我们将维度扩展两次,以便将它们输入到网络中。

    state = game.state * player
    state = np.expand_dims(state, 0)
    state = np.expand_dims(state, -1)

我们还从游戏对象中得到一个掩码,它将被用来把任何不可用的棋步的概率设置为0。

    mask = game.get_mask()

最后,我们返回由模型返回的probsval 变量。

    probs, val = policy(state, mask)
    probs = tf.squeeze(probs)
    val = tf.squeeze(val)

    return probs, val

第四步:选择下一步棋

现在我们已经使用explore 函数来学习要下的最佳棋步,现在是我们下棋的时候了。

下一步函数

Node 类中的第二个主要函数是next() 。这个函数负责选择将被下的实际棋步。它有一个可选的参数,控制树形搜索的探索与开发动态。我们将在文章后面更多地讨论这个问题。

    def next(self, tau=1.):

该函数首先检查我们是否处于有效的游戏状态,确保游戏还没有结束,并且当前节点有子节点。

        if self.game.score is not None:
            raise ValueError('game has ended with score {0:d}'.format(self.game.score))

        if not self.children:
            print(self.game.state)
            raise ValueError('no children found and game hasn\'t ended')

计算概率分布

在确保我们有一个有效的游戏状态后,prob 列表被创建prob 列表将保存概率分布,以后将用于选择给我们带来最高获胜概率的棋步。

为了确保prob 的尺寸正确,我们使用了列数,并将所有的值设置为0。

        prob = [0. for _ in range(self.game.w)]

接下来,我们检查是否有可以下出的赢棋。为了做到这一点,我们从所有的孩子中取出U 的最大值。

如果返回的值是float(‘inf’) ,那么我们就知道有一个获胜的棋步,我们将prob 的相应索引设置为1。

        max_U = max(c.U for c in self.children.values())

        if max_U == float('inf'):
            for k in self.children.keys():
                if self.children[k].U == float('inf'):
                    prob[k] = 1.

如果我们没有找到获胜的棋步,我们就必须使用每个孩子的访问次数(N )来计算prob

之所以使用N ,而不是U ,是因为U 的计算方式既有利于好棋,也有利于最不常用的棋。N 最高的棋步只能是最佳棋步。

这是因为,要使一个棋子拥有最高的访问次数,其数值要大到足以补偿在探索过程中拥有高访问次数所带来的惩罚。

为了计算prob ,第一步是找到子代中N 的最大值,并在其上加1。加1的原因是,我们将用每个孩子的N 值除以找到的N 的最大值,我们不希望这个值等于1。

        else:
            maxN = max(node.N for node in self.children.values()) + 1

探索VS开发的动态

接下来,对于每个孩子,我们将用他们的N 值除以孩子中的最大N 值,并将结果提高到1的幂,除以tau

💡 Tau是用于控制树搜索的探索与开发动态的变量。探索树是指我们搜索可能的动作;利用是指我们利用我们的知识来做出好的动作。

它的作用是,随着tau的增加,它使指数变小。另一方面,随着tau的减少,它使指数变大。

然后指数被用来收缩或扩大为每个孩子计算的概率值之间的距离。这一点的原因很快就会清楚。

最后,我们将计算出的值分配给prob 列表中的相应索引。

            for k in self.children.keys():
                p = (self.children[k].N / maxN) ** (1 / tau)
                prob[k] = p

接下来,我们用 [np.sum()](https://blog.finxter.com/how-to-sum-list-of-lists-in-python-rows/)在prob上找到prob 数组中的值的总数。

        tot = np.sum(prob)

最后,为了得到一个适当的概率分布,所有的值加起来都是1,我们用之前计算的tot ,除以prob 的所有值。正是由于这一步,tau ,以平衡树形搜索的探索与开发动态。

Tau的影响

随着tau的增加,概率值之间的距离变小。

例如,1%和1.1%的值很接近。这使得我们的模型更难选择最常用的行动(N)。当tau接近无穷大时,它使我们选择一个随机行动,因为所有的概率都是一样的。

随着tau的减少,概率值之间的距离也更大。

例如,1%和10%在数值上相差甚远。这使得我们的模型更容易选择最常用的行动(N)。随着tau接近0,它使价值最高的行动更有可能被选中。

        if tot > 0:
            prob = prob / tot

        else:
            # if sum is 0 set all probs to the same value
            prob = np.full((self.game.w,), 1 / self.game.w)

为了最终选择要执行的行动,我们创建了一个新的列表,并将prob 中的值填入其中。然后,这个新的列表被用作权重,由 [random.choices()](https://blog.finxter.com/sample-a-random-number-from-a-probability-distribution-in-python/)来返回与所选动作相关的游戏对象。

        weights = [prob[k] for k in self.children.keys()]
        state = random.choices(list(self.children.values()), weights=weights)[0]

在下一个函数的最后部分,我们返回。

  • 保存所选动作的state 变量
  • 当前节点的V
  • 我们计算的prob ,作为TensorFlowfloat32 变量,将在以后用于训练神经网络
  • 当前的游戏对象。

对于V ,我们返回负值,因为V 最初是相对于前一个玩家计算的。大多数返回的值都是用于训练神经网络的,我们将在下一篇文章中进行介绍。

        return state, -self.V, tf.Variable(prob, dtype=tf.float32), self.game

结论性的思考

现在我们已经完成了蒙特卡洛树形搜索,我们的人工智能可以玩四点连线游戏了

代码的深度学习元素将允许我们的人工智能玩多个游戏,从这些游戏中学习,并将这些知识应用于未来的游戏。从本质上讲,我们将使我们的人工智能变得更加聪明。我们将对模型和模型的训练代码进行编码。