37. BI - 强化学习案例:自动完成游戏 Flappy Bird

本文为 「茶桁的 AI 秘籍 - BI 篇 第 37 篇」

[TOC]

茶桁的AI秘籍_核心BI_37

Hi, 你好。我是茶桁。

几天不见,大家还记得咱们上一节课的内容吗?我们上一节课讲了强化学习的一些概念也原理,并且完成了一个简单的案例:迷宫问题。

那我们这一节课就来完成一个稍微复杂一点的案例,一起来看一看 Flappy Bird 该如何去学习。

截屏 2024-02-28 17.30.11

现在来一起思考一下,这个状态比咱们上一个案例多还是少?我们现在用的 Flappy Bird 是一个游戏,所以游戏就需要一个模拟的环境。我所使用的是 PyGame-Learning-Environment 库,缩写为 PLE。

如何安装 PLE 环境

之所以用这个环境是因为环境可以告诉我们这个小鸟所在的位置,水管出现的位置。就没有必要自己去搭建这个环境了,只需要在环境中得到更高的分数。PLE 的存在就是让我们不需要考虑仿真环境,因为它专门搭建的是一个 armage。你的决策就是如何去训练这个 AI。在 PLE 里面也有很多的游戏,除了Flappy Bird以外其他游戏也可以去做一些训练。包括:Catecher、Monster Kong、Pixelcopter、Pong、PuckWorld、RaycastMaze、Snake、WaterWorld。

那首先,没有安装的小伙伴我可以给大家一个简略的安装指南:

1
2
3
4
5
6
7
brew install sdl sdl_ttf sdl_image sdl_mixer portmidi  # brew or use equivalent means
pip install pygame
# 然后是下载PLE库
git clone https://github.com/ntasfi/PyGame-Learning-Environment
# 进入安装
cd PyGame-Learning-Environment
pip install -e .

image-20240229180800269

这样,你就安装成功了。

相关的 PLE 参数使用可以看这里:https://pygame-learning-environment.readthedocs.io/en/latest/modules/ple.html

接着我们来看一看 PLE 环境是如何记录状态的。还是像一个史官一样,把我们的状态记录下来,他的状态记录有以下的一些参数:

1
2
3
4
5
6
7
8
9
10
state = {
'player_y': 256,
'player_vel': 0,
'next_pipe_dist_to_player': 309.0,
'next_pipe_top_y': 29,
'next_pipe_bottom_y': 129,
'next_next_pipe_dist_to_player': 453.0,
'next_next_pipe_top_y': 107,
'next_next_pipe_bottom_y': 207
}

第一个是小鸟所在的高度,这个小鸟所在高度是位置 256,然后是小鸟的速度为 0,再接着是下一个水管到小鸟的距离 309,下个水管它的 top_y 是 29,下个水管的bottom_y 是 129。为什么会有 top_y 和 bottom_y 呢?玩过这个游戏的应该都清楚,鸟是在中间飞的,上下都有一个水管。下下个水管到 player 的距离是 453,所以我们最多是给它两个水管到它之间的距离,更好地方便你来去做一些决策。还有,下下个水管的 top_y 和 下下个水管的一个 bottom_y,大概的关系如下图:

截屏 2024-02-29 19.47.37

所以可以看到 stage 是很好的记录下鸟的状态和到障碍物之间的一个状态。

PLE 是一种简化的方式,以前我们要搭建这个游戏模拟人的操作行为还需要搭建视觉系统。很多玩游戏的是前期去分析你的游戏界面,识别出来哪些是水管,哪些是鸟,这个可能就更复杂一点。现在就不需要识别它,PLE 的环境可以把中间的状态假设已经识别好了,所以有点类似于像无人驾驶一样。游戏告诉你速度是多少,你就直接去做无人驾驶的决策,Flappy Bird 的一个 action 就可以了。

我们的参数来做一些设置,这里的状态有这么多,这个空间还比较大。Flappy Bird 还不是个很复杂的游戏,它比红警、星际要简单很多,但这个状态的可能性因为要把每一种状态都做记录,那这个可能性多不多呢?

