第六章:征服新维度:将PPO应用于连续控制任务
在上一章,我们像组装一台精密的发动机一样,亲手打造了PPO的每一个部件,并成功地让它在CartPole-v1这个离散动作空间的环境中稳定运行。我们的智能体学会了在“向左”和“向右”两个离散的选项中做出抉择。然而,真实世界中的许多问题,从机器人手臂的精准操控到自动驾驶中方向盘的转动角度,都不是简单的选择题,而是需要在无穷多的可能性中给出一个精确值的“填空题”。这就是连续控制(Continuous Control) 的范畴。
本章,我们将对第五章中建立的PPO框架进行一次关键性的升级,使其能够驾驭连续的动作空间。我们将探索如何用新的数学工具来描述连续动作的策略,如何修改我们的神经网络模型以输出一个精确的动作,以及在这个过程中需要注意的关键工程技巧。
我们的新试炼场是Pendulum-v1环境。在这个环境中,智能体的目标是施加一个连续变化的力矩,将一个随机位置的钟摆(Pendulum)竖立起来并保持稳定。这要求智能体不仅要决定“往哪个方向”用力,还要精确地决定“用多大的力”。
准备好迎接挑战,让我们的PPO智能体掌握更细腻、更强大的控制能力吧!
6.1 挑战升级:从离散到连续的鸿沟
想象一下,CartPole的动作是两个按钮,非黑即白。而Pendulum的动作则是一个可以360度平滑转动的旋钮。我们面临的核心问题是:
如何在一个无限的动作集合中进行带探索的决策?
对于离散动作,我们可以为每个动作计算一个概率,比如“向左的概率是70%,向右的概率是30%”,然后根据这个概率分布进行采样。这是通过Categorical分布(分类分布)实现的。但对于连续动作(例如,一个范围在 [−2.0,2.0][-2.0, 2.0][−2.0,2.0] 之间的力矩),我们无法为每一个可能的力矩值都分配一个概率,因为有无穷多个点。
答案是,我们不再为单个动作计算概率,而是让策略网络学习一个概率密度函数(Probability Density Function, PDF)。最常用的选择是高斯分布(Gaussian Distribution),也就是我们熟知的正态分布。
6.2 理论武器:高斯策略 (Gaussian Policy)
高斯策略的思想非常直观:对于一个给定的状态 sss,策略网络不再输出每个动作的概率,而是输出一个高斯分布的参数。一个一维高斯分布由两个参数决定:
- 均值 (Mean, μ\muμ): 分布的中心,代表了在该状态下,策略认为最应该执行的动作。
- 标准差 (Standard Deviation, σ\sigmaσ): 分布的宽度,代表了策略的不确定性。一个较大的 σ\sigmaσ 会让采样结果更多样化,意味着更强的探索;一个较小的 σ\sigmaσ 则会让采样结果紧密围绕在均值 μ\muμ 附近,意味着更强的利用。
因此,我们的策略 πθ(a∣s)\pi_\theta(a|s)πθ(a∣s) 不再是一个概率值,而是一个由 θ\thetaθ 参数化的概率分布:
πθ(a∣s)=N(μθ(s),σθ(s))\pi_\theta(a|s) = \mathcal{N}(\mu_\theta(s), \sigma_\theta(s))πθ(a∣s)=N(μθ(s),σθ(s))
当我们需要决策时,我们就从这个由当前状态 sss 决定的高斯分布中采样一个动作 aaa。
这个转变,将直接影响我们神经网络的设计和select_action的实现方式。
6.3 模型改造:输出高斯分布参数的Actor-Critic
为了输出高斯分布的参数,我们需要对第五章的ActorCritic模型进行改造。Critic部分保持不变,因为它仍然是评估状态的价值。核心变化在于Actor的输出头。
对于均值 μ\muμ,它直接由状态决定,所以我们可以让actor_head来预测它。
对于标准差 σ\sigmaσ,有几种处理方式。一个常见且稳健的做法是,将 σ\sigmaσ 作为一个独立于状态的、可学习的参数。这样做的好处是让探索的程度(由 σ\sigmaσ 控制)成为一个全局的策略属性,而不是随状态剧烈变化,这有助于稳定训练初期。为了保证 σ\sigmaσ 始终为正数,我们通常学习它的对数 logσ\log\sigmalogσ,然后通过取指数 elogσe^{\log\sigma}elogσ 来得到 σ\sigmaσ。
代码实现 (model.py的演进)
# class ActorCritic(nn.Module):
# ... (init和shared_layers保持不变) ...
class ActorCritic(nn.Module):
def __init__(self, state_dim, action_dim):
super(ActorCritic, self).__init__()
# --- 共享网络 (与之前相同) ---
self.shared_layers = nn.Sequential(
nn.Linear(state_dim, 64), nn.Tanh(),
nn.Linear(64, 64), nn.Tanh()
)
# --- Actor (策略) 输出头 ---
# Actor现在输出高斯分布的均值
self.actor_head = nn.Linear(64, action_dim)
# --- Critic (价值) 输出头 (与之前相同) ---
self.critic_head = nn.Linear(64, 1)
# --- 学习标准差 ---
# 创建一个可学习的参数 log_std,用于生成标准差
# action_dim 是动作空间的维度,对于Pendulum-v1是1
self.action_log_std = nn.Parameter(torch.zeros(1, action_dim))
def forward(self, state):
"""
前向传播,但这次返回的是动作分布和价值
"""
features = self.shared_layers(state)
action_mean = self.actor_head(features)
state_value = self.critic_head(features)
# 获取标准差
action_std = torch.exp(self.action_log_std.expand_as(action_mean))
# 创建高斯分布
dist = Normal(action_mean, action_std)
return dist, state_value
def select_action(self, state):
"""
根据当前策略,为一个状态选择一个动作
"""
# 确保输入是tensor
if not isinstance(state, torch.Tensor):
state = torch.tensor(state, dtype=torch.float32)
dist, state_value = self.forward(state)
# 从分布中采样一个动作 (这是探索的来源)
action = dist.sample()
# 计算该动作的对数概率 log(π(a|s))
log_prob = dist.log_prob(action).sum(axis=-1) # sum用于处理多维动作空间
# 计算分布的熵
entropy = dist.entropy().sum(axis=-1)
return action, log_prob, state_value, entropy
代码讲解:
-
__init__: 我们删除了原来的actor_head,转而使用一个新的actor_mean_head来输出均值。最关键的变化是增加了self.action_log_std,它是一个nn.Parameter。这告诉PyTorch,这是一个需要在训练过程中通过梯度下降来优化的张量。我们将其初始化为0。 -
forward:action_mean由网络根据状态动态计算得出。action_std = torch.exp(self.action_log_std)确保标准差永远是正的。expand_as是必要的,它将log_std(形状通常是[1, action_dim])复制扩展成和action_mean一样的形状(例如[batch_size, action_dim]),以便为batch中的每个样本创建分布。- 我们使用
torch.distributions.Normal来创建高斯分布。 - 该方法现在直接返回一个分布
dist对象和价值state_value,这让代码逻辑更清晰。
-
select_action:- 它首先调用
forward获得分布dist。 dist.sample()从该高斯分布中采样一个具体的动作值。dist.log_prob(action)计算了被采样动作的对数概率密度。这是PPO更新的核心要素。dist.entropy()计算熵,和离散情况一样,用于鼓励探索。.sum(axis=-1): 这是一个兼容多维动作空间(例如,一个机器人手臂有多个关节需要同时控制)的技巧。对于Pendulum(action_dim=1),它不起作用,但这是一个很好的编程习惯。
- 它首先调用
6.4 工程技巧:动作缩放 (Action Scaling)
高斯分布的采样范围是 (−∞,∞)(-\infty, \infty)(−∞,∞),但是,大多数强化学习环境的连续动作空间都有一个明确的界限,比如Pendulum-v1的力矩范围是 [−2.0,2.0][-2.0, 2.0][−2.0,2.0]。如果我们直接将无界的采样动作输入到环境中,可能会导致不可预测的行为或错误。
因此,一个至关重要的工程实践是动作缩放。
标准的做法是:
- 让策略网络输出一个在 (−∞,∞)(-\infty, \infty)(−∞,∞) 范围内的原始动作 arawa_{raw}araw。
- 使用
tanh函数将 arawa_{raw}araw 压缩到 [−1,1][-1, 1][−1,1] 范围内。tanh函数平滑、可导,非常适合这个任务。 - 将压缩后的动作线性地缩放到环境指定的范围 [actionmin,actionmax][action_{min}, action_{max}][actionmin,actionmax]。
对于Pendulum-v1,其动作范围是 [−2,2][-2, 2][−2,2],所以缩放因子就是2。
afinal=2.0×tanh(araw)a_{final} = 2.0 \times \tanh(a_{raw})afinal=2.0×tanh(araw)
这个小小的改动,将发生在select_action函数中,当我们从高斯分布中采样得到动作之后,与环境交互之前。
注意: 从理论上讲,tanh变换会改变原始的概率分布,严格来说需要对log_prob进行修正。但在许多PPO的实现中,为了简化,会忽略这个修正项,并且在实践中效果依然很好。对于一本“实干家”的指南,我们初期可以采纳这种简化,在后续章节再探讨其理论细节。
6.5 Agent的微调:适配连续动作
好消息是,PPO算法主体的优雅之处在于,从离散到连续,它的核心更新逻辑几乎不需要改变。我们仍然计算GAE,仍然有相同的Clipped Surrogate Objective。因为损失函数依赖的是对数概率 log_prob,而无论是Categorical分布还是Normal分布,我们都能计算出这个值。
我们需要调整的主要是PPOAgent中的select_action方法,以集成我们新的ActorCritic模型和动作缩放。
# 在 PPOAgent 类中
# ... (init基本不变, 只是现在用新的ActorCritic)
def select_action(self, state):
# 使用旧策略(policy_old)来收集数据
with torch.no_grad():
state_tensor = torch.tensor(state, dtype=torch.float32).to(device)
# 1. 从网络获取分布和价值
dist, state_value = self.policy_old.forward(state_tensor)
# 2. 从分布中采样原始动作 (raw action)
action_raw = dist.sample()
# 3. 计算对数概率
log_prob = dist.log_prob(action_raw).sum(axis=-1)
# 4. 动作缩放
# 将动作压缩到 [-1, 1]
action_tanh = torch.tanh(action_raw)
# 将动作缩放到环境的范围 [min_action, max_action]
# 对于Pendulum-v1, 范围是[-2, 2], 所以我们乘以2
final_action = 2.0 * action_tanh
return final_action.cpu().numpy(), log_prob.cpu(), state_value.cpu()
# update(...) 方法几乎保持不变!
# 主要的变化是在获取新旧log_prob时,它们现在是由高斯分布计算得来,
# 但 PPO 损失函数的公式本身 L_CLIP, L_VF 等,完全一样。
update方法之所以能保持不变,是因为它操作的是log_probs、advantages和returns这些抽象的量。它不关心这些log_probs是来自Categorical分布还是Normal分布,这正是PPO框架强大通用性的体现。
6.6 完整代码实战:用PPO解决Pendulum-v1
现在,我们将所有升级和改造整合起来,形成一个可以解决Pendulum-v1的完整程序。下面的代码是基于第五章的代码修改而来,关键的改动之处都附有详细注释。
任务目标:训练一个智能体,在Pendulum-v1环境中,用最小的力将钟摆尽快竖立起来并保持。该环境的奖励是负的,一个完美的表现得分接近0(例如-150分以上就算相当不错)。
# main_continuous.py
import torch
import torch.nn as nn
from torch.distributions import Normal # *** 关键改动:导入正态分布 ***
import gymnasium as gym
import numpy as np
import collections
# --- 0. 设置超参数 ---
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
env_name = "Pendulum-v1"
# 获取环境信息
temp_env = gym.make(env_name)
state_dim = temp_env.observation_space.shape[0] # 状态维度: 3
action_dim = temp_env.action_space.shape[0] # 动作维度: 1
max_action = float(temp_env.action_space.high[0]) # 动作最大值: 2.0
temp_env.close()
lr_actor = 0.0003
lr_critic = 0.001
gamma = 0.99
lambda_gae = 0.95
clip_epsilon = 0.2
n_epochs = 10
n_steps = 2048
batch_size = 64
c1_value_loss = 0.5
c2_entropy = 0.01
max_grad_norm = 0.5
# --- 1. 定义Rollout Buffer (与第五章完全相同) ---
class RolloutBuffer:
def __init__(self):
self.states, self.actions, self.log_probs, self.rewards, self.dones, self.values = [], [], [], [], [], []
def store(self, state, action, log_prob, reward, done, value):
self.states.append(torch.tensor(state, dtype=torch.float32))
self.actions.append(torch.tensor(action))
self.log_probs.append(log_prob)
self.rewards.append(torch.tensor(reward, dtype=torch.float32))
self.dones.append(torch.tensor(done, dtype=torch.float32))
self.values.append(value)
def get_batch(self):
return self.states, self.actions, self.log_probs, self.rewards, self.dones, self.values
def clear(self):
del self.states[:], self.actions[:], self.log_probs[:], self.rewards[:], self.dones[:], self.values[:]
def __len__(self):
return len(self.states)
# --- 2. 定义改造后的Actor-Critic模型 ---
class ActorCritic(nn.Module):
def __init__(self, state_dim, action_dim, action_std_init=0.6):
super(ActorCritic, self).__init__()
self.shared_layers = nn.Sequential(
nn.Linear(state_dim, 64), nn.Tanh(),
nn.Linear(64, 64), nn.Tanh()
)
self.actor_head = nn.Linear(64, action_dim)
self.critic_head = nn.Linear(64, 1)
# *** 关键改动:将标准差作为可学习的参数 ***
self.action_log_std = nn.Parameter(torch.ones(1, action_dim) * np.log(action_std_init))
def forward(self, state):
features = self.shared_layers(state)
action_mean = self.actor_head(features)
# *** 关键改动:计算标准差并创建正态分布 ***
action_std = torch.exp(self.action_log_std.expand_as(action_mean))
dist = Normal(action_mean, action_std)
# Critic的输出保持不变
state_value = self.critic_head(features)
return dist, state_value
# --- 3. 定义PPO Agent ---
class PPOAgent:
def __init__(self):
self.policy = ActorCritic(state_dim, action_dim).to(device)
# *** 关键改动:将action_log_std加入优化器 ***
self.optimizer = torch.optim.Adam([
{'params': self.policy.shared_layers.parameters(), 'lr': lr_actor},
{'params': self.policy.actor_head.parameters(), 'lr': lr_actor},
{'params': self.policy.critic_head.parameters(), 'lr': lr_critic},
{'params': self.policy.action_log_std, 'lr': lr_actor}
])
self.policy_old = ActorCritic(state_dim, action_dim).to(device)
self.policy_old.load_state_dict(self.policy.state_dict())
self.mse_loss = nn.MSELoss()
def select_action(self, state):
with torch.no_grad():
state_tensor = torch.tensor(state, dtype=torch.float32).to(device)
dist, state_value = self.policy_old(state_tensor)
# *** 关键改动:从正态分布采样,并进行缩放 ***
action_raw = dist.sample()
log_prob = dist.log_prob(action_raw).sum()
# 使用tanh进行缩放
action_scaled = max_action * torch.tanh(action_raw)
return action_scaled.cpu().numpy(), log_prob.cpu(), state_value.cpu(), action_raw.cpu()
def update(self, buffer):
# 从buffer获取数据
states, actions_raw, old_log_probs, rewards, dones, values = buffer.get_batch()
# GAE计算 (与第五章几乎一样)
T = len(rewards)
advantages = torch.zeros(T, dtype=torch.float32).to(device)
returns = torch.zeros(T, dtype=torch.float32).to(device)
rewards = torch.stack(rewards).to(device).squeeze()
dones = torch.stack(dones).to(device).squeeze()
values = torch.stack(values).to(device).squeeze()
last_advantage = 0
with torch.no_grad():
last_state = states[-1].to(device)
_, last_value_tensor = self.policy(last_state)
last_value = last_value_tensor.item() * (1 - dones[-1].item())
for t in reversed(range(T)):
if t == T - 1:
next_value = last_value
else:
next_value = values[t+1]
delta = rewards[t] + gamma * next_value * (1 - dones[t]) - values[t]
advantages[t] = delta + gamma * lambda_gae * (1 - dones[t]) * last_advantage
last_advantage = advantages[t]
returns = advantages + values
advantages = (advantages - advantages.mean()) / (advantages.std() + 1e-8)
# PPO更新 (与第五章几乎一样)
old_states_tensor = torch.stack(states).to(device)
old_actions_tensor = torch.stack(actions_raw).to(device)
old_log_probs_tensor = torch.stack(old_log_probs).to(device)
for _ in range(n_epochs):
indices = torch.randperm(T)
for start in range(0, T, batch_size):
end = start + batch_size
batch_indices = indices[start:end]
batch_states = old_states_tensor[batch_indices]
batch_actions_raw = old_actions_tensor[batch_indices]
batch_log_probs = old_log_probs_tensor[batch_indices]
batch_advantages = advantages[batch_indices]
batch_returns = returns[batch_indices]
# *** 关键改动:用新策略重新评估,得到新的分布 ***
new_dist, new_values = self.policy(batch_states)
# 计算新log_prob和熵
new_log_probs = new_dist.log_prob(batch_actions_raw).sum(axis=-1)
entropy = new_dist.entropy().mean()
# 计算策略损失 (公式完全一样!)
ratio = torch.exp(new_log_probs - batch_log_probs)
surr1 = ratio * batch_advantages
surr2 = torch.clamp(ratio, 1 - clip_epsilon, 1 + clip_epsilon) * batch_advantages
policy_loss = -torch.min(surr1, surr2).mean()
# 计算价值损失 (公式完全一样!)
value_loss = c1_value_loss * self.mse_loss(new_values.squeeze(), batch_returns)
loss = policy_loss + value_loss - c2_entropy * entropy
self.optimizer.zero_grad()
loss.backward()
torch.nn.utils.clip_grad_norm_(self.policy.parameters(), max_grad_norm)
self.optimizer.step()
self.policy_old.load_state_dict(self.policy.state_dict())
# --- 4. 训练主循环 ---
if __name__ == '__main__':
env = gym.make(env_name)
agent = PPOAgent()
buffer = RolloutBuffer()
log_rewards = collections.deque(maxlen=100)
time_step = 0
max_time_steps = 200000
while time_step < max_time_steps:
state, _ = env.reset()
current_ep_reward = 0
for t in range(n_steps):
action_scaled, log_prob, value, action_raw = agent.select_action(state)
next_state, reward, done, truncated, _ = env.step(action_scaled)
# *** 关键改动:存储原始未缩放的动作 (action_raw) ***
# 这是因为PPO的损失是基于原始动作的概率计算的
buffer.store(state, action_raw, log_prob, reward, done, value.squeeze())
state = next_state
time_step += 1
current_ep_reward += reward
if done or truncated:
log_rewards.append(current_ep_reward)
avg_reward = np.mean(log_rewards)
print(f"Episode Done | Timestep: {time_step} | Avg Reward (Last 100): {avg_reward:.2f}")
state, _ = env.reset()
agent.update(buffer)
buffer.clear()
if len(log_rewards) > 0 and np.mean(log_rewards) > -200:
print("="*20)
print(f"Solved {env_name}!")
torch.save(agent.policy.state_dict(), f'./PPO_{env_name}.pth')
break
env.close()
运行与观察:
- 确保已安装
torch和gymnasium。 - 将代码保存为
ppo_pendulum.py并运行。 Pendulum-v1的奖励是负数。你会看到初始的Avg Reward非常低(例如-1500到-1000)。- 随着训练,这个平均奖励会逐渐上升(即,数值上变大,越来越接近0)。
- 当平均奖励稳定在-200分以上时,说明智能体已经基本掌握了任务,能够有效地将钟摆竖立起来。这个过程通常比
CartPole需要更长的时间。
本章小结
我们成功地完成了对PPO算法的一次重大升级,赋予了它处理连续控制任务的能力。这次升级的核心在于用高斯策略替换了离散的分类策略。我们深入探讨了其中的关键环节:
- 模型改造:让Actor网络输出高斯分布的均值 μ\muμ,并将标准差 σ\sigmaσ 作为一个全局可学习的参数,以平衡探索与利用。
- 动作采样:我们从高斯分布中采样动作,这为智能体提供了在连续空间中探索的自然机制。
- 动作缩放:我们运用
tanh函数这一重要的工程技巧,将无限的采样范围映射到环境有限的动作空间内,确保了交互的有效性。 - 框架的通用性:我们惊喜地发现,PPO的核心损失函数和更新逻辑几乎无需改动。这有力地证明了PPO算法设计的精妙与强大,它不关心策略的具体形式,只关心策略的对数概率。
现在,你的PPO代码库已经变得更加强大和通用。你已经拥有了解决两大类主流强化学习问题——离散控制和连续控制——的武器。在下一章,我们将面对新的、更艰巨的挑战:当输入不再是简单的状态向量,而是充满像素信息的图像时,我们又该如何应对?我们将进入深度强化学习的另一个核心领域:视觉输入处理。

被折叠的 条评论
为什么被折叠?



