Proximal Policy Optimization(PPO)

English translation

[Key concepts of Proximal Policy Optimization (PPO)]

[Clipped surrogate objective]

[PPO limitations and directions for improvement]

[Activity: CartPole with PPO]

1. Import libraries and set hyperparameters

import torch
import torch.nn as nn
import torch.optim as optim
import torch.nn.functional as F
from torch.distributions import Categorical

import gymnasium as gym
import numpy as np
import matplotlib.pyplot as plt
import imageio

learning_rate = 0.0003
gamma = 0.99
lmbda = 0.95  # GAE lambda
eps_clip = 0.1  # Clipping range
K_epoch = 4  # Number of PPO update iterations
T_horizon = 128 # Maximum number of steps in 1 episode
num_episodes = 2000 # Number of training episodes
early_stopping_threshold = 450.0,  # Early-stopping threshold
early_stopping_patience = 10  # Number of consecutive episodes satisfying the early-stopping condition
gif_save_path = 'cartpole_simulation_ppo.gif' # GIF output path
progress_interval = 50  # Progress reporting interval

2. Define the actor–critic network

class ActorCritic(nn.Module):
    def __init__(self, state_dim, action_dim, hidden_dim):
        super(ActorCritic, self).__init__()
        self.actor = nn.Sequential(
            nn.Linear(state_dim, hidden_dim),
            nn.ReLU(),
            nn.Linear(hidden_dim, hidden_dim),
            nn.ReLU(),
            nn.Linear(hidden_dim, action_dim),
            nn.Softmax(dim=-1)
        )
        self.critic = nn.Sequential(
            nn.Linear(state_dim, hidden_dim),
            nn.ReLU(),
            nn.Linear(hidden_dim, hidden_dim),
            nn.ReLU(),
            nn.Linear(hidden_dim, 1)
        )
        self.init_weights()

    # Xavier initialization
    def init_weights(self):
        for m in self.modules():
            if isinstance(m, nn.Linear):
                nn.init.xavier_uniform_(m.weight)
                if m.bias is not None:
                    nn.init.zeros_(m.bias)

    def forward(self, x):
        return self.actor(x), self.critic(x)

3. Define the PPO algorithm class

class PPO:
    def __init__(self, state_dim, action_dim, hidden_dim, lr, gamma, lmbda, eps_clip, K_epoch):
        self.model = ActorCritic(state_dim, action_dim, hidden_dim)
        self.optimizer = optim.AdamW(self.model.parameters(), lr=lr) # AdamW optimizer
        self.gamma = gamma
        self.lmbda = lmbda
        self.eps_clip = eps_clip
        self.K_epoch = K_epoch
        self.data = []

    def get_action(self, state):
        state = torch.FloatTensor(state) # Convert to Tensor + device
        probs, _ = self.model(state)  # Call Actor, Critic separately
        m = Categorical(probs)
        action = m.sample()
        return action.item(), m.log_prob(action)

    def compute_gae(self, rewards, values, next_value, dones):
      advantages = []
      gae = 0
      values = values + [next_value]

      for i in reversed(range(len(rewards))):
          delta = rewards[i] + self.gamma * values[i + 1] * (1 - dones[i]) - values[i]
          gae = delta + self.gamma * self.lmbda * (1- dones[i]) * gae
          advantages.insert(0, gae)

      advantages = torch.tensor(advantages, dtype=torch.float) # Convert to Tensor and assign to device
      target_values = advantages + torch.tensor(values[:-1],dtype=torch.float) # Convert to Tensor and assign to device

      return advantages, target_values


    def update(self, states, actions, log_probs, advantages, target_values):
        states = torch.stack(states)
        actions = torch.tensor(actions) # Convert to Tensor, device
        log_probs = torch.stack(log_probs)

        # PPO update (repeat K_epoch times)
        for _ in range(self.K_epoch):
            # Generate minibatch indices
            indices = torch.randperm(states.size(0))

            for start in range(0, states.size(0), T_horizon): # Use a mini-batch of T_horizon steps
                end = start + T_horizon
                idx = indices[start:end]

                # Minibatch data
                mb_states = states[idx].detach()  # Take only the original values used in loss so they do not participate in loss backpropagation
                mb_actions = actions[idx].detach()
                mb_log_probs = log_probs[idx].detach()
                mb_advantages = advantages[idx].detach()
                mb_target_values = target_values[idx].detach()

                # New policy probabilities and values
                new_probs, values = self.model(mb_states) # forward
                new_dist = Categorical(new_probs)
                new_log_probs = new_dist.log_prob(mb_actions)

                # Compute the PPO ratio
                ratio = torch.exp(new_log_probs - mb_log_probs)

                # Clipped surrogate objective
                surr1 = ratio * mb_advantages
                surr2 = torch.clamp(ratio, 1 - self.eps_clip, 1 + self.eps_clip) * mb_advantages
                actor_loss = -torch.min(surr1, surr2).mean() # actor loss

                # Critic loss (MSE)
                critic_loss = F.mse_loss(values, mb_target_values.unsqueeze(1))

                # Total loss
                total_loss = actor_loss + critic_loss

                # Backpropagate and optimize
                self.optimizer.zero_grad()
                total_loss.backward()
                torch.nn.utils.clip_grad_norm_(self.model.parameters(), max_norm=1.0) # gradient clipping
                self.optimizer.step()

