AI-神经网络玩雅达利游戏(atari 2600)的预处理

AI-神经网络玩雅达利游戏(atari 2600)的预处理

在阅读DQN的一些基本文章时,发现都是大家着重于神经网络部分(这也没错),中文互联网缺乏对DQN实验环境——Atari 2600游戏环境的处理。本文通过阅读国外的一些博文、论文以及源码,整理了Atari游戏的环境处理步骤。

Atari游戏处理整体框架

Atari游戏是过去一款风靡世界的游戏机,有着丰富的游戏可选。2017年,OpenAI团队正是使用Atari 2600作为环境,发表了那篇神经网络领域的里程碑之作:《Human-level control through deep reinforcement learning》。这篇文章以及很多后续论文中,对Atrai环境本身的处理也是非常重要的引入了不少新的概念,如frame skip, frame stack, sticky action, max pooling等等,为DQN的发展打下了良好的基础。

为了适应DQN的处理,目前主流Atari游戏的整体处理步骤如下:

Atari处理流程.drawio.svg

注意,这些步骤在不同项目中可能属于不同的项目。例如,在ALE中实现了sticky action, gymnasium中就没有特地实现。stable-baselines3这个库基本上就都是自己实现的。另外,同一个大步骤在具体细节上也可能不同,例如对于游戏图像的处理,原始论文是直接缩放,没有剪裁图像,而现在一些实现代码例如《Deep Reinforcement Learning Hands On Second Edition》剪裁了图像。

获取观测与初始化

为了将Atari 2600游戏环境作为DQN的交互环境,我们通常使用 Arcade Learning Environment(ALE)来模拟游戏并提供用于选择要执行的操作的接口。ALE是一个简洁的框架,允许研究人员和爱好者为Atari 2600游戏开发AI代理。它构建在Atari 2600仿真器 Stella 之上,并将仿真细节与代理设计分开。ALE允许我们在每个时间步骤提取Atari游戏反馈的信息,这种信息既可以是人眼可观测的图像(RGB图像)也可以是计算机处理的内存内容(RAM),同时在每个时间步骤接收操作信息,用于与游戏环境交互。

本文默认使用RGB图像信息作为ALE的反馈内容,其图像大小为210 * 160像素,采用RGB模式,即返回的观测数据规模为210*160*3。每个像素点都是0-255取值范围的整数。为了不失一般性,本文采用Breakout(打砖块)这个游戏为例,Gym环境为0.29.1版本,环境创建时采用最原始(并非默认)的配置,即从ALE中获取1帧RGB图像,并给ALE每一帧一个操作(对应环境为BreakoutNoFrameskip-v4)。

注:默认情况下,ALE中Atari游戏每秒有60帧。

游戏环境初始化

我们可以通过如下代码构建环境:

1import gymnasium as gym
2import numpy as np
3import matplotlib.pyplot as plt
4import cv2
5from collections import deque

其中,gym版本更新后全程是gymnasium,为了与过去统一所以import gymnasium as gym。引入matplotlib.pyplotcv2是为了画图与处理图像,而deque是为了存储反馈的图像帧。为了保持可复现性,本文中设置了随机种子值都是100,至此,我们可以创建Breakout的游戏环境:

1np.random.seed(100) # 设置随机种子值
2env = gym.make("BreakoutNoFrameskip-v4")

其中,为了获得Atari游戏最原始,未经任何处理的观测数据,我们使用了BreakoutNoFrameskip-v4,这个名称的创建的环境返回的结果不会使用Sticky action过滤动作,也不会使用frame skip跳帧来增强随机性(这些我们接下来要自己实现)。

至于如何在gym中对环境返回的动作和观测进行修改,这里推荐使用gym Wrapper模块,具体操作方法超出了本文的阐述范围,需要了解的读者可以通过gym官方文档深入学习。

未经处理的Breakout游戏信息如下:

1print(env.unwrapped.get_action_meanings()) # 游戏动作的含义
2print(env.action_space) # 游戏操作的动作空间
3# 游戏图像的观测空间,对于Atari游戏来说,一般是210*160的RGB图像,每个像素点RGB范围都是[0,255]
4print(env.observation_space) 
5
6['NOOP', 'FIRE', 'RIGHT', 'LEFT']
7Discrete(4)
8Box(0, 255, (210, 160, 3), uint8)

从上述可以发现,Breakout游戏的操作动作有四种:无操作(NOOP),开火/开球(FIRE),向右移动(RIGHT)和向左移动(LEFT),其动作空间是四个离散的数值(Discrete(4)),分别对应着0——NOOP,1——FIRE,2——RIGHT,3——LEFT。而游戏的反馈(返回值)是一个210 $\times$ 160大小的RGB图像,以numpy.ndarray的形式返回,具体来说是 $210\times 160\times 3$的三维数组,每个元素都是0-255的整数(如果熟悉图像处理,这部分就很好理解了)。

获取观测图像与交互

在生成Atari环境之后,Gym主要通过两个函数与Agents进行操作,一是重置函数env.reset(),二是操作函数env.step()

env.reset()将环境重置为初始状态,返回初始观测结果和信息。该函数最好接收一个整数参数作为随机数种子。其返回值是一个210 $\times$ 160大小的初始RGB图像和一些与游戏初始化信息。常见用法为:

1observation, info = env.reset(seed=100)
2
3plt.imshow(observation) # 以图片的形式显示numpy.ndarray
4print(info) # info一般是游戏相关信息,如lives表示游戏中剩余的生命数,frame_number表示游戏进行了多少帧
5
6{'lives': 5, 'episode_frame_number': 0, 'frame_number': 0, 'seeds': (271914307, 3436027390)}

breakout_reset.png

env.step()函数根据给定的动作与游戏环境进行一次交互。在Gym atari实际环境中,每执行一次step函数,游戏进行一帧(1/60秒)。当游戏进行到终止时(terminated or truncated),需要重新调用env.reset()函数重置环境以继续游戏交互。常见的用法为:

1observation, reward, terminated, truncated, info = env.step(1)

step函数的参数来自于动作空间action_space,由上可知,Breakout的动作空间是0-3四个离散整数。observation和过去一样是numpy.ndarray数组。reward是采取该动作获取的奖励;terminated指的是游戏是否进入到终止状态,truncated则是在0.26版本后新添加的结果,表示是否达到一个截断状态,通常截断状态是由超时TimeLimit引起的。infoenv.reset()反馈的结果类似,也是游戏的辅助信息。函数返回的信息结果如下:

1plt.imshow(observation) # 图片多了球
2print('reward: ', reward, 'termianted: ',terminated, 'truncated: ', truncated)
3print(info)
4
5reward:  0.0 termianted:  False truncated:  False
6{'lives': 5, 'episode_frame_number': 1, 'frame_number': 1}

breakout_reset.png

FireReset 启动

对于像breakout、pong之类的Atari游戏,我们需要需要通过Fire操作来发球(或者说开始游戏),如果让Agent自己学习的话,可能会花很多时间来发球,因此有些神经网络项目在Atari游戏的预处理中,会将发球和reset进行绑定,如果这个游戏需要通过Fire来启动,那么则添加FireReset这个Wrapper。需要说明的是,对于是否需要使用FireReset,大家并没有统一意见,有人认为学习发球也是神经网络的工作之一,并不需要额外地设置。

 1from gymnasium.core import Env # typing类型提示
 2
 3class FireResetEnv(gym.Wrapper):
 4    '''
 5    对于使用FIRE开始游戏的游戏采取FIRE操作来初始化
 6    :param env: Environment to wrap
 7    '''
 8
 9    def __init__(self, env: Env):
