🧠 Policy Gradient Fundamentals
🎯 What are Policy Gradient Methods?
Policy Gradient methods are a family of reinforcement learning algorithms that directly learn a parameterized policy π_θ(a|s) by optimizing the expected return via gradient ascent. Unlike value-based methods (Q-learning), they can handle continuous action spaces, learn stochastic policies naturally, and have strong convergence guarantees.
Why Policy Gradients Matter
Continuous actions: Natural for robotics and control. Stochastic policies: Optimal in partially observable settings. Direct optimization: Optimize what you care about. Convergence: Theoretical guarantees. Foundation: Basis for PPO, TRPO, RLHF. Modern AI: Powers ChatGPT's alignment.
# Policy gradient — the big picture
import numpy as np
import torch
import torch.nn as nn
class PolicyNetwork(nn.Module):
"""Neural network that outputs action probabilities."""
def __init__(self, state_dim, action_dim):
super().__init__()
self.fc1 = nn.Linear(state_dim, 64)
self.fc2 = nn.Linear(64, 64)
self.fc3 = nn.Linear(64, action_dim)
def forward(self, state):
x = torch.relu(self.fc1(state))
x = torch.relu(self.fc2(x))
logits = self.fc3(x)
return torch.softmax(logits, dim=-1)
# The core idea:
# 1. Have a policy π_θ(a|s)
# 2. Sample trajectories by running the policy
# 3. Compute gradient of expected return w.r.t. θ
# 4. Update θ in direction that increases return
# 5. Repeat
def policy_gradient_concept():
"""
θ ← θ + α ∇_θ J(θ)
where J(θ) = E[return] under policy π_θ
Key insight: We can estimate ∇_θ J(θ) from samples!
"""
pass
# Comparison with value-based methods
comparison = {
'Policy Gradient': {
'learns': 'Policy π(a|s) directly',
'action_space': 'Discrete or continuous',
'exploration': 'Stochastic policy',
'convergence': 'Smooth, local optima',
'examples': 'REINFORCE, PPO, TRPO'
},
'Value-Based': {
'learns': 'Value Q(s,a) or V(s)',
'action_space': 'Discrete only',
'exploration': 'ε-greedy',
'convergence': 'Can oscillate',
'examples': 'Q-Learning, DQN'
}
}
for method, info in comparison.items():
print(f"\n{method}:")
for k, v in info.items():
print(f" {k}: {v}")📊 Policy Function
🎯 The Learning Target
The policy π(a|s) defines the probability of taking action a in state s. It can be:
- Deterministic: π(s) = a
- Stochastic: π(a|s) = P(a|s)
- Tabular: Table of probabilities
- Parameterized: π_θ(a|s) — neural network
Policy Representations
Discrete actions: Softmax over action logits. Continuous actions: Gaussian with mean and std. Mixed: Different heads for different action types. Recurrent: For partially observable MDPs. Attention: For multi-agent or structured tasks.
# Policy representations
import torch
import torch.nn as nn
import torch.nn.functional as F
from torch.distributions import Categorical, Normal
# 1. DISCRETE ACTION POLICY (Categorical)
class DiscretePolicy(nn.Module):
"""Policy for discrete action spaces."""
def __init__(self, state_dim, action_dim):
super().__init__()
self.fc1 = nn.Linear(state_dim, 128)
self.fc2 = nn.Linear(128, 128)
self.fc3 = nn.Linear(128, action_dim)
def forward(self, state):
x = F.relu(self.fc1(state))
x = F.relu(self.fc2(x))
logits = self.fc3(x)
return logits
def get_distribution(self, state):
"""Return action distribution."""
logits = self.forward(state)
return Categorical(logits=logits)
def sample(self, state):
"""Sample action from policy."""
dist = self.get_distribution(state)
action = dist.sample()
log_prob = dist.log_prob(action)
return action, log_prob
def log_prob(self, state, action):
"""Compute log probability of action."""
dist = self.get_distribution(state)
return dist.log_prob(action)
# 2. CONTINUOUS ACTION POLICY (Gaussian)
class ContinuousPolicy(nn.Module):
"""Policy for continuous action spaces."""
def __init__(self, state_dim, action_dim):
super().__init__()
self.fc1 = nn.Linear(state_dim, 128)
self.fc2 = nn.Linear(128, 128)
self.mean_head = nn.Linear(128, action_dim)
self.log_std = nn.Parameter(torch.zeros(action_dim))
def forward(self, state):
x = F.relu(self.fc1(state))
x = F.relu(self.fc2(x))
mean = self.mean_head(x)
std = torch.exp(self.log_std.clamp(-20, 2))
return mean, std
def get_distribution(self, state):
"""Return Gaussian distribution."""
mean, std = self.forward(state)
return Normal(mean, std)
def sample(self, state):
dist = self.get_distribution(state)
action = dist.rsample() # Reparameterized sample
log_prob = dist.log_prob(action).sum(-1)
return action, log_prob
# 3. USING THE POLICY
state_dim = 4
action_dim = 2
# Discrete
policy_discrete = DiscretePolicy(state_dim, action_dim)
state = torch.randn(1, state_dim)
action, log_prob = policy_discrete.sample(state)
print(f"Discrete action: {action.item()}")
print(f"Log prob: {log_prob.item():.4f}")
# Continuous
policy_continuous = ContinuousPolicy(state_dim, action_dim)
action, log_prob = policy_continuous.sample(state)
print(f"\nContinuous action: {action.detach().numpy().round(3)}")
print(f"Log prob: {log_prob.item():.4f}")
# 4. KEY PROPERTIES
print("\n=== Policy Properties ===")
print("""
1. SUPPORTS CONTINUOUS ACTIONS
- Output mean and std of Gaussian
- Natural for robotics and control
2. STOCHASTIC BY DESIGN
- Built-in exploration
- Optimal for POMDPs
- Mix strategies
3. SMOOTH POLICY CHANGES
- Small θ change → small π change
- Better for continuous control
4. NATURAL FOR MULTI-ACTION
- Can have different heads
- Handles different action types
5. COMPATIBLE WITH FUNCTION APPROX
- Neural networks
- Can use any architecture
""")
# 5. ACTION SAMPLING
print("=== Action Sampling ===")
# Discrete: sample from categorical
state = torch.randn(1, state_dim)
logits = policy_discrete(state)
probs = F.softmax(logits, dim=-1)
print(f"Action probabilities: {probs.detach().numpy().round(3)}")
# Sample multiple times
samples = [policy_discrete.sample(state)[0].item() for _ in range(10)]
print(f"10 samples: {samples}")
# Continuous: sample from Gaussian
mean, std = policy_continuous(state)
print(f"\nContinuous mean: {mean.detach().numpy().round(3)}")
print(f"Continuous std: {std.detach().numpy().round(3)}")
# Sample multiple times
samples = [policy_continuous.sample(state)[0].detach().numpy() for _ in range(5)]
print(f"5 samples:")
for s in samples:
print(f" {s.round(3)}")🎯 Objective Function
🎯 What We Optimize
The goal of policy gradient is to maximize the expected return J(θ) = E_τ~π_θ[R(τ)], where τ is a trajectory and R(τ) is the total reward. We optimize θ via gradient ascent.
Objective Variations
Total reward: J(θ) = E[Σ r_t]. Discounted: J(θ) = E[Σ γ^t r_t]. Average reward: For continuing tasks. Regularized: Add entropy for exploration. Constrained: Subject to safety constraints. Multi-objective: Balance multiple rewards.
# Objective functions for policy gradient
import numpy as np
import torch
# 1. TOTAL REWARD OBJECTIVE
def total_reward_objective(trajectories):
"""J(θ) = E[Σ_t r_t]"""
returns = []
for trajectory in trajectories:
total = sum(trajectory['rewards'])
returns.append(total)
return np.mean(returns)
# 2. DISCOUNTED RETURN OBJECTIVE
def discounted_objective(trajectories, gamma=0.99):
"""J(θ) = E[Σ_t γ^t r_t]"""
returns = []
for trajectory in trajectories:
discounted = sum(
gamma ** t * r
for t, r in enumerate(trajectory['rewards'])
)
returns.append(discounted)
return np.mean(returns)
# 3. AVERAGE REWARD OBJECTIVE
def average_reward_objective(trajectories):
"""For continuing tasks."""
rates = []
for trajectory in trajectories:
rate = np.mean(trajectory['rewards'])
rates.append(rate)
return np.mean(rates)
# 4. REGULARIZED OBJECTIVE (with entropy)
def regularized_objective(trajectories, policy, beta=0.01):
"""J(θ) = E[R] + β * E[H(π)]"""
returns = [sum(t['rewards']) for t in trajectories]
mean_return = np.mean(returns)
# Entropy bonus for exploration
entropy_bonus = 0
for trajectory in trajectories:
for state in trajectory['states']:
probs = policy(torch.FloatTensor(state))
entropy = -(probs * torch.log(probs + 1e-8)).sum()
entropy_bonus += entropy.item()
return mean_return + beta * entropy_bonus / len(trajectories)
# 5. CONSTRAINED OBJECTIVE
def constrained_objective(trajectories, cost_limit=0.1):
"""
maximize E[R]
subject to E[C] ≤ cost_limit
"""
returns = [sum(t['rewards']) for t in trajectories]
costs = [sum(t.get('costs', [0])) for t in trajectories]
mean_return = np.mean(returns)
mean_cost = np.mean(costs)
if mean_cost > cost_limit:
# Penalize constraint violation
return mean_return - 10 * (mean_cost - cost_limit)
return mean_return
# 6. GENERAL ADVANTAGE ESTIMATION (GAE)
def gae_objective(trajectories, gamma=0.99, lam=0.95):
"""
Use GAE for advantage estimation.
A_t = Σ_l (γλ)^l δ_{t+l}
where δ_t = r_t + γV(s_{t+1}) - V(s_t)
"""
advantages = []
for trajectory in trajectories:
rewards = trajectory['rewards']
values = trajectory['values']
advantages_t = []
gae = 0
for t in reversed(range(len(rewards))):
if t == len(rewards) - 1:
next_value = 0
else:
next_value = values[t + 1]
delta = rewards[t] + gamma * next_value - values[t]
gae = delta + gamma * lam * gae
advantages_t.insert(0, gae)
advantages.extend(advantages_t)
return np.array(advantages)
# 7. COMPARISON
print("=== Objective Function Comparison ===")
print("""
Total Reward:
+ Simple, intuitive
- Doesn't handle continuing tasks
- May not converge
Discounted Return:
+ Standard for episodic tasks
+ Prioritizes near-term rewards
- Bias-variance tradeoff with γ
Average Reward:
+ Good for continuing tasks
+ Natural for long-running systems
- Harder to estimate
Regularized:
+ Encourages exploration
+ Prevents premature convergence
- Tuning β is tricky
Constrained:
+ Safety-critical applications
+ Business constraints
- Requires careful formulation
""")
# 8. ENTROPY REGULARIZATION
print("=== Entropy Regularization ===")
def compute_entropy(probs):
"""H(π) = -Σ π log π"""
return -(probs * np.log(probs + 1e-8)).sum()
# Example probabilities
probs_confident = np.array([0.95, 0.03, 0.02])
probs_uniform = np.array([0.33, 0.33, 0.34])
print(f"Confident policy entropy: {compute_entropy(probs_confident):.4f}")
print(f"Uniform policy entropy: {compute_entropy(probs_uniform):.4f}")
print("\nHigher entropy = more exploration")
# Entropy in the objective
def policy_loss_with_entropy(log_probs, advantages, entropies, beta=0.01):
"""Loss = -E[A * log π] - β * H(π)"""
policy_loss = -(advantages * log_probs).mean()
entropy_bonus = -entropies.mean() # Negative for minimization
return policy_loss + beta * entropy_bonus
print("\nEntropy regularization encourages exploration!")📐 Policy Gradient Theorem
🎯 The Mathematical Foundation
The Policy Gradient Theorem provides an expression for the gradient of the expected return that doesn't require differentiating through the environment dynamics. This is what makes policy gradient methods practical.
The Theorem
∇_θ J(θ) = E_π[∇_θ log π_θ(a|s) · Q^π(s,a)]. Key insight: No environment gradient needed. Log-derivative trick: ∇_θ π = π · ∇_θ log π. Intuition: Increase prob of actions that led to high Q. Variants: With baseline, with advantage, with GAE.
# Policy Gradient Theorem — derivation and implementation
import numpy as np
import torch
import torch.nn as nn
import torch.nn.functional as F
# THE THEOREM:
# ∇_θ J(θ) = E_π[∇_θ log π_θ(a|s) · Q^π(s,a)]
#
# Where:
# - J(θ) = expected return
# - π_θ(a|s) = policy
# - Q^π(s,a) = action-value function under π
# DERIVATION (simplified):
# 1. J(θ) = ∫ p_θ(τ) R(τ) dτ
# 2. ∇_θ J = ∫ ∇_θ p_θ(τ) R(τ) dτ
# 3. Log trick: ∇_θ p_θ(τ) = p_θ(τ) ∇_θ log p_θ(τ)
# 4. ∇_θ J = ∫ p_θ(τ) ∇_θ log p_θ(τ) R(τ) dτ
# 5. = E_τ[∇_θ log p_θ(τ) R(τ)]
#
# Now, log p_θ(τ) = log p(s_0) + Σ log π_θ(a_t|s_t) + Σ log p(s_{t+1}|s_t,a_t)
# Since p(s_0) and p(s_{t+1}|s_t,a_t) don't depend on θ:
# ∇_θ log p_θ(τ) = Σ ∇_θ log π_θ(a_t|s_t)
#
# Therefore: ∇_θ J(θ) = E[Σ_t ∇_θ log π_θ(a_t|s_t) · R(τ)]
def policy_gradient_estimate(policy, trajectories):
"""
Estimate policy gradient from trajectories.
∇_θ J(θ) ≈ (1/N) Σ_i Σ_t ∇_θ log π_θ(a_t|s_t) · R(τ_i)
"""
all_log_probs = []
all_returns = []
for trajectory in trajectories:
# Compute return
returns = compute_returns(trajectory['rewards'])
for t, (state, action) in enumerate(zip(trajectory['states'],
trajectory['actions'])):
# log π_θ(a|s)
state_tensor = torch.FloatTensor(state).unsqueeze(0)
action_tensor = torch.LongTensor([action])
log_prob = policy.log_prob(state_tensor, action_tensor)
all_log_probs.append(log_prob)
all_returns.append(returns[t])
# Compute gradient
all_returns = torch.FloatTensor(all_returns)
log_probs = torch.stack(all_log_probs)
# Weight log probs by returns
weighted_log_probs = log_probs * all_returns
loss = -weighted_log_probs.mean() # Negative for gradient ascent
return loss
def compute_returns(rewards, gamma=0.99):
"""Compute discounted returns-to-go."""
returns = []
R = 0
for r in reversed(rewards):
R = r + gamma * R
returns.insert(0, R)
return returns
# IMPLEMENTATION: Simple REINFORCE
class PolicyNet(nn.Module):
def __init__(self, state_dim, action_dim):
super().__init__()
self.fc1 = nn.Linear(state_dim, 128)
self.fc2 = nn.Linear(128, 128)
self.fc3 = nn.Linear(128, action_dim)
def forward(self, state):
x = F.relu(self.fc1(state))
x = F.relu(self.fc2(x))
return F.softmax(self.fc3(x), dim=-1)
def log_prob(self, state, action):
probs = self.forward(state)
return torch.log(probs.gather(1, action.unsqueeze(1)) + 1e-8).squeeze()
# TRAINING LOOP
def train_policy_gradient(env, policy, optimizer, episodes=1000):
"""Train policy using policy gradient."""
for episode in range(episodes):
# Collect trajectory
state = env.reset()
log_probs = []
rewards = []
done = False
while not done:
state_tensor = torch.FloatTensor(state).unsqueeze(0)
probs = policy(state_tensor)
# Sample action
dist = torch.distributions.Categorical(probs)
action = dist.sample()
# Step environment
next_state, reward, done, _ = env.step(action.item())
log_probs.append(dist.log_prob(action))
rewards.append(reward)
state = next_state
# Compute returns
returns = compute_returns(rewards)
returns = torch.FloatTensor(returns)
# Normalize returns (variance reduction)
returns = (returns - returns.mean()) / (returns.std() + 1e-8)
# Compute loss
log_probs = torch.stack(log_probs)
loss = -(log_probs * returns).mean()
# Update
optimizer.zero_grad()
loss.backward()
optimizer.step()
if episode % 100 == 0:
total_reward = sum(rewards)
print(f"Episode {episode}, Return: {total_reward:.2f}")
# KEY INSIGHTS
print("=== Policy Gradient Theorem — Key Insights ===")
print("""
1. NO ENVIRONMENT GRADIENT NEEDED
- Works with black-box environments
- Only requires sampling
- Can use real-world systems
2. LOG-DERIVATIVE TRICK
- ∇_θ π = π ∇_θ log π
- Enables Monte Carlo estimation
- Foundation of all PG methods
3. INTERPRETATION
- Increase prob of good actions
- Decrease prob of bad actions
- "Good" measured by Q-value
4. VARIANTS
- REINFORCE: uses return R(τ)
- Actor-Critic: uses Q(s,a)
- PPO: uses advantage A(s,a)
- All derived from the theorem
5. LIMITATIONS
- High variance
- Slow convergence
- Local optima
- Sample inefficient
""")
# ADVANTAGE-BASED VARIANT
def advantage_policy_gradient(policy, value_fn, trajectories):
"""
Use advantage A(s,a) = Q(s,a) - V(s) instead of return.
∇_θ J(θ) = E[∇_θ log π_θ(a|s) · A^π(s,a)]
Lower variance than using raw returns!
"""
log_probs = []
advantages = []
for trajectory in trajectories:
returns = compute_returns(trajectory['rewards'])
for t, (state, action) in enumerate(zip(trajectory['states'],
trajectory['actions'])):
# Get value estimate
state_tensor = torch.FloatTensor(state).unsqueeze(0)
value = value_fn(state_tensor).item()
# Advantage = return - baseline
advantage = returns[t] - value
# Log prob
action_tensor = torch.LongTensor([action])
log_prob = policy.log_prob(state_tensor, action_tensor)
log_probs.append(log_prob)
advantages.append(advantage)
# Weighted loss
log_probs = torch.stack(log_probs)
advantages = torch.FloatTensor(advantages)
return -(log_probs * advantages).mean()🔄 REINFORCE Algorithm
🎯 The Simplest Policy Gradient
REINFORCE (Williams, 1992) is the simplest policy gradient algorithm. It uses Monte Carlo returns to estimate the gradient. Simple but high variance.
How REINFORCE Works
1. Sample: Run policy to collect trajectory. 2. Compute returns: Discounted returns G_t. 3. Compute gradient: ∇_θ log π(a_t|s_t) · G_t. 4. Update: θ ← θ + α ∇_θ J. 5. Repeat. Key: Unbiased but high variance.
# REINFORCE from scratch
import numpy as np
import torch
import torch.nn as nn
import torch.nn.functional as F
import torch.optim as optim
from torch.distributions import Categorical
import gym
class REINFORCEPolicy(nn.Module):
"""Policy network for REINFORCE."""
def __init__(self, state_dim, action_dim, hidden=128):
super().__init__()
self.net = nn.Sequential(
nn.Linear(state_dim, hidden),
nn.ReLU(),
nn.Linear(hidden, hidden),
nn.ReLU(),
nn.Linear(hidden, action_dim)
)
def forward(self, state):
logits = self.net(state)
return F.softmax(logits, dim=-1)
class REINFORCE:
"""REINFORCE algorithm implementation."""
def __init__(self, state_dim, action_dim, lr=1e-3, gamma=0.99):
self.policy = REINFORCEPolicy(state_dim, action_dim)
self.optimizer = optim.Adam(self.policy.parameters(), lr=lr)
self.gamma = gamma
def select_action(self, state):
"""Sample action from policy."""
state = torch.FloatTensor(state).unsqueeze(0)
probs = self.policy(state)
dist = Categorical(probs)
action = dist.sample()
return action.item(), dist.log_prob(action)
def compute_returns(self, rewards):
"""Compute discounted returns-to-go."""
returns = []
R = 0
for r in reversed(rewards):
R = r + self.gamma * R
returns.insert(0, R)
return returns
def update(self, log_probs, rewards):
"""Update policy using REINFORCE."""
# Compute returns
returns = self.compute_returns(rewards)
returns = torch.FloatTensor(returns)
# Normalize returns (variance reduction)
if len(returns) > 1:
returns = (returns - returns.mean()) / (returns.std() + 1e-8)
# Compute loss: -E[log π(a|s) * G_t]
log_probs = torch.stack(log_probs)
loss = -(log_probs * returns).mean()
# Update
self.optimizer.zero_grad()
loss.backward()
self.optimizer.step()
return loss.item()
def train_episode(self, env):
"""Train for one episode."""
state = env.reset()
if isinstance(state, tuple):
state = state[0]
log_probs = []
rewards = []
done = False
while not done:
action, log_prob = self.select_action(state)
next_state, reward, done, truncated, _ = env.step(action)
if isinstance(done, bool) and truncated:
done = True
log_probs.append(log_prob)
rewards.append(reward)
state = next_state
loss = self.update(log_probs, rewards)
return sum(rewards), loss
# TRAINING EXAMPLE
def train_reinforce():
"""Train REINFORCE on CartPole."""
try:
env = gym.make('CartPole-v1')
state_dim = env.observation_space.shape[0]
action_dim = env.action_space.n
agent = REINFORCE(state_dim, action_dim, lr=1e-2)
episode_rewards = []
for episode in range(1000):
total_reward, loss = agent.train_episode(env)
episode_rewards.append(total_reward)
if episode % 50 == 0:
avg_reward = np.mean(episode_rewards[-50:])
print(f"Episode {episode}, "
f"Avg Reward: {avg_reward:.2f}, "
f"Loss: {loss:.4f}")
if np.mean(episode_rewards[-50:]) > 475:
print(f"Solved in {episode} episodes!")
break
env.close()
return agent, episode_rewards
except Exception as e:
print(f"Training demo: {e}")
return None, None
# REINFORCE KEY CONCEPTS
print("=== REINFORCE Algorithm ===")
print("""
Algorithm:
1. Initialize policy π_θ with random θ
2. For each episode:
a. Generate trajectory τ = (s_0, a_0, r_0, ..., s_T)
b. For each timestep t:
- Compute return G_t = Σ_{k=t}^{T} γ^{k-t} r_k
- Compute gradient ∇_θ log π_θ(a_t|s_t) · G_t
c. Update: θ ← θ + α Σ_t ∇_θ log π_θ(a_t|s_t) · G_t
Properties:
✓ Unbiased gradient estimate
✓ Simple to implement
✓ Works with any differentiable policy
✗ High variance
✗ Slow convergence
✗ Requires full episodes
Variants:
- REINFORCE with baseline
- REINFORCE with entropy
- REINFORCE with normalization
""")
# VARIANCE REDUCTION TECHNIQUES
print("\n=== Variance Reduction for REINFORCE ===")
# 1. Return normalization
def normalized_returns(rewards, gamma=0.99):
"""Normalize returns for lower variance."""
returns = []
R = 0
for r in reversed(rewards):
R = r + gamma * R
returns.insert(0, R)
returns = np.array(returns)
return (returns - returns.mean()) / (returns.std() + 1e-8)
# 2. Baseline subtraction
def with_baseline(log_probs, returns, baseline_values):
"""Use baseline for variance reduction."""
advantages = returns - baseline_values
return -(log_probs * advantages).mean()
# 3. Reward-to-go
def reward_to_go(rewards, gamma=0.99):
"""Use reward-to-go instead of full return."""
returns = []
R = 0
for r in reversed(rewards):
R = r + gamma * R
returns.insert(0, R)
return returns
# 4. Causality trick
# Only use rewards AFTER action was taken
# (already incorporated in reward-to-go)
# COMPLETE REINFORCE WITH ALL IMPROVEMENTS
class ImprovedREINFORCE(REINFORCE):
"""REINFORCE with variance reduction."""
def __init__(self, state_dim, action_dim, lr=1e-3, gamma=0.99):
super().__init__(state_dim, action_dim, lr, gamma)
# Value function for baseline
self.value_fn = nn.Sequential(
nn.Linear(state_dim, 128),
nn.ReLU(),
nn.Linear(128, 1)
)
self.value_optimizer = optim.Adam(self.value_fn.parameters(), lr=lr)
def update(self, states, actions, log_probs, rewards):
"""Update with baseline and all improvements."""
# Compute returns
returns = self.compute_returns(rewards)
returns = torch.FloatTensor(returns)
# Compute values (baseline)
states_tensor = torch.FloatTensor(np.array(states))
values = self.value_fn(states_tensor).squeeze()
# Advantages
advantages = returns - values.detach()
# Normalize advantages
if len(advantages) > 1:
advantages = (advantages - advantages.mean()) / (advantages.std() + 1e-8)
# Policy loss
log_probs = torch.stack(log_probs)
policy_loss = -(log_probs * advantages).mean()
# Value loss
value_loss = F.mse_loss(values, returns)
# Update policy
self.optimizer.zero_grad()
policy_loss.backward()
self.optimizer.step()
# Update value function
self.value_optimizer.zero_grad()
value_loss.backward()
self.value_optimizer.step()
return policy_loss.item(), value_loss.item()📉 Variance Reduction & Baseline
🎯 Lower Variance, Same Gradient
The baseline is a function b(s) subtracted from returns. It doesn't change the expected gradient but can dramatically reduce variance. The optimal baseline is the value function V(s).
Why Baselines Work
Key insight: E[∇log π · b(s)] = 0 for any b(s). Proof: Σ_a π(a|s) ∇log π(a|s) = ∇Σ_a π(a|s) = ∇1 = 0. Advantage: A(s,a) = Q(s,a) - V(s). Best baseline: V(s). Effect: Reduces variance without bias.
# Variance reduction techniques for policy gradient
import numpy as np
import torch
import torch.nn as nn
# 1. THE BASELINE TRICK
print("=== The Baseline Trick ===")
print("""
Original gradient: E[∇log π(a|s) · R(τ)]
Modified gradient: E[∇log π(a|s) · (R(τ) - b(s))]
Why is this valid?
E[∇log π(a|s) · b(s)]
= Σ_a π(a|s) ∇log π(a|s) · b(s)
= Σ_a ∇π(a|s) · b(s)
= b(s) ∇Σ_a π(a|s)
= b(s) ∇1
= 0
So subtracting b(s) doesn't change the expected gradient!
But it CAN reduce variance dramatically.
""")
# 2. COMMON BASELINES
print("=== Common Baselines ===")
# a) No baseline (vanilla REINFORCE)
def no_baseline(log_probs, returns):
return -(log_probs * returns).mean()
# b) Constant baseline (average return)
def constant_baseline(log_probs, returns):
baseline = returns.mean()
advantages = returns - baseline
return -(log_probs * advantages).mean()
# c) State-dependent baseline (value function)
def value_baseline(log_probs, returns, values):
advantages = returns - values
return -(log_probs * advantages).mean()
# d) Optimal baseline (weighted by gradient magnitude)
def optimal_baseline(log_probs, returns):
"""b* = E[||∇log π||² R] / E[||∇log π||²]"""
grad_norms_sq = (log_probs ** 2).detach()
numerator = (grad_norms_sq * returns).sum()
denominator = grad_norms_sq.sum() + 1e-8
baseline = numerator / denominator
advantages = returns - baseline
return -(log_probs * advantages).mean()
# 3. VALUE FUNCTION AS BASELINE
class ValueBaseline(nn.Module):
"""Value function for baseline."""
def __init__(self, state_dim, hidden=128):
super().__init__()
self.net = nn.Sequential(
nn.Linear(state_dim, hidden),
nn.ReLU(),
nn.Linear(hidden, hidden),
nn.ReLU(),
nn.Linear(hidden, 1)
)
def forward(self, state):
return self.net(state).squeeze(-1)
# 4. ADVANTAGE FUNCTION
print("=== Advantage Function ===")
print("""
A^π(s,a) = Q^π(s,a) - V^π(s)
Interpretation:
- How much better is action a than average?
- Positive: better than expected
- Negative: worse than expected
Advantage estimation methods:
1. Monte Carlo: A_t = G_t - V(s_t)
2. TD(0): A_t = r_t + γV(s_{t+1}) - V(s_t)
3. TD(λ): A_t = Σ (γλ)^l δ_{t+l}
4. GAE: Generalized Advantage Estimation
""")
# 5. VARIANCE COMPARISON
def compare_variance():
"""Compare variance of different estimators."""
np.random.seed(42)
n_samples = 1000
# Simulate returns and log probs
returns = np.random.randn(n_samples) * 10 + 50
log_probs = np.random.randn(n_samples) * 0.5 - 1
# a) No baseline
gradients_no_baseline = -log_probs * returns
var_no_baseline = gradients_no_baseline.var()
# b) Constant baseline
advantages_const = returns - returns.mean()
gradients_const = -log_probs * advantages_const
var_const = gradients_const.var()
# c) Value function baseline (assume perfect)
values = returns + np.random.randn(n_samples) * 2 # noisy
advantages_value = returns - values
gradients_value = -log_probs * advantages_value
var_value = gradients_value.var()
print("\n=== Variance Comparison ===")
print(f"No baseline: {var_no_baseline:.2f}")
print(f"Constant baseline: {var_const:.2f}")
print(f"Value baseline: {var_value:.2f}")
print(f"\nVariance reduction: {var_no_baseline/var_value:.1f}x")
compare_variance()
# 6. ADVANTAGE NORMALIZATION
print("\n=== Advantage Normalization ===")
def normalize_advantages(advantages):
"""Normalize advantages for stability."""
return (advantages - advantages.mean()) / (advantages.std() + 1e-8)
# 7. FULL IMPLEMENTATION WITH BASELINE
class PPOWithBaseline:
"""Policy gradient with value function baseline."""
def __init__(self, policy, value_fn, optimizer, gamma=0.99):
self.policy = policy
self.value_fn = value_fn
self.optimizer = optimizer
self.gamma = gamma
def compute_gae(self, rewards, values, dones, lam=0.95):
"""Compute Generalized Advantage Estimation."""
advantages = []
gae = 0
for t in reversed(range(len(rewards))):
if t == len(rewards) - 1:
next_value = 0
else:
next_value = values[t + 1]
# TD error
delta = rewards[t] + self.gamma * next_value * (1 - dones[t]) - values[t]
# GAE
gae = delta + self.gamma * lam * (1 - dones[t]) * gae
advantages.insert(0, gae)
return advantages
def update(self, trajectories):
"""Update policy with baseline."""
# Collect all data
all_returns = []
all_log_probs = []
for traj in trajectories:
returns = self.compute_returns(traj['rewards'])
values = self.value_fn(traj['states']).detach().numpy()
advantages = self.compute_gae(
traj['rewards'], values, traj['dones']
)
all_returns.extend(returns)
all_log_probs.extend(traj['log_probs'])
# Normalize advantages
advantages = torch.FloatTensor(advantages)
advantages = normalize_advantages(advantages)
# Policy loss
log_probs = torch.stack(all_log_probs)
policy_loss = -(log_probs * advantages).mean()
# Update
self.optimizer.zero_grad()
policy_loss.backward()
self.optimizer.step()
return policy_loss.item()
print("""
=== Best Practices for Baselines ===
1. Always use a baseline
- Reduces variance dramatically
- Doesn't introduce bias
- Standard in modern methods
2. Use value function baseline
- V(s) is optimal baseline
- Train alongside policy
- Share network for efficiency
3. Normalize advantages
- Zero mean, unit variance
- Stabilizes training
- Standard in PPO
4. Use GAE for advantage estimation
- Balances bias and variance
- λ parameter controls tradeoff
- Standard in modern methods
5. Watch for baseline bias
- Value function errors
- Distribution shift
- Use old values for stability
""")🎭 Actor-Critic
🎯 Combining Policy and Value
Actor-Critic methods combine policy gradient (actor) with value function estimation (critic). The actor decides actions; the critic evaluates them. This reduces variance and speeds up learning.
Two Networks, One Goal
Actor: Policy π_θ(a|s) — decides actions. Critic: Value V_w(s) — evaluates states. Advantage: A(s,a) = r + γV(s') - V(s). Update: Actor uses advantage, critic uses TD error. Benefit: Lower variance, faster learning. Variants: A2C, A3C, PPO, SAC.
# Actor-Critic implementation
import numpy as np
import torch
import torch.nn as nn
import torch.nn.functional as F
import torch.optim as optim
from torch.distributions import Categorical
import gym
class ActorCritic(nn.Module):
"""Shared actor-critic network."""
def __init__(self, state_dim, action_dim, hidden=128):
super().__init__()
# Shared layers
self.shared = nn.Sequential(
nn.Linear(state_dim, hidden),
nn.ReLU(),
nn.Linear(hidden, hidden),
nn.ReLU()
)
# Actor head
self.actor = nn.Linear(hidden, action_dim)
# Critic head
self.critic = nn.Linear(hidden, 1)
def forward(self, state):
features = self.shared(state)
logits = self.actor(features)
value = self.critic(features)
return logits, value
def get_action(self, state):
"""Sample action and get value."""
logits, value = self.forward(state)
dist = Categorical(logits=logits)
action = dist.sample()
log_prob = dist.log_prob(action)
return action, log_prob, value
def evaluate(self, states, actions):
"""Evaluate actions (for training)."""
logits, values = self.forward(states)
dist = Categorical(logits=logits)
log_probs = dist.log_prob(actions)
entropy = dist.entropy()
return log_probs, values.squeeze(-1), entropy
class ActorCriticAgent:
"""Actor-Critic agent."""
def __init__(self, state_dim, action_dim, lr=3e-4, gamma=0.99):
self.model = ActorCritic(state_dim, action_dim)
self.optimizer = optim.Adam(self.model.parameters(), lr=lr)
self.gamma = gamma
def train_episode(self, env):
"""Train for one episode."""
state = env.reset()
if isinstance(state, tuple):
state = state[0]
log_probs = []
values = []
rewards = []
entropies = []
done = False
while not done:
state_tensor = torch.FloatTensor(state).unsqueeze(0)
action, log_prob, value = self.model.get_action(state_tensor)
next_state, reward, done, truncated, _ = env.step(action.item())
if isinstance(done, bool) and truncated:
done = True
log_probs.append(log_prob)
values.append(value)
rewards.append(reward)
state = next_state
# Compute returns
returns = self.compute_returns(rewards)
returns = torch.FloatTensor(returns)
# Values as tensor
values = torch.cat(values).squeeze(-1)
# Advantages
advantages = returns - values.detach()
# Normalize advantages
if len(advantages) > 1:
advantages = (advantages - advantages.mean()) / (advantages.std() + 1e-8)
# Policy loss (actor)
log_probs = torch.stack(log_probs)
actor_loss = -(log_probs * advantages).mean()
# Value loss (critic)
critic_loss = F.mse_loss(values, returns)
# Combined loss
total_loss = actor_loss + 0.5 * critic_loss
# Update
self.optimizer.zero_grad()
total_loss.backward()
self.optimizer.step()
return sum(rewards), total_loss.item()
def compute_returns(self, rewards):
returns = []
R = 0
for r in reversed(rewards):
R = r + self.gamma * R
returns.insert(0, R)
return returns
# TRAINING
def train_actor_critic():
"""Train Actor-Critic on CartPole."""
try:
env = gym.make('CartPole-v1')
state_dim = env.observation_space.shape[0]
action_dim = env.action_space.n
agent = ActorCriticAgent(state_dim, action_dim)
episode_rewards = []
for episode in range(1000):
total_reward, loss = agent.train_episode(env)
episode_rewards.append(total_reward)
if episode % 50 == 0:
avg_reward = np.mean(episode_rewards[-50:])
print(f"Episode {episode}, "
f"Avg Reward: {avg_reward:.2f}")
if np.mean(episode_rewards[-50:]) > 475:
print(f"Solved in {episode} episodes!")
break
env.close()
except Exception as e:
print(f"Training demo: {e}")
# ACTOR-CRITIC VARIANTS
print("=== Actor-Critic Variants ===")
print("""
A2C (Advantage Actor-Critic):
- Synchronous version of A3C
- Multiple workers in parallel
- Update from collected batch
- Simple but effective
A3C (Asynchronous Advantage Actor-Critic):
- Multiple workers asynchronously
- Each worker has own env
- Global shared parameters
- No replay buffer needed
PPO (Proximal Policy Optimization):
- Clipped objective
- Multiple epochs per batch
- State-of-the-art
- Used in RLHF
SAC (Soft Actor-Critic):
- Maximum entropy RL
- Off-policy
- Sample efficient
- Continuous control
TD3 (Twin Delayed DDPG):
- Deterministic policy
- Twin critics
- Delayed updates
- Continuous control
""")
# ADVANTAGE FUNCTION ESTIMATION
print("\n=== Advantage Estimation ===")
# 1. Monte Carlo (high variance, low bias)
def mc_advantage(rewards, values, gamma=0.99):
"""A_t = G_t - V(s_t)"""
returns = []
R = 0
for r in reversed(rewards):
R = r + gamma * R
returns.insert(0, R)
returns = torch.FloatTensor(returns)
return returns - values
# 2. TD(0) (low variance, high bias)
def td_advantage(rewards, values, gamma=0.99):
"""A_t = r_t + γV(s_{t+1}) - V(s_t)"""
advantages = []
for t in range(len(rewards)):
if t == len(rewards) - 1:
next_value = 0
else:
next_value = values[t + 1]
advantages.append(rewards[t] + gamma * next_value - values[t])
return torch.FloatTensor(advantages)
# 3. GAE (balanced)
def gae_advantage(rewards, values, gamma=0.99, lam=0.95):
"""Generalized Advantage Estimation."""
advantages = []
gae = 0
for t in reversed(range(len(rewards))):
if t == len(rewards) - 1:
next_value = 0
else:
next_value = values[t + 1]
delta = rewards[t] + gamma * next_value - values[t]
gae = delta + gamma * lam * gae
advantages.insert(0, gae)
return torch.FloatTensor(advantages)
# COMPARISON
print("""
Bias-Variance Tradeoff:
Method Bias Variance λ
------- ---- -------- ---
MC Low High λ=1
TD(0) High Low λ=0
GAE(0.95) Medium Medium 0.95
GAE(0.99) Lower Higher 0.99
Generalized Advantage Estimation:
- λ=0: TD(0) — high bias, low variance
- λ=1: Monte Carlo — low bias, high variance
- λ=0.95: Sweet spot — balanced
""")
# SHARED VS SEPARATE NETWORKS
print("\n=== Shared vs Separate Networks ===")
print("""
Shared Network:
✓ Fewer parameters
✓ Faster inference
✓ Feature sharing
✗ Interference between tasks
✗ Harder to tune
Separate Networks:
✓ Independent optimization
✓ No interference
✓ Easier to tune
✗ More parameters
✗ Slower inference
Rule of thumb:
- Shared: Small tasks, similar features
- Separate: Complex tasks, different features
- Shared is default in most implementations
""")📈 Generalized Advantage Estimation
🎯 The Sweet Spot
GAE (Schulman et al., 2016) provides a way to estimate advantages with controllable bias-variance tradeoff. It's the standard in modern policy gradient methods.
The GAE Formula
A_t^GAE(γ,λ) = Σ_{l=0}^∞ (γλ)^l δ_{t+l}. δ_t = r_t + γV(s_{t+1}) - V(s_t) (TD error). λ = 0: TD(0) — low variance, high bias. λ = 1: Monte Carlo — high variance, low bias. λ = 0.95: Balanced — sweet spot. γλ: Discount factor for multi-step returns.
# Generalized Advantage Estimation (GAE)
import torch
import numpy as np
def compute_gae(rewards, values, next_value=0, gamma=0.99, lam=0.95, dones=None):
"""
Compute Generalized Advantage Estimation.
A_t = Σ_{l=0}^{∞} (γλ)^l δ_{t+l}
where δ_t = r_t + γV(s_{t+1}) - V(s_t)
Args:
rewards: list of rewards [r_0, r_1, ..., r_{T-1}]
values: list of values [V(s_0), V(s_1), ..., V(s_{T-1})]
next_value: V(s_T) — bootstrap value
gamma: discount factor
lam: GAE parameter
dones: list of done flags
Returns:
advantages: GAE advantages
returns: TD(λ) returns
"""
if dones is None:
dones = [False] * len(rewards)
advantages = []
gae = 0
# Compute backwards (dynamic programming)
for t in reversed(range(len(rewards))):
if t == len(rewards) - 1:
next_val = next_value
else:
next_val = values[t + 1]
# Mask by done (if done, don't bootstrap)
mask = 1.0 - dones[t]
# TD error
delta = rewards[t] + gamma * next_val * mask - values[t]
# GAE accumulation
gae = delta + gamma * lam * mask * gae
advantages.insert(0, gae)
# Compute returns for value function
returns = [adv + val for adv, val in zip(advantages, values)]
return torch.FloatTensor(advantages), torch.FloatTensor(returns)
# EXAMPLE
print("=== GAE Example ===")
# Simulated episode
rewards = [1.0, 1.0, 1.0, -1.0, 1.0]
values = [0.5, 0.8, 0.9, 0.6, 0.3]
dones = [False, False, False, False, True]
# Compute GAE with different lambda values
for lam in [0.0, 0.5, 0.95, 1.0]:
advantages, returns = compute_gae(
rewards, values, next_value=0,
gamma=0.99, lam=lam, dones=dones
)
print(f"\nλ = {lam}:")
print(f" Advantages: {advantages.numpy().round(3)}")
print(f" Returns: {returns.numpy().round(3)}")
# INTERPRETING λ
print("\n\n=== Interpreting λ ===")
print("""
λ = 0 (TD(0)):
A_t = δ_t = r_t + γV(s_{t+1}) - V(s_t)
- Uses only 1-step return
- Low variance, high bias
- Fast but inaccurate
λ = 1 (Monte Carlo):
A_t = Σ_l γ^l r_{t+l} - V(s_t)
- Uses full return
- High variance, low bias
- Accurate but noisy
λ = 0.95 (GAE):
- Balanced estimator
- Geometric weighting of multi-step returns
- Standard in PPO and modern RL
- Trade-off controlled by λ
""")
# VISUALIZING GAE
print("=== GAE Weights ===")
print("\nWeight on k-step return (γλ)^{k-1}:")
gamma = 0.99
for lam in [0.0, 0.5, 0.9, 0.95, 0.99]:
weights = []
for k in range(1, 6):
weight = (gamma * lam) ** (k - 1)
weights.append(weight)
print(f" λ={lam}: {[f'{w:.3f}' for w in weights]}")
print("""
Notice:
- λ=0: Only 1-step (weight = [1, 0, 0, 0, 0])
- λ=0.95: Long tail (weights decrease slowly)
- λ=1: Never decays (all weights = 1)
The effective horizon is ~1/(1-γλ).
""")
# GAE IN PRACTICE
print("\n=== GAE in PPO ===")
class PPOWithGAE:
"""PPO with GAE for advantage estimation."""
def __init__(self, policy, value_fn, optimizer,
gamma=0.99, lam=0.95, clip_epsilon=0.2):
self.policy = policy
self.value_fn = value_fn
self.optimizer = optimizer
self.gamma = gamma
self.lam = lam
self.clip_epsilon = clip_epsilon
def collect_rollout(self, env, n_steps=2048):
"""Collect rollout data."""
states, actions, rewards, values, log_probs, dones = [], [], [], [], [], []
state = env.reset()
if isinstance(state, tuple):
state = state[0]
for _ in range(n_steps):
state_tensor = torch.FloatTensor(state).unsqueeze(0)
with torch.no_grad():
action, log_prob, value = self.policy.get_action(state_tensor)
next_state, reward, done, truncated, _ = env.step(action.item())
if isinstance(done, bool) and truncated:
done = True
states.append(state)
actions.append(action.item())
rewards.append(reward)
values.append(value.item())
log_probs.append(log_prob.item())
dones.append(done)
state = next_state if not done else env.reset()[0]
return {
'states': states,
'actions': actions,
'rewards': rewards,
'values': values,
'log_probs': log_probs,
'dones': dones
}
def update(self, rollout):
"""Update using PPO with GAE."""
# Compute GAE
with torch.no_grad():
next_state = rollout['states'][-1]
# Bootstrap value for last state
next_value = 0 # or value_fn(next_state)
advantages, returns = compute_gae(
rollout['rewards'],
rollout['values'],
next_value=next_value,
gamma=self.gamma,
lam=self.lam,
dones=rollout['dones']
)
# Normalize advantages
advantages = (advantages - advantages.mean()) / (advantages.std() + 1e-8)
# Convert to tensors
states = torch.FloatTensor(np.array(rollout['states']))
actions = torch.LongTensor(rollout['actions'])
old_log_probs = torch.FloatTensor(rollout['log_probs'])
# Multiple epochs
for epoch in range(10):
# Get current log probs and values
log_probs, values, entropy = self.policy.evaluate(states, actions)
# Ratio
ratio = torch.exp(log_probs - old_log_probs)
# Clipped objective
surr1 = ratio * advantages
surr2 = torch.clamp(ratio,
1 - self.clip_epsilon,
1 + self.clip_epsilon) * advantages
policy_loss = -torch.min(surr1, surr2).mean()
# Value loss
value_loss = F.mse_loss(values, returns)
# Entropy bonus
entropy_loss = -entropy.mean()
# Total loss
loss = policy_loss + 0.5 * value_loss + 0.01 * entropy_loss
# Update
self.optimizer.zero_grad()
loss.backward()
torch.nn.utils.clip_grad_norm_(self.policy.parameters(), 0.5)
self.optimizer.step()
# BEST PRACTICES
print("\n\n=== GAE Best Practices ===")
print("""
1. Use λ = 0.95 as default
- Works well across most tasks
- Standard in PPO implementations
2. Normalize advantages
- Zero mean, unit variance
- Critical for stability
- Especially with GAE
3. Bootstrap properly
- Use V(s_T) for terminal states
- Mask with done flag
- Important for episodic tasks
4. Watch for numerical issues
- Use float32 or float64
- Clip advantages if needed
- Monitor value predictions
5. Tune γ and λ together
- γ controls horizon
- λ controls bias-variance
- Consider task requirements
6. Combine with value function training
- Critic learns V(s)
- GAE uses V(s)
- Joint optimization works well
7. Use parallel environments
- Reduces variance further
- Faster data collection
- Standard in modern implementations
""")🚀 PPO (Proximal Policy Optimization)
🎯 The Workhorse of Modern RL
PPO (Schulman et al., 2017) is the most popular policy gradient algorithm. It's simple, stable, and effective. It's used to train ChatGPT (RLHF), robotics policies, and game-playing agents.
PPO's Key Innovation
Clipped objective: Prevents large policy updates. Trust region: Approximated via clipping. Multiple epochs: Reuse data efficiently. Simple: First-order optimization. Robust: Works across domains. Standard: Default in many applications.
# PPO from scratch
import numpy as np
import torch
import torch.nn as nn
import torch.nn.functional as F
import torch.optim as optim
from torch.distributions import Categorical
import gym
class PPONetwork(nn.Module):
"""Actor-Critic network for PPO."""
def __init__(self, state_dim, action_dim, hidden=64):
super().__init__()
# Shared feature extractor
self.shared = nn.Sequential(
nn.Linear(state_dim, hidden),
nn.Tanh(),
nn.Linear(hidden, hidden),
nn.Tanh()
)
# Actor head
self.actor = nn.Linear(hidden, action_dim)
# Critic head
self.critic = nn.Linear(hidden, 1)
def forward(self, state):
features = self.shared(state)
logits = self.actor(features)
value = self.critic(features)
return logits, value
def get_action(self, state, action=None):
"""Sample action and compute log prob."""
logits, value = self.forward(state)
dist = Categorical(logits=logits)
if action is None:
action = dist.sample()
log_prob = dist.log_prob(action)
entropy = dist.entropy()
return action, log_prob, entropy, value
class PPO:
"""PPO algorithm implementation."""
def __init__(
self,
state_dim,
action_dim,
lr=3e-4,
gamma=0.99,
lam=0.95,
clip_epsilon=0.2,
value_coef=0.5,
entropy_coef=0.01,
epochs=10,
batch_size=64,
rollout_steps=2048,
):
self.network = PPONetwork(state_dim, action_dim)
self.optimizer = optim.Adam(self.network.parameters(), lr=lr)
self.gamma = gamma
self.lam = lam
self.clip_epsilon = clip_epsilon
self.value_coef = value_coef
self.entropy_coef = entropy_coef
self.epochs = epochs
self.batch_size = batch_size
self.rollout_steps = rollout_steps
def collect_rollout(self, env):
"""Collect rollout data."""
states = []
actions = []
rewards = []
log_probs = []
values = []
dones = []
state = env.reset()
if isinstance(state, tuple):
state = state[0]
for _ in range(self.rollout_steps):
state_tensor = torch.FloatTensor(state).unsqueeze(0)
with torch.no_grad():
action, log_prob, _, value = self.network.get_action(state_tensor)
next_state, reward, done, truncated, _ = env.step(action.item())
if isinstance(done, bool) and truncated:
done = True
states.append(state)
actions.append(action.item())
rewards.append(reward)
log_probs.append(log_prob.item())
values.append(value.item())
dones.append(done)
state = next_state
if done:
state = env.reset()
if isinstance(state, tuple):
state = state[0]
# Compute GAE
advantages = self.compute_gae(rewards, values, dones)
return {
'states': np.array(states),
'actions': np.array(actions),
'rewards': np.array(rewards),
'log_probs': np.array(log_probs),
'values': np.array(values),
'dones': np.array(dones),
'advantages': advantages,
}
def compute_gae(self, rewards, values, dones, next_value=0):
"""Compute Generalized Advantage Estimation."""
advantages = []
gae = 0
for t in reversed(range(len(rewards))):
if t == len(rewards) - 1:
next_val = next_value
else:
next_val = values[t + 1]
mask = 1.0 - dones[t]
delta = rewards[t] + self.gamma * next_val * mask - values[t]
gae = delta + self.gamma * self.lam * mask * gae
advantages.insert(0, gae)
return np.array(advantages)
def update(self, rollout):
"""PPO update."""
# Convert to tensors
states = torch.FloatTensor(rollout['states'])
actions = torch.LongTensor(rollout['actions'])
old_log_probs = torch.FloatTensor(rollout['log_probs'])
returns = torch.FloatTensor(rollout['advantages'] + rollout['values'])
# Normalize advantages
advantages = torch.FloatTensor(rollout['advantages'])
advantages = (advantages - advantages.mean()) / (advantages.std() + 1e-8)
# Training
n_samples = len(states)
indices = np.arange(n_samples)
total_loss = 0
for epoch in range(self.epochs):
np.random.shuffle(indices)
for start in range(0, n_samples, self.batch_size):
end = start + self.batch_size
batch_idx = indices[start:end]
batch_states = states[batch_idx]
batch_actions = actions[batch_idx]
batch_old_log_probs = old_log_probs[batch_idx]
batch_advantages = advantages[batch_idx]
batch_returns = returns[batch_idx]
# Forward pass
_, new_log_probs, entropy, values = self.network.get_action(
batch_states, batch_actions
)
# Policy loss (clipped)
ratio = torch.exp(new_log_probs - batch_old_log_probs)
surr1 = ratio * batch_advantages
surr2 = torch.clamp(
ratio, 1 - self.clip_epsilon, 1 + self.clip_epsilon
) * batch_advantages
policy_loss = -torch.min(surr1, surr2).mean()
# Value loss
value_loss = F.mse_loss(values.squeeze(), batch_returns)
# Entropy bonus
entropy_loss = -entropy.mean()
# Total loss
loss = (
policy_loss
+ self.value_coef * value_loss
+ self.entropy_coef * entropy_loss
)
# Update
self.optimizer.zero_grad()
loss.backward()
torch.nn.utils.clip_grad_norm_(self.network.parameters(), 0.5)
self.optimizer.step()
total_loss += loss.item()
return total_loss / (self.epochs * (n_samples // self.batch_size))
# TRAINING
def train_ppo():
"""Train PPO on CartPole."""
try:
env = gym.make('CartPole-v1')
state_dim = env.observation_space.shape[0]
action_dim = env.action_space.n
agent = PPO(state_dim, action_dim)
episode_rewards = []
episode_reward = 0
for iteration in range(300):
rollout = agent.collect_rollout(env)
# Track rewards
for r, d in zip(rollout['rewards'], rollout['dones']):
episode_reward += r
if d:
episode_rewards.append(episode_reward)
episode_reward = 0
# Update
loss = agent.update(rollout)
if iteration % 10 == 0:
avg_reward = np.mean(episode_rewards[-20:]) if episode_rewards else 0
print(f"Iteration {iteration}, "
f"Avg Reward: {avg_reward:.2f}, "
f"Loss: {loss:.4f}")
if episode_rewards and np.mean(episode_rewards[-20:]) > 475:
print(f"Solved in {iteration} iterations!")
break
env.close()
except Exception as e:
print(f"Training demo: {e}")
# PPO VARIANTS
print("=== PPO Variants ===")
print("""
PPO-Clip (original):
- Clipped surrogate objective
- Standard implementation
PPO-Penalty:
- KL penalty in objective
- Adaptive β coefficient
- Earlier version, less common
PPO with GAE:
- GAE for advantage estimation
- Standard in practice
PPO with Shared Network:
- Shared features for actor & critic
- More efficient
PPO with Separate Networks:
- Independent networks
- More flexible
PPO with Recurrent Policy:
- LSTM/GRU for memory
- For POMDPs
PPO for LLMs (RLHF):
- Token-level actions
- KL penalty to reference model
- Used in ChatGPT
""")
# PPO BEST PRACTICES
print("\n=== PPO Best Practices ===")
print("""
Hyperparameters (standard):
- Learning rate: 3e-4
- γ (gamma): 0.99
- λ (lambda): 0.95
- Clip ε: 0.2
- Value coefficient: 0.5
- Entropy coefficient: 0.01
- Epochs: 10
- Batch size: 64
- Rollout steps: 2048
Implementation tips:
1. Normalize observations
2. Normalize advantages
3. Clip gradients
4. Use GAE for advantages
5. Multiple epochs per rollout
6. Check KL divergence
7. Monitor entropy
8. Early stopping on KL
9. Use vectorized environments
10. Reward scaling
Debugging:
- If KL too high: reduce learning rate
- If policy entropy collapses: increase entropy coefficient
- If returns don't improve: check advantage normalization
- If unstable: reduce clip epsilon
Common pitfalls:
- Not normalizing advantages
- Wrong KL sign
- Incorrect GAE implementation
- Not using done masks
- Too few rollout steps
""")⚡ A3C / A2C
🎯 Parallel Training
A3C (Asynchronous Advantage Actor-Critic) and A2C (Advantage Actor-Critic) use multiple workers to collect experience in parallel. This reduces correlation between samples and speeds up training.
A3C/A2C Key Ideas
A3C: Multiple async workers, global network. A2C: Synchronous version — simpler, often better. Parallel envs: Diverse experience. No replay: Fresh samples. Actor-critic: Value baseline. Standard: Foundation for modern RL.
# A2C (synchronous version of A3C)
import numpy as np
import torch
import torch.nn as nn
import torch.nn.functional as F
import torch.optim as optim
from torch.distributions import Categorical
import gym
class A2CNetwork(nn.Module):
"""Actor-Critic network for A2C."""
def __init__(self, state_dim, action_dim, hidden=128):
super().__init__()
self.shared = nn.Sequential(
nn.Linear(state_dim, hidden),
nn.ReLU(),
nn.Linear(hidden, hidden),
nn.ReLU()
)
self.actor = nn.Linear(hidden, action_dim)
self.critic = nn.Linear(hidden, 1)
def forward(self, state):
features = self.shared(state)
logits = self.actor(features)
value = self.critic(features)
return logits, value
class A2CAgent:
"""A2C agent with vectorized environments."""
def __init__(
self,
state_dim,
action_dim,
n_envs=8,
lr=7e-4,
gamma=0.99,
value_coef=0.5,
entropy_coef=0.01,
):
self.network = A2CNetwork(state_dim, action_dim)
self.optimizer = optim.Adam(self.network.parameters(), lr=lr)
self.gamma = gamma
self.value_coef = value_coef
self.entropy_coef = entropy_coef
self.n_envs = n_envs
def collect_rollout(self, envs, n_steps=5):
"""Collect rollout from n_envs environments."""
states = []
actions = []
rewards = []
dones = []
values = []
log_probs = []
# Store per-env
env_states = []
for env in envs:
s = env.reset()
if isinstance(s, tuple):
s = s[0]
env_states.append(s)
for _ in range(n_steps):
states.append(env_states)
# Batch states
state_tensor = torch.FloatTensor(np.array(env_states))
with torch.no_grad():
logits, value = self.network(state_tensor)
dist = Categorical(logits=logits)
action = dist.sample()
log_prob = dist.log_prob(action)
actions.append(action.numpy())
log_probs.append(log_prob.numpy())
values.append(value.squeeze(-1).numpy())
# Step all envs
next_states = []
step_rewards = []
step_dones = []
for i, env in enumerate(envs):
s, r, d, trunc, _ = env.step(action[i].item())
if isinstance(d, bool) and trunc:
d = True
step_rewards.append(r)
step_dones.append(d)
if d:
s = env.reset()
if isinstance(s, tuple):
s = s[0]
next_states.append(s)
rewards.append(step_rewards)
dones.append(step_dones)
env_states = next_states
# Bootstrap final values
with torch.no_grad():
final_state = torch.FloatTensor(np.array(env_states))
_, final_value = self.network(final_state)
return {
'states': np.array(states), # (T, N, S)
'actions': np.array(actions), # (T, N)
'rewards': np.array(rewards), # (T, N)
'dones': np.array(dones), # (T, N)
'values': np.array(values), # (T, N)
'log_probs': np.array(log_probs), # (T, N)
'final_value': final_value.squeeze(-1).numpy(), # (N,)
}
def compute_returns_and_advantages(self, rollout):
"""Compute returns and advantages for all envs."""
T, N = rollout['rewards'].shape
returns = np.zeros((T, N))
advantages = np.zeros((T, N))
# Bootstrap from final value
next_value = rollout['final_value']
for t in reversed(range(T)):
# Mask for done
mask = 1.0 - rollout['dones'][t]
# TD target
td_target = rollout['rewards'][t] + self.gamma * next_value * mask
# TD error
td_error = td_target - rollout['values'][t]
# Advantage (TD(0))
advantages[t] = td_error
# Return
returns[t] = td_target
# Next value for previous step
next_value = rollout['values'][t]
return returns, advantages
def update(self, rollout):
"""Update using A2C loss."""
returns, advantages = self.compute_returns_and_advantages(rollout)
# Flatten
T, N = rollout['rewards'].shape
states = rollout['states'].reshape(T * N, -1)
actions = rollout['actions'].flatten()
old_log_probs = rollout['log_probs'].flatten()
returns = returns.flatten()
advantages = advantages.flatten()
# Normalize advantages
advantages = (advantages - advantages.mean()) / (advantages.std() + 1e-8)
# Convert to tensors
states = torch.FloatTensor(states)
actions = torch.LongTensor(actions)
returns = torch.FloatTensor(returns)
advantages = torch.FloatTensor(advantages)
# Forward
logits, values = self.network(states)
dist = Categorical(logits=logits)
log_probs = dist.log_prob(actions)
entropy = dist.entropy()
# Losses
policy_loss = -(log_probs * advantages).mean()
value_loss = F.mse_loss(values.squeeze(), returns)
entropy_loss = -entropy.mean()
loss = (
policy_loss
+ self.value_coef * value_loss
+ self.entropy_coef * entropy_loss
)
# Update
self.optimizer.zero_grad()
loss.backward()
torch.nn.utils.clip_grad_norm_(self.network.parameters(), 0.5)
self.optimizer.step()
return loss.item()
# TRAINING
def train_a2c():
"""Train A2C on CartPole."""
try:
n_envs = 8
envs = [gym.make('CartPole-v1') for _ in range(n_envs)]
state_dim = envs[0].observation_space.shape[0]
action_dim = envs[0].action_space.n
agent = A2CAgent(state_dim, action_dim, n_envs=n_envs)
episode_rewards = [[] for _ in range(n_envs)]
current_rewards = [0] * n_envs
for iteration in range(2000):
rollout = agent.collect_rollout(envs, n_steps=5)
loss = agent.update(rollout)
# Track rewards
for t in range(len(rollout['rewards'])):
for i in range(n_envs):
current_rewards[i] += rollout['rewards'][t][i]
if rollout['dones'][t][i]:
episode_rewards[i].append(current_rewards[i])
current_rewards[i] = 0
if iteration % 50 == 0:
all_rewards = [r for env_rs in episode_rewards for r in env_rs[-20:]]
if all_rewards:
avg_reward = np.mean(all_rewards)
print(f"Iteration {iteration}, "
f"Avg Reward: {avg_reward:.2f}")
for env in envs:
env.close()
except Exception as e:
print(f"Training demo: {e}")
# A3C vs A2C
print("=== A3C vs A2C ===")
print("""
A3C (Asynchronous):
- Multiple workers in parallel
- Each worker has own env
- Asynchronous parameter updates
- No synchronization needed
- Historically important
- Rarely used now
A2C (Synchronous):
- Multiple envs in parallel
- Synchronized updates
- Simpler implementation
- Often better performance
- Standard in practice
- Good baseline
Key differences:
A3C: async updates, potential stale gradients
A2C: sync updates, consistent gradients
Both use:
- Actor-Critic architecture
- Advantage estimation
- Parallel environments
- Entropy regularization
""")
# VECTORIZED ENVIRONMENTS
print("\n=== Vectorized Environments ===")
print("""
Benefits:
1. Faster data collection
2. Less correlated samples
3. Better gradient estimates
4. More stable training
Implementation options:
- Stable-Baselines3: DummyVecEnv, SubprocVecEnv
- Gym: make_vec_env
- Ray: Distributed environments
Example:
from stable_baselines3.common.vec_env import DummyVecEnv
def make_env():
return gym.make('CartPole-v1')
env = DummyVecEnv([make_env for _ in range(8)])
""")
# N-STEP RETURNS
print("\n=== N-Step Returns ===")
print("""
A2C typically uses n-step returns:
G_t^{(n)} = r_t + γr_{t+1} + ... + γ^{n-1}r_{t+n-1} + γ^n V(s_{t+n})
Trade-off:
n=1: TD(0), low variance, high bias
n=5: Common choice for A2C
n=∞: Monte Carlo, high variance, low bias
In practice:
- Use n=5 for A2C
- Use GAE (λ=0.95) for PPO
- Both work well
""")🔒 TRPO (Trust Region)
🎯 Safe Policy Updates
TRPO (Schulman et al., 2015) constrains policy updates to a trust region, ensuring monotonic improvement. It's theoretically sound but complex — PPO is its practical successor.
Trust Region Concept
Constraint: KL(π_old || π_new) ≤ δ. Surrogate: L(θ) = E[ratio · A]. Solve: Constrained optimization. Theory: Monotonic improvement guarantee. Practice: Conjugate gradient + line search. Successor: PPO approximates TRPO with clipping.
# TRPO (Trust Region Policy Optimization)
import numpy as np
import torch
import torch.nn as nn
import torch.nn.functional as F
from torch.distributions import Categorical
# THE TRPO OBJECTIVE
# maximize_θ E[π_θ(a|s)/π_old(a|s) · A^π_old(s,a)]
# subject to E[KL(π_old(·|s) || π_θ(·|s))] ≤ δ
class TRPOPolicy(nn.Module):
"""Policy for TRPO."""
def __init__(self, state_dim, action_dim, hidden=64):
super().__init__()
self.net = nn.Sequential(
nn.Linear(state_dim, hidden),
nn.Tanh(),
nn.Linear(hidden, hidden),
nn.Tanh(),
nn.Linear(hidden, action_dim)
)
def forward(self, state):
logits = self.net(state)
return Categorical(logits=logits)
def get_distribution(self, states):
return self.forward(states)
class TRPOValue(nn.Module):
"""Value function for TRPO."""
def __init__(self, state_dim, hidden=64):
super().__init__()
self.net = nn.Sequential(
nn.Linear(state_dim, hidden),
nn.Tanh(),
nn.Linear(hidden, hidden),
nn.Tanh(),
nn.Linear(hidden, 1)
)
def forward(self, states):
return self.net(states).squeeze(-1)
class TRPO:
"""TRPO algorithm."""
def __init__(
self,
state_dim,
action_dim,
gamma=0.99,
lam=0.95,
max_kl=0.01,
damping=0.1,
cg_iters=10,
value_lr=1e-3,
value_iters=5,
):
self.policy = TRPOPolicy(state_dim, action_dim)
self.value = TRPOValue(state_dim)
self.value_optimizer = torch.optim.Adam(
self.value.parameters(), lr=value_lr
)
self.gamma = gamma
self.lam = lam
self.max_kl = max_kl
self.damping = damping
self.cg_iters = cg_iters
self.value_iters = value_iters
def collect_rollout(self, env, n_steps=2048):
"""Collect rollout."""
states, actions, rewards, values, log_probs, dones = [], [], [], [], [], []
state = env.reset()
if isinstance(state, tuple):
state = state[0]
for _ in range(n_steps):
state_tensor = torch.FloatTensor(state)
with torch.no_grad():
dist = self.policy.get_distribution(state_tensor)
action = dist.sample()
log_prob = dist.log_prob(action)
value = self.value(state_tensor)
next_state, reward, done, truncated, _ = env.step(action.item())
if isinstance(done, bool) and truncated:
done = True
states.append(state)
actions.append(action.item())
rewards.append(reward)
values.append(value.item())
log_probs.append(log_prob.item())
dones.append(done)
state = next_state if not done else env.reset()[0]
return {
'states': np.array(states),
'actions': np.array(actions),
'rewards': np.array(rewards),
'values': np.array(values),
'log_probs': np.array(log_probs),
'dones': np.array(dones),
}
def compute_gae(self, rewards, values, dones, next_value=0):
"""Compute GAE advantages."""
advantages = []
gae = 0
for t in reversed(range(len(rewards))):
if t == len(rewards) - 1:
next_val = next_value
else:
next_val = values[t + 1]
mask = 1.0 - dones[t]
delta = rewards[t] + self.gamma * next_val * mask - values[t]
gae = delta + self.gamma * self.lam * mask * gae
advantages.insert(0, gae)
return np.array(advantages)
def conjugate_gradient(self, Avp, b, n_iters=10):
"""Conjugate gradient for solving Ax = b."""
x = torch.zeros_like(b)
r = b.clone()
p = r.clone()
r_dot_r = (r * r).sum()
for _ in range(n_iters):
Ap = Avp(p)
alpha = r_dot_r / (p * Ap).sum()
x = x + alpha * p
r_new = r - alpha * Ap
r_dot_r_new = (r_new * r_new).sum()
if r_dot_r_new < 1e-10:
break
beta = r_dot_r_new / r_dot_r
p = r_new + beta * p
r = r_new
r_dot_r = r_dot_r_new
return x
def update(self, rollout):
"""TRPO update using conjugate gradient."""
states = torch.FloatTensor(rollout['states'])
actions = torch.LongTensor(rollout['actions'])
old_log_probs = torch.FloatTensor(rollout['log_probs'])
# Compute advantages
advantages = self.compute_gae(
rollout['rewards'], rollout['values'], rollout['dones']
)
advantages = (advantages - advantages.mean()) / (advantages.std() + 1e-8)
advantages = torch.FloatTensor(advantages)
# Compute returns
returns = advantages + torch.FloatTensor(rollout['values'])
# 1. Compute policy gradient
dist = self.policy.get_distribution(states)
log_probs = dist.log_prob(actions)
ratio = torch.exp(log_probs - old_log_probs)
surrogate = (ratio * advantages).mean()
# Policy gradient (using autograd)
grads = torch.autograd.grad(
surrogate, self.policy.parameters(), retain_graph=True
)
grads = torch.cat([g.flatten() for g in grads])
# 2. Compute Fisher vector product
def fisher_vector_product(v):
# Flatten v to match gradients
v = v.detach()
# Compute KL divergence
new_dist = self.policy.get_distribution(states)
kl = torch.distributions.kl_divergence(dist, new_dist).mean()
# Compute gradient of KL
kl_grads = torch.autograd.grad(
kl, self.policy.parameters(), create_graph=True
)
kl_grads = torch.cat([g.flatten() for g in kl_grads])
# FVP = ∇(∇KL · v)
kl_v = (kl_grads * v).sum()
fvp = torch.autograd.grad(kl_v, self.policy.parameters())
fvp = torch.cat([g.flatten() for g in fvp])
return fvp + self.damping * v
# 3. Conjugate gradient to solve Fx = g
step_dir = self.conjugate_gradient(
fisher_vector_product, grads, n_iters=self.cg_iters
)
# 4. Compute step size (line search)
# max_step = sqrt(2 * max_kl / (step_dir^T F step_dir))
fvp_step = fisher_vector_product(step_dir)
shs = 0.5 * (step_dir * fvp_step).sum()
lm = torch.sqrt(shs / self.max_kl)
max_step = 1.0 / (lm + 1e-8)
# 5. Line search
old_params = torch.cat([p.flatten() for p in self.policy.parameters()])
step_size = max_step.item()
improved = False
for _ in range(10):
new_params = old_params + step_size * step_dir
# Set new params
idx = 0
for p in self.policy.parameters():
size = p.numel()
p.data = new_params[idx:idx+size].view(p.shape)
idx += size
# Check KL constraint
with torch.no_grad():
new_dist = self.policy.get_distribution(states)
new_kl = torch.distributions.kl_divergence(dist, new_dist).mean()
# Compute new surrogate
new_log_probs = new_dist.log_prob(actions)
new_ratio = torch.exp(new_log_probs - old_log_probs)
new_surrogate = (new_ratio * advantages).mean()
if new_kl <= self.max_kl * 1.5 and new_surrogate > surrogate:
improved = True
break
step_size *= 0.5
if not improved:
# Restore old params
idx = 0
for p in self.policy.parameters():
size = p.numel()
p.data = old_params[idx:idx+size].view(p.shape)
idx += size
# 6. Update value function
for _ in range(self.value_iters):
values = self.value(states)
value_loss = F.mse_loss(values, returns)
self.value_optimizer.zero_grad()
value_loss.backward()
self.value_optimizer.step()
return new_kl.item()
# TRPO KEY CONCEPTS
print("=== TRPO Key Concepts ===")
print("""
1. TRUST REGION
- Limits policy change per update
- Prevents performance collapse
- KL divergence constraint
2. SURROGATE OBJECTIVE
L(θ) = E[π_θ(a|s)/π_old(a|s) · A(s,a)]
- Importance sampling
- Approximates policy improvement
3. CONSTRAINED OPTIMIZATION
maximize L(θ)
subject to E[KL(π_old || π_θ)] ≤ δ
4. SOLUTION METHOD
- Compute policy gradient
- Compute Fisher vector product
- Solve using conjugate gradient
- Line search for step size
- Check KL constraint
5. GUARANTEES
- Monotonic improvement (theory)
- Safe policy updates
- Sample efficient
6. LIMITATIONS
- Complex implementation
- Computationally expensive
- Second-order optimization
- PPO is a simpler alternative
""")
# TRPO vs PPO
print("=== TRPO vs PPO ===")
print("""
TRPO:
+ Theoretical guarantees
+ Monotonic improvement
+ Trust region constraint
- Complex (conjugate gradient)
- Second-order
- Slow per update
- Hard to implement
PPO:
+ Simple (first-order)
+ Fast per update
+ Easy to implement
+ Works well in practice
- No monotonic guarantee
- Clipping heuristic
- Sensitive to hyperparameters
Key insight:
PPO clips the ratio to approximate TRPO's
trust region constraint — simpler and effective.
""")
# WHY TRPO MATTERS
print("\n=== Why TRPO Matters ===")
print("""
Historical significance:
1. First practical trust region method
2. Theoretical foundation for PPO
3. Demonstrated safe RL updates
4. Enabled complex tasks
Conceptual contributions:
1. Trust region concept
2. Importance sampling for policy
3. Constrained optimization
4. Value function as baseline
Modern relevance:
- Mostly replaced by PPO
- Concepts used in safe RL
- Foundation for research
- Educational value
""")🎮 Continuous Action Spaces
🎯 Beyond Discrete Actions
Continuous action spaces are essential for robotics, autonomous driving, and control. Policy gradient methods naturally handle them via Gaussian policies, while value-based methods struggle.
Continuous Control
Gaussian policy: Output mean μ(s) and std σ(s). Reparameterization: a = μ + σ·ε, ε ~ N(0,1). Log prob: Computed from Gaussian PDF. State-dependent std: Learned or fixed. Squashing: tanh for bounded actions. Algorithms: DDPG, TD3, SAC, PPO.
# Continuous action spaces with policy gradient
import numpy as np
import torch
import torch.nn as nn
import torch.nn.functional as F
import torch.optim as optim
from torch.distributions import Normal
class ContinuousPolicy(nn.Module):
"""Gaussian policy for continuous actions."""
def __init__(self, state_dim, action_dim, hidden=64):
super().__init__()
# Shared features
self.shared = nn.Sequential(
nn.Linear(state_dim, hidden),
nn.Tanh(),
nn.Linear(hidden, hidden),
nn.Tanh()
)
# Mean head
self.mean_head = nn.Linear(hidden, action_dim)
# Log std head (learned)
self.log_std_head = nn.Linear(hidden, action_dim)
# Alternative: fixed log_std as parameter
# self.log_std = nn.Parameter(torch.zeros(action_dim))
def forward(self, state):
features = self.shared(state)
mean = self.mean_head(features)
log_std = self.log_std_head(features)
log_std = torch.clamp(log_std, -20, 2) # Clamp for stability
return mean, log_std
def get_distribution(self, state):
mean, log_std = self.forward(state)
std = torch.exp(log_std)
return Normal(mean, std)
def get_action(self, state, deterministic=False):
"""Sample action from policy."""
with torch.no_grad():
mean, log_std = self.forward(state)
std = torch.exp(log_std)
if deterministic:
return mean
# Sample with reparameterization
dist = Normal(mean, std)
action = dist.rsample()
# Compute log prob
log_prob = dist.log_prob(action).sum(-1)
return action, log_prob
def evaluate(self, states, actions):
"""Evaluate log prob and entropy."""
mean, log_std = self.forward(states)
std = torch.exp(log_std)
dist = Normal(mean, std)
log_probs = dist.log_prob(actions).sum(-1)
entropy = dist.entropy().sum(-1)
return log_probs, entropy
# BOUNDED ACTIONS (tanh squashing)
class SquashedContinuousPolicy(ContinuousPolicy):
"""Squashed Gaussian for bounded actions."""
def get_action(self, state, deterministic=False):
with torch.no_grad():
mean, log_std = self.forward(state)
std = torch.exp(log_std)
if deterministic:
return torch.tanh(mean)
dist = Normal(mean, std)
action = dist.rsample()
squashed = torch.tanh(action)
# Correct log prob for tanh squashing
log_prob = dist.log_prob(action).sum(-1)
log_prob -= torch.log(1 - squashed.pow(2) + 1e-6).sum(-1)
return squashed, log_prob
def evaluate(self, states, actions):
mean, log_std = self.forward(states)
std = torch.exp(log_std)
dist = Normal(mean, std)
# Inverse tanh
actions_clamped = torch.clamp(actions, -0.999999, 0.999999)
raw_actions = 0.5 * torch.log((1 + actions_clamped) / (1 - actions_clamped))
log_probs = dist.log_prob(raw_actions).sum(-1)
log_probs -= torch.log(1 - actions_clamped.pow(2) + 1e-6).sum(-1)
entropy = dist.entropy().sum(-1)
return log_probs, entropy
# TRAINING WITH CONTINUOUS ACTIONS
class ContinuousPPO:
"""PPO for continuous actions."""
def __init__(self, state_dim, action_dim, lr=3e-4):
self.policy = SquashedContinuousPolicy(state_dim, action_dim)
self.value = nn.Sequential(
nn.Linear(state_dim, 64), nn.Tanh(),
nn.Linear(64, 64), nn.Tanh(),
nn.Linear(64, 1)
)
self.optimizer = optim.Adam(
list(self.policy.parameters()) + list(self.value.parameters()),
lr=lr
)
def select_action(self, state):
state_tensor = torch.FloatTensor(state).unsqueeze(0)
action, log_prob = self.policy.get_action(state_tensor)
return action.squeeze(0).numpy(), log_prob.item()
# ACTION SPACE EXAMPLES
print("=== Continuous Action Space Examples ===")
print("""
Robotics:
- Joint angles: [θ1, θ2, ..., θn] ∈ ℝⁿ
- End-effector position: [x, y, z]
- Gripper force: scalar
Autonomous Driving:
- Steering angle: [-1, 1]
- Acceleration: [-1, 1]
- Braking: [0, 1]
Quadcopter Control:
- Thrust: [0, 1]
- Roll, pitch, yaw rates: [-π, π]
Portfolio Management:
- Asset allocations: [w1, w2, ..., wn] with Σwi = 1
Resource Allocation:
- CPU time, memory, bandwidth
- Real-valued allocations
Algorithms for continuous control:
- DDPG (Deterministic Policy Gradient)
- TD3 (Twin Delayed DDPG)
- SAC (Soft Actor-Critic)
- PPO for continuous
- A2C/A3C for continuous
""")
# LOG PROBABILITY FOR GAUSSIAN
print("=== Gaussian Log Probability ===")
print("""
For a Gaussian N(μ, σ²):
log p(a|s) = -½ [(a - μ)² / σ² + log(2πσ²)]
For multi-dimensional:
log p(a|s) = Σ_i log p(a_i|s)
Key insight: log prob is DIFFERENTIABLE w.r.t. μ and σ
Reparameterization trick:
a = μ + σ · ε, ε ~ N(0, 1)
This allows:
∇_θ a = ∇_θ μ + ε · ∇_θ σ
Gradient flows through!
""")
# WHY CONTINUOUS IS IMPORTANT
print("\n=== Why Continuous Actions Matter ===")
print("""
1. REAL-WORLD CONTROL
- Most physical systems are continuous
- Motors, hydraulics, etc.
2. FINE-GRAINED CONTROL
- Small adjustments
- Not limited to discrete choices
3. NATURAL REPRESENTATION
- Directly matches physical quantities
4. POWERFUL ALGORITHMS
- SAC, TD3 are SOTA
- Sample efficient
5. APPLICATION DOMAINS
- Robotics
- Autonomous vehicles
- Finance
- Resource management
- Drug discovery
""")
# COMPARISON
print("=== Discrete vs Continuous ===")
print("""
DISCRETE:
Actions: {a₁, a₂, ..., aₙ}
Policy: Categorical distribution
Methods: DQN, PPO, A2C
Examples: Chess, Atari, Go
CONTINUOUS:
Actions: a ∈ ℝⁿ
Policy: Gaussian distribution
Methods: DDPG, TD3, SAC, PPO
Examples: Robotics, Control
HYBRID:
Mixed discrete/continuous
Separate policy heads
Complex action structures
Key insight: Policy gradient methods
naturally extend to continuous actions,
while value-based methods struggle.
""")
# ADVANCED TOPICS
print("=== Advanced Topics ===")
print("""
1. State-dependent std
- Learn σ(s) as function of state
- More expressive than fixed σ
2. Beta distribution
- For bounded actions [0, 1]
- Alternative to tanh-Gaussian
3. Mixture of Gaussians
- Multi-modal action distributions
- More flexible
4. Normalizing flows
- Complex action distributions
- Invertible transformations
5. Hierarchical policies
- High-level: discrete options
- Low-level: continuous actions
6. Action repeat
- Repeat action multiple times
- Simpler temporal abstraction
""")💼 Applications (Games, Robotics, LLMs)
🎯 Policy Gradients in the Real World
Policy gradient methods power some of the most impressive AI systems: game-playing agents, robotic controllers, and the LLMs you use every day. Their versatility is unmatched.
Application Domains
Games: Atari, Dota 2, StarCraft II. Robotics: Manipulation, locomotion, navigation. LLMs: RLHF for ChatGPT, Claude. Recommendation: Personalization, ads. Finance: Trading, portfolio. Healthcare: Treatment, drug discovery.
# Applications of policy gradient methods
import numpy as np
# 1. GAME PLAYING
print("=== Games ===")
print("""
OpenAI Five (Dota 2):
- PPO with LSTM
- Beat world champions
- Complex team coordination
AlphaStar (StarCraft II):
- Actor-Critic
- Grandmaster level
- Real-time strategy
AlphaGo/AlphaZero:
- MCTS + Neural Networks
- Superhuman at Go, Chess, Shogi
Atari Games:
- DQN initially
- PPO/IMPALA better
- 57 games benchmark
Applications:
- Game AI opponents
- Playtesting
- Game content generation
""")
# 2. ROBOTICS
print("=== Robotics ===")
print("""
Locomotion:
- Quadruped robots (ANYmal, Spot)
- Bipedal walking
- Running, jumping, parkour
- Sim-to-real transfer
Manipulation:
- Grasping objects
- Assembly tasks
- Tool use
- In-hand manipulation
Navigation:
- Autonomous driving
- Drone flight
- Mobile robot navigation
- SLAM + RL
Key successes:
- OpenAI Rubik's Cube (PPO)
- DeepMind robot soccer
- ANYmal parkour
- Dexterous manipulation
Challenges:
- Sim-to-real gap
- Sample efficiency
- Safety constraints
- Real-world variability
""")
# 3. LLMs and RLHF
print("=== LLMs and RLHF ===")
print("""
RLHF (Reinforcement Learning from Human Feedback):
1. Pretrain LLM on large corpus
2. Train reward model on human preferences
3. Fine-tune with PPO using reward model
4. Result: Aligned LLM
Used in:
- ChatGPT (OpenAI)
- Claude (Anthropic)
- Gemini (Google)
- Llama 2/3 (Meta)
PPO details for LLMs:
- Token-level actions
- KL penalty to reference model
- Reward shaping
- Value function for advantages
Reward model training:
- Human comparisons
- Bradley-Terry model
- Preference learning
Modern variants:
- DPO (Direct Preference Optimization)
- RLAIF (RL from AI Feedback)
- Constitutional AI
- GRPO (Group Relative Policy Optimization)
""")
# RLHF pseudocode
print("--- RLHF PPO Pseudocode ---")
print("""
# 1. Initialize
policy = LLM()
reference = copy(policy) # frozen
reward_model = train_reward_model(human_preferences)
# 2. RLHF training loop
for iteration in range(N):
# Sample prompts
prompts = sample_prompts()
# Generate responses
responses = policy.generate(prompts)
# Compute rewards
rewards = reward_model(prompts, responses)
# Compute KL penalty
kl = kl_divergence(policy, reference, prompts, responses)
rewards = rewards - beta * kl
# PPO update
advantages = compute_gae(rewards, values)
ppo_update(policy, advantages)
# 3. Result: Aligned LLM
""")
# 4. RECOMMENDATION SYSTEMS
print("=== Recommendation Systems ===")
print("""
Applications:
- Content recommendation (YouTube, Netflix)
- Product recommendation (Amazon)
- Ad placement
- News ranking
RL approach:
- State: User features + context
- Action: Items to recommend
- Reward: Clicks, watch time, purchases
- Policy: Which items to show
Challenges:
- Off-policy evaluation
- Counterfactual reasoning
- Exploration vs exploitation
- Long-term rewards
Methods:
- PPO for ranking
- Actor-Critic for personalization
- Slate optimization
""")
# 5. FINANCE
print("=== Finance ===")
print("""
Applications:
- Algorithmic trading
- Portfolio management
- Risk management
- Market making
RL setup:
- State: Market features, portfolio
- Action: Buy/sell/hold quantities
- Reward: Returns - risk penalty
- Constraints: Budget, position limits
Challenges:
- Non-stationarity
- Low signal-to-noise
- Transaction costs
- Regulatory constraints
Methods:
- PPO for trading
- SAC for portfolio
- Multi-agent for market making
""")
# 6. HEALTHCARE
print("=== Healthcare ===")
print("""
Applications:
- Treatment optimization
- Drug discovery
- Clinical trial design
- Personalized medicine
RL setup:
- State: Patient features, history
- Action: Treatment decisions
- Reward: Health outcomes
- Constraints: Safety, cost
Challenges:
- Safety-critical
- Small datasets
- Causal inference
- Regulatory approval
Methods:
- Off-policy evaluation
- Safe RL
- Model-based RL
Examples:
- Sepsis treatment
- Diabetes management
- Chemotherapy dosing
- Ventilator settings
""")
# 7. OTHER DOMAINS
print("=== Other Domains ===")
print("""
Energy:
- Smart grid control
- Data center cooling
- Battery management
- HVAC optimization
Manufacturing:
- Process control
- Quality optimization
- Predictive maintenance
- Supply chain
Education:
- Adaptive learning
- Personalized tutoring
- Curriculum design
- Student engagement
Marketing:
- Campaign optimization
- Customer lifetime value
- Dynamic pricing
- Retention
Transportation:
- Traffic signal control
- Fleet management
- Route optimization
- Autonomous vehicles
""")
# 8. LANDMARK PAPERS
print("=== Landmark Papers ===")
print("""
REINFORCE (1992):
Williams — Policy gradient theorem
Actor-Critic (1999):
Sutton et al. — Actor-Critic methods
TRPO (2015):
Schulman et al. — Trust region policy optimization
GAE (2016):
Schulman et al. — Generalized advantage estimation
A3C (2016):
Mnih et al. — Asynchronous methods
PPO (2017):
Schulman et al. — Proximal policy optimization
SAC (2018):
Haarnoja et al. — Soft actor-critic
RLHF (2017, 2022):
Christiano et al., Ouyang et al. — Human feedback
Decision Transformer (2021):
Chen et al. — Sequence modeling for RL
DPO (2023):
Rafailov et al. — Direct preference optimization
""")
# 9. IMPACT
print("=== Impact on AI ===")
print("""
Policy gradient methods have enabled:
1. Game-playing AI
- Beating world champions
- Complex multi-agent scenarios
2. Real-world robotics
- Robust locomotion
- Dexterous manipulation
3. Aligned LLMs
- ChatGPT, Claude, Gemini
- Instruction following
- Safety alignment
4. Business applications
- Recommendation
- Advertising
- Trading
5. Scientific discovery
- Drug design
- Materials science
- Physics simulation
Key insight:
Policy gradients are the bridge between
supervised learning and interactive AI.
""")🚀 Future: RLHF & Beyond
🎯 The Next Frontier
Policy gradient methods are evolving rapidly: RLHF for LLM alignment, offline RL for real-world applications, model-based RL for sample efficiency, and multi-agent RL for complex coordination.
Future Directions
RLHF: Aligning LLMs with human values. Offline RL: Learning from fixed datasets. Model-based: Learning environment models. Multi-agent: Coordination and competition. Safe RL: Constrained optimization. Real-world: Deploying at scale.
# Future of policy gradient methods
import numpy as np
# 1. RLHF AND BEYOND
print("=== RLHF Evolution ===")
print("""
RLHF (2022):
- PPO for LLM alignment
- Reward modeling from preferences
- KL penalty to reference
DPO (2023):
- Direct preference optimization
- No reward model needed
- Simpler, more stable
- Closed-form objective
RLAIF (2023):
- RL from AI feedback
- Scale beyond human labels
- Constitutional AI
GRPO (2024):
- Group Relative Policy Optimization
- Used in DeepSeek
- No value function needed
- Efficient for LLMs
Future trends:
- More efficient alignment
- Better reward modeling
- Multi-modal alignment
- Long-context RL
- Online learning
""")
# DPO pseudocode
print("--- DPO Pseudocode ---")
print("""
# DPO: Direct Preference Optimization
# No reward model, no RL loop
# Loss function:
# L_DPO = -E[log σ(β log(π(y_w|x)/π_ref(y_w|x))
# - β log(π(y_l|x)/π_ref(y_l|x)))]
# where:
# y_w: preferred response
# y_l: dispreferred response
# π: policy being trained
# π_ref: reference policy
# β: temperature parameter
# Training:
for batch in data:
y_w, y_l, x = batch
# Compute log probabilities
log_pi_yw = compute_log_prob(policy, x, y_w)
log_pi_yl = compute_log_prob(policy, x, y_l)
log_ref_yw = compute_log_prob(reference, x, y_w)
log_ref_yl = compute_log_prob(reference, x, y_l)
# Compute DPO loss
logits = beta * (log_pi_yw - log_ref_yw - log_pi_yl + log_ref_yl)
loss = -F.logsigmoid(logits).mean()
# Update
loss.backward()
optimizer.step()
""")
# 2. OFFLINE RL
print("\n=== Offline RL ===")
print("""
Problem:
- Learn from fixed dataset
- No environment interaction
- Critical for safety, cost, time
Challenges:
- Distribution shift
- Extrapolation error
- Uncertainty quantification
Methods:
- CQL (Conservative Q-Learning)
- IQL (Implicit Q-Learning)
- TD3+BC (Behavior Cloning)
- Decision Transformer
- AWAC
Applications:
- Healthcare (treatment)
- Robotics (from demos)
- Recommendation
- Autonomous driving
- Finance
Future:
- Combining offline + online
- Better uncertainty
- Foundation models for RL
""")
# 3. MODEL-BASED RL
print("=== Model-Based RL ===")
print("""
Learn environment model, plan with it:
Components:
- Dynamics model: p(s'|s,a)
- Reward model: r(s,a)
- Policy/planner
Approaches:
- MBPO (Model-Based Policy Optimization)
- Dreamer / DreamerV3
- MuZero (learned model)
- PETS (Probabilistic Ensembles)
Benefits:
- Sample efficiency (10-100x)
- Planning capability
- Model reuse
Challenges:
- Model errors compound
- Uncertainty estimation
- Sim-to-real gap
Future:
- Foundation models for dynamics
- Better uncertainty quantification
- Hybrid model-free/model-based
""")
# DreamerV3 pseudocode
print("--- DreamerV3 Pseudocode ---")
print("""
# Dreamer: Learning in latent imagination
# 1. World model
encoder: s_t = enc(o_t) # Obs to latent
dynamics: s_{t+1} = dyn(s_t, a_t) # Latent dynamics
reward: r_t = rew(s_t) # Reward prediction
decoder: o_t = dec(s_t) # Reconstruction
# 2. Imagination training
for each iteration:
# Sample latents
s = sample_state_from_replay()
# Imagine trajectories
for t in range(horizon):
a = actor(s)
s = dynamics(s, a)
r = reward(s)
# Update actor using imagined returns
update_actor(imagined_trajectories)
update_critic(imagined_trajectories)
# Update world model
update_world_model(replay_buffer)
""")
# 4. MULTI-AGENT RL
print("\n=== Multi-Agent RL ===")
print("""
Setting:
- Multiple agents in shared environment
- Cooperation, competition, or mixed
Challenges:
- Non-stationarity (others learning)
- Credit assignment
- Scalability
- Coordination
Methods:
- MADDPG (Multi-Agent DDPG)
- MAPPO (Multi-Agent PPO)
- QMIX (value factorization)
- VDN (Value Decomposition)
- COMA (Counterfactual)
Applications:
- Team games (Dota 2, StarCraft)
- Traffic control
- Swarm robotics
- Economic simulation
Future:
- Emergent communication
- General-sum games
- Open-ended learning
""")
# 5. SAFE RL
print("=== Safe RL ===")
print("""
Goal:
Maximize reward while satisfying safety constraints
Formulation:
maximize E[return]
subject to E[cost] ≤ limit
Approaches:
- Constrained MDPs
- Lagrangian methods
- CPO (Constrained Policy Optimization)
- PPO-Lagrangian
- Safe exploration
Applications:
- Healthcare
- Autonomous driving
- Industrial control
- Finance
Future:
- Provable safety
- Runtime verification
- Human oversight
- Regulatory compliance
""")
# 6. FOUNDATION MODELS FOR RL
print("=== Foundation Models for RL ===")
print("""
Combining LLMs with RL:
Decision Transformer:
- Sequence modeling approach
- No value function needed
- Trained on offline data
Gato (DeepMind):
- Multi-task, multi-modal
- Text, images, actions
- One model for everything
RT-2 (Google):
- Vision-Language-Action
- Robot control via LLM
Foundation policies:
- Pretrained on diverse tasks
- Fine-tune for specific tasks
- Generalization to new tasks
Future:
- Unified RL + LLM
- Language-conditioned policies
- World models from internet data
- Embodied AI
""")
# 7. KEY CHALLENGES
print("=== Key Challenges ===")
print("""
1. SAMPLE EFFICIENCY
- Real-world data is expensive
- Sim-to-real transfer
- Offline RL methods
2. SCALABILITY
- Large state/action spaces
- Long horizons
- Multi-agent systems
3. STABILITY
- Hyperparameter sensitivity
- Non-stationarity
- Catastrophic forgetting
4. SAFETY
- Constrained optimization
- Provable guarantees
- Human oversight
5. GENERALIZATION
- New tasks
- New environments
- Distribution shift
6. INTERPRETABILITY
- Understanding policies
- Explaining decisions
- Building trust
7. EVALUATION
- Off-policy evaluation
- Safety metrics
- Real-world impact
""")
# 8. FUTURE VISION
print("=== Future Vision ===")
print("""
Near-term (1-2 years):
- Better RLHF methods
- Multi-modal RL
- Efficient offline RL
- Foundation policies
Mid-term (3-5 years):
- AGI-level agents
- Sim-to-real mastery
- Multi-agent coordination
- Real-world deployment
Long-term (5-10 years):
- General-purpose agents
- Autonomous systems
- Scientific discovery
- Human-AI collaboration
Grand challenges:
- Open-ended learning
- Lifelong learning
- Human-level reasoning
- Safe superintelligence
""")
# 9. RECOMMENDATIONS
print("=== Recommendations ===")
print("""
For practitioners:
1. Start with PPO
2. Use GAE for advantages
3. Normalize everything
4. Vectorized environments
5. Monitor KL, entropy
6. Tune hyperparameters
7. Test on multiple tasks
For researchers:
1. Focus on sample efficiency
2. Safety and robustness
3. Offline and hybrid methods
4. Foundation models
5. Multi-agent systems
6. Real-world validation
For LLM alignment:
1. RLHF or DPO
2. Careful reward modeling
3. KL regularization
4. Red teaming
5. Continuous monitoring
For real-world deployment:
1. Safety first
2. Offline pretraining
3. Online fine-tuning
4. Human oversight
5. Monitoring and rollback
""")
# 10. FINAL THOUGHTS
print("\n=== Final Thoughts ===")
print("""
Policy gradient methods have evolved from:
Simple REINFORCE → Modern PPO/RLHF
They've enabled:
- Game-playing champions
- Robotic dexterity
- Aligned LLMs
- Business optimization
They will continue to evolve:
- More sample efficient
- More robust
- More general
- More aligned
Key insight:
Policy gradients bridge supervised learning
and interactive intelligence.
They're the foundation for:
- AGI
- Real-world AI
- Human-AI collaboration
"Reinforcement learning is the first
computational approach to learning
from interaction that is both
powerful and general."
— Richard Sutton
The future is interactive.
The future is RL.
The future is now.
""")