【指南】使用优化解决对抗问题

2025年02月11日 由 alex 发表 1614 0

在本文中,我将探讨一个更困难的问题,即为棋盘游戏中的两个对抗性玩家制定策略。


我具体研究的是猫捉老鼠游戏,其中有两个玩家,猫和老鼠,它们被放置在一个n x m的网格上,类似于国际象棋棋盘,但可以有任意数量的行和列。猫或老鼠可以先行一步,然后它们轮流行动,直到决出胜者。在每一步中,它们必须向左、右、上或下移动;它们不能停留在同一个单元格中,也不能沿对角线移动。因此,它们通常有四种可能的移动方式,但如果处于边缘,则只有三种,如果处于角落,则只有两种。


如果猫能够捕获老鼠,即移动到老鼠所在的单元格,则猫获胜。老鼠则通过足够长时间地躲避捕获来获胜。我们将探讨几种定义这一点的方式。


游戏开始时,猫和老鼠分别位于相对的角落,猫在左下角,老鼠在右上角。所以,开始时(如果我们有8行7列),棋盘看起来像这样:


5


训练猫和老鼠在这场游戏中玩得好的方法有多种。从高层次上看,我们可以将这些方法分为两大类:1)玩家在游戏中各自决定每一步的走法;2)玩家在游戏开始前各自确定一个完整的策略,即在不同情况下如何移动。


第一种方法在游戏领域更常用,且通常更具可扩展性和鲁棒性,至少在更复杂的游戏中是如此。然而,对于这个问题,由于其相对简单,我将探讨为每个玩家学习一个完整的、确定性的策略的方法,这样他们在游戏中只需执行这些策略即可。


在游戏中实时决定每一步的走法

虽然对于猫和老鼠游戏来说,提前确定一个完整的策略是可行的,但对于更复杂的游戏来说,情况并非如此。例如,一个训练来玩国际象棋的应用程序,不可能在游戏开始前就制定出一个策略,来指示如何处理游戏中可能遇到的所有情况;国际象棋太复杂了,无法做到这一点。


开发国际象棋等应用程序的方法有多种,但传统方法是构建所谓的博弈树。此外,强化学习也常用于开发游戏应用程序,并且可以将两者结合使用。


博弈树描述了每个玩家从当前棋盘开始可以进行的每一步可能的走法序列。例如,在国际象棋中,如果轮到黑方走,且黑方有8种合法走法,那么博弈树的根节点将表示当前棋盘。由黑方当前可能的走法产生的棋盘是树的下一层,所以树的第二层将有8个可能的棋盘。对于这些棋盘中的每一个,白方都有一组合法的走法。如果第二层中的8个棋盘每个都有,比如说,白方的10种合法应对,那么第三层就有80个棋盘。第四层则是基于第三层棋盘的所有可能走法产生的棋盘,以此类推。这样,树的每一层都比前一层大很多倍。


对于井字棋或连连看等简单游戏,可以创建完整的博弈树,并确定每一步的最佳走法。对于更复杂的游戏,如跳棋、国际象棋、围棋等,则无法做到这一点。不过,我们可以只构建有限步数的博弈树,并在每个叶节点估计当前玩家的棋盘质量。


因此,如果树扩展到,比如说,五层深,我们将在树的第五层有一组棋盘,但这些棋盘中很少有能直接决出胜负的。然而,我们必须评估棋盘对哪一方玩家更有利。对于跳棋或国际象棋等类似游戏,这可以通过简单地计算棋子数量来完成。一个更有效但更慢的方法是同时观察棋盘位置。例如,在国际象棋中,我们可以评估每个棋子的前进程度、暴露程度等。


还可以使用所谓的蒙特卡洛树搜索。在这里,通过完成一组随机游戏(双方玩家都完全随机走棋)来扩展树的叶子节点。这是一种评估每个棋盘的方法,但无需分析棋盘本身。所以,如果一棵树扩展到5层,那一层可能有1000个棋盘。为了评估这些棋盘,我们可以从每个棋盘开始执行一定数量的随机游戏,并统计每个玩家获胜的次数,从而得出对双方玩家来说棋盘强度的合理估计。


猫和老鼠游戏的基于策略的解决方案

猫和老鼠游戏相当简单,为猫制定一个单一、完全定义的策略,再为老鼠制定另一个策略(允许两者在游戏中以确定性的方式遵循这些策略)实际上是可行的。这在我们首先考虑的情况下尤其如此,即假设棋盘是已知且固定大小的,并且这个大小相对较小。我们稍后会探讨更困难的情况,即棋盘尺寸非常大的情况。


6


在这张图片中,有一个非常小的3x3棋盘。在这种情况下,很容易看出猫可以制定一个完全确定的策略,指明当猫处于9个方格中的任意一个,且老鼠也处于9个方格中的任意一个时,猫应该怎么做。考虑到猫和老鼠可能处于的所有位置组合,只有81种组合,因此可以训练一个策略,以便在这81种场景中的每一种都能最优地玩耍。也就是说,在猫的策略中,对于这81种情况中的每一种,我们都有一个方向(左、右、上或下),猫将朝那个方向移动,老鼠的策略也类似。


猫鼠游戏的最优玩法

根据棋盘的大小和形状,以及哪个玩家先走,在完美玩法下(即双方玩家都不犯错),猫鼠游戏实际上有一个已知的胜者。


