对于像《Space Invaders》这样的 Atari 游戏,我们需要把游戏帧作为状态和输入,单帧图像由 210x160 像素组成。由于图像为彩色(RGB),因此包含 3 个通道。所以观测空间形状为 (210, 160, 3)。每个像素的值范围从 0 到 255,所以共有 种可能的观测结果。
在这种情况下生成和更新 Q 表效率会很低。因此我们会使用 Deep Q-Learning 而不是 Q-Learning 这样的 Tabular Method,选择用神经网络作为 Q 函数的近似器。该神经网络将根据给定状态,近似估计该状态下每个可能动作的 Q 值。
DQN#
输入预处理与时序局限性#
我们肯定希望降低状态复杂度以减少训练所需的计算时间。
灰度化#
颜色并不提供重要信息,因此可以将三个颜色通道(RGB)减少到一个。
剪裁屏幕#
不包含重要信息的区域可以剪裁掉。
捕获时序信息#
无法通过单帧知道一个像素的运动信息(方向、速度),为了得到时序信息,我们将四帧堆叠在一起。
CNN#
堆叠的帧通过三个卷积层进行处理,目的是 捕捉并利用图像中的空间关系。此外,由于帧是堆叠在一起的,我们还可以得到跨帧的时间信息。
MLP#
最后是全连接层作为输出,为该状态下每个可能的动作输出一个 Q 值。
class QNetwork(nn.Module):
def __init__(self, env):
super().__init__()
self.network = nn.Sequential(
nn.Conv2d(4, 32, 8, stride=4),
nn.ReLU(),
nn.Conv2d(32, 64, 4, stride=2),
nn.ReLU(),
nn.Conv2d(64, 64, 3, stride=1),
nn.ReLU(),
nn.Flatten(), # 展平多维输入为一维
nn.Linear(3136, 512), # 全连接层,将3136维输入映射到512维
nn.ReLU(),
nn.Linear(512, env.single_action_space.n), # 输出层,对应动作空间的维度
)
def forward(self, x):
return self.network(x / 255.0) # 将输入归一化到[0,1]范围
网络的输入:通过网络传递的4 帧堆栈作为状态
输出:该状态下每个可能动作的Q 值向量。
然后与 Q-Learning 类似,我们只需使用 epsilon-greedy 策略来选择采取哪个动作。
在训练阶段,我们不再像 Q - 学习那样直接更新状态 - 动作对的 Q 值:通过设计损失函数和梯度下降来优化 DQN 的权重。
训练流程#
深度 Q 学习训练算法有两个阶段:
- 采样:执行操作并将观察到的经验元组存储在重放缓存中。
- 训练:随机选择一小批量元组,并利用梯度下降更新步骤从该批次中学习。
由于深度 Q 学习(off-policy)中结合了非线性的 Q-Value 函数(神经网络)和自举法(即使用现有估计而非实际完整回报来更新目标,有偏),其训练过程可能会出现不稳定性。Sutton 和 Barto 提出的 “deadly triad” 指的正是这种情况。
稳定训练#
为了帮助我们稳定训练,我们实施了三种不同的解决方案:
- 经验回放以更高效利用经验。
- 固定 Q-Target 以稳定训练。
- 双重 DQN,用于解决 Q 值过高估计的问题。
经验回放#
深度 Q 学习中的经验回放具有两个功能:
- 更高效地利用训练过程中的经验。通常的在线强化学习中,智能体与环境交互获取经验(状态、动作、奖励和下一状态),从中学习(更新神经网络),然后丢弃这些经验的方式是非常低效的。经验回放通过更高效地利用训练经验来提供帮助。我们使用一个回放缓冲区来保存经验样本,以便在训练期间重复使用。
Agent 能够从相同经验中多次学习。
- 避免遗忘先前的经验(即灾难性干扰,或灾难性遗忘)并减少经验之间的关联性。Replay Buffer 的设置可以在与环境交互时存储经验元组,然后从中抽取一小批元组。这防止网络仅学习最近执行的操作。通过随机抽样经验,可以使接触到的经验多样化,防止过度拟合短期状态,并避免了动作值的剧烈波动或灾难性发散。
采样经验并计算损失:
rb = ReplayBuffer(
args.buffer_size, # 回放缓冲区大小,决定存储多少经验。
envs.single_observation_space,
envs.single_action_space,
device,
optimize_memory_usage=True,
handle_timeout_termination=False,
)
if global_step > args.learning_starts:
if global_step % args.train_frequency == 0:
data = rb.sample(args.batch_size) # 随机采样一个 batch
with torch.no_grad():
target_max, _ = target_network(data.next_observations).max(dim=1)
td_target = data.rewards.flatten() + args.gamma * target_max * (1 - data.dones.flatten())
old_val = q_network(data.observations).gather(1, data.actions).squeeze()
loss = F.mse_loss(td_target, old_val)
固定 Q-Target#
在 Q-Learning 有一个关键的问题是,TD 目标(即 Q-Target)和当前 Q Value(即 Q 估计)的参数是共享的。这导致了 Q 目标和 Q 估计同时变化,就像你在追逐一个不断移动的目标。一个美妙的比喻方式是一个牛仔(Q 估计)试图追赶一头移动的奶牛(Q 目标)。尽管牛仔逐渐接近奶牛(误差减少),但目标仍然在移动,导致训练中的显著振荡。
好喜欢这个表示🥹
为了解决这个问题,我们引入了一个固定的 Q-Target。它的核心思想是引入一个独立的网络,它不会在每个时间步都更新,而是每隔 C 步将主网络的参数复制到这个目标网络中。这意味着我们在多个时间步内的目标(Q-Target)保持固定,并且仅根据旧的估计来更新网络。这样就能显著减少目标和估计之间的振荡问题。
如上面的伪代码所示,关键在于使用两个不同的网络:一个是主网络(用来选择动作并进行更新),另一个是目标网络(用来计算 Q-Target),并且每隔 C 步会将主网络的权重拷贝到目标网络中。这样,我们可以稳定训练过程,使 “牛仔能够更有效地追逐奶牛”,减少振荡并加快收敛速度。
q_network = QNetwork(envs).to(device) # 当前策略网络,负责选择动作和预测Q值
optimizer = optim.Adam(q_network.parameters(), lr=args.learning_rate)
target_network = QNetwork(envs).to(device) # 目标网络,计算TD目标,提供稳定的学习目标。
target_network.load_state_dict(q_network.state_dict()) # 初始化:目标网络的参数与当前策略网络相同
每隔 args.target_network_frequency
步将主网络的参数全量复制到目标网络。这意味着在多个时间步内,Q-Target 保持固定,仅根据旧的估计来更新网络,从而显著减少目标和估计之间的振荡问题。
tau = 1.0
if global_step % args.target_network_frequency == 0:
for target_param, param in zip(target_network.parameters(), q_network.parameters()):
target_param.data.copy_(args.tau * param.data + (1.0 - args.tau) * target_param.data)
Double DQN#
Double DQN 是由 Hado van Hasselt 提出的,专门用于解决 Q 值过估计的问题。
在 Q-Learning 的 TD-Target 计算中,一个常见问题是 “如何确定下一个状态的最佳动作是 Q 值最高的动作?” 我们知道,Q 值的准确性取决于我们尝试的动作和探索过的邻近状态。因此,在训练初期,关于最佳动作的信息并不充分。如果仅根据最高 Q 值来选择动作,可能会导致错误判断。
举例来说,如果非最优的动作被赋予了一个高于最佳动作的 Q 值,学习过程将变得复杂,难以收敛。为了解决这个问题,Double DQN 引入了两个网络来解耦动作选择和 Q 值目标的生成:
- 主网络(DQN 网络) 用于选择下一个状态的最佳动作(即 Q 值最高的动作)。
- 目标网络(Target 网络) 用于计算执行该动作后产生的目标 Q 值。
with torch.no_grad():
# 使用主网络选择下一个状态的最佳动作
next_q_values = q_network(data.next_observations)
next_actions = torch.argmax(next_q_values, dim=1, keepdim=True)
# 使用目标网络评估这些动作的 Q Value
target_q_values = target_network(data.next_observations)
target_max = target_q_values.gather(1, next_actions).squeeze()
# 计算 TD-Target
td_target = data.rewards.flatten() + args.gamma * target_max * (1 - data.dones.flatten())
# 计算当前 Q Value
old_val = q_network(data.observations).gather(1, data.actions).squeeze()
loss = F.mse_loss(td_target, old_val)
现代的深度强化学习中还有进一步改进的技术,如优先级经验回放和决斗网络(Dueling Networks),这里暂不涉及。
Optuna#
深度强化学习中最关键的任务之一是找到一组良好的训练超参数。Optuna 是一个帮助你自动化搜索最佳的超参数组合的库。
Policy Gradient#
前面的 Q-Learning 和 DQN 都属于 Value-based 方法,它们通过估计价值函数来间接地寻找最优策略。策略()的存在完全依赖于动作价值的估计,因为策略是一个从价值函数生成的,比如贪婪策略,在给定状态下选择具有最高价值的动作。
而通过基于 Policy-based 的方法,我们希望直接优化策略,从而绕过学习价值函数的中间步骤。接下来,我们将深入学习其中的一个子集,即策略梯度(Policy Gradient)。
在基于策略的方法中,优化大多数情况下是 on-policy 的,因为在每次更新时,我们仅使用由 最新版本的 收集的数据(行动轨迹)。
参数化随机策略#
例如让神经网络 输出一个动作的概率分布(随机策略):
目标函数 ,优化参数 ,通过梯度上升最大化参数化策略的性能。
优点#
方便集成#
- 可以直接估计策略,而无需存储额外数据(action value),可以理解为是端到端的。
可以学习随机策略#
由于输出是动作的概率分布,agent 能够探索状态空间而不总是遵循相同的轨迹,无需手动实现探索 / 利用权衡。DNQ 学习的是确定性策略(deterministic policy),我们是通过一些技巧(如 ε- 贪婪策略)引入了随机性,但这并不是价值函数方法的内在特性。同时能够自然地处理状态的不确定性,解决了感知混淆问题。
例如在下面的情景中,agent 吸尘器要吸走灰尘并避免伤害仓鼠,吸尘器只能感知墙壁的位置。在图中这两个红色状态被称为 “混淆状态”(aliased states),因为在这些状态中,agent 感知到的是墙壁的位置 —— 即在上方和下方都有墙壁。这导致了状态的模糊性,无法区分出它是在哪个具体的红色状态。
在使用确定性策略时,吸尘器在红色状态下总是向右或向左移动,若选择错误方向,则会陷入循环。即使使用 ε- 贪心策略,吸尘器主要遵循最佳策略,但在错误状态下仍可能反复探索错误方向,导致效率低下。
高维、连续动作空间中有效#
在高维或连续动作空间中策略梯度方法尤为有效。
自动驾驶汽车在每个状态下可能有无穷多的动作选择 —— 比如方向盘可以转动 15°,17.2°, 19.4°,或者进行其他动作如鸣笛。Deep Q-Learning 必须为每个可能动作计算 Q 值,而连续动作空间中选取最大 Q 值本身也是个优化问题。
相反,策略梯度方法直接输出动作的概率分布,无需计算和存储每个动作的 Q 值,在复杂的连续动作场景下更加高效。
收敛性更好#
在值方法中,我们通过 来取最大 Q 值来更新策略。这种情况下即使 Q 值发生细微变化,动作选择可能会剧烈改变。例如,训练时左转的 Q 值为 0.22,接着右转的 Q 值变为 0.23,策略将发生了显著变化,会更多地选择向右而不是向左。
而在策略梯度方法中,动作的概率随时间平滑变化,策略更稳定。
缺点#
局部最优#
常收敛于局部最优而非全局最优。
训练效率低#
训练过程缓慢,效率低。
high variance#
方差较大,这一点会在后面的 actor-critic 中探讨原因及解决方法。
具体分析#
策略梯度是通过每次 agent 与环境交互来调整参数(策略),使动作的概率分布能够更多地采样到那些能最大化回报的良好动作。
目标函数#
我们的目标是找到能够最大化期望回报的参数 :
由于这是一个凹函数(我们希望值最大),所以用梯度上升方法: .
然而目标函数的真实梯度是无法计算的,因为它需要计算每条可能轨迹的概率,这在计算上极其昂贵。因此,我们希望通过基于样本的估计(收集一些轨迹)来计算梯度估计。
除此之外,环境的状态转移概率(或状态分布)往往是未知的,或者即使已知,它也是复杂的、非线性的,无法直接计算其导数,也就是无法直接对这种状态转移动力学(受马尔可夫决策过程控制)进行微分,来优化策略。
Policy Gradient Theorem#
完整的推导可以在 Andrej Karpathy 的博客里看到,我这里之前也做了学习总结:
Policy Gradient 入门 6. PG 推导
强化算法(蒙特卡洛强化)#
利用整个 episode 的估计回报来更新策略参数 .
使用策略 收集一个片段 ,利用该 episode 来估计梯度
优化:
-
:
这个部分表示对于某个状态 和动作 ,我们计算的不是具体的动作值(Q 值),而是动作概率的对数的梯度。 -
:
这里 是整个轨迹 上的累积回报,用来衡量执行策略 后的总回报。回报率高则提高(状态,动作)组合的概率,反之则降低。
也可以 收集多个片段(轨迹) 来估计梯度: