目录
本文章代码发布在github上,欢迎交流学习。
https://github.com/SwayDy/RlMinesweeper.githttps://github.com/SwayDy/RlMinesweeper.git
一、概要
扫雷是一个非常经典的小游戏,8090后乃至00后可能都非常熟悉。1992年4月6日,扫雷和纸牌,空当接龙等小游戏搭载在Windows 3.1系统中与我们见面,游戏的玩法很简单,有初级、中级、高级和自定义等模式,雷区中随机布置一定数量的地雷,我们需要尽快找出所有不是地雷的方块,但不许踩到地雷。
难度等级 | 网格大小 | 地雷数量 |
初级 | 9x9 | 10 |
中级 | 16x16 | 40 |
高级 | 30x16 | 99 |
游戏非常简单,方块上显示的数字就是该方块周围8个方块总的地雷数,空白方块则表示周围都没有地雷。我们需要根据这些信息,推断出哪些方块是地雷,哪些方块不是地雷,最终将所有地雷找出(即所有数字方块全部展开),获得游戏胜利。
那么该如何训练一个扫雷游戏AI呢?PPO又是什么呢?
为了回答以上问题,需要引出一个概念——强化学习,完全没听过的小伙伴可以看这一篇文章,做一个初步了解强化学习入门这一篇就够了!!!万字长文。而PPO呢是强化学习中非常著名的一个算法,是由openAI于2017年在官网发布的。下面简单介绍一下强化学习以及PPO算法
强化学习(Reinforcement Learning, RL)是一种机器学习方法,通过与环境交互学习如何采取行动以最大化长期回报。它模拟了人类或动物在试错中学习的过程,非常适合解决需要连续决策的复杂问题。以下几个基本概念
1、环境(Environment):系统与之交互的外部世界。在给定状态下,环境会根据动作提供反馈,并更新为新的状态。
2、智能体(Agent):强化学习的主体,负责感知环境状态并采取行动。目标是通过试探和学习,找到最优策略来最大化累计回报。
3、状态(State):当前环境的表示,是智能体在某一时刻对环境的理解。
4、动作(Action):智能体可以在某状态下采取的行为,会影响环境状态的变化。
5、奖励(Reward):智能体采取动作后,环境提供的反馈信号,用于衡量动作的好坏。
6、策略(Policy):决定智能体在某状态下采取的动作的规则或模型。
7、值函数(Value Function):状态值函数评估在某个状态下的长期回报期望值。动作值函数评估在某个状态执行某个动作后的长期回报期望值。
8、折扣因子(Discount Factor):用于权衡即时奖励和未来奖励的重要性,通常取值为0.99,但不能超出区间[0, 1]。
PPO(Proximal Policy Optimization,近端策略优化)是一种深度强化学习算法,是策略优化类算法的一种改进。PPO在性能和稳定性之间取得了良好的平衡,广泛应用于连续动作空间和离散动作空间的任务。其关键在于利用了裁剪机制(Clipping)来限制策略函数的更新幅度,使得其在优化策略时变得更加稳定。(ps:也就是结果对学习率啥的超参数不那么敏感了)。这里就不做过多的介绍了。
二、整体流程
为实现利用PPO训练一个扫雷游戏AI的目标。根据强化学习的训练流程,首先得有个环境(Environment),有了环境,就需要有一个智能体(Agent)与之交互,也就是智能体能够根据环境返回的状态(State)输出动作(Action),而环境能够根据智能体返回的动作输出下一个状态和采取当前动作(Action)得到的奖励(Reward)。其中环境由gymnasium搭建,pygame实现可视化,智能体则由卷积神经网络构建。
2.1 环境搭建
在搭建之前,先清楚需要哪些量。经典的扫雷游戏分为三个难度等级,每个难度等级由网格大小和地雷数量区分,所以为不失一般性,游戏开始前需要指定网格大小和地雷数量。同时为使得环境能够与智能体进行交互,需要定义状态表示以及奖励函数。
实现方面,网格大小可以用python的元组进行表示,地雷数量则用int类型即可,例如初级难度的扫雷游戏则设置为
grid_size = (9, 9)
num_mines = 10
状态表示需要尽可能包含当前环境的信息且不能透露智能体不该获得的信息(例如地雷的位置或未展开格子的数字等),可以选择将当前的网格转化为数字矩阵,例如将未展开的格子视为-1,展开的格子视为对应的数字,这样一来,环境的初始状态就是一个网格大小的全零数字矩阵了。当然,如果你不想用卷积神经网络作为智能体,而是用多层感知机,只接受一维向量输入,你可以将上述的数字矩阵展成一维的向量,不过可能会丢失一些二维位置信息。
话说回利用数字矩阵作为状态表示,缺点是无法知道智能体之前点过了哪些格子,可能会导致这样一种情况:智能体在某一时刻选择了点击之前点过的格子,而扫雷对于点击已经展开的格子是没有任何反应的,所以环境根据这个动作返回的状态跟上一个动作返回的状态是相同的,而智能体在下一时刻一看状态,欸!这不跟上一个状态一样吗?然后又返回一个一样的动作,环境一看,返回一个一样的状态,智能体一看......周而复始,陷入死循环了。所以为避免这种情况的发生,还得把之前点击过的格子位置信息告诉智能体,“这个你已经点过了哟,不能再选择点它了哟”。那么该怎么做呢?直接利用位置坐标指定不行,像什么(0, 0)、(2, 1)这样的坐标该怎么和之前那个数字矩阵合一块传给智能体,能合一块吗?合不了,没那个条件知道吧。最好还是再构建一个相同大小的矩阵来表示这些位置信息,因为矩阵本身就代表一种位置信息,所以只需要将没有点过的置为0,点过的置为1就可以表示这些信息了。举个例子,初级难度的扫雷游戏,初始状态就是一个大小为(2, 9, 9)的张量,由两个大小为(9, 9)的全零矩阵拼接而成。
奖励函数可以用Reward(State, Action)来表示,即根据当前的状态和动作返回一个标量值。最直观的,智能体点到数字则奖励为1,点到地雷则奖励为-1,游戏胜利则奖励为2,点击已经展开的格子则奖励为0,一般来说,奖励为正则代表鼓励这种动作,奖励为负则代表惩罚这种动作,奖励为0则有时视为鼓励,有时视为惩罚。
喜欢观察的小伙伴就可能发现了,那刚开始点到数字跟最后几步点到数字那能一样吗?确实不一样,刚开始未展开的格子多,相对来说点到地雷的概率不大,而最后几步未展开的格子少,相对来说点到的地雷概率更大,所以点到数字才显得弥足珍贵。为了体现这种差异,可以在奖励结算的时候根据当前状态计算出一个系数,将奖励乘以系数之后再返回。系数计算代码如下,未展开格子数越少,系数越大,奖励越大。
# num_mines: 地雷数量 hidden_state_count: 未展开格子数
reward_weight = num_mines / hidden_state_count
点到地雷也有类似情况,不同于上述系数计算代码,针对点到地雷的情况
# grids_num: 网格数量 hidden_state_count: 未展开格子数
reward_weight = (grids_num - hidden_state_count) / grids_num
其他细节可以看代码文件minesweeper.py。
2.2 智能体实现
智能体可以简单理解为Action=Agent(State),即根据当前状态返回动作,在本文中agent就是一个卷积神经网络,利用pytorch构造。以初级难度的扫雷游戏为例,卷积神经网络接受形状为(2, 9, 9)的张量输入,输出一个长度为9x9=81的一维向量。其中输入是环境返回的状态,输出表示所有动作的概率。构造如下:
# layer_init: 参数初始化函数 envs.single_observation_space.shape: 状态空间大小
# self.actor和self.critic分别输出动作和动作价值
self.network = nn.Sequential(
layer_init(nn.Conv2d(2, 32, 3, stride=1, padding=1)),
nn.ReLU(),
layer_init(nn.Conv2d(32, 64, 3, stride=1, padding=1)),
nn.ReLU(),
layer_init(nn.Conv2d(64, 64, 3, stride=1, padding=1)),
nn.ReLU(),
nn.Flatten(),
layer_init(
nn.Linear(64 * envs.single_observation_space.shape[1] * envs.single_observation_space.shape[2], 512)),
nn.ReLU(),
)
self.actor = layer_init(nn.Linear(512, envs.single_action_space.n), std=0.01)
self.critic = layer_init(nn.Linear(512, 1), std=1)
细心的小伙伴可能发现了,除了有个卷积神经网络network外,还有两个线性层self.actor和self.critic,这两个线性层跟用到的强化学习算法PPO有关,感兴趣的可以去看看PPO算法,这里不做过多介绍。
说回智能体的实现,先看代码
class Agent_ppo_minesweeper(nn.Module):
def __init__(self, envs):
super().__init__()
self.network = nn.Sequential(
layer_init(nn.Conv2d(2, 32, 3, stride=1, padding=1)),
nn.ReLU(),
layer_init(nn.Conv2d(32, 64, 3, stride=1, padding=1)),
nn.ReLU(),
layer_init(nn.Conv2d(64, 64, 3, stride=1, padding=1)),
nn.ReLU(),
nn.Flatten(),
layer_init(
nn.Linear(64 * envs.single_observation_space.shape[1] * envs.single_observation_space.shape[2], 512)),
nn.ReLU(),
)
self.actor = layer_init(nn.Linear(512, envs.single_action_space.n), std=0.01)
self.critic = layer_init(nn.Linear(512, 1), std=1)
def get_value(self, x):
return self.critic(self.network(x))
def get_action(self, x):
return self.get_action_and_value(x)[0]
def get_action_and_value(self, x, action=None):
hidden = self.network(x)
logits = self.actor(hidden)
# 降低已经点击过的格子再次点击的概率
logits_mu = torch.mean(logits, dim=1, keepdim=True)
logits_std = torch.std(logits, dim=1, keepdim=True)
bias = torch.flatten(x[:, 1], 1)
bias_mu = torch.mean(bias, dim=1, keepdim=True)
bias_std = torch.std(bias, dim=1, keepdim=True)
logits = logits - (logits_std * (bias - bias_mu) / (bias_std + 1e-7) + logits_mu)
probs = Categorical(logits=logits)
if action is None:
action = probs.sample()
return action, probs.log_prob(action), probs.entropy(), self.critic(hidden)
在get_action_and_value函数中,主要关注logits和action,前者表示采取某个动作的概率,后者表示从这个概率分布中采样得到的一个动作标量。而且不难注意到,在对logits采样前,对其进行了"标准化"操作,目的是为了避免在2.1中提到的agent采取重复动作的问题。其他的一些输出主要用于在PPO算法训练agent时用到,agent玩扫雷的时候只需要用到action,这块就不做过多介绍了。
logits = logits - (logits_std * (bias - bias_mu) / (bias_std + 1e-7) + logits_mu)
当然这个"标准化"操作也不是必须的,你也可以利用其他方法如将点击重复格子的奖励设为负值或者直接从环境构建层面禁止重复点击动作等等。另外,动作除了需要确定点击格子的位置之外,还可以选择是点击还是标记,所以你完全可以把输出的动作向量长度乘2来表示是点击还是标记。(ps: 一开始我是这么想的,但是后面仔细一想,标记不是给人看的吗?人的大脑容量不够,所以需要标记来帮助记忆,但是AI有这个问题吗?我觉得应该是没有,当然我没试过,不知道是否有效,感兴趣的可以试试。)
三、训练细节
到这里,环境搭建好了,智能体也出生了,基本上就可以训练了。只需要把俩套进PPO算法中就可以愉快的开始训练了。那么PPO算法咋实现呢?这里给大家分享一个开源项目,非常的好用,那就是cleanrl,这里有当前所有主流的强化学习算法代码,且每一个算法都是单独的一个py文件,即拿即用,非常nice。我主要用的是ppo_atari.py这个,改改就能用。
为了方便训练加调试,我还写了一个sh文件,也借此说一些我训练的超参数是怎么设置的。顺便说一下,我训练时的设备是T4,显存16G。
nohup python run_ppo.py --exp_name run_ppo \
--grid_size "(9, 9)" \
--num_mines 10 \
--train_num_mines_range "(10, 10)" \
--seed 123 \
--env_id Minesweeper-v1 \
--model_id Agent_ppo_minesweeper \
--total_timesteps 300000000 \
--learning_rate 2.5e-4 \
--num_envs 128 \
--num_levels 128 \
--num_steps 1024 \
--pretrained "" \
--freeze_weight False \
--eval_frequence 50000 \
--anneal_lr True \
--num_minibatches 4 \
--update_epochs 32 \
--start_iter 1 \
>> run_log.log &
主要的超参数就是total_timesteps、learning_rate、num_envs、num_steps和update_epochs。从零开始训练的时候用的就是以上参数。训练完胜率能到30%左右,后面就是调低学习率,减少update_epochs继续训练,训练到胜率不上升时就再调低学习率,减少update_epochs或者减少num_envs加快速度,大概按照这个思路去。但是num_steps最好不要往小了改(个人经验)。最后,如果实在练不动了,你可以试试用Minesweeper-v2去练,我不确定是否真的有效,所以这里就不做介绍了,感兴趣的可以去看我的代码。
另外,如果你显存不够大,你可以试着降低num_envs,升高num_minibatches,如果显存很大不想浪费,你就可以反着来。再说一下train_num_mines_range这个参数,这个参数表示的是训练过程中扫雷环境的地雷数量,目的是增强模型的鲁棒性,可用可不用,一般设置为num_mines加减2,范围太大了容易练不动。
还有一些其他的细节,都在代码里了!感兴趣的就去看看吧,例如评估模型的函数有utils.py中的eval_model()还有multi_eval_model.py中的aggregate_results(),都是返回模型的average_return和胜率win_rate,区别就在于前者主要用于训练时评估以确定是否保存最好的模型(结果不太稳定),后者主要用于评估已经训练好的模型(结果比较稳定)。值得一提的是,代码中所计算的胜率都包含agent脸黑第一次点击就点到地雷的情况,以初级难度的扫雷游戏为例,概率有10/81,所以如果能够确保第一次不可能点到地雷,胜率应该会提高一些。
四、结果展示
说一下我最新的训练成果,初级难度的扫雷游戏大概能到63%的胜率,去除脸黑的情况,能有72%左右的胜率,最新的模型在这agent_8.5220_0.6376.pt。
AI扫雷
以上画面由test_env.py生成。