为了想象这一点,可以考虑一个3x3棋盘的情况。就像井字棋一样,猫(以及老鼠)可以创建一个游戏树,涵盖它能做出的每一步移动、老鼠可能做出的每一步回应、猫的下一步移动,以及如此类推,直到游戏结束(要么猫捕捉到老鼠,要么老鼠在足够长的时间内躲避捕捉)。由于游戏具有有限且相对较小的长度,因此可以考虑每一个可能的移动序列,并确定每个可能场景中的理想移动。


然而,我们还希望支持更大的棋盘,例如8x8棋盘,在这种情况下,考虑每一个可能的移动序列可能是不可行的。在这里,游戏树可能会增长到巨大的规模。因此,如上所述,开发部分游戏树并评估叶节点中的棋盘是相当可能的。但是,开发完整的游戏树是不可行的。


尽管如此,在这些情况下,仍然可以使用爬山优化技术为双方玩家开发一个完整的策略。在下面的一个示例中,我们对一个8x7棋盘这样做了。在这里,棋盘上有56个方格,因此猫有56个可能的位置,老鼠也有56个可能的位置(实际上如果它们在同一个方格上,游戏就结束了,猫已经赢了,但我们将简化这一点,假设每个玩家都可以在任何方格上)。


那么就有3,136种可能的猫和老鼠位置组合,因此为每个玩家开发一个玩耍策略(至少在使用我将在这里描述的第一种也是最简单的方法时——在这种方法中,每个玩家对于每种猫和老鼠位置的组合都有一个定义的移动,即左、右、上或下)需要开发一个大小为3,136的策略。


这种方法不会扩展到更大的棋盘(稍后我们将介绍一种类似的方法,这种方法可以覆盖任意大小的棋盘),但它能很好地处理中等大小的棋盘,并且是一个很好的起点。


猫何时获胜?

在我们寻找这个问题的算法解决方案之前,猫鼠游戏本身是一个有趣的问题,可能值得在这里停下来思考一下:什么时候游戏对猫来说是可赢的,什么时候不是(即老鼠能够获胜)?


我来讲解一下至少一种看待这个问题的方式。就像许多类似的问题一样,这个问题可以从棋盘上的颜色角度来看。方格在黑色和白色之间交替。每一步,老鼠从一个颜色的方格移动到另一个颜色的方格,猫也是如此。


两个玩家都从某个颜色的方格开始。假设猫(在左下角)在黑色方格上。如果行数和列数都是偶数(如8x8的国际象棋棋盘),那么老鼠开始的位置(在右上角)也将是黑色。


如果猫先走,它从黑色方格移动到白色方格。老鼠也会如此。因此,每次猫和老鼠移动后,它们都会处于相同的颜色上(都在黑色上,或都在白色上)。这意味着,当猫移动时,它移动到与老鼠当前所在颜色相反的方格上。猫永远无法捕捉到老鼠。


在这种情况下,实际上老鼠并没有可赢的策略:它只需要随机移动,只要它不移动到猫的位置(本质上是一种自杀式的移动)。但是,猫确实需要一个好的策略(否则它无法在允许的移动次数内捕捉到老鼠),而随机移动不太可能获胜(尽管在足够小的棋盘上,随机移动常常能获胜,这很有趣)。


对于老鼠来说,虽然在这种情况下没有可赢的策略,但仍然有一种最优玩法的概念——即它至少能够躲避捕捉尽可能长的时间。


另一方面,如果行数或列数是奇数,或者老鼠先走,那么猫就可以捕捉到老鼠。要做到这一点,它只需要接近老鼠,使其与老鼠处于对角相邻的位置,这将迫使老鼠远离猫,根据它们所处的具体位置,有两个可能的方向。然后猫可以跟随老鼠,保持与老鼠对角相邻的位置,直到老鼠最终被迫进入角落并被捕捉。


在这种情况下,如果猫玩得最优,老鼠无法获胜,但如果猫玩得不是最优,老鼠仍然可以获胜,因为它只需要躲避捕捉一定的移动次数即可。


下一张图片展示了一个例子,其中猫已经对角移动到老鼠旁边,迫使老鼠朝其中一个角落(在这个例子中是右上角)移动。


7


一旦老鼠被逼到角落(如下图所示),如果猫位于老鼠的对角线相邻位置,那么老鼠在下一步就会输掉。老鼠将只剩下两个合法的移动位置,且这两个位置都紧邻猫,猫可以在其下一步移动到老鼠所在的位置。


8


训练两种独立的策略

那么,问题就在于,如何从一无所知开始训练两名玩家玩出完美游戏,即双方都采取最优策略,且都不犯错。


我们可以考虑两种情况:

  1. 猫能赢的游戏。在这种情况下,我们希望确保能够训练出一只猫,无论老鼠如何玩,它都能可靠地获胜。这意味着要学会靠近老鼠,并将其逼到角落。同时,我们希望确保老鼠能尽可能长时间地躲避捕捉。
  2. 猫无法赢的游戏。在这种情况下,我们希望确保我们已经训练出一只足够聪明的老鼠,能够躲避捕捉足够长的时间,从而被宣布为胜者。同时,我们希望确保猫也训练得足够好,如果老鼠犯错,它能够捕捉到老鼠。


显然,猫和老鼠的游戏远比象棋、跳棋、围棋或黑白棋等游戏简单,但它有一个难点,即游戏是不对称的,两名玩家必须各自开发不同的策略。


在只需要一种策略的游戏中,可以让两名玩家相互对抗,并随着时间的推移逐渐开发出更好的策略。这里我们也将采取这种方法,但类似于训练生成对抗网络时经常做的那样,代码实际上会在训练猫和训练老鼠之间交替进行。即,先训练猫直到它能赢,然后训练老鼠直到它能赢,再训练猫,如此反复。


确定最优策略的方法

