【深度强化学习】DQN:深度Q网络

前言

重读《Deep Reinforcemnet Learning Hands-on》, 常读常新, 极其深入浅出的一本深度强化学习教程。 本文的唯一贡献是对其进行了翻译和提炼, 加一点自己的理解组织成一篇中文笔记。

原英文书下载地址: 传送门
原代码地址: 传送门

第六章 DQN

DQN,其实是我看这本书的初衷, 大名鼎鼎的改变了强化学习领域的方法 。前一章中,我们熟悉了贝尔曼方程, 并介绍了值迭代方法。 虽然我们在FrokenLake游戏中, 很好地使用值迭代的方法解决了问题, 但在许多更复杂的情况下, 例如Atari游戏中, 状态的维度可能会爆炸——比如一张图片。 同时, 其中99%的状态可能只是在浪费时间——他们可能并不会出现在游戏中或很少出现。 总之, 这一系列问题使得看似成功的Q-learning方法很难找到适用的问题。 维度爆炸是其面临的最大挑战。

表格化的Q-learning

在值迭代算法中, 我们真的需要关心那些几乎不出现的state吗? 因此, 我们其实可以省略掉这些值的迭代,或者说,我们更在意实际中出现的状态的动作Q值。 Q-learning就是这样的:

  • 初始化一张空的Q表,行为状态, 列为动作, 每个格子代表当前状态时采用不同动作的Q值。
  • 不断和环境交互, 得到s, a, r, s’ (状态,动作, 奖励, 新状态)。与环境的交互,我们一般采用这样的策略: 一定概率按当前已迭代的Q表进行决策, 一定概率按随机动作进行探索。
  • 根据贝尔曼方程,更新Q值。 (利用上一步中获取的经验, 即s, a, r, s’)
    Q s , a r + γ max a A Q s , a Q_{s, a} \leftarrow r+\gamma \max _{a^{\prime} \in A} Q_{s^{\prime}, a^{\prime}}
  • 一直重复步骤2,3, 完善Q表

为了训练更稳定, 我们也往往在第三步中采用如下的更新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)
有一个 α \alpha 参数代表学习率, 当 α = 1 \alpha=1 时, Q值完全更新为新计算得到的Q值。 否则, 将在保留一部分历史Q值的基础上,进行修改微调。

Deep Q-learning

一如文章开头提到, 上一节中所说的Q-learning方法, 在面对复杂问题时显得非常挣扎——比如Atari游戏,存在的状态实在过多——而且均可能在游戏中出现,因此,用Q-learning直接去做,非常的复杂。 甚至在有些游戏如CartPole中, 状态的数目可能是无限的——因为部分参数值是连续值。

为了解决这个问题,我们可以换一种思路: 我们不再维护一张Q表, 并通过查表得到Q值。 相反, 我们试图得到一种非线性变换, 可以将输入的状态转变为相应动作的Q值。 许多读者可能已经猜到了, 这种在机器学习中很常见的“回归问题”,当下最流行的方法,就是使用神经网络来解决。 根据这一思路,我们提出了DQN的雏形:

  • 基于一些初始的估计, 初始化Q网络 Q(s,a)
  • 和环境交互, 获得 (s, a, r, s’)
  • 计算损失值,
    L = ( Q s , a ( r + γ max a A Q s , a ) ) 2 \mathcal{L}=\left(Q_{s, a}-\left(r+\gamma \max _{a^{\prime} \in A} Q_{s^{\prime}, a^{\prime}}\right)\right)^{2}
  • 使用梯度下降法,优化Q网络, 降低损失值
  • 从2步骤开始重复,直到收敛。

现在,传统Q-learning方法中的Q表,在这里就变成了神经网络, 其他部分其实是不变的——3和4步骤其实就是对应了Q-learning中的第3步——让更新的Q值(DQN中通过训练网络)尽可能满足贝尔曼方程。 这个算法看起来非常巧妙和简单, 但是,存在一些问题。

和环境的交互

显然,我们需要和环境进行交互, 获取足够的经验。 但是,如果简单地用随机的策略进行与环境的交互, 很可能会得到许多无用的经验——FrokenLake例子还好, 比如Atari的Pong游戏(乒乓游戏),赢一球才能得分——如果随机出板的话, 基本没有战胜电脑得分的可能性,也就是说, 绝大部分的经验都是无效的。 因此, 为了获取更多有效的策略, 我们可以用自己正在训练的Q网络来进行决策。

