PPO 算法

目录:

  1. 背景
  2. 损失函数
  3. 核心思想
  4. 伪代码
  5. python 例子

一、背景

在 PPO 之前,策略梯度(Policy Gradient)家族主要面临两个两难的困境:

  1. 传统策略梯度(Vanilla Policy Gradient, 比如 REINFORCE):
    • 痛点:步长(Step Size)极其敏感。 深度学习中的神经网络一旦更新步长过大,策略就可能"崩溃"(性能直线下降),且很难恢复;如果步长太小,学习速度又像蜗牛一样慢。
    • 样本效率极低: 每次采集的数据用算完一次梯度后就必须丢弃,不能重复利用。
  2. TRPO (Trust Region Policy Optimization, 信任域策略优化):
    • 痛点:数学和计算极其复杂。 TRPO 引入了 KL 散度约束,巧妙地从数学上保证了策略每次更新都在变好(单调递增)。但是,它在求解时需要计算 Fisher 信息矩阵以及使用共轭梯度法(二阶优化)。这不仅代码实现复杂,而且计算开销极大,很难与包含 Dropout 或参数共享的复杂神经网络架构(如RNN/CNN配合 Actor-Critic)兼容。

PPO 的核心动机就是: 能不能发明一种算法,既保留 TRPO 那样稳定、不崩溃的更新保证,又能像传统策略梯度那样只使用简单的一阶优化


二 损失函数

1: Actor 动作网络的损失函数

2 Critic 价值网络的损失函数

一般用GAE 目标


三 核心思想

1. 概率比率 (Probability Ratio)

首先,论文定义了新旧策略在给定状态 下采取动作的概率比率:

  • 时,说明新策略采取该动作的概率变大了。
  • 时,说明概率变小了。
2. 无约束的目标函数 (CPI)

如果我们只考虑最大化优势函数(Advantage Function,用 表示,代表这个动作比平均水平好多少),目标函数是:

如果直接用神经网络优化这个函数,由于没有任何约束,网络为了追求最大化,会把变得无限大(如果 ),导致新旧策略差异过大,策略直接崩溃。

3. PPO 的杀手锏:Clipped Objective 函数

PPO 引入了一个超参数 (论文中通常取 0.2),并设计了如下的截断目标函数

这个公式看起来复杂,但原理异常优雅。它包含了两部分,并取最小值(即悲观下界,Pessimistic Bound):

  1. 正常计算的项:
  2. 强行截断的项:将比率限制在 `0.8`, `1.2`之间,再乘上优势值。

为什么这个公式有效?我们分两种情况来理解:

  • 情况 A:优势 (说明这个动作是个好动作,我们希望提高它的概率) 由于 是正数,随着策略优化,会不断增大。 但是,当增大到 (比如 1.2)时,clip 函数生效,截断项变成了 (一个常数)。 因为外层有一个 min 操作,此时目标函数的值就不再随 的增加而增加了。 专家直白解释: "既然这个动作好,那我们就增加它的概率。但不要贪心,比旧策略增加 20% 的概率就足够了!见好就收,停止更新该动作的权重,防止更新步子迈太大扯着蛋。"

  • 情况 B:优势 (说明这个动作是个坏动作,我们希望降低它的概率) 此时我们希望 变小。当 降低到 (比如 0.8)时,clip 函数生效。 同样因为外层的 min 操作(注意这里 是负数,一旦达到 0.8,梯度就变成了 0。 专家直白解释: "这个动作很糟糕,我们要降低它的概率。但也不要一棍子打死,比原来降低 20% 就行了,保留一点探索的可能性,也防止策略剧烈震荡。"

根据论文的实验和随后的业界实践,PPO 胜出主要归功于以下几点:

  1. 样本利用率大幅提高(Sample Efficiency): 传统的 Policy Gradient,数据用一次就要扔掉。而 PPO 因为有了截断机制的保护,新旧策略不会偏差太远。因此,同一批采样来的数据,可以在神经网络中放心地循环训练好几个 Epoch(通常是 4 到 10 次)。这极大地节省了与环境交互的时间和成本。
  2. 实现简单,易于调试(Simplicity): 没有复杂的二阶导数,没有共轭梯度,只有几十行普通的PyTorch/TensorFlow代码加上一个 torch.clamp(截断)操作。它可以使用标准的 Adam 优化器直接训练。
  3. 超强的鲁棒性和通用性(Robustness): PPO 对超参数(如学习率、网络结构)相对不敏感。无论是在离散动作空间(如玩雅达利游戏)、还是连续动作空间(如控制机器人走路、机械臂抓取),甚至是自然语言处理的生成任务(RLHF),一套标准配置的 PPO 往往都能跑到不错的 Baseline,不需要像 DDPG 或 SAC 那样疯狂调参。

四 伪代码


五 Python 例子

BipedalWalkerHardcore-v3

复制代码
# -*- coding: utf-8 -*-
"""
基于 PyTorch 的 PPO 完整实现(针对经典离散控制环境 CartPole-v1 进行深度定制)
参考 PPO 论文 (Schulman et al., 2017) 以及离散决策空间下的 PPO 最佳实践
作者: chengxf2
日期: 2026-06-17
"""

import gym
import numpy as np
import torch
import torch.nn as nn
import torch.optim as optim
from torch.distributions import Categorical
from torch.utils.data.sampler import BatchSampler, SubsetRandomSampler


# ---------------------------- 超参数配置 ----------------------------
GAMMA = 0.99                # 折扣因子
LAMBDA_GAE = 0.95           # GAE 系数
CLIP_EPSILON = 0.2          # PPO 截断范围
ACTOR_LR = 1e-3             # Actor 学习率 (经典值)
CRITIC_LR = 1e-3            # Critic 学习率 (更快速地拟合简单价值函数)
BATCH_SIZE = 500            # 每次更新前收集的步数 (CartPole 满行为 500 步,设为 500 可保证高频稳定的策略迭代)
MINI_BATCH_SIZE = 64        # 每次更新时的小批量大小
K_EPOCHS = 10               # 每批数据重复更新的轮数
ENTROPY_COEF = 0.01         # 熵正则系数 (离散空间使用 0.01 确保前期策略探索概率均匀,防止动作过早固化)
MAX_EPISODES = 200          # 最大训练回合数 (重构后在 100-150 回合内即可完美达到 500 分满分)
MAX_STEPS_PER_EP = 500      # 每回合最大步数(CartPole-v1 官方上限为 500 步)
STATE_DIM = 4               # 状态维度:4维连续观测向量(车位、车速、杆角、杆角速度)
ACTION_DIM = 2              # 动作维度:2维离散动作空间(0: 向左推小车, 1: 向右推小车)

# 设备配置
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")


def orthogonal_init(layer, gain=1.0):
    """正交初始化权重,极大提高 PPO 策略梯度的更新平滑度"""
    if isinstance(layer, nn.Linear):
        nn.init.orthogonal_(layer.weight, gain=gain)
        if layer.bias is not None:
            nn.init.constant_(layer.bias, 0.0)

def kaiming_init(layer, bias=0.0):
    """正交初始化权重,极大提高 PPO 策略梯度的更新平滑度"""
    if isinstance(layer, nn.Linear):
        nn.init.kaiming_uniform_(layer.weight,nonlinearity='relu')
        if layer.bias is not None:
            nn.init.constant_(layer.bias, 0)


  
  

class ActorNet(nn.Module):
    """
    策略网络(Actor):
    针对 CartPole-v1 离散 2 维动作空间重新设计。
    网络接收 4 维姿态输入,输出 2 维离散动作的未归一化对数概率(Logits)。
    在更新和采样时结合 PyTorch 的 Categorical 离散概率分布:
    - action = 0: 向左推, 施加-10N的力
    - action = 1: 向右推, 施加10N的力
    """
    def __init__(self, state_dim, action_dim=2, hidden_dim=64):
        super(ActorNet, self).__init__()
        self.fc1 = nn.Linear(state_dim, hidden_dim)
        self.fc2 = nn.Linear(hidden_dim, hidden_dim)
        self.fc3 = nn.Linear(hidden_dim, action_dim)

        # 权重正交初始化
        kaiming_init(self.fc1, bias=0.0)
        kaiming_init(self.fc2, bias=0.0)
        kaiming_init(self.fc3, bias=0.0)  # 最后一层选用极小的增益,使初始输出的左右推动动作概率极度平衡

    def forward(self, state):
        x = torch.relu(self.fc1(state))
        x = torch.relu(self.fc2(x))
        logits = self.fc3(x)
        return logits

    def get_action(self, state):
        """
        根据状态进行离散概率采样
        :param state: torch.Tensor [1, state_dim]
        :return: action (torch.Tensor [1]), log_prob (torch.Tensor [1, 1])
        """
        logits = self.forward(state)
        #内部自动做了 softmax,把 logits 转成概率分布:
        dist = Categorical(logits=logits)  # 构建离散 Categorical 分布
        action = dist.sample()              # 采样动作 (0 或 1)
        log_prob = dist.log_prob(action).unsqueeze(-1)  # 离散对数概率 [1, 1] tensor([[-0.5653]]) torch.Size([1, 1])
       
        return action, log_prob

    def evaluate(self, state, action):
        """
        评估动作:计算在当前控制律下,执行指定离散动作的对数概率和熵。
        :param state:  torch.Tensor [batch, state_dim]
        :param action: torch.Tensor [batch],通常为 LongTensor
        :return: log_prob (torch.Tensor [batch, 1]), entropy (torch.Tensor [])
        """
        logits = self.forward(state)
        #[batch_size, action_dim]
        dist = Categorical(logits=logits)
        log_prob = dist.log_prob(action).unsqueeze(-1)
        entropy = dist.entropy().unsqueeze(-1).mean()
        #熵值越大,代表动作概率分布越平坦,策略的随机性越强(探索性越好);
        #熵值越小,代表策略越"自信"(倾向于某一特定动作,易陷入局部最优)。
        return log_prob, entropy


class CriticNet(nn.Module):
    def __init__(self, state_dim, hidden_dim=64):
        super(CriticNet, self).__init__()
        self.fc1 = nn.Linear(state_dim, hidden_dim)
        self.fc2 = nn.Linear(hidden_dim, hidden_dim)
        self.fc3 = nn.Linear(hidden_dim, 1)

        # 使用Kaiming初始化
        kaiming_init(self.fc1)
        kaiming_init(self.fc2)
        kaiming_init(self.fc3)

    def forward(self, state):
        x = torch.relu(self.fc1(state))
        x = torch.relu(self.fc2(x))
        value = self.fc3(x)
        return value


class PPO:
    """PPO 离散算法主体"""
    def __init__(self, state_dim, action_dim):
        self.state_dim = state_dim
        self.action_dim = action_dim

        # 神经网络实例化并移至设备
        self.actor_net = ActorNet(state_dim, action_dim).to(device)
        self.critic_net = CriticNet(state_dim).to(device)

        # 优化器
        self.actor_optimizer = optim.Adam(self.actor_net.parameters(), lr=ACTOR_LR, eps=1e-5)
        self.critic_optimizer = optim.Adam(self.critic_net.parameters(), lr=CRITIC_LR, eps=1e-5)

        # 经验缓冲区
        self.states = []
        self.actions = []
        self.rewards = []
        self.terminateds = []
        self.truncateds = []
        self.log_probs = []
        self.values = []

    def choose_action(self, state):
        """
        纯净非阻塞动作采样(解耦存储逻辑,易于评估)
        """
        #(feature_dim)
        state_t = torch.FloatTensor(state)
        #(feature_dim)--->(batch, feature_dim)
        state_t = state_t.unsqueeze(0).to(device)   # [1, state_dim]
        with torch.no_grad():
            action_t, log_prob_t = self.actor_net.get_action(state_t)
            value_t = self.critic_net(state_t)
            
        action = action_t.squeeze(0).cpu().numpy().item()    # 转换为 Python 整数 (0 或 1)
        log_prob = log_prob_t.squeeze().cpu().numpy().item() # 对数概率标量
        value = value_t.squeeze().cpu().numpy().item()       # 状态价值标量
        return action, log_prob, value

    def get_value(self, state):
        """姿态评估 V(s)"""
        state_t = torch.FloatTensor(state).unsqueeze(0).to(device)
        with torch.no_grad():
            value = self.critic_net(state_t)
        return value.squeeze().cpu().numpy().item()

    def store_transition(self, state, action, reward, terminated, truncated, log_prob, value):
        """向缓冲区存入一步物理交互经验"""
        self.states.append(state)
        self.actions.append(action)
        self.rewards.append(reward)
        self.terminateds.append(terminated)
        self.truncateds.append(truncated)
        self.log_probs.append(log_prob)
        self.values.append(value)

    def compute_gae(self, next_value):
        """
        计算广义优势估计(GAE),为 PPO 提供平滑、低方差、低偏差的优势值。
    
        GAE 的核心作用:
        -----------------
        - **平衡偏差与方差**:直接使用蒙特卡洛回报(高方差)或 TD(0)(高偏差)都不理想。
          GAE 通过指数加权平均所有时刻的 TD 误差,将方差压到极低(因为不依赖未来随机回报的累加),
          同时允许价值网络的微小误差被后续的时序差分项逐步修正,从而显著降低偏差。
        - **稳定策略更新**:GAE 提供的优势值极其平滑且可信,使得 PPO 能够以较大的步长进行多次梯度更新
          而不导致性能崩溃------这是 PPO 成为工业界首选 on-policy 算法的关键原因之一。
        - **超参数控制权衡**:参数 λ(LAMBDA_GAE)允许平滑地在偏差(λ→0,接近 TD(0))和
          方差(λ→1,接近蒙特卡洛)之间调节,适应不同环境的时间关联性。
    
        回合结束状态的处理(防止非平稳自举):
        ------------------------------------------
        本实现区分两种"done"状态,分别处理 GA 的自举与重置:
        1. **terminated**(真正的任务终止,如失败/成功):
           - 未来奖励为零,因此 `next_val = 0.0`(不自举)。
           - GAE 累计因子 `(1 - done_i)` 置零,使得 `gae = δ_i`,即历史优势被丢弃,
             当前步的 TD 误差成为新的起点(这等价于"GAE 重置")。
        2. **truncated**(因达到最大时间步而截断,如 500 步限制):
           - 环境可能尚未真正结束,因此使用价值网络自举 `next_val = V(s_{i+1})`,以保留截断后的潜在回报。
           - 但同样将 `(1 - done_i)` 置零,使 GAE 重置,避免将"人为截断"误判为长期优势,从而防止价值估计偏差传播。
    
        计算步骤(逆序):
        -----------------
        对每个时间步 i(从后向前):
           δ_i = r_i + γ * next_val - V(s_i)
           gae_i = δ_i + γ * λ * (1 - done_i) * gae_{i+1}
           adv_i = gae_i
           return_i = gae_i + V(s_i)
    
        参数:
            next_value (float):最后一个时间步之后的状态价值 V(s_T),用于在截断时自举。
    
        返回:
            advantages (list[float]):每个时间步的优势估计 adv_i,用于策略网络更新。
            returns    (list[float]):每个时间步的回报估计 return_i,用于价值网络拟合。
    
        全局超参数(在外部定义):
            GAMMA      (float) : 折扣因子,控制未来奖励的权重。
            LAMBDA_GAE (float) : GAE 衰减系数,0~1 之间,越大越偏向蒙特卡洛(高方差),越小越偏向 TD(高偏差)。
        """
        advantages = []
        returns = []
        gae = 0.0
        values = self.values + [next_value]
    
        for i in reversed(range(len(self.rewards))):
            done_i = self.terminateds[i] or self.truncateds[i]
    
            if self.terminateds[i]:
                next_val = 0.0
            else:
                next_val = values[i + 1]
    
            delta = self.rewards[i] + GAMMA * next_val - values[i]
            gae = delta + GAMMA * LAMBDA_GAE * (1.0 - done_i) * gae
            advantages.insert(0, gae)
            returns.insert(0, gae + values[i])
    
        return advantages, returns

    def update(self, next_state):
        """
        离散动作 PPO 策略参数优化
        """
        data_len = len(self.states)
        if data_len == 0:
            return

        if self.terminateds[-1]:
            next_value = 0.0
        else:
            next_value = self.get_value(next_state)

        advantages, returns = self.compute_gae(next_value)
        
        states = torch.FloatTensor(np.array(self.states)).to(device)
        # 离散动作更新在 PyTorch 索引中必须使用 LongTensor 格式类型
        actions = torch.LongTensor(np.array(self.actions)).to(device)
        old_log_probs = torch.FloatTensor(np.array(self.log_probs)).to(device)
        advantages = torch.FloatTensor(np.array(advantages)).to(device)
        returns = torch.FloatTensor(np.array(returns)).to(device)

    

        # 10 轮小批量策略迭代
        for _ in range(K_EPOCHS):
            sampler = BatchSampler(SubsetRandomSampler(range(data_len)), MINI_BATCH_SIZE, drop_last=False)
            for indices in sampler:
                batch_states = states[indices]
                batch_actions = actions[indices]
                batch_old_log_probs = old_log_probs[indices]
                batch_adv = advantages[indices]
                batch_returns = returns[indices]

                # ------------------ Actor 离散优化 ------------------
                new_log_probs, entropy = self.actor_net.evaluate(batch_states, batch_actions)
                ratio = torch.exp(new_log_probs.squeeze(-1) - batch_old_log_probs)
                
                surr1 = ratio * batch_adv
                surr2 = torch.clamp(ratio, 1 - CLIP_EPSILON, 1 + CLIP_EPSILON) * batch_adv
                clip_loss = torch.min(surr1, surr2).mean()
                #熵越大,探索性越好,损失函数中增加探索性
                actor_loss = -clip_loss - ENTROPY_COEF * entropy

                self.actor_optimizer.zero_grad()
                actor_loss.backward()
                #nn.utils.clip_grad_norm_(self.actor_net.parameters(), max_norm=0.5)
                self.actor_optimizer.step()

                # ------------------ Critic 价值优化 ------------------
                current_values = self.critic_net(batch_states).squeeze(-1)
                critic_loss = nn.MSELoss()(current_values, batch_returns)

                self.critic_optimizer.zero_grad()
                critic_loss.backward()
                #nn.utils.clip_grad_norm_(self.critic_net.parameters(), max_norm=0.5)
                self.critic_optimizer.step()

        # 清除本批次缓冲区
        self.states.clear()
        self.actions.clear()
        self.rewards.clear()
        self.terminateds.clear()
        self.truncateds.clear()
        self.log_probs.clear()
        self.values.clear()

    def train(self, env, max_episodes=MAX_EPISODES, max_steps=MAX_STEPS_PER_EP):
        """
        PPO 离散训练主循环
        """
        total_steps = 0
        all_rewards = []

        for ep in range(max_episodes):
            state, _ = env.reset()
            episode_reward = 0

            for t in range(max_steps):
                action, log_prob, value = self.choose_action(state)
                next_state, reward, terminated, truncated, _ = env.step(action)

                self.store_transition(state, action, reward, terminated, truncated, log_prob, value)

                state = next_state
                episode_reward += reward
                total_steps += 1

                # 缓冲区集满 BATCH_SIZE 步,触发策略更新
                if len(self.states) >= BATCH_SIZE:
                    self.update(next_state)
                
                if terminated or truncated:
                    break
            
            all_rewards.append(episode_reward)
            
            if (ep + 1) % 10 == 0:
                avg_reward = np.mean(all_rewards[-10:])
                print(f"Episode {ep+1:4d} | Avg Reward (last 10): {avg_reward:8.2f} | Total Steps: {total_steps}")
                
                # 如果最近十个回合已经达到 500 分满分(说明完全训练解决),可以提前终止训练
                if avg_reward >= 495.0:
                    print(f"--> 环境在第 {ep+1} 回合已被完全解决 (Solved)! 退出训练。")
                    break

        if len(self.states) > 0:
            self.update(state)

        return all_rewards


def test_agent(env_name, agent, num_episodes=100, render=False, device='cpu'):
    """
    使用给定智能体在 CartPole-v1 离散环境中进行评估
    :param env_name:     环境名称,如 'CartPole-v1'
    :param agent:        PPO 离散智能体对象
    :param num_episodes: 测试回合数(建议 ≥30,默认100)
    :param render:       是否渲染显示画面
    :param device:       推理设备
    :return:             平均步数(每步 reward=1,所以也是平均 reward)
    """
    if render:
        test_env = gym.make(env_name, render_mode='human')
        print(f"-> 创建渲染环境: {env_name} (图形窗口即将显示)")
    else:
        test_env = gym.make(env_name)

    all_rewards = []

    for ep in range(num_episodes):
        state, _ = test_env.reset()
        episode_reward = 0
        done = False
        t = 0

        while not done:
            state_t = torch.FloatTensor(state).unsqueeze(0).to(device)
            with torch.no_grad():
                logits = agent.actor_net(state_t)
            action = torch.argmax(logits, dim=-1).cpu().numpy().item()

            next_state, reward, terminated, truncated, _ = test_env.step(action)
            state = next_state
            episode_reward += reward
            done = terminated or truncated
            t += 1

        all_rewards.append(episode_reward)

        # 按评分标准判断
        if t >= 450:
            print(f"   [✅ 好策略] 回合 {ep+1:3d} | 步数: {t:4d}")
        elif t >= 200:
            print(f"   [⚠️ 一般]   回合 {ep+1:3d} | 步数: {t:4d}")
        else:
            print(f"   [❌ 差]     回合 {ep+1:3d} | 步数: {t:4d}")

    test_env.close()

    avg_steps = np.mean(all_rewards)
    std_steps = np.std(all_rewards)

    print(f"\n-> 测试完成 | 平均步数: {avg_steps:6.2f} ± {std_steps:5.2f}")

    # 根据评分标准给出明确结论
    if avg_steps >= 450:
        print("   结论: ✅ 好策略(≥450 步)")
    elif avg_steps >= 200:
        print("   结论: ⚠️ 学了但没学好(200~450 步)")
    else:
        print("   结论: ❌ 差策略(<200 步,接近随机水平)")

    return avg_steps


# ---------------------------- 主程序入口 ----------------------------
if __name__ == "__main__":
    env_name = 'CartPole-v1'
    '''
    环境说明: 用一个可移动的小车,通过左右推,让杆子始终保持直立不倒
    类型:Box(4,) --- 4维连续向量
    State(观测 / 状态)
    索引	变量名	物理含义	取值范围
    0	cart_pos	小车位置(轨道中心为0)	[-4.8, 4.8] 米
    1	cart_vel	小车速度	(-∞, +∞),实际约 [-3.5, 3.5]
    2	pole_angle	杆子与垂直方向的夹角	[-0.418, 0.418] rad ≈ [-24°, 24°]
    3	pole_vel	杆子的角速度	(-∞, +∞),实际约 [-8, 8】
     
    Action(动作)
    类型:Discrete(2) --- 离散2个动作
    
    动作值	物理含义	施加的力
    0	向左推小车	-10 N
    1	向右推小车	+10 N
    力是瞬间施加的,不是持续的。每一步只能选一个方向推         

    Reward(奖励)
    情况	奖励
    每存活一步	+1
    终止(杆子倒了/小车出界)	0(无额外惩罚)
    所以最大化奖励 = 尽可能存活更多步数。    

    Done(终止条件)
    满足任一条件即终止:
    
    条件	阈值	含义
    杆子角度过大	`	pole_angle
    小车出界	`	cart_pos
    达到最大步数	steps >= 500	成功保持了500步(v1的标准)                 
    '''
    
    # 创建训练环境
    env = gym.make(env_name)

    # 实例化针对离散动作空间的 PPO 智能体
    agent = PPO(STATE_DIM, ACTION_DIM)

    # 1. 评估未训练的离散控制律,展现初始的平衡动作分配
    print("====================================================")
    print("【功能 1】使用 [未训练] 的网络在环境中执行离散动作并评估...")
    print("      (下方将打印前几个物理步中智能体的原始直觉动作指令)")
    print("====================================================")
    test_agent(env_name, agent, num_episodes=3, render=True)
    
    # 2. 训练智能体
    print("\n====================================================")
    print(f"在设备: {device} 上启动离散 PPO 神经网络训练...")
    print("====================================================")
    reward_history = agent.train(env, max_episodes=MAX_EPISODES)
    
    # 3. 评估已训练的离散控制律,展示平衡控制律如何完美平控直杆
    print("\n====================================================")
    print("【功能 2】使用 [已训练] 的网络在环境中执行离散动作并评估...")
    print("      (下方将展示策略神经网络学习平衡后,如何精准对冲车杆抖动)")
    print("====================================================")
    test_agent(env_name, agent, num_episodes=3, render=True)

    env.close()

https://www.youtube.com/watch?v=_B2oMdOVVJc

https://medium.com/@csobrinofm/reinforcement-learning-a-practical-guide-to-proximal-policy-optimization-ppo-276df3e5099e

https://medium.com/@csobrinofm/reinforcement-learning-a-practical-guide-to-proximal-policy-optimization-ppo-276df3e5099e