由于目标是开发最优策略,因此使用优化技术是相当自然的,这也是我们在这里所做的。一些选择包括爬山算法、模拟退火、遗传算法和群体智能算法。


每种方法都有其优点,但在这篇文章中,就像在“博彩世界大赛”文章中一样,我们将研究为两名玩家开发策略的爬山方法。爬山算法可能是上述优化技术中最简单的一种,但足以处理这个问题。在未来的文章中,我将介绍更困难的问题以及解决这些问题的更复杂方案。


对于此类游戏中的任意一名玩家,爬山算法从某个策略(通常是随机创建的,或初始化为某种较为合理的策略)开始,然后逐渐改进。通过反复尝试小的变化,选择其中最好的,再对其尝试小的变化,如此反复,直到最终找到一个看似最优的解决方案。


使用固定的小棋盘尺寸

如前文所述,在我们研究的第一种方法中,我们以可能最简单的方式开发策略:猫的策略指定了在猫和老鼠位置的各种组合下应采取的具体移动。老鼠的策略也同样如此。


对于8x7的棋盘,这要求为每个玩家制定一个大小为3136的策略。最初,策略将设置得非常糟糕:在这个例子中,我们为两名玩家都指定了总是向上移动,除非已经在顶行,那么他们就向下移动。但是,随着时间的推移,爬山过程会为两名玩家逐渐开发出越来越强的策略。


第一个单元格包含一些选项,你可以调整这些选项,以查看不同值下的训练过程。


NUM_ROWS = 8
NUM_COLS = 7
FIRST_MOVE = "cat"           # Set to "cat" or "mouse"
INITIAL_CAT_POLICY = "WEAK"  # Set to "WEAK" or "STRONG"
ANIMATION_TIME_PER_MOVE = 0.5


为了简洁起见,我将不详细介绍此处的INITIAL_CAT_POLICY,并假设它被设置为“弱”,但如果设置为“强”,则猫将被初始化为总是朝向老鼠移动(如果设置为“弱”,则猫必须学习这一点)。


代码首先初始化棋盘,使两名玩家位于相对的角落。它还为两名玩家初始化了策略(如上文所述——因此两名玩家总是向上移动,除非已经在顶行,那么他们就向下移动)。


然后,它进行一场游戏。由于游戏是确定性的,因此只需一场游戏即可确定给定猫策略和给定老鼠策略下的胜者。这场游戏的结果是猫随后试图反复改进的基线,直到它能够打败老鼠。然后,我们反复改进老鼠的策略,直到它能够打败猫,如此反复。


这被设置为执行100,000次迭代,这在几分钟内即可完成,并且足以让两名玩家都建立起相当不错的玩法。


评估每场游戏

随着猫的学习,它在任何时间点都有一个当前策略:到目前为止发现的最优策略。然后,它在此基础上创建一些变体,每个变体都对当前最优策略进行少量修改(在策略的3,126个单元格中的少数几个中改变猫的移动方向)。然后,通过与当前老鼠的最优策略进行对战来评估这些变体。猫随后选择其中表现最好的新策略候选者(除非没有比当前猫的最优策略更好的策略,在这种情况下,它会继续生成随机变体,直到至少发现一个有所改进的策略)。


为了使爬山算法良好工作,它需要能够检测到从一个策略到下一个策略的即使是微小的改进。因此,如果玩家在每场游戏结束后只知道赢或输,那么将很难实现这一点。


相反,在每场游戏结束后,我们报告直到产生胜者所需的移动次数,以及游戏结束时两名玩家之间的距离。当猫获胜时,这个距离为零。然而,当老鼠获胜时,猫希望最小化这个距离:它希望至少结束在靠近老鼠的地方。而老鼠希望最大化这个距离:它希望结束在远离猫的地方。


一般来说,对于猫来说,如果之前的最优策略导致老鼠获胜,而新策略导致猫获胜,则找到了改进。但是,如果之前的最优策略中老鼠获胜,且新策略中老鼠仍然获胜,但猫结束时的位置更接近老鼠,这也算是改进。或者,如果之前的最优策略中猫获胜,且新策略中猫仍然获胜,但用的移动次数更少,这也算是改进。


有趣的是,在这里,至少在一定程度上奖励猫进行更长的游戏也是有用的。这鼓励猫更多地移动,而不是停留在同一区域。然而,我们必须小心,因为我们不希望鼓励猫在能够捕捉老鼠时比必要地移动得更慢。


对于老鼠来说,如果之前的最优策略导致猫获胜,而新策略导致老鼠获胜,则找到了改进。同样,如果之前的最优策略中猫获胜,且新策略中猫仍然获胜,但游戏时间更长,这也算是改进。如果之前的最优策略中老鼠获胜,且新策略中老鼠仍然获胜,但结束时离猫更远,这也算是改进。


版本1的代码

在每次迭代中,要么是猫在学习,要么是老鼠在学习,这里的学习意味着尝试新策略并选择其中最好的。


def init_board():
    # The cat starts in the bottom-left corner; the mouse in the upper-right.
    # The y values start with 0 at the bottom, with the top row being NUM_ROWS-1
    board = {'cat_x': 0, 
             'cat_y': 0, 
             'mouse_x': NUM_COLS-1, 
             'mouse_y': NUM_ROWS-1}
    return board
