用神经网络玩井字游戏

2025-06-07

用神经网络玩井字游戏

在《井字棋与表格 Q 学习》,我们利用强化学习开发了一个井字棋代理。我们使用表格为给定位置的每次移动分配一个 Q 值。通过训练游戏,我们逐渐将这些 Q 值推向产生更好结果的方向:好的结果会拉高导致该结果的操作的 Q 值,而坏的结果则会降低 Q 值。在本文中,我们将不使用表格,而是将强化学习的相同理念应用于神经网络。

神经网络作为一种函数

我们可以将 Q 表视为一个多变量函数:输入是给定的井字棋位置,输出是与该位置每次移动对应的 Q 值列表。我们将尝试训练神经网络来逼近这个函数。

对于我们网络的输入,我们将棋盘位置展开为一个包含9 个值的数组:1表示X-1表示O0表示空单元格。输出层将是一个包含9 个值的数组,表示每种可能走法的 Q 值:接近0的低值表示不好,接近1 的较高值表示好。训练完成后,网络将选择与该模型中最高输出值对应的走法。

下图显示了训练后给定位置的输入和输出(最初所有值都在0.5左右徘徊):

神经网络模拟 q 值函数

我们可以看到,X的制胜走法A2拥有最高的 Q 值,为 0.998,而非法走法的 Q 值非常低。其他合法走法的 Q 值大于非法走法,但小于制胜走法。这正是我们想要的。

模型

该网络(使用 PyTorch)具有以下结构:

class TicTacNet(nn.Module):
    def __init__(self):
        super().__init__()
        self.dl1 = nn.Linear(9, 36)
        self.dl2 = nn.Linear(36, 36)
        self.output_layer = nn.Linear(36, 9)

    def forward(self, x):
        x = self.dl1(x)
        x = torch.relu(x)

        x = self.dl2(x)
        x = torch.relu(x)

        x = self.output_layer(x)
        x = torch.sigmoid(x)
        return x
Enter fullscreen mode Exit fullscreen mode

代表当前棋盘位置的9 个输入值通过两个各有36 个神经元的密集隐藏层,然后到达输出层,该输出层由9 个值组成,每个值对应于给定移动的 Q 值

训练

该代理的大部分训练逻辑与本系列前面讨论过的 Q 表实现相同。然而,在该实现中,我们阻止了非法移动。对于神经网络,我决定它不要进行非法移动,以便针对任何给定位置获得一组更切合实际的输出值。

下面的代码来自qneural.py,展示了如何针对单个训练游戏更新网络参数:

def update_training_gameover(net_context, move_history, q_learning_player,
                             final_board, discount_factor):
    game_result_reward = get_game_result_value(q_learning_player, final_board)

    # move history is in reverse-chronological order - last to first
    next_position, move_index = move_history[0]

    backpropagate(net_context, next_position, move_index, game_result_reward)

    for (position, move_index) in list(move_history)[1:]:
        next_q_values = get_q_values(next_position, net_context.target_net)
        qv = torch.max(next_q_values).item()

        backpropagate(net_context, position, move_index, discount_factor * qv)

        next_position = position

    net_context.target_net.load_state_dict(net_context.policy_net.state_dict())


def backpropagate(net_context, position, move_index, target_value):
    net_context.optimizer.zero_grad()
    output = net_context.policy_net(convert_to_tensor(position))

    target = output.clone().detach()
    target[move_index] = target_value
    illegal_move_indexes = position.get_illegal_move_indexes()
    for mi in illegal_move_indexes:
        target[mi] = LOSS_VALUE

    loss = net_context.loss_function(output, target)
    loss.backward()
    net_context.optimizer.step()
Enter fullscreen mode Exit fullscreen mode

我们维护两个网络:策略网络 ( policy_net) 和目标网络 ( target_net)。我们在策略网络上执行反向传播,但从目标网络获取下一个状态的最大 Q 值。这样,在单个游戏的训练过程中,从目标网络获取的 Q 值不会发生变化。完成游戏训练后,我们会使用策略网络 ( load_state_dict) 的参数更新目标网络。

move_history每次包含 Q-learning 代理在单场训练游戏中的移动。对于 Q-learning 代理的最后一步移动,我们会用该游戏的奖励值更新其选择的移动——0表示输,1表示赢或平局。然后,我们会按时间倒序遍历游戏历史记录中的剩余移动。我们会将当前移动的 Q 值拉向下一个状态(下一个状态是当前状态下采取的操作所导致的状态)中 Q 值最大的方向。

这类似于表格 Q 学习方法中使用的指数移动平均数:在这两种情况下,我们都将当前值拉向下一个状态可用的最大 Q 值。对于给定游戏位置的任何非法移动,我们还会作为反向传播的一部分提供该移动的负反馈。这样,我们的网络有望学会避免做出非法移动。

结果

结果与表格 Q 学习代理相当。下表(每种情况基于1,000 场游戏)代表了典型训练运行后获得的结果:

神经结果

这些结果来自一个模型,该模型针对XO分别进行了200 万次训练游戏(对抗一个随机移动的代理)。在我的电脑上训练这个模型需要一个多小时。这比训练表格代理所需的游戏数量有了巨大的提升。

