为白痴解释极小化算法

16

我浪费了一整天的时间尝试使用极小化极大算法来制作一个无敌的井字游戏人工智能。在过程中我错过了什么(脑子已经炸了)。

我不是在这里寻找代码,只是想更好地解释我在哪里出错了。

这是我的当前代码(极小化极大方法总是返回0,原因不明):

from copy import deepcopy


class Square(object):
    def __init__(self, player=None):
        self.player = player
    
    @property
    def empty(self):
        return self.player is None


class Board(object):
    winning_combos = (
        [0, 1, 2], [3, 4, 5], [6, 7, 8], [0, 3, 6], [1, 4, 7], [2, 5, 8],
        [0, 4, 8], [2, 4, 6],
    )
    
    def __init__(self, squares={}):
        self.squares = squares
        for i in range(9):
            if self.squares.get(i) is None:
                self.squares[i] = Square()
    
    @property
    def available_moves(self):
        return [k for k, v in self.squares.iteritems() if v.empty]
    
    @property
    def complete(self):
        for combo in self.winning_combos:
            combo_available = True
            for pos in combo:
                if not pos in self.available_moves:
                    combo_available = False
            if combo_available:
                return self.winner is not None
        return True
    
    @property
    def player_won(self):
        return self.winner == 'X'
    
    @property
    def computer_won(self):
        return self.winner == 'O'
    
    @property
    def tied(self):
        return self.complete == True and self.winner is None
    
    @property
    def winner(self):
        for player in ('X', 'O'):
            positions = self.get_squares(player)
            for combo in self.winning_combos:
                win = True
                for pos in combo:
                    if pos not in positions:
                        win = False
                if win:
                    return player
        return None
    
    @property
    def heuristic(self):
        if self.player_won:
            return -1
        elif self.tied:
            return 0
        elif self.computer_won:
            return 1
    
    def get_squares(self, player):
        return [k for k,v in self.squares.iteritems() if v.player == player]
    
    def make_move(self, position, player):
        self.squares[position] = Square(player)
    
    def minimax(self, node, player):
        if node.complete:
            return node.heuristic
        a = -1e10000
        for move in node.available_moves:
            child = deepcopy(node)
            child.make_move(move, player)
            a = max([a, -self.minimax(child, get_enemy(player))])
        return a


def get_enemy(player):
    if player == 'X':
        return 'O'
    return 'X'

值得一提的是,查看您的代码以及您出现错误的地方可能是有益的。 - Anthony Forloney
@Anthony Forloney - 请看我的编辑。 - orokusaki
@引人注目的编译器 - 你能把那些放到答案里并详细解释一下吗?那正是我所寻找的。 - orokusaki
其中,+1 表示对手获胜,-1 表示你获胜,0 表示平局。然后从每个叶子节点开始向上走,每个节点的值是所有子树中的最大值。一直进行到根节点,然后选择一个具有最低值的子树。(很多时候可能会出现平局。) - Conspicuous Compiler
@orokusaki:在你最初的问题中,我认为你想要解释极小化极大算法 - 而不是调试你的代码。回答一个不断变化的问题有点困难。另外,这是作业吗? - Guy Sirton
显示剩余4条评论
3个回答

20

步骤1:构建游戏树

从当前的局面开始,生成对手可以进行的所有可能走法。然后对于每一个走法,再生成所有你可以做出的可能走法。对于井字棋,一直继续到没有人可以下为止。对于其他游戏,通常在给定的时间或深度之后停止。

这看起来像一棵树,在一张纸上画出来,当前局面位于顶部,所有对手的走法在下一层,所有你对其进行回应的可能走法在下一层等等。

步骤2:计算树底部所有局面分数

对于象棋、五子棋之类的简单游戏,如果你输了,则分数为0,平局则为50,胜利则为100。

步骤3:向上传递分数