def draw_board(board, round_idx):
    clear_output(wait=True)
    s = sns.scatterplot(x=[], y=[])
    for i in range(NUM_ROWS):
        s.axhline(i, linewidth=0.5)
    for i in range(NUM_COLS):
        s.axvline(i, linewidth=0.5)    
    s.set_xlim(0, NUM_COLS)
    s.set_ylim(0, NUM_ROWS)
    offset = 0.1 
    size = 250 / max(NUM_ROWS, NUM_COLS)
    plt.text(board['cat_x'] + offset, board['cat_y'] + offset, '?', size=size, color='brown') 
    plt.text(board['mouse_x'] + offset, board['mouse_y'] + offset, '?', size=size, color='darkgray')
    s.set_xticks([])
    s.set_yticks([])
    plt.title(f"Round: {round_idx}")
    plt.show()
    time.sleep(ANIMATION_TIME_PER_MOVE)
    
def set_initial_cat_policy():
    # Initially, the cat is set to simply move towards the mouse
    policy = np.zeros([NUM_COLS, NUM_ROWS, NUM_COLS, NUM_ROWS]).tolist()
    for cat_x in range(NUM_COLS):
        for cat_y in range(NUM_ROWS):
            for mouse_x in range(NUM_COLS):
                for mouse_y in range(NUM_ROWS):
                    
                    if INITIAL_CAT_POLICY == 'WEAK':
                        if cat_y == NUM_ROWS-1:
                            policy[cat_x][cat_y][mouse_x][mouse_y] = 'D'
                        else:
                            policy[cat_x][cat_y][mouse_x][mouse_y] = 'U'                            
                    
                    else: # STRONG
                        dist_x = abs(cat_x - mouse_x)
                        dist_y = abs(cat_y - mouse_y)
                        if dist_x > dist_y:
                            if mouse_x > cat_x:
                                policy[cat_x][cat_y][mouse_x][mouse_y] = 'R'
                            else:
                                policy[cat_x][cat_y][mouse_x][mouse_y] = 'L'
                        else:
                            if mouse_y > cat_y:
                                policy[cat_x][cat_y][mouse_x][mouse_y] = 'U'
                            else:
                                policy[cat_x][cat_y][mouse_x][mouse_y] = 'D'                        
    return policy
                        
        
def set_initial_mouse_policy():  
    # Intially, the mouse is set to simply move up, unless it is in the top row,
    # in which case it moves down. This will initially cause it to oscillate between
    # the top-right corner and the cell immediately below this.
    policy = np.zeros([NUM_COLS, NUM_ROWS, NUM_COLS, NUM_ROWS]).tolist()
    for cat_x in range(NUM_COLS):
        for cat_y in range(NUM_ROWS):
            for mouse_x in range(NUM_COLS):
                for mouse_y in range(NUM_ROWS):
                    if mouse_y == NUM_ROWS-1:
                        policy[cat_x][cat_y][mouse_x][mouse_y] = 'D'
                    else:
                        policy[cat_x][cat_y][mouse_x][mouse_y] = 'U'
    return policy

def convert_board_to_tuple(board):
    """
    Used to create a dictionary key, which tracks which board positions have
    been seen before. 
    """
    return tuple((board['cat_x'], board['cat_y'], 
                  board['mouse_x'], board['mouse_y']))

def execute(cat_policy, mouse_policy, draw_execution=False, round_idx=None):
    """
    Execute a game given a cat policy and a mouse policy. Return the winner
    as well as stats regarding the number of moves and their distance apart
    at the end of the game. 
    """
    
    def check_winner(board):
        """
        Determine if either player has won.
        """
        if convert_board_to_tuple(board) in board_history:
            return 'mouse'
        if (board['cat_x'] == board['mouse_x']) and (board['cat_y'] == board['mouse_y']):
            return 'cat'
        return None
            
    
    def move_cat(board, cat_policy): 
        """
        Move the cat from one position on the board to another, given the 
        current cat position and mouse position and the cat's policy.
        """
        move = cat_policy[board['cat_x']] \
                         [board['cat_y']] \
                         [board['mouse_x']] \
                         [board['mouse_y']]
        if move == 'R':
            board['cat_x'] += 1
        elif move == 'L':
            board['cat_x'] -= 1
        elif move == 'U':
            board['cat_y'] += 1
        elif move == 'D':
            board['cat_y'] -= 1
        else:
            assert "Invalid move type"                        
        return board
    def move_mouse(board, mouse_policy):
        """
        Move the mouse from one position on the board to another, given the 
        current cat position and mouse position and the mouse's policy.
        """        
        move = mouse_policy[board['cat_x']] \
                           [board['cat_y']] \
                           [board['mouse_x']] \
                           [board['mouse_y']]
        if move == 'R':
            board['mouse_x'] += 1
        elif move == 'L':
            board['mouse_x'] -= 1
        elif move == 'U':
            board['mouse_y'] += 1
        elif move == 'D':
            board['mouse_y'] -= 1
        else:
            assert "Invalid move type"
        return board
    
    def get_distance(board):
        """
        Return the distance between the cat and mouse.
        """
        return abs(board['cat_x'] - board['mouse_x']) + abs(board['cat_y'] - board['mouse_y'])
    
    board = init_board()
    board_history = {convert_board_to_tuple(board): True}  
        
    if FIRST_MOVE == 'cat':
        board = move_cat(board, cat_policy)
    
    # Execute for at most the possible number of unique board positions. 
    # After this, there must be a cycle if there is no capture.
    for move_number in range(NUM_ROWS * NUM_COLS * NUM_ROWS * NUM_COLS + 1):
        # Move the mouse
        board = move_mouse(board, mouse_policy)
        if draw_execution: 
            draw_board(board, round_idx)
        winner = check_winner(board)
        if winner:
            return winner, move_number, get_distance(board)
        board_history[convert_board_to_tuple(board)] = True
        
        # Move the cat
        board = move_cat(board, cat_policy)
        if draw_execution: 
            draw_board(board, round_idx)
        winner = check_winner(board)
        if winner:
            return winner, move_number, get_distance(board)
        board_history[convert_board_to_tuple(board)] = True
    
    # If the mouse evades capture for the full execution, it is the winner
    assert False, "Executed maximum moves without a capture or repeated board"
    return 'mouse', move_number, get_distance(board)