游戏窗口是 288 乘 512,假设这个 y 可以是 512,速度假设是 100 个速度,还有可能下一个到它的距离,就比如说这几种状态,把它数值算出来,y 的情况都算完以后,算算这个值大不大。这个状态数量怎么算,这个状态数量是看有几个参数,每个参数之间是做乘法。因为每一种参数都有可能会发生一些变化,所以 y 的值是 512,横坐标可能也会有可能性,所以要把它做一个连乘。连乘就代表我们的状态的空间非常非常大。

我们来自己算一算,这里有 8 种参数,这 8 个参数每个参数如果假设都是 512 的话,就是 8 个 512 进行相乘。想一想咱们之前提到的 Q-table,左边是 state,上面是 action。好在 Flappy Bird 的 action 比较简单,它只有两种 action。第一个就是往上,第二个就是不动,不动的话小鸟就会自动的下来。

如果要把 Flappy Bird 学好,这个学习的速度时长会很长。这是按照 PLE 原来的参数量来进行学习,这还仅仅是一个 Flappy Bird,你要把它学完以后会发现它的维度会很高。512 的 8 次方,而且还跟训练的次数相关。他不是一次就训练完了,还有训练次数。所以这个状态如果状态空间太多容易爆炸,那有什么方法可以让他学出来呢?我们就需要对其降维。

原来是 8 个参数,有哪些维度可以降维?我们简化成只考虑下一个水管,去掉下下个水管。下一个水管也只考量距离顶端水管的一个垂直距离,这个小鸟到顶端水管一个垂直距离。那最后我们其实就简化成只考虑三个参数,一个是小鸟的速度 player_vel,这个是必须的,然后是距离顶端水管的垂直距离 player_y - next_pipe_top_y,再接着是距离下一个水管的水平距离 next_pipe_dist_to_player

image-20240229195404979

这三个参数肯定是有缺陷的,只用一个水管,并没有考虑下下个水管。实际上在某些情况下,可能就达不到很好的效果。还有,我们只计算了上面这个水管的一个 y,并没有下面的,其实下面这个对计算也有一些帮助。这是为了我们训练可以更快地来进行收敛,给它简化成这三个维度。

第一个就是参数量的降维,第二个,参数量降维以后要学习的时间已经大幅缩短了,但这个时间还是比较长的。比如鸟的速度可能是一个区间范围,下一个水管到这个鸟的距离是 0 到 288,然后 player 到上一个水管的范围有可能是 -512 到 +512,一共有 1,024 个区值。所以大体上算一算,1,024 乘上 288 秒的速度,假设有 16 种可能性,这个粒度也比较大。

这种情况大体上可能也有 300 万的一个计算量,300 多万 400 万一个计算量。如果要把参数学出来,这个时间肯定很长。即使已经把它用了三个参数,但是要学出来时间也很难收敛。那还可以怎么做?

参数的个数少了,现在目标应该聚焦到范围上,接着采用降维的方式。0 到 288,就不要给他做这么一个连续的区间了,能不能把它设成几段?比如说距离远、距离近、距离中、距离超近、超远。可以给他做一个范围的设定,速度也是一样。

为了简化,我设成了以下的3种等级:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
# 设置小鸟速度的等级
if velocity < -15:
velocity_category = 0
elif velocity < -10:
velocity_category = 1
elif velocity < -5:
velocity_category = 2
elif velocity < 0:
velocity_category = 3
elif velocity < 5:
velocity_category = 4
else:
velocity_category = 5

# 设置小鸟高度等级
if dist_to_pipe_bottom < 8: # very close
height_category = 0
elif dist_to_pipe_bottom < 20: # close
height_category = 1
elif dist_to_pipe_bottom < 50: # not close
height_category = 2
elif dist_to_pipe_bottom < 125: # mid
height_category = 3
elif dist_to_pipe_bottom < 250: # far
height_category = 4
else:
height_category = 5

# 设置distance等级
if dist_to_pipe_horz < 8: # very close
dist_category = 0
elif dist_to_pipe_horz < 20: # close
dist_category = 1
elif dist_to_pipe_horz < 50: # not close
dist_category = 2
elif dist_to_pipe_horz < 125: # mid
dist_category = 3
elif dist_to_pipe_horz < 250: # far
dist_category = 4
else:
dist_category = 5