这就是极小化极大算法的运作方式。以前未计算分数的局面的分数取决于它的子节点和轮到谁下棋。假设你和对手都会选择给定状态下最好的走法。对手的最佳走法是那个能让得到最低分数的走法。同样地,你的最佳走法是能给你最高分数的走法。如果轮到对手下棋,你选择得分最小的子节点(这样他能够获益最大)。如果轮到你下棋,你假定会做出最好的走法,因此选择最大值。

步骤4:选择最佳走法

现在选择从当前局面开始所有可能走法中导致传播分数最高的那个走法。

在一张纸上尝试一下,如果从空棋盘开始太困难了,可以从某个进阶的井字棋局面开始。

使用递归: 很多时候,可以通过使用递归来简化这个过程。在每个层次上,“得分”函数都会被递归地调用,并根据深度是奇数还是偶数选择最大值或最小值,以获取所有可能的走法。当没有可行的走法时,该函数会评估棋盘的静态得分。递归解决方案(例如示例代码)可能比较难以理解。


看一下我的代码更新——在我的极小化最大化方法中,有没有什么明显的愚蠢错误(它现在已经崩溃了,只返回0)? - orokusaki
我个人不会在极小化算法中使用否定技巧。你确定你正在正确的层级上应用min和max吗?你调用了哪个函数?我认为我们缺少实际的游戏代码...为了调试这个问题,我建议从一个非空且相对深入的位置开始,并在执行过程中与纸质版本进行匹配。 - Guy Sirton

7
正如你已经知道的那样,Minimax的思想是深度搜索最佳值,假设对手始终会选择价值最低的移动(对我们来说最差,他们最好)。
这个想法是,你会尝试为每个位置给出一个值。你失败的位置是负面的(我们不希望这样),而你胜利的位置是正面的。你假设你总是尝试获得最高价值的位置,但你也假设对手总是瞄准最低价值的位置,这对我们来说是最坏的结果,对他们来说却是最好的(他们赢了,我们输了)。所以,你会把自己放在他们的鞋子里,尽可能地像他们一样打得好,并假设他们会这样做。
因此,如果你发现你有可能有两个移动,一个让他们选择赢或输,另一个无论如何都会导致平局,那么你假设他们会选择让他们赢的一步。所以最好还是选择平局。
现在来看一个更“算法化”的视角。
假设你的网格几乎满了,只剩下两个可能的位置。考虑当你玩第一个位置时会发生什么:
对手将玩另一个。这是他们唯一的可能步骤,所以我们不必考虑他们的其他步骤。看看结果,关联一个结果值(+∞如果赢了,0如果平局,-∞如果输了:对于井字游戏,你可以将它们表示为+1 0和-1)
现在考虑当你玩第二个位置时会发生什么:
(同样,对手只有一步可走,看看结果,估价)。
你需要在这两个移动之间做出选择。轮到我们走了,所以我们想要最好的结果(这是Minimax中的“max”)。选择具有更高结果的移动作为我们的“最佳”移动。对于“距离结束还有2步”的示例,就是这样了。
现在想象你不是剩下2个,而是3个移动。
原则是相同的,你想给你的3个可能的移动中的每一个分配一个值,以便你可以选择最好的一个。
所以你开始考虑其中一个移动。
现在,你处于上述情况下,只有2个可能的移动,但现在是对手的回合。然后你开始考虑对手可能的移动之一,就像我们上面做的那样。同样,你查看每个可能的移动,然后为它们找到一个结果值。现在该轮到对手走了,所以我们假设他们会为他们最好的一步,即对我们来说最糟糕的一步,选择“最佳”移动(这是Minimax中的“min”)。忽略另一个;假设他们无论如何都会按照你找到的最佳步骤去走。这是你的移动将产生的价值,因此是你分配给你三个移动中的第一个的价值。
现在你考虑其他两个可能的移动。用同样的方式给它们一个值。从你的三个移动中选择具有最大价值的移动。
现在考虑4步棋的情况。对于你的每一步,你都会看你的对手3步棋的结果,并且对于每一步,你都假设他们会选择让你处于最糟糕的情况或者是对你来说剩下的两个行动中更好的一个。
你能看出这将会发生什么。为了评估离结束还有n步的行动,你需要考虑到每一个可能的n步行动,尝试给它们赋值以便你能够选择最好的。在这个过程中,你需要尝试找到玩家在n-1时的最佳行动:对手,并且选择价值较小的步骤。在评估n-1步骤的过程中,你需要在可能的n-2步骤之间进行选择,这些步骤将属于我们,并且假设我们在这一步能够尽可能地发挥得好。等等。
这就是为什么这个算法本质上是递归的。无论n为何值,在第n步,你都要评估在n-1步所有可能的步骤。反复操作。
对于井字游戏,现在的机器已经足够强大,可以从游戏开始时计算所有可能的结果,因为只有几百种情况。当你想要为一个更复杂的游戏实现它时,你将不得不在某个点停止计算,因为这将需要太长时间。所以对于一个复杂的游戏,你还需要编写代码来决定是否继续寻找所有可能的下一步,或者尝试给当前位置赋值并提前返回。这意味着你还需要计算一个不是最终位置的价值——例如,在国际象棋中,你会考虑每个对手在棋盘上有多少材料,没有将死的情况下的直接可能性,你控制了多少个格子等等,这使得这个过程并不简单。