def get_variations(policy, curr_player):
    """
    For a given policy, return a set of similar, random policies.
    """
    num_changes = np.random.randint(1, 11)
    new_policies = []
    for _ in range(num_changes):
        cat_x = np.random.randint(NUM_COLS)
        cat_y = np.random.randint(NUM_ROWS)
        mouse_x = np.random.randint(NUM_COLS)
        mouse_y = np.random.randint(NUM_ROWS)
        direction = np.random.choice(['R', 'L', 'U', 'D'])
        
        # Skip this variation if the move is illegal (going outside the grid)
        if (curr_player == 'cat') and (cat_x == (NUM_COLS-1)) and (direction == 'R'):
            continue
        if (curr_player == 'cat') and (cat_x == 0) and (direction == 'L'):
            continue
        if (curr_player == 'cat') and (cat_y == (NUM_ROWS-1)) and (direction == 'U'):
            continue
        if (curr_player == 'cat') and (cat_y == 0) and (direction == 'D'):
            continue
        if (curr_player == 'mouse') and (mouse_x == (NUM_COLS-1)) and (direction == 'R'):
            continue
        if (curr_player == 'mouse') and (mouse_x == 0) and (direction == 'L'):
            continue
        if (curr_player == 'mouse') and (mouse_y == (NUM_ROWS-1)) and (direction == 'U'):
            continue
        if (curr_player == 'mouse') and (mouse_y == 0) and (direction == 'D'):
            continue            
        
        p = copy.deepcopy(policy)
        p[cat_x][cat_y][mouse_x][mouse_y] = direction
        new_policies.append(p)
    return new_policies

np.random.seed(0)
cat_policy = set_initial_cat_policy()
mouse_policy = set_initial_mouse_policy()
winner, num_moves, distance = execute(cat_policy, mouse_policy, draw_execution=True, round_idx="Initial Policies")
prev_winner, prev_num_moves, prev_distance = winner, num_moves, distance
game_stats_winner = []
game_stats_num_moves = []
game_stats_distance = []
# Execute 100,000 iterations. Each iteration we attempt to improve either
# the cat's or the mouse's policy, depending which is weaker at that time.
for round_idx in range(100_000):
    
    # Display progress as the two players learn
    if (((round_idx % 1000) == 0) and (round_idx > 0)) or \
        (prev_winner != winner) or (prev_num_moves != num_moves) or (prev_distance != distance):
        print(f"Iteration: {round_idx:>6,}, Current winner: {winner:<5}, number of moves until a win: {num_moves:>2}, distance: {distance}")
        prev_winner, prev_num_moves, prev_distance = winner, num_moves, distance
    
    if winner == 'cat':
        # Improve the mouse
        best_p = copy.deepcopy(mouse_policy)
        best_num_moves = num_moves
        best_distance = distance
        policy_variations = get_variations(mouse_policy, curr_player='mouse')        
        for p in policy_variations:
            p_winner, p_num_moves, p_distance = execute(cat_policy, p)
            
            # The mouse's policy improves if it starts winning, the execution takes longer, or the execution takes
            # the same number of time, but the mouse ends farther from the cat
            if ((winner == 'cat') and (p_winner == 'mouse')) or \
               ((winner == 'mouse') and (p_winner == 'mouse') and (p_num_moves > best_num_moves)) or \
               ((winner == 'cat') and (p_winner == 'cat') and (p_num_moves > best_num_moves)) or \
               ((winner == 'cat') and (p_winner == 'cat') and (p_num_moves == best_num_moves) and (p_distance > best_distance)):
                winner = p_winner
                best_p = copy.deepcopy(p)
                best_num_moves = p_num_moves
                best_distance = p_distance
                
        mouse_policy = copy.deepcopy(best_p)
        num_moves = best_num_moves
        distance = best_distance
                
    else:
        # Improve the cat
        best_p = copy.deepcopy(cat_policy)
        best_num_moves = num_moves
        best_distance = distance
        policy_variations = get_variations(cat_policy, curr_player='cat')
        for p in policy_variations:
            p_winner, p_num_moves, p_distance = execute(p, mouse_policy)
            
            # The cat's policy improves if it starts winning, or it wins in fewer moves, or it still loses, but 
            # after more moves, or if it still loses in the same number of moves, but it's closer to the mouse
            if ((winner == 'mouse') and (p_winner == 'cat')) or \
               ((winner == 'mouse') and (p_winner == 'mouse') and (p_distance < best_distance)) or \
               ((winner == 'mouse') and (p_winner == 'mouse') and (p_distance == best_distance) and (p_num_moves > best_num_moves)) or \
               ((winner == 'cat') and (p_winner == 'cat') and (p_num_moves < best_num_moves)):
                winner = p_winner
                best_p = copy.deepcopy(p)
                best_num_moves = p_num_moves
                best_distance = p_distance
                
        cat_policy = copy.deepcopy(best_p)
        num_moves = best_num_moves
        distance = best_distance
        
    game_stats_winner.append(winner)
    game_stats_num_moves.append(num_moves)
    game_stats_distance.append(distance)
     
    draw_execution = (round_idx % 10_000 == 0) and (round_idx > 0)
    if draw_execution:
        execute(cat_policy, mouse_policy, draw_execution=True, round_idx=round_idx)
            