这样的话也有一个弊端, 如果Q网络本身训练的不够好,或者只是个局部优解, 因此,我们也需要对环境进行一些尝试和探索。

常用的就是 ϵ \epsilon -greedy方法——每次与环境交互时,有 ϵ \epsilon 概率随机选择动作, 1 ϵ 1-\epsilon 的概率通过Q网络选择动作。 训练开始的时候, 我们会设定 ϵ = 1 \epsilon=1 ,即随机选择动作,进行初始化的经验积累。 后面随着训练的推进, 逐渐降低 ϵ \epsilon 值, 慢慢降低到 2 % 5 % 2\% \sim 5\%

SGD优化

在DQN中,我们建立深度网络,而我们采用的优化方法也就是 最常用的 SGD, 随机梯度下降法。 但需要注意的是, SGD方法要求,训练样本应当满足 i.i.d.分布, 即independent and identically distributed, 独立同分布。

然而,在我们刚刚提出的算法中,无法满足这一条件:

  • 我们的样本来自于同一局游戏,那么他们之间有很强的关联性。
  • 我们的样本可能并不支撑我们训练最好的网络——样本并不来源于最优策略,而是通过 ϵ \epsilon -greedy策略获得。

为解决这一问题, 一种有效的方法叫 经验池 方法, 我们首先获取了大量的经验样本, 并将它们存在内存中(经验池中)。 然后在训练中, 我们每次从中取出一批样本,进行训练。同时,随着训练的推进,我们会不断更新新的经验, 而经验池的总大小是固定的,也就是说, 我们会加入新的经验,然后排出旧的经验。

经验池方法让我们可以尽可能地在不相关的数据上进行训练, 同时保持更新。

步骤间的相关性

根据贝尔曼公式: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的挑战和相应的调整, 我们可以总结出规范的DQN流程:

  • 随机初始化Q(s,a)网络和 Q ^ \hat{Q} 网络,清空经验池
  • ϵ \epsilon 的概率随机选择动作, 否则 a = a r g m a x a Q ( s , a ) a=\mathrm{argmax}_a Q(s,a)
  • 执行动作a, 获得s’ 和 r
  • 将 (s,a,r,s’)保存到经验池中。
  • 从经验池中采用一组随机的经验 ( s i , a i , r i , s i s_i,a_i,r_i,s'_i i = 0 , . . . , n i=0, ...,n
  • 对于每一条经验,计算其标签值: y = r + γ max a A Q ^ s , a y=r+\gamma \max _{a^{\prime} \in A} \hat{Q}_{s^{\prime}, a^{\prime}}
  • 计算损失函数值: L = ( Q s , a y ) 2 \mathcal{L}=\left(Q_{s, a}-y\right)^{2}
  • 用SGD方法,更新Q(s,a)网络来最小化损失值。
  • 每N步后, Q ^ = Q \hat{Q}=Q
  • 重复步骤2

用DQN方法,解决Pong游戏

代码详见 Chapter06/02_dqn_pong.py
由于pytorch版本的不同等原因, 有一些常见的bug,这里列出来,方便大家修改:

  • 开头路径的修改
    如果是直接clone的github库,路径上会报错, 在lib前加上Chapter06就行了。
#!/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

对Gym游戏的装饰

  • 每局Gym游戏可以被切分成几个部分——比如某些游戏,玩家有多条命,可以将一个episode拆分成多个部分。
  • 每K帧做一个动作决策——K经常取3或4, 可以理解为这3,4帧中, 重复了同一个动作。 这可以加速训练——因为用神经网络去处理每一帧会显得很耗时,而且提升极小。
  • 取最新的两帧的每个像素点的最大值作为观测。可以有效解决Atari游戏偶尔的闪烁情况。
  • 有些游戏要求玩家开始游戏时要按下一个“FIRE”button,这个显然可以让网络去学习去按。 但从方便角度出发,我们之间用Gym的装饰器来实现。
  • 把每帧 210 × 160 210\times 160 大小的三色图片, 缩小处理为 84 × 84 84\times 84 的单色图片。
  • 把几个连续帧叠加在一起给网络,让网络获得动态的游戏信息。
  • 把不同游戏的奖励, 统一归一化到 -1到1之间。
  • 0~255的颜色值, 会被归一化到0 ~1之间。

DQN模型代码

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方法, 从经验池中提取一小组训练样本。

Agent

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()

猜你喜欢

转载自blog.csdn.net/weixin_39274659/article/details/107291587