4. Define the training loop function

def train(env, ppo_agent):
    scores = [] # Record score, loss

    for episode in range(num_episodes):
        state, _ = env.reset()
        done = False
        score = 0
        episode_states, episode_actions, episode_log_probs, episode_rewards, episode_dones = [], [], [], [], []

        # Collect data by episode
        while not done:
            action, log_prob = ppo_agent.get_action(state) # action, log prob
            next_state, reward, terminated, truncated, _ = env.step(action)
            done = terminated or truncated

            # Store data (s, a, r, s', prob_a, done)
            state_tensor = torch.from_numpy(state).float()
            episode_states.append(state_tensor)
            episode_actions.append(action)
            episode_log_probs.append(log_prob)
            episode_rewards.append(reward)
            episode_dones.append(done)

            state = next_state
            score += reward

        # Compute GAE
        with torch.no_grad():
          values = ppo_agent.model(torch.stack(episode_states))[1].squeeze().tolist() # [batch, 1] -> [batch]
          _, next_value = ppo_agent.model(torch.FloatTensor(next_state))
          next_value = next_value.item()

        advantages, target_values = ppo_agent.compute_gae(
            rewards=episode_rewards,
            values=values,
            next_value=next_value,
            dones=episode_dones
        )

        # PPO update
        ppo_agent.update(episode_states, episode_actions, episode_log_probs, advantages, target_values)

        # Record score
        scores.append(score)

        # Check early stopping at each 'progress_interval' specified in config.
        if (episode + 1) % progress_interval == 0:
            avg_score = np.mean(scores[-progress_interval:])
            print(f"Episode {episode + 1}: Avg. Score = {avg_score:.2f}")

        # Check the early-stopping condition.
        if np.min(scores[-early_stopping_patience:]) >= early_stopping_threshold:
            print(f"Early stopping at episode {episode + 1} (score: {score:.2f})")
            break

    return scores # Return the score list

5. Run training and visualize results

# Create a PPO agent
env = gym.make('CartPole-v1', render_mode = 'rgb_array')
state_dim = env.observation_space.shape[0]
action_dim = env.action_space.n
hidden_dim = 128
agent = PPO(state_dim, action_dim, hidden_dim, learning_rate, gamma, lmbda, eps_clip, K_epoch)

# Run training
scores = train(env, agent)

# Visualize results (learning curve)
plt.plot(scores)
plt.xlabel("Episode")
plt.ylabel("Average Score")
plt.title("PPO Training Progress")
plt.show()
Episode 50: Avg. Score = 17.92
Episode 100: Avg. Score = 20.22
Episode 150: Avg. Score = 128.42
Episode 200: Avg. Score = 241.90
Episode 250: Avg. Score = 291.58
Episode 300: Avg. Score = 429.42
Early stopping at episode 345 (score: 500.00)
<Figure size 640x480 with 1 Axes>
실행 결과

6. Validate the trained agent and save a GIF

# Evaluation (save a GIF)
print("Generating GIF...")
frames = []
state, _ = env.reset()
done = False
truncated = False
total_reward = 0

with imageio.get_writer(gif_save_path, duration=33) as writer:  # imageio writer
    while not done:
        action, _ = agent.get_action(state)
        next_state, reward, terminated, truncated, _ = env.step(action)
        total_reward += reward
        state = next_state
        done = terminated or truncated

        frames.append(env.render())

    # Save frames as a GIF using imageio
    for frame in frames:
        writer.append_data(frame)

print(f"Total reward for the test episode: {total_reward}")
env.close() # Close the environment
Generating GIF...
Total reward for the test episode: 500.0