我认为这体现了大量高质量数据对深度学习的重要性,尤其是在我们从像这样的简单示例应用到实际问题时。当然,神经网络的优势在于它具有泛化能力——也就是说,它可以处理训练过程中从未见过的输入(至少在一定程度上如此)。

使用表格方法,无需插值:如果遇到从未见过的局面,我们能做的最好的事情就是应用启发式算法。在围棋和国际象棋等游戏中,局面数量极其庞大,我们甚至无法全部存储。我们需要一种能够泛化的方法,而这正是神经网络相较于现有技术真正能够脱颖而出的地方。

我们的网络对胜利和平局的奖励相同。我尝试过将平局的奖励降低到胜利的水平,但即使将平局的奖励降低到0.95左右,似乎也会降低网络的稳定性。尤其是在扮演X时,网络最终可能会在与随机极小极大代理的比赛中输掉大量比赛。将胜利和平局的奖励设为相同似乎可以解决这个问题。

尽管我们对胜利和平局的奖励相同,但代理似乎在赢得比赛方面表现良好。我认为这是因为胜利通常会提前结束比赛,在棋盘上所有9个格子都填满之前。这意味着,在回顾游戏历史的每一步时,奖励的稀释程度较小(同样的道理也适用于失败和违规操作)。另一方面,平局(根据定义)需要完成所有9步,这意味着在给定的游戏中,随着Q学习代理从一步走到上一步,导致平局的那些操作的奖励稀释程度会更大。因此,如果某个特定的操作持续更快地导致胜利,那么它仍然比最终导致平局的操作更具优势。

网络拓扑和超参数

如前所述,该模型有两个隐藏的密集层,每个层有36 个神经元。MSELoss用作损失函数,学习率为0.1relu用作隐藏层的激活函数。sigmoid用作输出层的激活,将结果压缩到01之间的范围内。

鉴于网络的简单性,这种设计似乎不言而喻。然而,即使对于这个简单的案例研究,调整这个网络也相当耗时。起初,我尝试使用tanh(双曲正切)作为输出层——我认为将-1设置为损失值,将1设置为获胜值是合理的。然而,我无法用这个激活函数获得稳定的结果。最终,在尝试了其他几种方案后,我将其替换为sigmoid,结果好多了。同样,relu在隐藏层中替换为其他值会使结果更糟。

我还尝试了几种不同的网络拓扑结构,包括一层、两层或三层隐藏层的组合,以及每个隐藏层分别使用9 个18 个27 个36 个神经元的组合。最后,我尝试了不同的训练游戏数量,从10 万个开始,逐渐增加到200 万个,这似乎能产生最稳定的结果。

数据质量网络

此实现的灵感源自 DeepMind 的 DQN 架构(参见通过深度强化学习实现人类级别的控制),但两者并不完全相同。DeepMind 使用了一个卷积网络,该网络将直接屏幕图像作为输入。在这里,我认为目标是教会网络井字游戏的核心逻辑,因此我认为简化表示是合理的。无需将输入处理为图像也意味着所需的层数更少(无需层来识别棋盘的视觉特征),从而加快了训练速度。

DeepMind 的实现也使用了经验回放,即在训练过程中将随机的经验片段作为网络的输入。我的感觉是,在这种情况下,生成新的随机游戏会更简单。

我们能把这种井字棋式的实现称为“深度”学习吗?我认为这个术语通常指的是至少有三层隐藏层的网络,所以可能不行。我认为增加层数对卷积网络更有价值,我们可以更清楚地理解为一个过程,每一层都进一步抽象前一层识别出的特征,并且与全连接层相比,参数数量有所减少。无论如何,只有在能够产生更好结果的情况下,我们才应该增加层数。

代码

完整代码可在 github 上找到(qneural.pymain_qneural.py):

GitHub 徽标 nestedsoftware / tictac

尝试不同的井字游戏技巧

玩井字游戏的不同方法的演示项目。

代码需要 Python 3、numpy 和 pytest。神经网络/dqn 实现 (qneural.py) 需要 Pytorch。

使用 pipenv 创建虚拟环境:

  • pipenv --site-packages

使用 pipenv 安装:

  • pipenv shell
  • pipenv install --dev

设置PYTHONPATH为主项目目录:

  • 在 Windows 中,运行path.bat
  • 在 bash 运行中source path.sh

运行测试和演示:

  • 运行测试:pytest
  • 运行演示:python -m tictac.main
  • 运行神经网络演示:python -m tictac.main_qneural

最新结果:

C:\Dev\python\tictac>python -m tictac.main
Playing random vs random
-------------------------
x wins: 60.10%
o wins: 28.90%
draw  : 11.00%

Playing minimax not random vs minimax random:
---------------------------------------------
x wins: 0.00%
o wins: 0.00%
draw  : 100.00%

Playing minimax random vs minimax not random:
---------------------------------------------
x wins: 0.00%
o wins: 0.00%
draw  : 100.00%

Playing minimax not random vs minimax not random:
-------------------------------------------------
x wins: 0.00%
o wins: 0.00%
draw  : 100.00%

Playing minimax random vs minimax random:

有关的

参考

文章来源:https://dev.to/nestedsoftware/tic-tac-toe-with-a-neural-network-1fjn
PREV
选择 JavaScript 构建工具:配置还是不配置
NEXT
😎 React App 通过开源 SSO Auth Wizardry 升级 🪄