重读《Deep Reinforcemnet Learning Hands-on》, 常读常新, 极其深入浅出的一本深度强化学习教程。 本文的唯一贡献是对其进行了翻译和提炼, 加一点自己的理解组织成一篇中文笔记。
原英文书下载地址: 传送门
原代码地址: 传送门
DQN,其实是我看这本书的初衷, 大名鼎鼎的改变了强化学习领域的方法 。前一章中,我们熟悉了贝尔曼方程, 并介绍了值迭代方法。 虽然我们在FrokenLake游戏中, 很好地使用值迭代的方法解决了问题, 但在许多更复杂的情况下, 例如Atari游戏中, 状态的维度可能会爆炸——比如一张图片。 同时, 其中99%的状态可能只是在浪费时间——他们可能并不会出现在游戏中或很少出现。 总之, 这一系列问题使得看似成功的Q-learning方法很难找到适用的问题。 维度爆炸是其面临的最大挑战。
在值迭代算法中, 我们真的需要关心那些几乎不出现的state吗? 因此, 我们其实可以省略掉这些值的迭代,或者说,我们更在意实际中出现的状态的动作Q值。 Q-learning就是这样的:
为了训练更稳定, 我们也往往在第三步中采用如下的更新Q值方式:
Q s , a ← ( 1 − α ) Q s , a + α ( r + γ max a ′ ∈ A Q s ′ , a ′ ) Q_{s, a} \leftarrow(1-\alpha) Q_{s, a}+\alpha\left(r+\gamma \max _{a^{\prime} \in A} Q_{s^{\prime}, a^{\prime}}\right) Qs,a←(1−α)Qs,a+α(r+γa′∈AmaxQs′,a′)
有一个 α \alpha α参数代表学习率, 当 α = 1 \alpha=1 α=1时, Q值完全更新为新计算得到的Q值。 否则, 将在保留一部分历史Q值的基础上,进行修改微调。
一如文章开头提到, 上一节中所说的Q-learning方法, 在面对复杂问题时显得非常挣扎——比如Atari游戏,存在的状态实在过多——而且均可能在游戏中出现,因此,用Q-learning直接去做,非常的复杂。 甚至在有些游戏如CartPole中, 状态的数目可能是无限的——因为部分参数值是连续值。
为了解决这个问题,我们可以换一种思路: 我们不再维护一张Q表, 并通过查表得到Q值。 相反, 我们试图得到一种非线性变换, 可以将输入的状态转变为相应动作的Q值。 许多读者可能已经猜到了, 这种在机器学习中很常见的“回归问题”,当下最流行的方法,就是使用神经网络来解决。 根据这一思路,我们提出了DQN的雏形:
现在,传统Q-learning方法中的Q表,在这里就变成了神经网络, 其他部分其实是不变的——3和4步骤其实就是对应了Q-learning中的第3步——让更新的Q值(DQN中通过训练网络)尽可能满足贝尔曼方程。 这个算法看起来非常巧妙和简单, 但是,存在一些问题。
显然,我们需要和环境进行交互, 获取足够的经验。 但是,如果简单地用随机的策略进行与环境的交互, 很可能会得到许多无用的经验——FrokenLake例子还好, 比如Atari的Pong游戏(乒乓游戏),赢一球才能得分——如果随机出板的话, 基本没有战胜电脑得分的可能性,也就是说, 绝大部分的经验都是无效的。 因此, 为了获取更多有效的策略, 我们可以用自己正在训练的Q网络来进行决策。
这样的话也有一个弊端, 如果Q网络本身训练的不够好,或者只是个局部优解, 因此,我们也需要对环境进行一些尝试和探索。
常用的就是 ϵ \epsilon ϵ-greedy方法——每次与环境交互时,有 ϵ \epsilon ϵ概率随机选择动作, 1 − ϵ 1-\epsilon 1−ϵ的概率通过Q网络选择动作。 训练开始的时候, 我们会设定 ϵ = 1 \epsilon=1 ϵ=1,即随机选择动作,进行初始化的经验积累。 后面随着训练的推进, 逐渐降低 ϵ \epsilon ϵ值, 慢慢降低到 2 % ∼ 5 % 2\% \sim 5\% 2%∼5%。
在DQN中,我们建立深度网络,而我们采用的优化方法也就是 最常用的 SGD, 随机梯度下降法。 但需要注意的是, SGD方法要求,训练样本应当满足 i.i.d.分布, 即independent and identically distributed, 独立同分布。
然而,在我们刚刚提出的算法中,无法满足这一条件:
为解决这一问题, 一种有效的方法叫 经验池 方法, 我们首先获取了大量的经验样本, 并将它们存在内存中(经验池中)。 然后在训练中, 我们每次从中取出一批样本,进行训练。同时,随着训练的推进,我们会不断更新新的经验, 而经验池的总大小是固定的,也就是说, 我们会加入新的经验,然后排出旧的经验。
经验池方法让我们可以尽可能地在不相关的数据上进行训练, 同时保持更新。
根据贝尔曼公式:Q(s,a)的价值其实通过Q(s’, a’)给出。 然而,s和s’之间只相隔一个步骤,这就使得他们非常相似,且网络很难区分。 为了使得Q(s,a)的值更接近想要的结果, 我们会间接地改变了Q(s’, a’)的值, 这使得训练极其不稳定, 就像自己追着自己的尾巴。
因此,为了使训练更加稳定, 我们采取的策略是使用两个网络——Target目标网络和真正的Q网络。 我们训练的时候是训练真正的Q网络, 而此时产生的标签Q值,则是通过Target网络得到。 Target网络是真正的Q网络的复制, 但是有一个同步的时间差——即每经过N步训练后, 将Q网络复制给Target网络进行同步。 一般会选择10000步之后。
根据刚刚提到的DQN的挑战和相应的调整, 我们可以总结出规范的DQN流程:
代码详见 Chapter06/02_dqn_pong.py
由于pytorch版本的不同等原因, 有一些常见的bug,这里列出来,方便大家修改:
#!/usr/bin/env python3
from Chapter06.lib import wrappers
from Chapter06.lib import dqn_model
state_action_values = net(states_v).gather(1, actions_v.unsqueeze(-1)).squeeze(-1)
b = actions_v.unsqueeze(-1).type(torch.LongTensor)
state_action_values = net(states_v).gather(1, b).squeeze(-1)
使用torch张量的内置方法 .type()
,可以直接将张量修改成需要的类型。
还有一个会报警告的问题:
next_state_values[done_mask] = 0
会报这样的错误:
UserWarning: indexing with dtype torch.uint8 is now deprecated, please use a dtype torch.bool instead.
在此之前插入一句类型转换即可:
done_mask = done_mask.bool()
next_state_values[done_mask] = 0
import torch
import torch.nn as nn
import numpy as np
class DQN(nn.Module):
def __init__(self, input_shape, n_actions):
super(DQN, self).__init__()
self.conv = nn.Sequential(
nn.Conv2d(input_shape[0], 32, kernel_size=8, stride=4),
nn.ReLU(),
nn.Conv2d(32, 64, kernel_size=4, stride=2),
nn.ReLU(),
nn.Conv2d(64, 64, kernel_size=3, stride=1),
nn.ReLU()
)
conv_out_size = self._get_conv_out(input_shape)
self.fc = nn.Sequential(
nn.Linear(conv_out_size, 512),
nn.ReLU(),
nn.Linear(512, n_actions)
)
def _get_conv_out(self, shape):
o = self.conv(torch.zeros(1, *shape))
return int(np.prod(o.size()))
def forward(self, x):
conv_out = self.conv(x).view(x.size()[0], -1)
return self.fc(conv_out)
使用pytorch库, 将整个网络分成了两部分:输入的图像先经过卷积网络,即self.conv
,然后通过self.conv(x).view(x.size()[0], -1)
,将卷积层提取的3维张量,展开成一维张量,也就是类似于tensorflow中Flatten层的工作。 最后,再通过定义的全连接层即可。最终输出的是一个一维张量,维度为动作总数, 每个值就代表对应动作的Q值。
训练的代码详见Github库, 这里具体说下几个重要的部分:
Experience = collections.namedtuple('Experience', field_names=['state', 'action', 'reward', 'done', 'new_state'])
class ExperienceBuffer:
def __init__(self, capacity):
self.buffer = collections.deque(maxlen=capacity)
def __len__(self):
return len(self.buffer)
def append(self, experience):
self.buffer.append(experience)
def sample(self, batch_size):
indices = np.random.choice(len(self.buffer), batch_size, replace=False)
states, actions, rewards, dones, next_states = zip(*[self.buffer[idx] for idx in indices])
return np.array(states), np.array(actions), np.array(rewards, dtype=np.float32), \
np.array(dones, dtype=np.uint8), np.array(next_states)
首先,这里用collections.deque()
来创建经验池。collections.deque()
的特点是一个队列——有一个固定的长度,当经验池的大小超出队列长度时,会自动将最老的经验踢出,保持总长度不变。 其余用法类似列表。同时,经验池的每条经验用collections.namedtuple
命名元组来实现。 经验池通过append方法,往池里添加新经验。 最后,经验池实现了sample方法, 从经验池中提取一小组训练样本。
class Agent:
def __init__(self, env, exp_buffer):
self.env = env
self.exp_buffer = exp_buffer
self._reset()
def _reset(self):
self.state = env.reset()
self.total_reward = 0.0
def play_step(self, net, epsilon=0.0, device="cpu"):
done_reward = None
if np.random.random() < epsilon:
action = env.action_space.sample()
else:
state_a = np.array([self.state], copy=False)
state_v = torch.tensor(state_a).to(device)
q_vals_v = net(state_v)
_, act_v = torch.max(q_vals_v, dim=1)
action = int(act_v.item())
# do step in the environment
new_state, reward, is_done, _ = self.env.step(action)
self.total_reward += reward
exp = Experience(self.state, action, reward, is_done, new_state)
self.exp_buffer.append(exp)
self.state = new_state
if is_done:
done_reward = self.total_reward
self._reset()
return done_reward
Agent除了简单的初始化和reset动作, 最重要的就是step步骤,也就是获取经验的步骤,由play_step()
方法实现。 代码思想很简单——按 ϵ \epsilon ϵ-greedy策略进行动作选择, 然后将获得的经验 (s,a,r,s’)存储进经验池。
def calc_loss(batch, net, tgt_net, device="cpu"):
states, actions, rewards, dones, next_states = batch
states_v = torch.tensor(states).to(device)
next_states_v = torch.tensor(next_states).to(device)
actions_v = torch.tensor(actions).to(device)
rewards_v = torch.tensor(rewards).to(device)
done_mask = torch.ByteTensor(dones).to(device)
done_mask = done_mask.bool()
b = actions_v.unsqueeze(-1).type(torch.LongTensor)
state_action_values = net(states_v).gather(1, b).squeeze(-1)
next_state_values = tgt_net(next_states_v).max(1)[0]
next_state_values[done_mask] = 0
next_state_values = next_state_values.detach()
expected_state_action_values = next_state_values * GAMMA + rewards_v
return nn.MSELoss()(state_action_values, expected_state_action_values)
这里就是通过贝尔曼方程 和 target Q网络(tgt_net)来计算标签值expected_state_action_values
。注意如果当前为done状态时,下一状态的Q值为0.
env = wrappers.make_env(args.env)
net = dqn_model.DQN(env.observation_space.shape, env.action_space.n).to(device)
tgt_net = dqn_model.DQN(env.observation_space.shape, env.action_space.n).to(device)
writer = SummaryWriter(comment="-" + args.env)
print(net)
buffer = ExperienceBuffer(REPLAY_SIZE)
agent = Agent(env, buffer)
epsilon = EPSILON_START
optimizer = optim.Adam(net.parameters(), lr=LEARNING_RATE)
total_rewards = []
frame_idx = 0
ts_frame = 0
ts = time.time()
best_mean_reward = None
这一段先进行了初始化——Q网络,目标网络, 环境, 经验池, Agent和优化器等参数。
while True:
frame_idx += 1
epsilon = max(EPSILON_FINAL, EPSILON_START - frame_idx / EPSILON_DECAY_LAST_FRAME)
reward = agent.play_step(net, epsilon, device=device)
if reward is not None:
total_rewards.append(reward)
speed = (frame_idx - ts_frame) / (time.time() - ts)
ts_frame = frame_idx
ts = time.time()
mean_reward = np.mean(total_rewards[-100:])
print("%d: done %d games, mean reward %.3f, eps %.2f, speed %.2f f/s" % (
frame_idx, len(total_rewards), mean_reward, epsilon,
speed
))
writer.add_scalar("epsilon", epsilon, frame_idx)
writer.add_scalar("speed", speed, frame_idx)
writer.add_scalar("reward_100", mean_reward, frame_idx)
writer.add_scalar("reward", reward, frame_idx)
if best_mean_reward is None or best_mean_reward < mean_reward:
torch.save(net.state_dict(), args.env + "-best.dat")
if best_mean_reward is not None:
print("Best mean reward updated %.3f -> %.3f, model saved" % (best_mean_reward, mean_reward))
best_mean_reward = mean_reward
if mean_reward > args.reward:
print("Solved in %d frames!" % frame_idx)
break
if len(buffer) < REPLAY_START_SIZE:
continue
if frame_idx % SYNC_TARGET_FRAMES == 0:
tgt_net.load_state_dict(net.state_dict())
optimizer.zero_grad()
batch = buffer.sample(BATCH_SIZE)
loss_t = calc_loss(batch, net, tgt_net, device=device)
loss_t.backward()
optimizer.step()