这个降维处理就是把原来的数值类型,做成了一个离散的一个分段区间。只要在这个区间里面,就按照这个区间的策略。所以它不是那么的个性化,不需要把每一个数值都计算出来,只要按区间的方式就可以了。

可以看一下,速度区间给它设成了6个,高度等级也是设成6个,0-5。除了高度以外 distance 也是一样,也按照刚才的逻辑进行设置。我们再算一下,这里的状态数量大体上是多少个? 6*6*6=216,得到216个。现在我们就由原来的 300 万变成了216。这个计算量降维处理就很多了,减少了 1 万 3 千多倍。计算量减少 1 万多倍,精度肯定有损失,但是总体来说如果训练的好应该也能拿到不少的分数。

这是第一种,比起最初是减少了 2 万多倍(20 * 1014 * 288 -> 6 * 6 * 6)。我们就由原来的速度 20 个,假设乘上 1024,再乘上 288,现在可以转换成216个,减少了 2.73 万倍。

那以上已经把思路简单梳理了一下,我们看一看这个流程,该怎么去完成这个任务。现在是 Q-Learning 的实现方式,是要做后面 Q-table,每个状态 216 个,action 有两种:上和不动,都可以得到它的一个分数:

Q-Table \(a_1\) \(a_2\)
\(s_1\) \(Q(s_1, a_1)\) \(Q(s_1, a_2)\)
\(s_2\) \(Q(s_2, a_1)\) \(Q(s_2, a_2)\)
\(s_3\) \(Q(s_3, a_1)\) \(Q(s_3, a_2)\)

算法输入:迭代轮数 T,状态集 S,动作集 A,步长 \(\alpha\),衰减因子\(\gamma\),探索率\(\epsilon\) 输出:所有的状态和动作对应的价值 Q

这个分数怎么来的?在游戏的仿真环境过程中,怎么样得到一个 reward?是你操作了 action 以后到了某一个阶段,就会自动得到一个分数。以 Flappy Bird 为例,撞到水管上就是一个负分,通过一个水管就加 1 分,就是让机器去模拟、去训练,我们就会采集到不同的分数。通过采集到的环境分数来指导迭代。也就是说 Q-table 的生成实际上是不断的去玩这个游戏得出来的一个反馈。

在最开始的时候信息量是非常有限的,得到的反馈也很有限,也很难找到哪个地方是可以找到拿分的一个结果。

  1. 随机初始化所有的状态和动作对应的价值 Q,对于终止状态其 Q 值初始化为 0。
  2. for i in range(0, T) 进行迭代:
    1. 初始化 S 为当前状态序列的第一个状态
    2. \(\epsilon\) - 贪婪法在当前状态 S 选择出动作 A
    3. 在状态 S 执行当前动作 A,得到新状态 S’ 和奖励 R
    4. 更新价值函数: \(Q(S, A) + \alpha(R + \gamma max_aQ(S’, a) - Q(S, A))\)
    5. S = S’
    6. 如果 S’是种植状态,当前轮迭代完毕,否则转到步骤 b

最终选择动作比较简单,就直接用 action 选择最大的就好了。假设你已经训练好了,那大家都是独立的,按照训练好的指示去走就 OK 了。

接着我们来看看我训练好之后的一个结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
# 将 force_fps 设置为 True 进行快速 fps
env = PLE(game, fps = 30, display_screen = True, force_fps = True)

# 运行结果
Episodes: 0, Current score: -5.0, Max score: 0
...
Episodes: 30, Current score: -3.0, Max score: 0
Episodes: 31, Current score: 18.0, Max score: 18.0
...
Episodes: 33, Current score: 8.0, Max score: 18.0
Episodes: 34, Current score: 69.0, Max score: 69.0
...
Episodes: 62, Current score: 8.0, Max score: 69.0
Episodes: 63, Current score: 113.0, Max score: 113.0
...
Episodes: 126, Current score: 52.0, Max score: 113.0
Episodes: 127, Current score: 138.0, Max score: 138.0
...
Episodes: 949, Current score: 0.0, Max score: 138.0
Episodes: 950, Current score: 174.0, Max score: 174.0
...
Episodes: 3246, Current score: 12.0, Max score: 174.0
Episodes: 3247, Current score: 217.0, Max score: 217.0
...
Episodes: 4675, Current score: 8.0, Max score: 217.0
Episodes: 4676, Current score: 256.0, Max score: 256.0
...
Episodes: 9813, Current score: 18.0, Max score: 256.0