看看我的最新代码更新(它仍然有问题)。你能从中获取任何有用的信息吗? - orokusaki

3

你的完整函数没有按预期工作,导致比赛在任何事情发生之前被宣布为平局。例如,考虑以下设置:

>> oWinning = {
 1: Square('X'),
 3: Square('O'), 4: Square('X'),
 6: Square('O'), 8: Square('X'),
}
>> nb = Board(oWinning)
>> nb.complete
True
>> nb.tied
True

这应该是计算机下一步获胜的,但是它说游戏打平了。

问题在于你现在的逻辑只检查一个组合中的所有方格是否全部空闲。如果有任何一个方格不是空闲的,就假定这个组合赢不了。实际上,需要检查该组合中的任何位置是否被占用,只要所有这些组合都是“无”或相同玩家,那么该组合仍然应该被视为可用。

例如

def available_combos(self, player):
    return self.available_moves + self.get_squares(player)

@property
def complete(self):
    for player in ('X', 'O'):
        for combo in self.winning_combos:
            combo_available = True
            for pos in combo:
                if not pos in self.available_combos(player):
                   combo_available = False
            if combo_available:
                return self.winner is not None
    return True

现在我使用更新的代码进行了适当的测试,在这个测试用例中我得到了预期的结果:

>>> nb.minimax(nb, 'O')
-1
>>> nb.minimax(nb, 'X')
1

我真是个白痴,犯了这样的错误。非常感谢。顺便问一下,极小化极大算法如何知道在只使用3个值且它们在向上的过程中不是可加的情况下,向下遍历树时最佳节点是什么(例如,在叶子级别上+1赢了,但在从叶子级别上面的下一个节点上,它将与潜在的其他+1进行比较,对吧?) - orokusaki
这个东西现在需要大约2分钟才能完成。你会实现Alpha-Beta剪枝吗? - orokusaki
@orokusaki:坦白地说,我会彻底删除Square()对象,将Board更改为存储一个9个字符长的字符串,其中空格表示未占用的移动,记忆棋盘位置的启发式,并在递归之前检查是否有棋盘的记忆启发式,以及旋转90度、180度和270度的棋盘。这会使代码变得相当复杂,但也会提高效率。 - Conspicuous Compiler
@orokusaki:(哦,澄清一下,任何旋转90度倍数的井字游戏在得分方面基本上是相同的。) - Conspicuous Compiler
哦,抱歉,忽略了第一个问题。一个给定节点呈现多少种可能的获胜路径并不重要。这是因为我们假设两个玩家都使用极小值极大算法,所以如果一棵树代表了更理想的路线,我们的对手无论如何都会避开它。在这样一个零和游戏中,可以证明如果我们使用极小值极大算法,那么不使用该算法的对手最终结果将比使用该算法时更糟糕,使其成为最优策略。 - Conspicuous Compiler

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接