《PPO从入门到精通:一本写给实干家的深度强化学习指南》——第6章

第六章:征服新维度:将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,策略网络不再输出每个动作的概率,而是输出一个高斯分布的参数。一个一维高斯分布由两个参数决定:

  1. 均值 (Mean, μ\muμ): 分布的中心,代表了在该状态下,策略认为最应该执行的动作。
  2. 标准差 (Standard Deviation, σ\sigmaσ): 分布的宽度,代表了策略的不确定性。一个较大的 σ\sigmaσ 会让采样结果更多样化,意味着更强的探索;一个较小的 σ\sigmaσ 则会让采样结果紧密围绕在均值 μ\muμ 附近,意味着更强的利用。

因此,我们的策略 πθ(a∣s)\pi_\theta(a|s)πθ(as) 不再是一个概率值,而是一个由 θ\thetaθ 参数化的概率分布:
πθ(a∣s)=N(μθ(s),σθ(s))\pi_\theta(a|s) = \mathcal{N}(\mu_\theta(s), \sigma_\theta(s))πθ(as)=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

代码讲解:

  1. __init__: 我们删除了原来的actor_head,转而使用一个新的actor_mean_head来输出均值。最关键的变化是增加了self.action_log_std,它是一个nn.Parameter。这告诉PyTorch,这是一个需要在训练过程中通过梯度下降来优化的张量。我们将其初始化为0。

  2. 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,这让代码逻辑更清晰。
  3. select_action:

    • 它首先调用forward获得分布dist
    • dist.sample() 从该高斯分布中采样一个具体的动作值。
    • dist.log_prob(action) 计算了被采样动作的对数概率密度。这是PPO更新的核心要素
    • dist.entropy() 计算熵,和离散情况一样,用于鼓励探索。
    • .sum(axis=-1): 这是一个兼容多维动作空间(例如,一个机器人手臂有多个关节需要同时控制)的技巧。对于Pendulumaction_dim=1),它不起作用,但这是一个很好的编程习惯。

6.4 工程技巧:动作缩放 (Action Scaling)

高斯分布的采样范围是 (−∞,∞)(-\infty, \infty)(,),但是,大多数强化学习环境的连续动作空间都有一个明确的界限,比如Pendulum-v1的力矩范围是 [−2.0,2.0][-2.0, 2.0][2.0,2.0]。如果我们直接将无界的采样动作输入到环境中,可能会导致不可预测的行为或错误。

因此,一个至关重要的工程实践是动作缩放

标准的做法是:

  1. 让策略网络输出一个在 (−∞,∞)(-\infty, \infty)(,) 范围内的原始动作 arawa_{raw}araw
  2. 使用 tanh 函数将 arawa_{raw}araw 压缩到 [−1,1][-1, 1][1,1] 范围内。tanh 函数平滑、可导,非常适合这个任务。
  3. 将压缩后的动作线性地缩放到环境指定的范围 [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_probsadvantagesreturns这些抽象的量。它不关心这些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()

运行与观察:

  1. 确保已安装 torchgymnasium
  2. 将代码保存为ppo_pendulum.py并运行。
  3. Pendulum-v1的奖励是负数。你会看到初始的Avg Reward非常低(例如-1500到-1000)。
  4. 随着训练,这个平均奖励会逐渐上升(即,数值上变大,越来越接近0)。
  5. 当平均奖励稳定在-200分以上时,说明智能体已经基本掌握了任务,能够有效地将钟摆竖立起来。这个过程通常比CartPole需要更长的时间。

本章小结

我们成功地完成了对PPO算法的一次重大升级,赋予了它处理连续控制任务的能力。这次升级的核心在于用高斯策略替换了离散的分类策略。我们深入探讨了其中的关键环节:

  1. 模型改造:让Actor网络输出高斯分布的均值 μ\muμ,并将标准差 σ\sigmaσ 作为一个全局可学习的参数,以平衡探索与利用。
  2. 动作采样:我们从高斯分布中采样动作,这为智能体提供了在连续空间中探索的自然机制。
  3. 动作缩放:我们运用tanh函数这一重要的工程技巧,将无限的采样范围映射到环境有限的动作空间内,确保了交互的有效性。
  4. 框架的通用性:我们惊喜地发现,PPO的核心损失函数和更新逻辑几乎无需改动。这有力地证明了PPO算法设计的精妙与强大,它不关心策略的具体形式,只关心策略的对数概率。

现在,你的PPO代码库已经变得更加强大和通用。你已经拥有了解决两大类主流强化学习问题——离散控制和连续控制——的武器。在下一章,我们将面对新的、更艰巨的挑战:当输入不再是简单的状态向量,而是充满像素信息的图像时,我们又该如何应对?我们将进入深度强化学习的另一个核心领域:视觉输入处理

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

爱看烟花的码农

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值