Q-learning与DQN
Q-learning
Q-learning算法是用来给出一个智能体(agent)从一个初始状态(state)出发到一个最优状态(State)的“路线”的算法。在每个不同的state,agent都可以执行不同的动作(action)转移到不同的state。Q-learning通过计算每个state下不同action的下的Q值,来规划出一个最优的动作集,这些动作集组成了路线。下面通过举一个简单的例子说明Q-learning算法的流程。
假设有一幢建筑,有5个房间,我们想训练agant从任意一个房间出发都能走到出口。
我们把这幢建筑的结构抽象成一个Graph。
Q-learning算法需要计算每个状态下不同动作的Q值,这些Q值我们用Q表存储起来。在算法的开始,先把所有的Q值储存为0。Q表中元素的行号代表号state,列号表示号action。
# 初始化Q表
Q_table=[[0,0,0,0,0,0],[0,0,0,0,0,0],[0,0,0,0,0,0],
[0,0,0,0,0,0],[0,0,0,0,0,0],[0,0,0,0,0,0]]
除此之外,还需要一个表存储Reward。R表中每个元素表示房间和房间之间是否有通路。我们将有通路记为0,出口记为100,无通路记为-1。
# 初始化R表
R_table=[[-1,-1,-1,-1,0,-1],[-1,-1,-1,0,-1,100],[-1,-1,-1,0,-1,-1],
[-1,0,0,-1,0,-1],[0,-1,-1,0,-1,100], [-1,0,-1,-1,0,100]]
Q的更新公式为。我们在代码中定义Alpha,Gamma,以及函数来更新Q值。
def find_max_Q(state:int,Q_table:list):
return max(Q_table[state])
Alpha=1
Gamma=0.8
def update_Q_table(state:int,action:int,Q_table:list,R_table:list):
Q_table[state][action]=Q_table[state][action]+Alpha*(R_table[state][action]+Gamma*find_max_Q(action,Q_table)-Q_table[state][action])
整个算法流程:
1.给定参数和Reward矩阵
2.初始化
3.For each episode:
3.1. 随机选一个初始状态
3.2. 若为到达目标状态,执行:
3.2.1. 在当前状态s随机选择一个,到达新状态
3.2.2. 更新
3.2.3. 令
#整个流程
import random
state_set = [0, 1, 2, 3, 4, 5]
action_set = [0, 1, 2, 3, 4, 5]
end_state_set = {5}
episode = 1000
for i in range(episode):
current_state = random.choice(state_set)
while True:
current_action = random.choice([i for i, r in enumerate(R_table[current_state]) if r >= 0])
update_Q_table(current_state, current_action, Q_table, R_table)
current_state = current_action
if current_state in end_state_set:
break
print(Q_table)
# Q_table迭代完成后,根据最大Q值寻找路径
def find_path(inital_state: int):
path = []
current_state = inital_state
path.append(current_state)
while current_state not in end_state_set:
current_state = Q_table[current_state].index(max(Q_table[current_state]))
path.append(current_state)
return path
DQN
既然有了Q-Learning,为什么还要DQN呢?因为Q-Learning本质上还是一个穷举算法。在前一个例子中,我们经过迭代,计算了每个状态下的所有动作的Q值。然而,对于很复杂的问题,靠穷举计算所有的Q值是不现实的。因此,使用神经网络来表示Q值的DQN就诞生了。我们以OpenAI Gym的一个仿真环境CartPole为例,通过源码来介绍DQN是如何解决大规模RL问题的。
强化学习环境--gym
Cart Pole即车杆游戏,游戏模型如上图所示。游戏里面有一个小车,上有竖着一根杆子,每次重置后的初始状态会有所不同。由于重力的存在,小车需要左右移动来保持杆子竖直,为了保证游戏继续进行需要满足以下两个条件:
- 杆子倾斜的角度不能大于15°
- 小车移动的位置需保持在一定范围(中间到两边各2.4个单位长度)
环境的状态变量(state variables):
- :小车在轨道上的位置(position of the cart on the track)
- :杆子与竖直方向的夹角(angle of the pole with the vertical)
- :小车速度(cart velocity)
- :角速度(rate of change of the angle)
动作(action):
- 左移(0)
- 右移(1)
游戏奖励(reward):
在gym的Cart Pole环境(env)里面,左移或者右移小车的action之后,env会返回一个+1的reward。其中CartPole-v0中到达200个reward之后,游戏也会结束,而CartPole-v1中则为500。
下面这段代码告诉我们如何使用这个环境。在使用之前,你需要安装gym这个包。该段代码来自gym官方文档。
# 引入gym包
import gym
# 采用CartPole环境
env = gym.make('CartPole-v0')
average_t = 0
for i_episode in range(20):
# 重置环境的状态,返回观察(observation),observation可以理解为(可观测)状态
observation = env.reset()
for t in range(100):
# 重绘环境的一帧
env.render()
# observation是一个1*4大小的数组,四个值分别代表小车位置,杆角度,小车速度,杆角速度,
print(observation)
# 从该环境的动作空间中随机挑选一个动作。动作空间有左移和右移两种
action = env.action_space.sample()
# step函数推进一个时间步长,返回值为observation, reward, done, info
observation, reward, done, info = env.step(action)
if done:
print("Episode finished after {} timesteps".format(t+1))
average_t = average_t + t + 1
break
# 输出平均结束时间。在随机动作下,这个值为20左右。
print("Average finished timesteps is {}".format(average_t))
# 关闭环境,并清除内存。
env.close()
在上面代码中使用了env.step()函数来对每一步进行仿真,在Gym中,env.step()会返回 4 个参数:
- 观测 Observation (Object):当前step执行后,环境的观测(类型为对象)。例如,从相机获取的像素点,机器人各个关节的角度或棋盘游戏当前的状态等;
- 奖励 Reward (Float): 执行上一步动作(action)后,智能体(agent)获得的奖励(浮点类型),不同的环境中奖励值变化范围也不相同,但是强化学习的目标就是使得总奖励值最大;
- 完成 Done (Boolen): 表示是否需要将环境重置 env.reset。大多数情况下,当 Done 为True 时,就表明当前回合(episode)或者试验(tial)结束。例如当机器人摔倒或者掉出台面,就应当终止当前回合进行重置(reset);
- 信息 Info (Dict): 针对调试过程的诊断信息。在标准的智体仿真评估当中不会使用到这个info,具体用到的时候再说。
总结来说,这就是一个强化学习的基本流程,即"agent-environment loop",在每个时间点上,智能体(可以认为是你写的算法)选择一个动作(action),环境返回上一次action的观测(Observation)和奖励(Reward),用图表示为
以上为gym的基本用法。在后面的训练过程中,state的获取并没有使用gym提供的api,而是直接读取了环境的图像,因为卷积神经网络有提取图像特征的能力。我们希望通过训练,为每个state找到reward最大的action,也就是让游戏持续时间最长。如果我们使用Q-learning算法,为每个state都记录两个action的Q值,那么这张Q表的规模是无法想象的,因为每一个不同的帧都可以认为是一个state。所以为了解决这种大规模的强化学习的问题,融合了神经网络和Q-Learning算法的DQN(Deep Q-Learning Network)就诞生了。
DQN的输入和输出
DQN是一个神经网络。它的输入是状态,输出是该状态下每个动作的Q值。
DQN的经验回放
经验回放就是把每一个时间步的状态,执行的动作,获得的奖励,以及执行到达的下一时间步的组成四元组,放到一个回放池中,训练时,神经网络会从池中随机选出四元组作为训练数据,通过小批量梯度下降更新权重参数。这么做,而不是直接使用游戏过程中实时遇到的数据的好处主要有:
- 打破了数据之间的相关性,也减少了随时间推移不同批次的训练数据差距过大的问题
- 数据利用率高,一条数据可以被多次利用
- 能够使神经网络利用很久以前的数据进行训练,克服了神经网络“健忘”的问题
DQN的固定目标网络
训练DQN的目的是使其能根据输入的state产生不同action的准确Q值。对于“准确Q值”的要求是,所有的Q值都必须满足贝尔曼方程。在DQN中,所有的Q值都是由同一个网络产生的,也就是说,DQN中的神经网络其实是做了两件事:产生预测值,产生标签。然而这就产生了一个问题,如果预测值和标签都是由一个网络产生的,那么当根据预测值和标签之间的loss更新网络权重时,标签也会会发生变化。如果预测值和标签同时变化,网络就不容易收敛。毕竟如果运动员和裁判是一个人,就没必要努力了是吧。因此,DQN用了两个网络,分别产生预测值和标签。论文中把产生预测值的网络叫做动作-值网络,把产生标签的网络叫做目标动作-值网络。这两个网络的结构是一样的,参数大部分时间是不一样的。我们训练的主要是动作-值网络,但是每隔一定的时间步就会把动作-值网络的参数赋值给目标动作-值网络。这样,目标动作-值网络的变化就不会很剧烈,促进了网络的收敛。事实上,GAN中也有同样的技巧。在GAN要训练生成器和判别器两个网络,我们选择每更新若干次判别器再更新一次生成器,这样也是为了使网络参数收敛。
DQN算法流程
初始化大小为的回放经验池
使用随机参数初始化动作-值网络
使用相同的随机参数 初始化目标动作-值网络
For do
初始化状态序列和预处理序列
For do
以的概率选择随机挑选一个动作
否则选择满足
执行动作,得到这一动作的奖励和新的图像
置并且置
在中存储转换
在中随机选出小批量的转换
置
根据损失函数执行一步梯度下降,更新
每步置
End For
End For
代码分析
以pytorch官方文档中DQN的代码实现为例,熟悉DQN的编写。在关键部分我会加上注释。为了运行这个代码,您必须要安装pytorch 1.0版和matplotlib。要说一下,episodes的次数不可设的过大,因为在训练中该代码并没有实时释放gym的内存。
import gym
import math
import random
import numpy as np
import matplotlib
import matplotlib.pyplot as plt
from collections import namedtuple
from itertools import count
from PIL import Image
import torch
import torch.nn as nn
import torch.optim as optim
import torch.nn.functional as F
import torchvision.transforms as T
# .unwrapped让开发人员能调整模型的底层数据,例如解开reward的最大限制等。在这里加入或删除unwrapped无影响
env = gym.make('CartPole-v0').unwrapped
# 建立 matplotlib,如果使用pycharm等IDE则可注释
is_ipython = 'inline' in matplotlib.get_backend()
print(is_ipython)
if is_ipython:
from IPython import display
plt.ion()
# 如果可以使用gpu,则将全局device设为gpu。device决定了pytorch会将tensor放在哪里运算。
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
print(device)
# namedtuple是一种特殊的数据结构,类似于C中的struct,也可以理解为只有属性的类
# Transition可以理解为类名,state,action,next_state,reward都为这个类的属性
# 声明一个namedtuple:Transition(state,action,next_state,reward)
Transition = namedtuple('Transition',
('state', 'action', 'next_state', 'reward'))
# 经验回放池。把每条Transition(包括state,action,next_state,reward)放入池中,从中随机抽样来训练网络
# 使用经验回放目的是打破每一条数据之间的相关性。神经网络应该只关心在一条训练数据中,输入state与action所得到的reward,借此来调整神经网络的参数
# 例如在一个游戏中相邻两帧之间的数据就是有“有相关性的”数据。经验回放就是从一场游戏中抽取若干不相邻的帧
class ReplayMemory(object):
def __init__(self, capacity):
# 经验回放池的最大容量
self.capacity = capacity
# 使用列表作为存储结构
self.memory = []
# 列表下标
self.position = 0
# 若池中尚有空间,直接追加新数据;若无空间,则覆盖旧数据
def push(self, *args):
if len(self.memory) < self.capacity:
self.memory.append(None)
self.memory[self.position] = Transition(*args)
self.position = (self.position + 1) % self.capacity
# 随机抽样一个batch_size大小的数据集
def sample(self, batch_size):
return random.sample(self.memory, batch_size)
def __len__(self):
return len(self.memory)
# DQN本体,一个卷积神经网络,继承于nn.Module,必须实现forward方法,该方法决定了数据如何前向通过网络
# 神经网络的输入为40*90游戏图像(图像包含着状态信息),输出为两个动作的Q值
class DQN(nn.Module):
def __init__(self, h, w):
super(DQN, self).__init__()
# nn.Conv2d的参数依次为:输入维度,输出维度,卷积核大小,步长
self.conv1 = nn.Conv2d(3, 16, kernel_size=5, stride=2)
self.bn1 = nn.BatchNorm2d(16)
self.conv2 = nn.Conv2d(16, 32, kernel_size=5, stride=2)
self.bn2 = nn.BatchNorm2d(32)
self.conv3 = nn.Conv2d(32, 32, kernel_size=5, stride=2)
self.bn3 = nn.BatchNorm2d(32)
# 线性输入连接的数量取决于conv2d层的输出,因此需要计算输入图像的大小。
def conv2d_size_out(size, kernel_size=5, stride=2):
return (size - (kernel_size - 1) - 1) // stride + 1
convw = conv2d_size_out(conv2d_size_out(conv2d_size_out(w)))
convh = conv2d_size_out(conv2d_size_out(conv2d_size_out(h)))
linear_input_size = convw * convh * 32
self.head = nn.Linear(linear_input_size, 2) # 448 或者 512
def forward(self, x):
x = F.relu(self.bn1(self.conv1(x)))
x = F.relu(self.bn2(self.conv2(x)))
x = F.relu(self.bn3(self.conv3(x)))
x = self.head(x.view(x.size(0), -1))
return x
# 定义了一个流水线函数,把以下三个步骤整合到一起:1. 把tensor转为图像;2.调整图像大小,把较短一条边的长度设为40;3.将图像转为tensor
resize = T.Compose([T.ToPILImage(),
T.Resize(40, interpolation=Image.CUBIC),
T.ToTensor()])
# 获取小车的中心位置,用于裁剪图像
def get_cart_location(screen_width):
# 小车左右横跳的宽度是4.8
world_width = env.x_threshold * 2
scale = screen_width / world_width
return int(env.state[0] * scale + screen_width / 2.0) # 车子的中心
# 获取环境的图像,可以不关注细节
def get_screen():
# 返回 gym 需要的400x600x3 图片, 但有时会更大,如800x1200x3. 将其转换为torch (CHW).
screen = env.render(mode='rgb_array').transpose((2, 0, 1))
# 车子在下半部分,因此请剥去屏幕的顶部和底部。
_, screen_height, screen_width = screen.shape
# print(screen.shape)
screen = screen[:, int(screen_height * 0.4):int(screen_height * 0.8)]
view_width = int(screen_width * 0.6)
cart_location = get_cart_location(screen_width)
if cart_location < view_width // 2:
slice_range = slice(view_width)
elif cart_location > (screen_width - view_width // 2):
slice_range = slice(-view_width, None)
else:
slice_range = slice(cart_location - view_width // 2,
cart_location + view_width // 2)
# 去掉边缘,这样我们就可以得到一个以车为中心的正方形图像。
screen = screen[:, :, slice_range]
# 转化为 float, 重新裁剪, 转化为 torch 张量(这并不需要拷贝)
screen = np.ascontiguousarray(screen, dtype=np.float32) / 255
screen = torch.from_numpy(screen)
# 重新裁剪,加入批维度 (BCHW)
return resize(screen).unsqueeze(0).to(device)
env.reset()
plt.figure()
# gpu中的tensor转成numpy数组必须移入cpu
plt.imshow(get_screen().cpu().squeeze(0).permute(1, 2, 0).numpy(),
interpolation='none')
plt.title('Example extracted screen')
plt.show()
# BATCH_SIZE大小
BATCH_SIZE = 128
# gamma
GAMMA = 0.999
# 选择随机动作的概率,就是论文中的epsilon,上界是0.9
EPS_START = 0.9
# 选择随机动作的概率,下界是0.05
EPS_END = 0.05
# 从上界到下界的衰减速率
EPS_DECAY = 200
# 每个多少回合,更新target网络的Q值
TARGET_UPDATE = 10
# 获取屏幕大小,以便我们可以根据从ai-gym返回的形状正确初始化层。这一点上的典型尺寸接近3x40x90,这是在get_screen()中抑制和缩小的渲染缓冲区的结果。
init_screen = get_screen()
_, _, screen_height, screen_width = init_screen.shape
# 创建两个网络
# 实时训练的网络
policy_net = DQN(screen_height, screen_width).to(device)
# 目标网络,每隔若干回合更新一次
target_net = DQN(screen_height, screen_width).to(device)
# 把policy_net的权重参数复制给目标网络
target_net.load_state_dict(policy_net.state_dict())
target_net.eval()
# RMSprop优化器
optimizer = optim.RMSprop(policy_net.parameters())
# 经验回放池大小
memory = ReplayMemory(10000)
# 全局变量,记录了网络训练的步数
steps_done = 0
def select_action(state):
global steps_done
sample = random.random()
# 与论文不同的是,这里的epsilon是变化的,随着训练的进行,选取随机动作的概率会越来越小
eps_threshold = EPS_END + (EPS_START - EPS_END) * \
math.exp(-1. * steps_done / EPS_DECAY)
steps_done += 1
if sample > eps_threshold:
with torch.no_grad():
# t.max(1)将为每行的列返回最大值。max result的第二列是找到max元素的索引,因此我们选择预期回报较大的操作。
return policy_net(state).max(1)[1].view(1, 1)
else:
return torch.tensor([[random.randrange(2)]], device=device, dtype=torch.long)
episode_durations = []
# 画图函数,横坐标是训练批次(一次游戏结束为一个批次),纵坐标为游戏的reward,也就是持续时间
def plot_durations():
plt.figure(2)
plt.clf()
durations_t = torch.tensor(episode_durations, dtype=torch.float)
plt.title('Training...')
plt.xlabel('Episode')
plt.ylabel('Duration')
plt.plot(durations_t.numpy())
# 平均 100 次迭代画一次
if len(durations_t) >= 100:
means = durations_t.unfold(0, 100, 1).mean(1).view(-1)
means = torch.cat((torch.zeros(99), means))
plt.plot(means.numpy())
plt.pause(0.001) # 暂定一会等待屏幕更新
# if is_ipython:
# display.clear_output(wait=True)
# display.display(plt.gcf())
# 该函数手动实现了一个minibatch梯度下降
# 如果结合注释还是看不懂,建议手动输出一下看看
def optimize_model():
if len(memory) < BATCH_SIZE:
return
# 抽样
transitions = memory.sample(BATCH_SIZE)
# 将BATCH_SIZE个的四元组转换成一个大的四元组,每一个元素都是大小为BATCH_SIZE的列表
batch = Transition(*zip(*transitions))
# non_final_mask由0和1组成,0表示该索引位置对应的state是结束状态,1表示不为结束状态
non_final_mask = torch.tensor(tuple(map(lambda s: s is not None,
batch.next_state)), device=device, dtype=torch.uint8)
# 直接存储状态图像,把不为空的state首尾拼接(cat():拼接函数)
# 大小小于等于BATCH_SIZE
non_final_next_states = torch.cat([s for s in batch.next_state
if s is not None])
state_batch = torch.cat(batch.state)
action_batch = torch.cat(batch.action)
reward_batch = torch.cat(batch.reward)
# 计算Q(s_t, a),在后面用于计算损失
state_action_values = policy_net(state_batch).gather(1, action_batch)
# next_state_values[non_final_mask]这一句的作用是,若对应位置的mask为1,则将该值设为下一个状态的最大Q动作的Q值
# 否则保持初始值0
next_state_values = torch.zeros(BATCH_SIZE, device=device)
next_state_values[non_final_mask] = target_net(non_final_next_states).max(1)[0].detach()
# 根据贝尔曼公式计算期望 Q 值
expected_state_action_values = (next_state_values * GAMMA) + reward_batch
# 计算两个网络的两个Q值的Huber 损失
loss = F.smooth_l1_loss(state_action_values, expected_state_action_values.unsqueeze(1))
# 优化模型
optimizer.zero_grad()
loss.backward()
for param in policy_net.parameters():
# 梯度大小限制在-1和1之间,防止梯度爆炸
param.grad.data.clamp_(-1, 1)
optimizer.step()
num_episodes = 300
for i_episode in range(num_episodes):
# 初始化环境和状态
env.reset()
last_screen = get_screen()
current_screen = get_screen()
# 这里把两帧图像之间的差异设为state,我个人不太理解
state = current_screen - last_screen
for t in count():
# 选择并执行动作
action = select_action(state)
_, reward, done, _ = env.step(action.item())
reward = torch.tensor([reward], device=device)
# 观察新状态
last_screen = current_screen
current_screen = get_screen()
if not done:
next_state = current_screen - last_screen
else:
next_state = None
# 在内存中储存当前参数
memory.push(state, action, next_state, reward)
# 进入下一状态
state = next_state
# 进行一步优化
optimize_model()
if done:
episode_durations.append(t + 1)
print("Episode {} finished after {} timesteps".format(i_episode,t + 1))
plot_durations()
break
# 更新目标网络, 复制在 DQN 中的所有权重参数
if i_episode % TARGET_UPDATE == 0:
target_net.load_state_dict(policy_net.state_dict())
print('Complete')
env.render()
env.close()
plt.ioff()
plt.show()
整个模型的流程如下图所示:
模型的训练过程如下图,横坐标是训练批次(一次游戏结束为一个批次),纵坐标为游戏的reward。由于训练批次较少,所以效果一般,不过还是比random choice好了不少华莱士。