10        super().__init__(env)
11        # 检查第2个动作是否为FIRE。
12        assert env.unwrapped.get_action_meanings()[1] == "FIRE"  # type: ignore[attr-defined]
13        assert len(env.unwrapped.get_action_meanings()) >= 3 # type: ignore[attr-defined]
14
15    # 重载reset函数,使用FireReset
16    def reset(self, **kwargs):
17        # 核心代码
18        _, info = self.env.reset(**kwargs)
19        # Fire to start
20        obs, _, terminated, truncated, _ = self.env.step(1)
21
22        # 特殊情况处理
23        # 如果已经结束了,则重置环境
24        if terminated or truncated:
25            _, info = self.env.reset(**kwargs)
26        # 存疑,为啥这里用使用step(2)???
27        obs, _, terminated, truncated, _ = self.env.step(2)
28        if terminated or truncated:
29            obs, info = self.env.reset(**kwargs)
30        return obs, info

FireResetEnv类继承了gym.Wrapper,它的初始化函数仅仅检查动作空间中是否存在“Fire”这个选项。由于只需要在重置阶段起作用,因此只重写了reset()函数。主要修改就是在原始的reset()之后添加一步“Fire”动作,从而触发开始游戏。后面特殊处理的代码是为了防止游戏已经结束从而重置游戏。从本人实践的实际效果来看,至少对于(初期)探索性比较强的算法,FireResetEnv并不是很必要。参见讨论https://github.com/openai/baselines/issues/240

如果要使用上述Wrapper,只需要将原始的环境作为参数传递给Wrapper即可:

1env = gym.make("BreakoutNoFrameskip-v4")
2env_firereset = FireResetEnv(env)

那么新的环境env_firereset在调用重置函数reset时就会调用FireResetEnv中重写的reset函数。

最后需要指出的是,FireResetEnv类虽然负责开启特定游戏,但是在各个项目的Atari预处理流程中并不一定是最先处理的,甚至Gym项目中已经移除了FireReset的相关代码。(个人觉得只要放在NOOP处理的后面就问题不大)。

增加随机性

《The arcade learning environment: An evaluation platform for general agents》中指出,Atari 游戏是完全确定性的。 因此,玩家可以通过简单地记住最佳的动作序列而完全忽略对环境的观察来实现最先进的性能(例如背板行为)。为了避免这种情况,游戏环境都需要增加随机性。从添加随机性的时机来看,可以分为在初始阶段添加随机性(例如:NOOP)和交互阶段添加随机性(例如:Sticky action、frame skip)。

在初始阶段,我们可以等一段时间在开始操作(即开始有几步什么都不做,No Operation),让环境自动演变一会,从而获得不同的初始状态。在交互阶段,可以使用Sticky action(粘连动作):即动作不是总是准确传递到环境中的,而是有概率使用之前执行的动作,从而增加动作的随机性。此外,还可以应用Frame Skip(随机跳帧):在每个环境步骤中,都会针对随机数量的帧重复该操作。可以通过将关键字参数frameskip设置为正整数或两个正整数的元组来更改此行为。如果frameskip是一个整数,则跳帧是确定性的,并且在每个步骤中动作都会重复frameskip很多次。否则,frameskip若是一个元组,则在每个环境步骤中,在(frameskip[0], frameskip[1])之间均匀随机选择跳过帧的数量来创造随机性。

下面我们按照处理流程介绍增加随机性的功能。

Sticky Action

Sticky Action,中文翻译为粘连动作,是论文《Revisiting the Arcade Learning Environment: Evaluation Protocols and Open Problems for General Agents》在Section 5.2中提出的方法,具体步骤为设定一个概率阈值repeat_action_probability,每次Agent执行动作前先生成一个随机数,如果这个随机数大于repeat_action_probability,则执行Agent本身的动作,否则沿用上一次的动作。这就使得动作产生了随机性,Atari游戏实际执行的动作并不一定是Agent传递的操作。

ALE和Gymnasium都自己实现了Sticky action(粘连动作)代码,为了方便理解,我们将以Gymnaisum的代码作为基础进行适当简化。

 1class StickyAction(
 2    gym.ActionWrapper[ObsType, ActType, ActType]
 3):
 4
 5    def __init__(
 6        self, env: gym.Env[ObsType, ActType], repeat_action_probability: float
 7    ):
 8        """Initialize StickyAction wrapper.
 9
10        Args:
11            env (Env): the wrapped environment
12            repeat_action_probability (int | float): a probability of repeating the old action.
13        """
14        if not 0 <= repeat_action_probability < 1:
15                raise InvalidProbability(
16                f"repeat_action_probability should be in the interval [0,1). Received {repeat_action_probability}"
17            )
18        gym.ActionWrapper.__init__(self, env)
19
20        self.repeat_action_probability = repeat_action_probability
21        self.last_action: ActType | None = None
22
23    def reset(
24        self, *, seed: int | None = None, options: dict[str, Any] | None = None
25    ) -> tuple[ObsType, dict[str, Any]]:
26        """Reset the environment."""
27        self.last_action = None # 为了方便NOOP操作,这里设置为None
28
29        return super().reset(seed=seed, options=options)
30
31    def action(self, action: ActType) -> ActType:
32        # 如果随机数未超过阈值概率,则不接受新的动作,执行之前的动作
33        if (
34            self.last_action is not None
35            and self.np_random.uniform() < self.repeat_action_probability
36        ):
37            action = self.last_action
38
39        self.last_action = action
40        return action

Noop

在游戏刚开始阶段,在一定随机步数内不做任何操作,从而获得一定的初始随机化能力。我只需要设置一个最大Noop步数,代码会在0-max Noop中选择一个随机数N,执行N步的Noop。

Frame skip 与 Max pooling

补充:Frame stack

Gym提供的随机性

在Gym V26中所有Atari游戏均提供三个随机性版本。它们的不同之处在于上述参数的默认设置。默认参数差异如下表所示:

Version frame skip repeat_action_probability
V0 (2,5) 0.25
V4 (2,5) 0
V5 (2,5) 0.25

具体来说,对于每个 Atari 游戏,Gymnasium 中都会注册几种不同的配置。 v0 和 v4 的命名方案类似。 让我们以Amidar游戏为例看一下在gymnasium注册的Amidar的所有变体:

Env-id obs_type= frameskip= repeat_action_probability=
Amidar-v0 rgb (2, 5) 0.25
Amidar-ram-v0 ram (2, 5) 0.25
Amidar-ramDeterministic-v0 ram 4 0.25
Amidar-ramNoFrameskip-v0 ram 1 0.25
AmidarDeterministic-v0 rgb 4 0.25
AmidarNoFrameskip-v0 rgb 1 0.25
Amidar-v4 rgb (2, 5) 0
Amidar-ram-v4 ram (2, 5) 0
Amidar-ramDeterministic-v4 ram 4 0
Amidar-ramNoFrameskip-v4 ram 1 0
AmidarDeterministic-v4 rgb 4 0
AmidarNoFrameskip-v4 rgb 1 0
ALE/Amidar-v5 rgb 4 0.25
ALE/Amidar-ram-v5 ram 4 0.25

注:obs_type表示观测返回的类型。可以有三种

  1. “ram”: The 128 Bytes of RAM
  2. “rgb”: 类似于人眼看到的RGB图像
  3. “grayscale”: 返回的是单一色的灰度图

总体步骤的限制

TimeLimit

游戏图像的处理

WarpFrame流程:灰度图+缩放,有的需要裁剪

为了适应于神经网络的处理

Episodic Life

ClipReward

ScaledFloat

Frame Stack

FrameToPytorch

参考文献

  1. https://stable-baselines3.readthedocs.io/en/master/common/atari_wrappers.html
  2. https://danieltakeshi.github.io/2016/11/25/frame-skipping-and-preprocessing-for-deep-q-networks-on-atari-2600-games/
  3. https://www.gymlibrary.dev/environments/atari/index.html
  4. https://stable-baselines3.readthedocs.io/en/master/_modules/stable_baselines3/common/atari_wrappers.html
  5. https://github.com/PacktPublishing/Deep-Reinforcement-Learning-Hands-On-Second-Edition/blob/master/Chapter06/lib/wrappers.py