Flappy Bird

学习的轮次太多,这里我只是贴了一些关键的轮次,大体上可以看一看整个这套流程,还有一个学习过程中的 GIF 动画,如果在文内无法正常观看,可以到这里查看:https://cdn.jsdelivr.net/gh/hivandu/notes/img/Flappy%20Bird.gif

完整的代码可以去代码仓库里寻找并下载,下载之后大家可以自己去跑一跑,最好是自己完整的去看一遍逻辑,然后自己写一写。

整个流程跟之前的流程实际上也是大同小异的过程,最开始的时候,这个撞上墙的反馈就是一个负分。从 -5 开始,通过了 5 关就认为他已经达到一个还可以的智力。如果他通了一关,更有可能学到东西。如果之前一直没有通关,其实找不到通关的方向,一旦通关了,很快的就更容易拿到一些分数。因为他能学到一些内容,至少按照原来的策略可以得到一些分数。

来看代码,一开始是定义一个智能体 Agent:

1
2
class Agent():
...

在智能体的 __init__ 内做一些初始化的设计:

1
2
3
4
5
6
7
8
9
10
11
def __init__(self, action_space):
# 获得游戏支持的动作集合
self.action_set = action_space
# 创建q-table
self.q_table = np.zeros((6, 6, 6, 2))
# 学习率
self.alpha = 0.7
# 折现因子
self.gamma = 0.8
# 贪婪率
self.greedy = 0.8

然后我们做了一些分段,写一个 get_state,将之前我们设计的分段,包括速度的等级,高度等级以及 distance 等级都写进去:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
def get_state(self, state):
return_state = np.zeros((3,), dtype=int)
# x距离
dist_to_pipe_horz = state["next_pipe_dist_to_player"]
# y距离
dist_to_pipe_bottom = state["player_y"] - state["next_pipe_top_y"]
# 小鸟的速度
velocity = state['player_vel']
# 设置小鸟速度的等级
if velocity < -15:
...

# 设置小鸟高度等级
if dist_to_pipe_bottom < 8: # very close
...

# 设置distance等级
if dist_to_pipe_horz < 8: # very close
...

# 返回等级参数
return_state[0] = height_category
return_state[1] = dist_category
return_state[2] = velocity_category
return return_state

原来的 state 是来自于 PLE 这个环境给你的 state,我们只取了三个参数,还对这三个参数做了一个分段处理,这个前面已经讲过了。

在训练过程中我还采用一个策略 greedy,greedy 实际上是一种贪婪的方法。有时候我们并不是完全的采用最大化,还是有一定小的概率去跳脱出来去做一些随机数,随机数就是更有可能发现新大陆。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
def get_best_action(self, state, greedy=False):
...
# 是否执行策略
if greedy:
if np.random.rand(1) < self.greedy:
return np.random.choice([0, 1])
else:
if jump > no_jump:
return 0
else:
return 1
else:
if jump > no_jump:
return 0
else:
return 1

如果之前已经把它定义好了,那它每次都是采用这种决策。所以我们用 greedy 去做个限制。greedy 是一个比较小的值,如果它有 greedy 的策略,比如说我们假设 1%,那你有1%的概率是做 random choice。0 和 1 代表要么就是往上,要么就是不动,有 1% 的概率是在 0 和 1 之间来做一个随机数。否则我们就看一看,到底是 jump 还是 no_jump,按照它原来的 Q-table 里面的最好值。如果前面 jump 那就为 0,否则就为 1。这个是 action 的一些决策。所以这个决策是带有随机数,这是 epsilon greedy 的一种方法。greedy 这种方式其实随着训练次数,随机数也是个动态的调整。

基于 DQN 的强化学习

Q-table 是有局限性的,使用表格来表示 Q(s, a),但是现实问题中状态太多,使用表格根本存不下。Q-table 的这个过程,计算是 Q-Learning,我们想要对这个价值函数去做一个评估,有没有一些其他的计算的方法?这里的计算方法实际上还可以给他用一个神经网络的方式来去完成。

我们可以对值去做一个近似的处理。来去预估可以得到这样的一个值。

价值函数近似 Value Function Approximation

\[ \begin{align*} & Q(s, a) = f(s, a) & 这里 f 可以是任意函数,比如线性函数 \\ & Q(s, a) = \omega_1s + \omega_2a + b & 我们用 w 来表示函数 f 的参数,即 \\ & Q(s, a) = f(s, a, w) \end{align*} \]

因为我们并不知道 Q 值的实际分布情况,本质上是用一个函数来近似 Q 值的分布:

\[ \begin{align*} Q(s, a) = f(s, a, w) \end{align*} \]

其实它本身就是个函数,它的 Q 值实际上就是 state(s),采用一个 action(a),然后传入神经网络(w),通过一个 function 得到一个结果。

\[ \begin{align*} Q(s) \approx f(s, w) \end{align*} \]

输入的是一个 s,可以知道它每一个动作的 Q 值,即输出一个向量,得到 Q 的所有取值。

\[ \begin{align*} \left[ Q(s, a_1), Q(s, a_2), Q(s, a_3), ..., Q(s, a_n) \right] \end{align*} \]

使用 DQN 进行 Q(s, a)值预测:

使用 DQN 进行 Q(s, a)值预测

那怎么样去训练 DQN 呢? 其实 DQN 就等于 Q-learning 的一个深度版本,它的公式原理都是一样的,只不过用 DQN 去输出一个结果,得出来了我们预估的结果。

每个 episode 都会根据公式更新 Q-Table \[ \begin{align*} \\ & Q(S_t, A_t) \gets Q(S_t, A_t) + \alpha \left [ R_{t+1} + \gamma max_a Q(S_{t+1}, a) - Q(S_t. A_t)\right ] \\ \end{align*} \]

为了简化,将学习率 \(\alpha\) 设置为 1,更新公式为 :

\[ \begin{align*} Q(S_t, A_t) \gets R_{t+1} + \gamma max_a Q(S_{t+1}, a) \end{align*} \]

所以 DQN 的 Loss function 为:

\[ \begin{align*} L(w) = E \left[\underbrace{(\tau + \gamma max_{a'}Q(s', a', w) - Q(s, a, w))^2} \right] \end{align*} \]

DQN 实际上就是 Q-Learning 的一个神经网络版本。它通过神经网络来得出来 value 的取值。通过 Q-Learning 获取无限量的训练样本,然后对神经网络进行训练。样本之间具有连续性,如果每次得到样本就更新 Q 值,效果会不好。类似于人的大脑的学习机制,在回忆中学习。

截屏 2024-03-01 09.43.43

image-20240301094525271

其实这种框架还是有很多人去做,很多套件里也会给你生成这样一个框架,我们通过调用框架的一个 agent 的结构去完成整个的过程。

image-20240301095229253

agent 是智能体,可以做一些决策。他的决策是可以通过神经网络来去做判断决策的。agent 负责直接与环境交互,sample() 在训练时使用 e-greedy 策略采样, predict() 为训练完成后实际根据环境的决策,learn() 负责调用更新算法,控制训练的过程。algorithm 负责神经网络模型的训练,目标模型的同步,model 是整个神经网络的具体结构。

我们训练一个 learning 就是逐渐让预测的 loss 跟实际 loss 会更小一点,通过它去完成一个实际的判断。

在之前我们说到很多套件都有 DQN 的一个使用,飞桨就是其中之一。大家可以去官网看看这个工具套件:https://www.paddlepaddle.org.cn/,这是百度开发的开源深度学习平台。

如果你未来去百度工作,基本上神经神经网络只能用飞桨,Tensorflow 和 PyTorch 都用不了。好的地方就是他做了一些优化的处理,会有一些统一框架,而且还加了一些新的一些功能。有的时候同样一篇论文,百度上面加了一些新的算法,它可能跑出来的结果比原作者的准确性还要高一点。

那 DQN 也有一些挑战,因为需要用一些新的经验把原来那些经验的来去做一些覆盖,所以我们要存储它,会设一个 memory 去做一个经验之间的一个存储。

1
memory = [(state, action, reward, next_state, done)...]

学习过程实际上就称为「经验回放」,使用 memory 来训练神经网络。

学的过程实际上就是给他很多的样本集,从这里面去不断地去学,回放就是从 memory 一个大的数据库里面去抽样,随机的抽取这样的一个 batch_size 的一个个数,传完以后去做一个学习。

1
2
minibatch = random.sample(self.memory, batch_size)
target = reward + gamma * np.amax(model.predict(next_state))

然后模型通过 fit() 方法学习输入输出数据对

1
model.fit(state, reward_value, epochs=1, verbose=0)

小结

那整个的强化学习的内容通过这几节课的内容基本就给大家讲完了,强化学习就是机器学习的一个分支,除此之外,机器学习还包括了监督学习和无监督学习。

我们通过两个例子,迷宫问题和 Flappy Bird 来让大家去了解,它本身不需要人打标签,通过环境,智能体可以自己来进行学习。因为它有一个目标奖励最大化的一个机制。如果他得到了正向反馈那在智能体的后续过程中这种决策的可能性就会越来越强。

强化学习也是我们最接近自然的学习的方法,未来会有越来越多的一些难题会采用强化学习。其实不光是一些难题,包括推荐系统也有一些人在去尝试使用它,因为强化学习的空间上限会比较高。我们也可以把它跟深度学习来进行结合,比如说 AlphaGo 等等。

最后也给大家看一看一些强化学习的其他场景。举个生产的例子,日本公司 Fanuc,工厂的机器人在拿起一个物体时,会捕捉这个过程的视频,记住它每次操作的行动(成功 or 失败),不断积累经验,下一次可以更快更准的行动。

image-20240301102030550

这个其实就是强化学习,任何以前人的行为现在机器来去做,而且人并不直接教他怎么做,只是给他一个裁判的情况,机器就要想方设法去达到更好的一个奖励。

还有库存管理也可以使用强化学习来完成,在库存管理中,库存量大,库存需求波动较大,而库存补货速度缓慢,通过建立强化学习算法来减少库存周转时间,提高空间利用率。这里也有一些 action,实际上就是通过 action 来进行完成。

还有动态定价,强化学习中的 Q-Learning 可以用来处理动态定价问题。

运输行业中,制造商在各个客户运输时,想要在满足客户的所有需求的同时降低车队总成本,通过 multi-agents 和 Q-learning,可以降低时间,减少车辆数量。运输公司也可以根据交通、天气和安全状况的变化实时优化旅行路线。

电商的个性化推荐,可以用强化学习算法来学习和分析顾客行为,定制产品和服务以满足客户的个性化需求。

还有像广告服务,LinUCB算法,属于强化学习算法 bandit 的一种算法,它会尝试投放更广泛为的广告(尽管过去还没有被浏览很多)。这个其实研究的人会更多一点,就是大部分的比例个人感觉还是其他机器学习在推荐系统里面用的会更多一些,不过强化学习也有也有一些场景在去做一些尝试。比如说以阿里的双11为例,他们就建立了这样一些模型帮助人们快速的去发现一些感兴趣的一些商品。

金融也是一样的,实际上环境只要创建好了,那么机器就会在环境中得到更高的一个奖励。所以强化学习的难点是在于环境如何去做一些搭建。此外就是你的空间如果特别多的话,如何来进行一些降维的处理,让它可以在一个可行的时间范围之内得到一个结果。

那咱们到这里为止,整个 核心 BI 的课程也就结束了。希望大家在整个课程中有所,也希望大家后续能继续支持我的公众号,以及一些收费课程。

感谢,我们之后的课程中再见。

37. BI - 强化学习案例:自动完成游戏 Flappy Bird

https://hivan.me/37. BI - 强化学习案例:自动完成游戏 Flappy Bird-1/

作者

Hivan Du

发布于

2024-05-05

更新于

2024-06-01

许可协议

评论