winner, num_moves, distance = execute(cat_policy, mouse_policy, draw_execution=True, round_idx="Final Policies")            
print(f"The {winner} wins in {num_moves} moves.")


动画展示移动过程

随着笔记本的执行,每10,000次迭代,它会根据当时猫和老鼠的策略进行一场游戏。随着时间的推移,我们看到两名玩家玩的游戏越来越合理。为了实现这一点,它在绘制每一步时会调用clear_output()(由IPython.display提供),因此会清除笔记本的输出单元格并重新绘制两名玩家当前的棋盘位置,从而创造出动画效果。


还有打印语句描述两名玩家学习更好玩法的过程。


版本1代码的限制

版本1的笔记本展示了为游戏中的玩家开发完整策略的基本思想,但并没有处理我们在生产质量系统中希望解决的所有问题。这没关系,因为这只是一个简单示例,但我会在这里列出一些可以在其他环境中改进的地方(尽管这会使代码变得更复杂一些)。


首先,在这个版本中,为了简化,我们声明如果玩家重复了棋盘位置,则老鼠获胜。这并不理想,但在这种情况下有一定道理,因为两名玩家的策略都是确定性的——如果他们两次进入相同的位置,我们知道这种模式将继续重复,而猫将不会捕捉到老鼠。


代码还可以改进以检测当两名玩家在一段时间内都没有改进时的情况,以允许提前停止,或者允许类似模拟退火的过程(以允许移动到看似较弱的策略,从而跳出局部最优解),或者允许测试不仅是对当前最佳策略进行小修改的新策略,而是进行更大修改的新策略。


另外,一旦一名玩家无法打败另一名玩家,仍然可以允许另一名玩家继续学习,以开发出更强的策略。


这里采用的另一个简化是,每名玩家都试图简单地打败另一名玩家的当前策略。这在另一名玩家也在持续改进的情况下效果相当不错,但为了创建更稳健的玩家,最好不仅基于其如何对抗另一名玩家的当前策略来评估每个策略,而是基于其如何对抗任意策略,或者至少对抗大量已知相当强大的策略。然而,这是一个较慢的过程,因此在这个笔记本中跳过了。


其中一些问题在解决这个问题的第二个方案中得到了解决(该方案侧重于处理更大、未知的棋盘大小,但也提供了一些其他改进),下面将对此进行介绍。


对版本1过程的观察

这种类型的学习可以被称为协同进化,其中两个代理一起学习。当一个变得更强大时,它也有助于另一个变得更强大。在这种情况下,两名玩家在训练过程中最终各赢了大约一半的游戏。在笔记本末尾打印出每名玩家的总胜场数,我们有:


mouse    54760
cat      45240


随着它们的训练,两者都可能做出一些意想不到的举动。这些举动不太可能像Alpha Go与李世石对弈中的第78步(一个非常强大,但新颖且出人意料的走法)。这些通常只是(猫的位置和老鼠的位置)组合,对于这些组合,策略尚未定义出合理的走法。随着训练的继续,这些情况的数量会减少。


使用更通用的策略,允许玩家在任何棋盘大小上玩

上述方法在棋盘相对较小的情况下可以令人满意地工作,但如果棋盘更大,比如20x20或30x35,这种方法就不切实际了。例如,对于30x35的棋盘,我们将需要调整超过一百万个参数的策略。


而且这是完全不必要的,因为当老鼠相对较远时,猫的移动方式可能相同,无论它们在棋盘上的具体位置如何;同样,当老鼠非常近且不在任何边缘附近时,猫的移动方式也可能相同,无论具体位置如何,其他类似场景也是如此。


可以定义一种策略,以更通用的方式描述如何玩,而不参考棋盘的具体单元格。


可以为猫(老鼠的策略类似)定义一种策略,基于它们位置的相当通用的属性,但这些属性要足够详细地描述它们的位置,以便开发出良好的策略。


这可以包括,例如:

  • 老鼠是在猫的上方、下方还是同一行
  • 老鼠是在猫的左边、右边还是同一列
  • 老鼠在垂直方向还是水平方向上离猫更近
  • 老鼠是否在角落
  • 老鼠是否在边缘
  • 老鼠是否不在但接近顶部/底部/左侧/右侧边缘


我们还可以考虑老鼠在每个维度上距离边缘是奇数还是偶数个空格,以及它距离每个边缘是奇数还是偶数个空格——因为猫试图避免老鼠与它形成一个圈。


另一种解决这个问题的方法是,不是开发一个单一的策略,而是开发一组小的子策略,每个子策略都类似于第一种方法中开发的策略。在那里,如果我们有一个5x6的棋盘,我们会开发一个5x6x5x6的策略。但也可以定义,例如,一系列3x3的策略,供每个玩家确定在它们彼此接近的各种场景中如何移动(它们还将有一个或多个子策略来描述当它们相距较远时如何移动)。


例如,我们可以定义一个3x3的策略,用于当两个玩家都在棋盘左上角的3x3区域时猫应该如何移动,另一个策略用于当它们在棋盘右上角的3x3区域时,当它们在顶部边缘(但不是角落)时,等等。


为了简化这一点,我们实际上可以为角落定义一个策略(根据玩家所在的角落旋转它)。同样,对于四个边缘,严格来说只需要一个策略(而不是四个),等等。


下面的图像显示了当猫和老鼠彼此接近且都在右上角区域时,它们可能会使用与这种情况相关的子策略,仅考虑这里勾勒出的3x3区域。


9


