注意
点击此处下载完整示例代码
使用 TorchRL 进行强化学习 (PPO) 教程¶
创建于: 2023 年 3 月 15 日 | 最后更新于: 2025 年 3 月 20 日 | 最后验证于: 2024 年 11 月 5 日
作者: Vincent Moens
本教程演示如何使用 PyTorch 和 torchrl
训练一个参数化策略网络来解决来自 OpenAI-Gym/Farama-Gymnasium 控制库的倒立摆任务。

倒立摆¶
关键学习点
如何在 TorchRL 中创建环境、转换其输出并从该环境中收集数据;
如何使用
TensorDict
使你的类之间相互通信;使用 TorchRL 构建训练循环的基础知识
如何计算策略梯度方法的优势信号;
如何使用概率神经网络创建随机策略;
如何创建动态回放缓冲区并从中不重复地进行采样。
我们将介绍 TorchRL 的六个关键组件
如果你在 Google Colab 中运行此代码,请确保安装以下依赖项
!pip3 install torchrl
!pip3 install gym[mujoco]
!pip3 install tqdm
近端策略优化 (PPO) 是一种策略梯度算法,它收集一批数据并直接用于训练策略,以便在给定某些近端约束的情况下最大化预期回报。你可以将其视为基础策略优化算法 REINFORCE 的改进版本。更多信息请参见论文 Proximal Policy Optimization Algorithms。
PPO 通常被认为是用于在线、同策略强化学习算法的快速高效方法。TorchRL 提供了一个损失模块,可以为你完成所有工作,因此你可以依赖此实现并专注于解决问题,而无需在每次想要训练策略时都重新发明轮子。
为了完整起见,此处简要概述损失的计算方式,尽管这已由我们的 ClipPPOLoss
模块处理——该算法工作原理如下:1. 我们将在环境中运行策略,收集给定步数的数据批次。2. 然后,使用 REINFORCE 损失的剪裁版本,对此批次的随机子样本执行给定次数的优化步骤。3. 剪裁将对我们的损失设置一个悲观边界:与较高的回报估计相比,较低的回报估计将受到青睐。精确的损失公式为
该损失包含两个组成部分:在最小化算子的第一部分,我们简单地计算了 REINFORCE 损失的加权重要性版本(例如,我们已经校正了当前策略配置滞后于用于数据收集的策略配置这一事实)。最小化算子的第二部分是类似的损失,我们在比率超过或低于给定一对阈值时对其进行了剪裁。
该损失确保无论优势是正还是负,都会阻止导致与先前配置显著偏移的策略更新。
本教程结构如下
首先,我们将定义一组用于训练的超参数。
接下来,我们将重点介绍如何使用 TorchRL 的包装器和变换创建我们的环境或模拟器。
接下来,我们将设计策略网络和值模型,这对于损失函数是必不可少的。这些模块将用于配置我们的损失模块。
接下来,我们将创建回放缓冲区和数据加载器。
最后,我们将运行训练循环并分析结果。
在本教程中,我们将使用 tensordict
库。TensorDict
是 TorchRL 的通用语言:它帮助我们抽象模块的读写内容,从而减少关注具体的数据描述,更多关注算法本身。
import warnings
warnings.filterwarnings("ignore")
from torch import multiprocessing
from collections import defaultdict
import matplotlib.pyplot as plt
import torch
from tensordict.nn import TensorDictModule
from tensordict.nn.distributions import NormalParamExtractor
from torch import nn
from torchrl.collectors import SyncDataCollector
from torchrl.data.replay_buffers import ReplayBuffer
from torchrl.data.replay_buffers.samplers import SamplerWithoutReplacement
from torchrl.data.replay_buffers.storages import LazyTensorStorage
from torchrl.envs import (Compose, DoubleToFloat, ObservationNorm, StepCounter,
TransformedEnv)
from torchrl.envs.libs.gym import GymEnv
from torchrl.envs.utils import check_env_specs, ExplorationType, set_exploration_type
from torchrl.modules import ProbabilisticActor, TanhNormal, ValueOperator
from torchrl.objectives import ClipPPOLoss
from torchrl.objectives.value import GAE
from tqdm import tqdm
定义超参数¶
我们为算法设置超参数。根据可用资源,可以选择在 GPU 或其他设备上执行策略。frame_skip
控制单个动作执行多少帧。计算帧数的其他参数必须针对此值进行校正(因为一个环境步骤实际将返回 frame_skip
帧)。
is_fork = multiprocessing.get_start_method() == "fork"
device = (
torch.device(0)
if torch.cuda.is_available() and not is_fork
else torch.device("cpu")
)
num_cells = 256 # number of cells in each layer i.e. output dim.
lr = 3e-4
max_grad_norm = 1.0
数据收集参数¶
收集数据时,我们可以通过定义 frames_per_batch
参数来选择每个批次的大小。我们还将定义允许使用的帧数(例如与模拟器的交互次数)。通常,强化学习算法的目标是以环境交互次数衡量,尽可能快地学会解决任务:total_frames
越少越好。
frames_per_batch = 1000
# For a complete training, bring the number of frames up to 1M
total_frames = 50_000
PPO 参数¶
每次数据收集(或批次收集)时,我们将在一定数量的 epoch 内运行优化,每次都在嵌套的训练循环中消耗刚刚获取的全部数据。此处,sub_batch_size
与上面的 frames_per_batch
不同:请记住,我们处理的是来自收集器的“数据批次”,其大小由 frames_per_batch
定义,并且我们将在内部训练循环中将其进一步分割成更小的子批次。这些子批次的大小由 sub_batch_size
控制。
sub_batch_size = 64 # cardinality of the sub-samples gathered from the current data in the inner loop
num_epochs = 10 # optimization steps per batch of data collected
clip_epsilon = (
0.2 # clip value for PPO loss: see the equation in the intro for more context.
)
gamma = 0.99
lmbda = 0.95
entropy_eps = 1e-4
定义环境¶
在强化学习中,环境通常是我们指代模拟器或控制系统的方式。有各种库提供强化学习的模拟环境,包括 Gymnasium(以前的 OpenAI Gym)、DeepMind control suite 和许多其他库。作为一个通用库,TorchRL 的目标是为大量强化学习模拟器提供可互换的接口,使你能够轻松地用一个环境替换另一个环境。例如,创建包装后的 gym 环境只需几个字符即可完成
base_env = GymEnv("InvertedDoublePendulum-v4", device=device)
这段代码中有几点需要注意:首先,我们通过调用 GymEnv
包装器创建了环境。如果传入额外的关键字参数,它们将被传递给 gym.make
方法,从而覆盖最常见的环境构造命令。另外,也可以直接使用 gym.make(env_name, **kwargs)
创建一个 gym 环境,然后将其包装在 GymWrapper 类中。
还有 device
参数:对于 gym,这只控制输入动作和观察状态存储在哪个设备上,但执行始终在 CPU 上完成。原因很简单,除非另有说明,否则 gym 不支持设备端执行。对于其他库,我们可以控制执行设备,并且尽可能在存储和执行后端方面保持一致。
变换¶
我们将向环境添加一些变换,以准备策略所需的数据。在 Gym 中,这通常通过包装器实现。TorchRL 采用了不同的方法,与 PyTorch 的其他领域库更相似,通过使用变换来实现。要向环境添加变换,只需将其包装在 TransformedEnv
实例中,并将变换序列附加到其上。转换后的环境将继承被包装环境的设备和元数据,并根据其包含的变换序列进行转换。
归一化¶
首先要编码的是归一化变换。根据经验法则,最好使数据大致符合单位高斯分布:为了实现这一点,我们将在环境中运行一定数量的随机步骤,并计算这些观测值的摘要统计信息。
我们将添加另外两个变换:DoubleToFloat
变换将双精度条目转换为单精度数字,以便策略读取。StepCounter
变换将用于计算环境终止前的步数。我们将使用此指标作为性能的补充衡量标准。
正如我们稍后将看到的,TorchRL 的许多类依赖于 TensorDict
进行通信。你可以将其视为一个具有额外张量特征的 Python 字典。实际上,这意味着我们将使用的许多模块需要知道它们将接收到的 tensordict
中要读取哪些键(in_keys
)以及要写入哪些键(out_keys
)。通常,如果省略 out_keys
,则假定 in_keys
条目将在原地更新。对于我们的变换,我们感兴趣的唯一条目被称为 "observation"
,我们的变换层将被告知只修改此条目
env = TransformedEnv(
base_env,
Compose(
# normalize observations
ObservationNorm(in_keys=["observation"]),
DoubleToFloat(),
StepCounter(),
),
)
你可能已经注意到,我们创建了一个归一化层,但没有设置其归一化参数。为此,ObservationNorm
可以自动收集我们环境的摘要统计信息
env.transform[0].init_stats(num_iter=1000, reduce_dim=0, cat_dim=0)
ObservationNorm
变换现在已经填充了位置和比例,这将用于归一化数据。
让我们对摘要统计信息的形状进行一些完整性检查
print("normalization constant shape:", env.transform[0].loc.shape)
环境不仅由其模拟器和变换定义,还由一系列描述其执行期间预期的元数据定义。为了效率,TorchRL 对环境规范要求非常严格,但你可以轻松检查你的环境规范是否足够。在我们的示例中,GymWrapper
及其继承者 GymEnv
已经负责为你的环境设置正确的规范,因此你不必担心这一点。
尽管如此,让我们通过查看其规范来了解一个使用转换后环境的具体示例。有三个规范需要查看:observation_spec
定义了在环境中执行操作时预期的内容,reward_spec
指示了奖励域,最后是 input_spec
(其中包含 action_spec
),它代表了环境执行单个步骤所需的一切。
print("observation_spec:", env.observation_spec)
print("reward_spec:", env.reward_spec)
print("input_spec:", env.input_spec)
print("action_spec (as defined by input_spec):", env.action_spec)
check_env_specs()
函数运行一个小型的 rollout,并将其输出与环境规范进行比较。如果没有引发错误,我们可以确信规范已正确定义
check_env_specs(env)
为了好玩,让我们看看一个简单的随机 rollout 是什么样的。你可以调用 env.rollout(n_steps),了解环境的输入和输出是什么样的。动作将自动从动作规范域中抽取,因此你无需关心设计随机采样器。
通常,在每一步,强化学习环境接收一个动作作为输入,并输出一个观测、一个奖励和一个完成状态。观测可以是复合的,意味着它可以由多个张量组成。这对 TorchRL 来说不是问题,因为整个观测集会自动打包到输出 TensorDict
中。在给定步数上执行 rollout(例如,一系列环境步骤和随机动作生成)后,我们将检索到一个 TensorDict
实例,其形状与轨迹长度匹配
rollout = env.rollout(3)
print("rollout of three steps:", rollout)
print("Shape of the rollout TensorDict:", rollout.batch_size)
我们的 rollout 数据形状为 torch.Size([3])
,这与我们运行的步数相匹配。"next"
条目指向当前步骤之后的数据。在大多数情况下,时间 t 的 "next"
数据与 t+1
的数据匹配,但如果我们使用某些特定的变换(例如,多步),情况可能并非如此。
策略¶
PPO 利用随机策略来处理探索。这意味着我们的神经网络必须输出分布的参数,而不是对应于所采取动作的单个值。
由于数据是连续的,我们使用 Tanh-Normal 分布来尊重动作空间的边界。TorchRL 提供了这种分布,我们唯一需要关心的是构建一个神经网络,输出策略工作所需的正确数量的参数(位置,即均值,和比例)
此处唯一增加的难度是将我们的输出分成两个相等的部分,并将第二部分映射到严格正空间。
我们分三步设计策略
定义一个神经网络
D_obs
->2 * D_action
。实际上,我们的loc
(mu) 和scale
(sigma) 都具有维度D_action
。添加一个
NormalParamExtractor
来提取位置和比例(例如,将输入分成两个相等的部分,并对比例参数应用正变换)。创建一个概率
TensorDictModule
,它可以生成此分布并从中采样。
actor_net = nn.Sequential(
nn.LazyLinear(num_cells, device=device),
nn.Tanh(),
nn.LazyLinear(num_cells, device=device),
nn.Tanh(),
nn.LazyLinear(num_cells, device=device),
nn.Tanh(),
nn.LazyLinear(2 * env.action_spec.shape[-1], device=device),
NormalParamExtractor(),
)
为了让策略能够通过 tensordict
数据载体与环境“对话”,我们将 nn.Module
包装在 TensorDictModule
中。这个类会简单地读取提供的 in_keys
,并将输出原地写入已注册的 out_keys
。
policy_module = TensorDictModule(
actor_net, in_keys=["observation"], out_keys=["loc", "scale"]
)
现在我们需要根据正态分布的位置和比例构建一个分布。为此,我们指示 ProbabilisticActor
类根据位置和比例参数构建一个 TanhNormal
。我们还提供了此分布的最小值和最大值,这些值是从环境规范中获取的。
in_keys
的名称(以及因此上面 TensorDictModule
中 out_keys
的名称)不能随意设置,因为 TanhNormal
分布构造函数将期望 loc
和 scale
关键字参数。话虽如此,ProbabilisticActor
也接受类型为 Dict[str, str]
的 in_keys
,其中键值对指示每个要使用的关键字参数应使用哪个 in_key
字符串。
policy_module = ProbabilisticActor(
module=policy_module,
spec=env.action_spec,
in_keys=["loc", "scale"],
distribution_class=TanhNormal,
distribution_kwargs={
"low": env.action_spec.space.low,
"high": env.action_spec.space.high,
},
return_log_prob=True,
# we'll need the log-prob for the numerator of the importance weights
)
值网络¶
值网络是 PPO 算法的关键组成部分,尽管它在推理时不会使用。该模块将读取观测值并返回后续轨迹的折现回报估计。这使我们能够通过依赖训练期间动态学习的一些效用估计来摊销学习。我们的值网络与策略共享相同的结构,但为了简单起见,我们为其分配了自己的一组参数。
value_net = nn.Sequential(
nn.LazyLinear(num_cells, device=device),
nn.Tanh(),
nn.LazyLinear(num_cells, device=device),
nn.Tanh(),
nn.LazyLinear(num_cells, device=device),
nn.Tanh(),
nn.LazyLinear(1, device=device),
)
value_module = ValueOperator(
module=value_net,
in_keys=["observation"],
)
让我们试试我们的策略和值模块。正如我们之前所说,TensorDictModule
的使用使得可以直接读取环境的输出以运行这些模块,因为它们知道要读取哪些信息以及写入到哪里
print("Running policy:", policy_module(env.reset()))
print("Running value:", value_module(env.reset()))
数据收集器¶
TorchRL 提供了一系列 数据收集器类。简而言之,这些类执行三个操作:重置环境,根据最新观测计算动作,在环境中执行一步,然后重复最后两个步骤,直到环境发出停止信号(或达到完成状态)。
它们允许你控制每次迭代收集多少帧(通过 frames_per_batch
参数)、何时重置环境(通过 max_frames_per_traj
参数)、策略应在哪个 device
上执行等。它们还设计用于高效处理批量和多进程环境。
最简单的数据收集器是 SyncDataCollector
:它是一个迭代器,你可以用它获取给定长度的数据批次,并且在收集到总帧数 (total_frames
) 后停止。其他数据收集器 (MultiSyncDataCollector
和 MultiaSyncDataCollector
) 将在一组多进程工作器上以同步和异步方式执行相同的操作。
与之前的策略和环境一样,数据收集器将返回总元素数与 frames_per_batch
匹配的 TensorDict
实例。使用 TensorDict
将数据传递给训练循环,使你能够编写完全不受 rollout 内容实际细节影响的数据加载流水线。
collector = SyncDataCollector(
env,
policy_module,
frames_per_batch=frames_per_batch,
total_frames=total_frames,
split_trajs=False,
device=device,
)
回放缓冲区¶
回放缓冲区是离策略强化学习算法的常见组成部分。在同策略环境中,每次收集到一批数据时都会重新填充回放缓冲区,并且其数据会在一定数量的 epoch 中重复使用。
TorchRL 的回放缓冲区使用通用容器 ReplayBuffer
构建,它接受缓冲区的组件作为参数:存储、写入器、采样器以及可能的变换。只有存储(指示回放缓冲区的容量)是必需的。我们还指定了一个不重复的采样器,以避免在一个 epoch 中多次采样同一项。对于 PPO,使用回放缓冲区不是必需的,我们可以简单地从收集到的批次中采样子批次,但使用这些类可以轻松地以可重复的方式构建内部训练循环。
replay_buffer = ReplayBuffer(
storage=LazyTensorStorage(max_size=frames_per_batch),
sampler=SamplerWithoutReplacement(),
)
损失函数¶
为了方便起见,PPO 损失可以直接从 TorchRL 中导入,使用 ClipPPOLoss
类。这是使用 PPO 最简单的方式:它隐藏了 PPO 的数学运算和相关的控制流。
PPO需要计算一些“优势估计”。简而言之,优势是一个反映回报值预期的值,同时处理偏差/方差的权衡。要计算优势,只需(1)构建利用我们价值算子的优势模块,以及(2)在每个epoch之前将每批数据通过它。GAE模块将使用新的tensordict
和"advantage"
条目更新输入的"value_target"
。 "value_target"
是一个无梯度的张量,表示价值网络应该用输入观测值表示的经验值。这两者都将由ClipPPOLoss
使用,以返回策略损失和价值损失。
advantage_module = GAE(
gamma=gamma, lmbda=lmbda, value_network=value_module, average_gae=True, device=device,
)
loss_module = ClipPPOLoss(
actor_network=policy_module,
critic_network=value_module,
clip_epsilon=clip_epsilon,
entropy_bonus=bool(entropy_eps),
entropy_coef=entropy_eps,
# these keys match by default but we set this for completeness
critic_coef=1.0,
loss_critic_type="smooth_l1",
)
optim = torch.optim.Adam(loss_module.parameters(), lr)
scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(
optim, total_frames // frames_per_batch, 0.0
)
训练循环¶
现在我们已经具备了编写训练循环所需的所有部分。步骤包括
收集数据
计算优势
循环遍历收集到的数据以计算损失值
反向传播
优化
重复
重复
重复
logs = defaultdict(list)
pbar = tqdm(total=total_frames)
eval_str = ""
# We iterate over the collector until it reaches the total number of frames it was
# designed to collect:
for i, tensordict_data in enumerate(collector):
# we now have a batch of data to work with. Let's learn something from it.
for _ in range(num_epochs):
# We'll need an "advantage" signal to make PPO work.
# We re-compute it at each epoch as its value depends on the value
# network which is updated in the inner loop.
advantage_module(tensordict_data)
data_view = tensordict_data.reshape(-1)
replay_buffer.extend(data_view.cpu())
for _ in range(frames_per_batch // sub_batch_size):
subdata = replay_buffer.sample(sub_batch_size)
loss_vals = loss_module(subdata.to(device))
loss_value = (
loss_vals["loss_objective"]
+ loss_vals["loss_critic"]
+ loss_vals["loss_entropy"]
)
# Optimization: backward, grad clipping and optimization step
loss_value.backward()
# this is not strictly mandatory but it's good practice to keep
# your gradient norm bounded
torch.nn.utils.clip_grad_norm_(loss_module.parameters(), max_grad_norm)
optim.step()
optim.zero_grad()
logs["reward"].append(tensordict_data["next", "reward"].mean().item())
pbar.update(tensordict_data.numel())
cum_reward_str = (
f"average reward={logs['reward'][-1]: 4.4f} (init={logs['reward'][0]: 4.4f})"
)
logs["step_count"].append(tensordict_data["step_count"].max().item())
stepcount_str = f"step count (max): {logs['step_count'][-1]}"
logs["lr"].append(optim.param_groups[0]["lr"])
lr_str = f"lr policy: {logs['lr'][-1]: 4.4f}"
if i % 10 == 0:
# We evaluate the policy once every 10 batches of data.
# Evaluation is rather simple: execute the policy without exploration
# (take the expected value of the action distribution) for a given
# number of steps (1000, which is our ``env`` horizon).
# The ``rollout`` method of the ``env`` can take a policy as argument:
# it will then execute this policy at each step.
with set_exploration_type(ExplorationType.DETERMINISTIC), torch.no_grad():
# execute a rollout with the trained policy
eval_rollout = env.rollout(1000, policy_module)
logs["eval reward"].append(eval_rollout["next", "reward"].mean().item())
logs["eval reward (sum)"].append(
eval_rollout["next", "reward"].sum().item()
)
logs["eval step_count"].append(eval_rollout["step_count"].max().item())
eval_str = (
f"eval cumulative reward: {logs['eval reward (sum)'][-1]: 4.4f} "
f"(init: {logs['eval reward (sum)'][0]: 4.4f}), "
f"eval step-count: {logs['eval step_count'][-1]}"
)
del eval_rollout
pbar.set_description(", ".join([eval_str, cum_reward_str, stepcount_str, lr_str]))
# We're also using a learning rate scheduler. Like the gradient clipping,
# this is a nice-to-have but nothing necessary for PPO to work.
scheduler.step()
结果¶
在达到100万步上限之前,算法应该达到最大步数1000步,这是轨迹被截断前的最大步数。
plt.figure(figsize=(10, 10))
plt.subplot(2, 2, 1)
plt.plot(logs["reward"])
plt.title("training rewards (average)")
plt.subplot(2, 2, 2)
plt.plot(logs["step_count"])
plt.title("Max step count (training)")
plt.subplot(2, 2, 3)
plt.plot(logs["eval reward (sum)"])
plt.title("Return (test)")
plt.subplot(2, 2, 4)
plt.plot(logs["eval step_count"])
plt.title("Max step count (test)")
plt.show()
结论和下一步¶
在本教程中,我们学习了
如何使用
torchrl
创建和自定义环境;如何编写模型和损失函数;
如何设置典型的训练循环。
如果您想进一步尝试本教程,可以应用以下修改:
从效率角度来看,我们可以并行运行多个模拟以加快数据收集速度。有关更多信息,请查看
ParallelEnv
。从日志记录角度来看,可以在请求渲染后向环境添加一个
torchrl.record.VideoRecorder
转换,以获取倒立摆运行的视觉渲染。要了解更多信息,请查看torchrl.record
。
脚本总运行时间: ( 0 分钟 0.000 秒)