文章目录
1 Flappy Bird游戏简述
Flappy Bird是2013年开发的一款休闲游戏,曾经风靡一时,游戏中玩家操纵一只小鸟越过管道障碍物,玩家只可以进行“跳跃”或者“不操作”两种操作。游戏截图如下:
我们的目标是教会计算机自己玩Flappy Bird游戏,注意,在训练计算机玩游戏时,我们告诉计算机的信息,仅仅包括游戏的每一帧的图像、小鸟的动作列表(跳跃、无操作)、计算机做出动作之后的reward、游戏是否结束的标志。也就是说,计算机获得的信息其实和人类玩家是一样的,也就是说计算机并没有“作弊”。训练计算机玩游戏,想想就觉得非常有趣,Let’s do it。
2 Q-Learning简述
关于Q-Learning,觉得这篇博客写得比较通俗易懂:A Painless Q-learning Tutorial (一个 Q-learning 算法的简明教程)
PaddlePaddle版Flappy-Bird—使用DQN算法实现游戏智能
Q-Learning的核心是两张表,R表与Q表,R表与Q表的维度是相同的,行表示状态state,列表示动作action。 经过反复不断的学习后,使得Q表收敛,我们就可以根据Q表,在当前状态下执行回报值最大的动作
- R表记录了每个状态执行不同的动作所得到的reward值,是人为设置且固定不变的,R表的第n行m列的值表示在状态n下执行动作m所得到的即时的回报值。
- Q表则是用来记录从经验中学习到的知识。Q表的第n行m列的值表示,经过一段时间学习后,在状态n执行动作m能得到的未来的长期的回报值,这个回报值不仅考虑了执行动作之后得到的即时的回报值,而且还考虑了执行该动作对游戏未来的进展的回报值
在博客A Painless Q-learning Tutorial (一个 Q-learning 算法的简明教程)中根据值迭代计算出目标Q值,然后直接将这个Q值(是估计值)直接赋予新的Q,转移规则如下:
但实际上,不直接使用估计Q值来更新Q值,而是采用渐进的方式,类似梯度下降,朝目标迈近一小步,迈的步子的大小取决于α,这就能够减少估计误差造成的影响,最后可以收敛到最优的Q值,转移规则如下:
下面详细说明一下上述Q值的更新方法:
- S t S_{t} St表示在t时刻的状态, A t A_{t} At表示在t时刻执行的动作, S t + 1 S_{t+1} St+1表示在t时刻执行动作 A t A_{t} At之后的状态, R t + 1 R_{t+1} Rt+1表示从状态 S t S_{t} St转移到 S t + 1 S_{t+1} St+1所获得的即时的reward,Q( S t S_{t} St, A t A_{t} At)表示在状态 S t S_{t} St下执行动作 A t A_{t} At所能得到的总的reward(总的reward考虑了该动作对未来的影响,而即时reward没有考虑执行该动作对未来的影响)。
- R t + 1 R_{t+1} Rt+1+ λmaxQ( S t + 1 S_{t+1} St+1,a)表示根据当前的Q表,在状态 S t S_{t} St执行动作 A t A_{t} At转移到状态 S t + 1 S_{t+1} St+1后得到的真实的Q值
- Q(
S
t
S_{t}
St,
A
t
A_{t}
At)表示Q表中已经学习到的在状态
S
t
S_{t}
St执行动作
A
t
A_{t}
At所获得的的总的reward
3 Deep Q Network(DQN)
DQN论文:Playing Atari with Deep Reinforcement Learning
3.1 为什么要用DQN
在Q-learning中,每一行代表一个状态,每一列代表一个动作,对于Flappy Bird游戏来说,假设我们设定输入一帧图片的像素矩阵为80x80,每个像素有256种可能,那么一帧图像就有 256^(80x80) 种状态,再加上游戏中的小鸟有两种动作(跳跃、无操作)。若使用Q-learning,那么程序就需要维护一个大小为2x256^(80x80)的Q表和R表,无论是维护表格,还是查表,代价都是比较大的。
我们知道,神经网络天生比较适合用来表示十分复杂的形式,所以用神经网络来维护Q表是比较合适的方式。DQN就是Q-learning+神经网络的结果,在Q-learning中,Q值需要使用一张表来进行维护,而在DQN中,输入一个状态(4帧图片),神经网络直接生成Q值。 由此可见,DQN更加适合用来处理状态复杂的问题
3.2 DQN中的几个巧妙的地方
3.2.1 experience replay(经验池、记忆库)
几个变量:
- s t s_{t} st:t时刻的游戏状态
- a t a_{t} at:小鸟做出的动作
- r t r_{t} rt:小鸟做出该动作at之后获得的直接reward
- s t + 1 s_{t+1} st+1:小鸟做出动作at之后的游戏状态
在t时刻,小鸟做出动作at之后,产生的样本记做( s t s_{t} st, a t a_{t} at, r t r_{t} rt, s t + 1 s_{t+1} st+1) ,我们将( s t s_{t} st, a t a_{t} at, r t r_{t} rt, s t + 1 s_{t+1} st+1) 保存在experience replay中。也就是说,记忆库中保存着小鸟曾经的经历。当我们需要对计算机进行训练时,我们便从记忆库中随机抽样,取出batch个样本,然后进行训练。先抛出一个结论:由于样本之间的强相关性,直接从连续样本中学习是低效的,随机化样本会破坏这些相关性,从而减少更新的方差。因此这里采取记忆库+随机抽样,破坏样本的连续性,使得训练更加有效。这也是experience replay的巧妙之处
3.2.2 使用Q-target网络来更新Q网络
我们使用神经网络来预测动作,这就意味着我们需要训练神经网络的权重,那么问题来了,如何训练该网络?该网络的损失函数是什么? 这也是我一开始最困惑的地方,在分类问题中,每个训练数据都有便签,输出层的每个神经元对应一个类别,因此可以使用交叉熵函数作为损失函数。但在DQN中,输出层有两个神经元,分别对应两个动作,而且对于每个输入状态没有标签,到底如何训练该网络?
作者使用了两个结构一模一样的DQN网络,Q_net与Q_netT,两个网络唯一的区别在于参数不同。Q_net是我们要训练的网络,Q_netT中的参数是旧的Q_net的参数。其实就是说每隔若干个timestep训练之后,将Q_net的参数赋予Q_netT。Q_net的损失函数如下,其中
y
i
y_{i}
yi表示的是训练网络Q_net计算出来的Q值,而Q(s,a;
θ
i
\theta_{i}
θi)表示的是旧的网络Q_netT预测的Q值。使用参数化逼近,参数化逼近是指值函数可以由一组参数 θ 来近似,如 Q-learning 中的 Q(s,a) 可以写成 Q(s,a|θ) 的形式。在文章PaddlePaddle版Flappy-Bird—使用DQN算法实现游戏智能中的参数化逼近部分说得比较清楚
让我们再回顾一下在Bellman方程中我们是如何更新Q值的,其更新公式如下:
许多强化学习算法背后的基本思想是通过使用上面的Bellman方程来迭代更新Q值,但实际上,这种方法是不可行的,因为动作值函数是针对每个序列单独估计的,没有任何泛化能力。相反,通常使用上述所说的函数逼近的方法来训练网络。
3.3 DQN实现细节
3.3.1 神经网络结构
神经网络结构:
- 输入层:从记忆库里随机选取4帧图像,因为颜色对于该游戏没有意义,先将4帧图像进行预处理,处理成二值的黑白图像,看做4通道的输入
- 卷积层:输入的数据经过三个卷积层
- 全连接层:有两个全连接层
- 输出层:因为该游戏只有两个动作,所有输出为2
3.3.2 代码实现的细节
代码的实现的一些细节(用言语表达起来感觉还是比较吃力,结合代码看会更清楚):
- 记忆库replayMemory使用一个大小确定的队列来进行维护,其中存放的是游戏过程中的数据(
s
t
s_{t}
st,
a
t
a_{t}
at,
r
t
r_{t}
rt,
s
t
+
1
s_{t+1}
st+1,terminal)。
- s t s_{t} st表示游戏在t时刻的状态,包含4帧连续的图,也就是一个4通道的输入
- a t a_{t} at表示游戏执行的动作
- r t r_{t} rt是游戏自行返回的,表示执行动作 a t a_{t} at之后的reward
- s t + 1 s_{t+1} st+1表示在状态 s t s_{t} st下,执行动作 a t a_{t} at,得到的新状态 s t + 1 s_{t+1} st+1。
- terminal也是是游戏自行返回的,作为游戏是否结束的标志
- 游戏开始时,获取游戏的第一帧图像,将该图像重复四次,便得到 s 1 s_{1} s1,就是这么简单粗暴
- 每执行一次动作,游戏会返回执行该动作之后的一帧图像。假设 s t s_{t} st是连续的四帧图像[1,2,3,4],执行 a t a_{t} at之后,游戏会返回一帧图像5,则 s t + 1 s_{t+1} st+1=[2,3,4,5],然后会将( s t s_{t} st, a t a_{t} at, r t r_{t} rt, s t + 1 s_{t+1} st+1,terminal)存入记忆库中,若记忆库已满,则将最早存入的数据替换出去。
- 每个timestep,从记忆库中随机选择4个样本,将输入变成0-255的灰色图像,然后二值化处理,变成黑白图像
- 前OBSERVE轮次不会对网络进行训练,让小鸟采取随机动作,主要是为了收集state,并将这些state存到记忆库中,供后续训练使用
- 经过OBSERVE轮次的采集数据之后,开始对网络进行训练。从记忆库中随机取出batch个数据,上面我们说过,DQN中有两个神经网络,分别计算出两个网络预测Q值,使用误差平方和作为损失函数,来优化网络
- 神经网络的Q值是什么?这个问题纠结了很久,神经网络输出层是两个神经元,连个神经元的输出值可以看做两个动作的reward值,将输出值最大的作为网络输出的Q值即可
- 小鸟采取的动作并不都是根据神经网络计算出来的,有epsilonde概率是随机选择动作,1-epsilon的概率执行神经网络预测的动作
- 每隔UPDATE_TIME轮次,用训练的网络的参数来更新旧的网络的参数
算法流程如下:
4 问题
随着训练次数增大,貌似训练效果有时会更差。训练70万次时,可以越过15次左右障碍物,但训练到90万次时,效果反而更差了,有时只能越过两三次障碍物,可能此时训练次数还不够吧。
5 代码注释
代码详见:Playing-Flappy-Bird-by-DQN-on-PyTorch
我对main文件下的代码进行了比较详细的注释,另外两个代码文件是用pygame写的游戏代码,大可不必深究细节
import pdb
import cv2
import sys
import os
sys.path.append("/")
import wrapped_flappy_bird as game
import random
import numpy as np
from collections import deque
import torch
from torch.autograd import Variable
import torch.nn as nn
GAME = 'bird' # the name of the being played for log files
ACTIONS = 2 # number of valid actions
GAMMA = 0.99 # decay rate of past observations
OBSERVE = 1000. # 前OBSERVE轮次,不对网络进行训练,只是收集数据,存到记忆库中
# 第OBSERVE到OBSERVE+EXPLORE轮次中,对网络进行训练,且对epsilon进行退火,逐渐减小epsilon至FINAL_EPSILON
# 当到达EXPLORE轮次时,epsilon达到最终值FINAL_EPSILON,不再对其进行更新
EXPLORE = 2000000.
FINAL_EPSILON = 0.0001 # epsilon的最终值
INITIAL_EPSILON = 0.0001 # epsilon的初始值
REPLAY_MEMORY = 50000 # 记忆库
BATCH_SIZE = 32 # 训练批次
FRAME_PER_ACTION = 1 # 每隔FRAME_PER_ACTION轮次,就会有epsilon的概率进行探索
UPDATE_TIME = 100 # 每隔UPDATE_TIME轮次,对target网络的参数进行更新
width = 80
height = 80
# 将一帧彩色图像处理成黑白的二值图像
def preprocess(observation):
observation = cv2.cvtColor(cv2.resize(observation, (80, 80)), cv2.COLOR_BGR2GRAY)
ret, observation = cv2.threshold(observation, 1, 255, cv2.THRESH_BINARY)
return np.reshape(observation, (1, 80, 80))
# 神经网络结构,结构较为容易理解
class DeepNetWork(nn.Module):
def __init__(self, ):
super(DeepNetWork, self).__init__()
self.conv1 = nn.Sequential(
nn.Conv2d(in_channels=4, out_channels=32, kernel_size=8, stride=4, padding=2),
nn.ReLU(inplace=True),
nn.MaxPool2d(kernel_size=2)
)
self.conv2 = nn.Sequential(
nn.Conv2d(in_channels=32, out_channels=64, kernel_size=4, stride=2, padding=1),
nn.ReLU(inplace=True)
)
self.conv3 = nn.Sequential(
nn.Conv2d(in_channels=64, out_channels=64, kernel_size=3, stride=1, padding=1),
nn.ReLU(inplace=True)
)
self.fc1 = nn.Sequential(
nn.Linear(1600, 256),
nn.ReLU()
)
self.out = nn.Linear(256, 2)
def forward(self, x):
x = self.conv1(x);
x = self.conv2(x);
x = self.conv3(x);
x = x.view(x.size(0), -1)
x = self.fc1(x);
return self.out(x)
class BrainDQNMain(object):
def save(self):
print("save model param")
torch.save(self.Q_net.state_dict(), 'params3.pth')
def load(self):
if os.path.exists("params3.pth"):
print("load model param")
self.Q_net.load_state_dict(torch.load('params3.pth'))
self.Q_netT.load_state_dict(torch.load('params3.pth'))
def __init__(self, actions):
# 在每个timestep下agent与环境交互得到的转移样本 (st,at,rt,st+1) 储存到回放记忆库,
# 要训练时就随机拿出一些(minibatch)数据来训练,打乱其中的相关性
self.replayMemory = deque() # init some parameters
self.timeStep = 0
# 有epsilon的概率,随机选择一个动作,1-epsilon的概率通过网络输出的Q(max)值选择动作
self.epsilon = INITIAL_EPSILON
# 初始化动作
self.actions = actions
# 当前值网络
self.Q_net = DeepNetWork()
# 目标值网络
self.Q_netT = DeepNetWork();
# 加载训练好的模型,在训练的模型基础上继续训练
self.load()
# 使用均方误差作为损失函数
self.loss_func = nn.MSELoss()
LR = 1e-6
self.optimizer = torch.optim.Adam(self.Q_net.parameters(), lr=LR)
# 使用minibatch训练网络
def train(self): # Step 1: obtain random minibatch from replay memory
# 从记忆库中随机获得BATCH_SIZE个数据进行训练
minibatch = random.sample(self.replayMemory, BATCH_SIZE)
state_batch = [data[0] for data in minibatch]
action_batch = [data[1] for data in minibatch]
reward_batch = [data[2] for data in minibatch]
nextState_batch = [data[3] for data in minibatch] # Step 2: calculate y
# y_batch用来存储reward
y_batch = np.zeros([BATCH_SIZE, 1])
nextState_batch = np.array(nextState_batch) # print("train next state shape")
# print(nextState_batch.shape)
nextState_batch = torch.Tensor(nextState_batch)
action_batch = np.array(action_batch)
# 每个action包含两个元素的数组,数组必定是一个1,一个0,最大值的下标也就是该action的下标
index = action_batch.argmax(axis=1)
print("action " + str(index))
index = np.reshape(index, [BATCH_SIZE, 1])
# 预测的动作的下标
action_batch_tensor = torch.LongTensor(index)
# 使用target网络,预测nextState_batch的动作
QValue_batch = self.Q_netT(nextState_batch)
QValue_batch = QValue_batch.detach().numpy()
# 计算每个state的reward
for i in range(0, BATCH_SIZE):
# terminal是结束标志
terminal = minibatch[i][4]
if terminal:
y_batch[i][0] = reward_batch[i]
else:
# 这里的QValue_batch[i]为数组,大小为所有动作集合大小,QValue_batch[i],代表
# 做所有动作的Q值数组,y计算为如果游戏停止,y=rewaerd[i],如果没停止,则y=reward[i]+gamma*np.max(Qvalue[i])
# 代表当前y值为当前reward+未来预期最大值*gamma(gamma:经验系数)
#网络的输出层的维度为2,将输出值中的最大值作为Q值
y_batch[i][0] = reward_batch[i] + GAMMA * np.max(QValue_batch[i])
y_batch = np.array(y_batch)
y_batch = np.reshape(y_batch, [BATCH_SIZE, 1])
state_batch_tensor = Variable(torch.Tensor(state_batch))
y_batch_tensor = Variable(torch.Tensor(y_batch))
y_predict = self.Q_net(state_batch_tensor).gather(1, action_batch_tensor)
loss = self.loss_func(y_predict, y_batch_tensor)
print("loss is " + str(loss))
self.optimizer.zero_grad()
loss.backward()
self.optimizer.step()
# 每隔UPDATE_TIME轮次,用训练的网络的参数来更新target网络的参数
if self.timeStep % UPDATE_TIME == 0:
self.Q_netT.load_state_dict(self.Q_net.state_dict())
self.save()
# 更新记忆库,若轮次达到一定要求则对网络进行训练
def setPerception(self, nextObservation, action, reward, terminal): # print(nextObservation.shape)
# 每个state由4帧图像组成
# nextObservation是新的一帧图像,记做5。currentState包含4帧图像[1,2,3,4],则newState将变成[2,3,4,5]
newState = np.append(self.currentState[1:, :, :], nextObservation,
axis=0) # newState = np.append(nextObservation,self.currentState[:,:,1:],axis = 2)
# 将当前状态存入记忆库
self.replayMemory.append((self.currentState, action, reward, newState, terminal))
# 若记忆库已满,替换出最早进入记忆库的数据
if len(self.replayMemory) > REPLAY_MEMORY:
self.replayMemory.popleft()
# 在训练之前,需要先观察OBSERVE轮次的数据,经过收集OBSERVE轮次的数据之后,开始训练网络
if self.timeStep > OBSERVE: # Train the network
self.train()
# print info
state = ""
# 在前OBSERVE轮中,不对网络进行训练,相当于对记忆库replayMemory进行填充数据
if self.timeStep <= OBSERVE:
state = "observe"
elif self.timeStep > OBSERVE and self.timeStep <= OBSERVE + EXPLORE:
state = "explore"
else:
state = "train"
print("TIMESTEP", self.timeStep, "/ STATE", state, "/ EPSILON", self.epsilon)
self.currentState = newState
self.timeStep += 1
# 获得下一步要执行的动作
def getAction(self):
currentState = torch.Tensor([self.currentState])
# QValue为网络预测的动作
QValue = self.Q_net(currentState)[0]
action = np.zeros(self.actions)
# FRAME_PER_ACTION=1表示每一步都有可能进行探索
if self.timeStep % FRAME_PER_ACTION == 0:
if random.random() <= self.epsilon: # 有epsilon得概率随机选取一个动作
action_index = random.randrange(self.actions)
print("choose random action " + str(action_index))
action[action_index] = 1
else: # 1-epsilon的概率通过神经网络选取下一个动作
action_index = np.argmax(QValue.detach().numpy())
print("choose qnet value action " + str(action_index))
action[action_index] = 1
else: # 程序貌似不会走到这里
action[0] = 1 # do nothing
# 随着迭代次数增加,逐渐减小episilon
if self.epsilon > FINAL_EPSILON and self.timeStep > OBSERVE:
self.epsilon -= (INITIAL_EPSILON - FINAL_EPSILON) / EXPLORE
return action
# 初始化状态
def setInitState(self, observation):
# 增加一个维度,observation的维度是80x80,讲过stack()操作之后,变成4x80x80
self.currentState = np.stack((observation, observation, observation, observation), axis=0)
print(self.currentState.shape)
if __name__ == '__main__':
actions = 2 # 动作个数
brain = BrainDQNMain(actions)
flappyBird = game.GameState()
action0 = np.array([1, 0]) # 一个随机动作
# 执行一个动作,获得执行动作后的下一帧图像、reward、游戏是否终止的标志
observation0, reward0, terminal = flappyBird.frame_step(action0)
# 将彩色图像转化为灰度值图像
observation0 = cv2.cvtColor(cv2.resize(observation0, (80, 80)), cv2.COLOR_BGR2GRAY)
# 将灰度图像转化为二值图像
ret, observation0 = cv2.threshold(observation0, 1, 255, cv2.THRESH_BINARY)
# 将一帧图片重复4次,每一张图片为一个通道,变成通道为4的输入,即初始输入是4帧相同的图片
brain.setInitState(observation0)
print(brain.currentState.shape)
while 1 != 0:
# 获取下一个动作
action = brain.getAction()
# 执行动作,获得执行动作后的下一帧图像、reward、游戏是否终止的标志
nextObservation, reward, terminal = flappyBird.frame_step(action)
# 将一帧彩色图像处理成黑白的二值图像
nextObservation = preprocess(nextObservation)
# print(nextObservation.shape)
brain.setPerception(nextObservation, action, reward, terminal)