下一张图片展示的正是这个3x3的区域。这个区域的子策略可以经过优化,并成为玩家整体策略的一部分。针对棋盘上这个区域进行优化是一个较小且易于管理的问题,可以与棋盘上其他区域的考虑分开处理。


10


如前文所述,只需优化一个这样的3x3策略即可处理四个角落的情况,因为通过旋转策略以匹配整个棋盘,可以在所有四种情况下使用同一个策略。


版本2笔记本

在这个的第二个版本(在名为version_2的第二个笔记本中编码)中,我们采用了一种通用的方法来训练策略,即我们不假设棋盘有任何特定的尺寸。这实际上与前面描述的一些通用解决方案方法略有不同,但思路相似,展示了另一种可能的方法。


这同样基于当猫和老鼠彼此接近时定义策略,这里再次定义为在某个共同的3x3空间内。在这种情况下,我们不是旋转3x3空间,而是保持与整个棋盘相同的方向,但在策略中添加其他维度来指示我们是否处于棋盘的一条或多条边缘上。


因此,这使用了一个3x3x3x3x3x3(大小为729)的策略。前四个元素代表猫和老鼠的x和y位置。下一个元素有三个维度,指定老鼠相对于棋盘左右边缘的位置。这可以是以下之一:

  • 两侧都没有边缘
  • 棋盘左侧边缘位于3x3区域的左侧
  • 棋盘右侧边缘位于3x3区域的右侧


最后一个维度类似,但与棋盘的顶部和底部边缘相关。


也就是说,我们为以下每种组合都有一个特定的策略:

  • 猫在这个3x3区域内的x位置
  • 猫在这个3x3区域内的y位置
  • 老鼠在这个3x3区域内的x位置
  • 老鼠在这个3x3区域内的y位置
  • 如果这个3x3区域的左侧位于整个空间的最左侧边缘,如果这个3x3区域的右侧位于整个空间的最右侧边缘,或者如果两者都不是。
  • 如果这个3x3区域的底部位于整个空间的最底部,如果这个3x3区域的顶部位于整个空间的最顶部,或者如果两者都不是。


例如,当猫和老鼠在棋盘上的任何3x3网格内时,我们可以实施针对此情况的策略,该策略还考虑了3x3区域是否毗邻整个棋盘的边缘(在此图中以粗线显示)。


11


以下展示的是它们可能所处的3x3空间。为这种简单情况开发一个子策略,可以使我们忽略棋盘的其他部分,只关注它们的相对位置以及附近是否有边缘。


12


因此,这些子策略仅在两名玩家彼此靠近时才使用。


为了简化本笔记本的内容,如果猫和老鼠不够靠近,无法投影到一个3x3的子区域,那么猫就会简单地移动,以减少与老鼠之间的欧几里得距离。这一点同样可以很容易地学习到,但为了保持这个例子的简单性,它只涵盖当它们靠近时的策略学习。


作为另一个简化,本笔记本只训练猫。老鼠随机移动,这允许猫开发一个更稳健的策略,因为它可以被训练到在任何给定数量的游戏中,无论老鼠如何移动,都能一致地击败老鼠。


老鼠也可以使用前一个笔记本中展示的过程进行训练。但是,在这个例子中,我想主要关注扩展上面的例子,以定义可以处理任何棋盘大小的策略。


由于本笔记本专注于训练猫,我们用猫可以赢得游戏的情况来演示。


版本2的训练过程

与版本1一样,猫通过保持一个当前最佳策略来训练。每次迭代时,它会在这个策略上生成10个随机的、小的变体,并确定是否有任何一个击败了之前的版本(如果有,则选择其中最好的作为新的当前最佳策略)。


为了评估每个候选策略,我们使用该候选策略与老鼠进行1000场游戏。策略的比较主要基于它们在1000场游戏中击败随机移动的老鼠的场次数。它还考虑了(以便过程可以为猫选择稍微更好的策略,即使两者在1000场游戏中的胜场数相同)赢得游戏的平均步数(越少越好),以及与老鼠的平均距离(在所有游戏的所有步数中)(这里也是越少越好)。


代码实际上分为两步。第一步,猫与纯粹随机移动的老鼠对战,直到能够一致地击败它。然后,它与稍微聪明一点的老鼠对战,即老鼠除非没有其他合法选择,否则不会移动到猫旁边的方格。


像这样将训练分为两步并不是绝对必要的。在这个的更大版本中(目前不在github上可用,但可能很快就会添加——它有一些进一步的小改进,包括训练两名玩家),这被简化为只有一个训练循环,对训练猫所需的时间几乎没有影响。


但它确实呈现了爬山算法的一个重要思想:创建一个能够检测到策略小幅改进的情况是很重要的,在这个例子中,就是允许猫在1000场游戏中赢得更多场次(因为它最初是在一个赢得比赛相当可能的情况下进行的)。


运行版本2的笔记本,猫需要30次迭代才能在与老鼠的1000场游戏中全部获胜。然后它开始与更聪明的老鼠对战。最初,它只能在1000场游戏中赢得821场,但在另外17次迭代后,它能够一致地全部击败老鼠。此时,它尝试减少赢得比赛所需的步数。


以下是切换到更聪明的老鼠后前16次迭代的输出:


Iteration:      1, Number of wins: 821, avg. number of moves until a win: 8.309, avg_distance: 2.241806490961094
Iteration:      2, Number of wins: 880, avg. number of moves until a win: 8.075, avg_distance: 2.2239653936929944
Iteration:      3, Number of wins: 902, avg. number of moves until a win: 9.143, avg_distance: 2.2353713664032475
Iteration:      4, Number of wins: 950, avg. number of moves until a win: 7.371, avg_distance: 2.1287877056217774
Iteration:      5, Number of wins: 957, avg. number of moves until a win: 7.447, avg_distance: 2.1256372455916117
Iteration:      7, Number of wins: 968, avg. number of moves until a win: 7.433, avg_distance: 2.129003455466747
Iteration:      8, Number of wins: 979, avg. number of moves until a win: 7.850, avg_distance: 2.167468227927774
Iteration:      9, Number of wins: 992, avg. number of moves until a win: 7.294, avg_distance: 2.1520372286793874
Iteration:     10, Number of wins: 993, avg. number of moves until a win: 7.306, avg_distance: 2.15156512341623
Iteration:     11, Number of wins: 994, avg. number of moves until a win: 7.263, avg_distance: 2.1409090350777533
Iteration:     13, Number of wins: 997, avg. number of moves until a win: 7.174, avg_distance: 2.137799442343003
Iteration:     15, Number of wins: 998, avg. number of moves until a win: 7.125, avg_distance: 2.128880373673454
Iteration:     16, Number of wins: 999, avg. number of moves until a win: 7.076, avg_distance: 2.1214920528568437


使用1000场游戏作为评估猫的表现已经足够大,并且也足以检测到猫策略中即使相对较小的改进,例如,当它的胜场数从678场增加到679场(总共1000场)时。尽管这只是一个小小的进步,但毕竟是进步。


总的来说,只需要大约200次迭代就可以为猫训练出一个强大的策略。


在笔记本中,训练是在5x5的棋盘上进行的,因为这样可以快速执行游戏,并且可以为四个角落以及角落之间的边缘分别开发不同的策略。笔记本的最后一个单元格在一个15x15的棋盘上执行策略,这证明了所发现的策略可以应用于任何大小的棋盘。


在版本2中,我们定义老鼠赢得游戏为至少在指定数量的移动内躲避捕捉,这个数量是:(NUM_ROWS + NUM_COLS) * 2。这是在猫表现良好的情况下应该能够捕捉到老鼠的移动次数(实际上这个数字略长于必要值,给猫一些灵活性)。这是定义老鼠赢得游戏的一种更可取的方式,并且在这里是可行的,因为老鼠以非确定性的方式移动。


与版本1相比,这也更新了猫的适应度函数,以考虑每一步与老鼠的平均距离,而不是游戏结束时的距离。这也允许一旦猫能够可靠地赢得所有1000场游戏后,其玩法能够持续、逐步地改进。


为玩家相距较远时训练策略

这个例子仅涵盖了开发一个子策略来处理玩家靠近的情况,但一个完整的解决方案还需要一个子策略来处理他们相距较远的情况。这可以像在这个笔记本中那样硬编码,但通常更希望允许优化过程(或游戏树、蒙特卡洛游戏树或其他类似方法)来发现这一点。


为了模拟玩家相距较远时的选择,我们希望捕捉他们位置的相关属性,但不捕捉额外的属性(因为这将需要更多的优化努力)。然而,选择参数可能会陷入一个循环论证——即,事先确定最优策略是什么,然后简单地定义捕捉该策略所需的参数。


例如,我们可能假设当玩家相距较远时,猫的最佳策略是最小化到老鼠的旅行距离(即曼哈顿距离)。因此,我们可能仅用一个变量来表示他们在棋盘上的位置,这个变量有四个可能的值,指示老鼠是离左边、右边、上边还是下边最近。但实际上,使用欧几里得距离对于猫鼠游戏来说可能比曼哈顿距离效果更好。同样,捕捉棋盘边缘的信息可能很有用,这样猫就可以把老鼠推向最近的角落。


也就是说,从假设的最佳策略开始,并且只捕捉执行该策略所需的棋盘属性,可能会阻碍我们找到真正最优的解决方案。


我们可能希望包含一些额外的参数来捕捉那些可能只是潜在相关的因素,即使我们怀疑它们并不相关。一个可能的集合是,和之前一样:

  • 老鼠是在猫的上方、下方还是同一行
  • 老鼠是在猫的左边、右边还是同一列
  • 老鼠在垂直方向还是水平方向上离猫更近
  • 老鼠是否在角落
  • 老鼠是否在边缘
  • 老鼠是否不在但接近顶部/底部/左侧/右侧边缘


与任何建模情况一样,这需要在捕捉过多细节(训练缓慢,难以解释)和过少细节(导致次优玩法)之间取得平衡。


结论

这里展示的两个例子是解决这个问题的可行选项,并且是这种方法的有用示例:基于优化算法(在本例中为爬山算法)定义游戏玩法的完整策略。


这里的想法相当简单,并且不能直接扩展到更复杂得多的游戏,但可以很好地处理相似甚至稍微更复杂的问题。例如,它们可以处理在这里呈现的猫鼠游戏中可以添加的相当数量的复杂情况,例如,棋盘上障碍物的放置、一个玩家以不同速度移动的能力、存在多只猫或多只老鼠等等。


同样,在特定条件下生效的明确定义的子策略是一个有用的想法,可以将其融入到甚至更复杂问题的解决方案中,通过游戏树或这里所示的优化技术来定义这些子策略。

文章来源:https://medium.com/towards-data-science/using-optimization-to-solve-adversarial-problems-99943614dde8
欢迎关注ATYUN官方公众号
商务合作及内容投稿请联系邮箱:bd@atyun.com
评论 登录
热门职位
Maluuba
20000~40000/月
Cisco
25000~30000/月 深圳市
PilotAILabs
30000~60000/年 深圳市
写评论取消
回复取消