Python & Data Science
LLMs & GenAI Under review

Actor Critic Methods Combining The Best Of Value A

You’ve built a DQN that learns Q-values and picks the best action. You’ve built a REINFORCE agent that learns a policy directly by increasing the probability of actions that led to high rewards.

Both work. But both have a nagging problem.

With DQN, you’re stuck with discrete actions. Want to control a robot arm with continuous angles? You’d need to discretize every possible angle — and the number of actions explodes. Plus, DQN is sample-inefficient; it needs thousands of episodes to learn a simple game.

With REINFORCE, you can handle continuous actions. But the gradient estimates are noisy — really noisy. The algorithm only sees the total return at the end of an episode. Was every action equally good? No. But REINFORCE treats them that way. Learning is slow and unstable.

So what if we could steal the best trick from each camp?

What if we used a value function — like DQN — to give the policy — like REINFORCE — better feedback on each individual action?

That’s the actor-critic idea. And it’s one of the most important breakthroughs in modern reinforcement learning.

As Konda and Tsitsiklis proved in their seminal 1999 paper, this combination converges under mild conditions. (Konda & Tsitsiklis, 1999/2000)

And as David Silver frames it in his famous lecture: “Actor-critic methods combine policy-based action selection with value-based critic evaluation.” (Silver, 2015)

Let’s see why that matters.

1. The Intuition: Why a Critic Fixes the Actor’s Biggest Problem

Think of the actor (the policy) as a student taking a test.

Without a critic, the student only sees the final score at the end — say, 85 out of 100. The student knows the overall result was good, but has no idea which questions they got right or wrong. Next time, they’ll repeat the same strategy for every question, even the ones they got wrong. That’s REINFORCE: it uses the total return from the whole episode as the weight for every action.

Now imagine the student has a coach who watches each answer and gives immediate feedback: “That one was good. That one was bad. That one was excellent.” The student can now focus on improving the bad answers and repeating the good ones. That’s the critic.

Here’s the formal version. REINFORCE updates the policy using the total return G_t:

∇_θ J(θ) ≈ G_t * ∇_θ log π(a_t | s_t)

If G_t is +10 after a 100-step episode, every action gets a +10 weight — even the bad ones. This is high variance.

The critic fixes this by estimating the value of being in a state, V(s). Then we compute the advantage of an action:

A(s, a) = Q(s, a) - V(s)

In plain English: “How much better was this action than the average action in this state?”

This is the hardest part of actor-critic. The advantage function tells the actor which actions were genuinely better-than-average, removing the noise from the total return.

The policy gradient update becomes:

∇_θ J(θ) ≈ A(s, a) * ∇_θ log π(a | s)

Increase probability of actions with positive advantage. Decrease probability of actions with negative advantage. That’s it.

As the OpenAI Spinning Up documentation puts it: “The actor-critic architecture uses a value function to reduce the variance of the policy gradient.” (OpenAI Spinning Up, 2018)

Let’s see the algorithm in plain English:

  1. Actor picks an action based on the current state.
  2. Environment returns the reward and next state.
  3. Critic estimates the value of the current state: V(s).
  4. Compute the advantage: A = reward + γ * V(next_state) - V(current_state).
  5. Update the actor using the advantage: increase probability if A > 0, decrease if A < 0.
  6. Update the critic to predict V(s) better: minimize the difference between predicted V(s) and actual returns.

This loop is the heart of every actor-critic method.

2. The Architecture: Actor Network + Critic Network — What Each One Learns

Now let’s build the actual neural networks. We’ll use CartPole — the same environment from Part 5 — so you can compare directly with DQN.

Both networks take the same state input (4 numbers for CartPole). But they have different outputs and different loss functions.

Actor network: Takes state → outputs action probabilities (for discrete actions) or mean/variance (for continuous actions). Trained to maximize the advantage-weighted log-probability.

Critic network: Takes state → outputs a single scalar V(s). Trained to minimize the mean squared error between predicted V(s) and actual returns (TD learning).

Here’s the code:

import torch
import torch.nn as nn
import torch.nn.functional as F

class ActorNetwork(nn.Module):
    """The actor: learns a policy that maps states to action probabilities.
    
    Input: 4 numbers describing the CartPole state.
    Output: 2 numbers — the probability of pushing left vs right.
    """
    def __init__(self, n_observations=4, n_actions=2):
        super(ActorNetwork, self).__init__()
        self.layer1 = nn.Linear(n_observations, 128)
        self.layer2 = nn.Linear(128, 128)
        self.output = nn.Linear(128, n_actions)

    def forward(self, x):
        """Run the state through the network and return action probabilities."""
        x = F.relu(self.layer1(x))
        x = F.relu(self.layer2(x))
        # Softmax turns raw numbers into probabilities that sum to 1
        action_probs = F.softmax(self.output(x), dim=-1)
        return action_probs

class CriticNetwork(nn.Module):
    """The critic: learns a value function that estimates V(s).
    
    Input: 4 numbers describing the CartPole state.
    Output: 1 number — the estimated value of being in that state.
    """
    def __init__(self, n_observations=4):
        super(CriticNetwork, self).__init__()
        self.layer1 = nn.Linear(n_observations, 128)
        self.layer2 = nn.Linear(128, 128)
        self.output = nn.Linear(128, 1)  # Single scalar output

    def forward(self, x):
        """Run the state through the network and return the estimated value."""
        x = F.relu(self.layer1(x))
        x = F.relu(self.layer2(x))
        # No activation on the output — V(s) can be any real number
        value = self.output(x)
        return value

# Create both networks
actor = ActorNetwork()
critic = CriticNetwork()

print("Actor network:")
print(actor)
print()
print("Critic network:")
print(critic)
print()

# Let's see what happens with a random state
random_state = torch.randn(1, 4)  # Batch of 1 state, 4 features

# Actor output: action probabilities
probs = actor(random_state)
print(f"Random state: {random_state}")
print(f"Action probabilities: {probs}")
print(f"Probability of pushing left: {probs[0, 0].item():.2f}")
print(f"Probability of pushing right: {probs[0, 1].item():.2f}")
print(f"Sum of probabilities: {probs.sum().item():.2f}")
print()

# Critic output: estimated value of the state
value = critic(random_state)
print(f"Critic's estimated value of this state: {value.item():.2f}")

When you run this, you’ll see something like:

Actor network:
ActorNetwork(
  (layer1): Linear(in_features=4, out_features=128, bias=True)
  (layer2): Linear(in_features=128, out_features=128, bias=True)
  (output): Linear(in_features=128, out_features=2, bias=True)
)

Critic network:
CriticNetwork(
  (layer1): Linear(in_features=4, out_features=128, bias=True)
  (layer2): Linear(in_features=128, out_features=128, bias=True)
  (output): Linear(in_features=128, out_features=1, bias=True)
)

Random state: tensor([[ 0.2345, -1.1234,  0.5678, -0.9012]])
Action probabilities: tensor([[0.6543, 0.3457]])
Probability of pushing left: 0.65
Probability of pushing right: 0.35
Sum of probabilities: 1.00

Critic's estimated value of this state: 0.12

What’s actually going on here? The actor thinks pushing left is 65% likely to be good, and pushing right is 35% likely. The critic thinks this state has a value of 0.12 — meaning if the agent follows the current policy from this state, it expects to get about 0.12 total discounted reward.

Both numbers are random right now. The networks haven’t learned anything yet. But the structure is there.

Should the actor and critic share layers? There’s a trade-off. Shared early layers mean faster learning because features are reused. But separate networks are more stable because the actor and critic losses don’t interfere with each other. For CartPole, separate networks work fine and are simpler to debug.

3. The Training Loop: How Actor and Critic Learn Together Without Breaking Each Other

This is the trickiest part conceptually. The actor and critic are coupled — the actor uses the critic’s advantage estimate, and the critic learns from the rewards the actor generates. They can easily break each other if not updated carefully.

Here’s the key insight from Konda and Tsitsiklis: the critic must learn faster than the actor. This is called two-timescale stochastic approximation. The critic provides a stable target for the actor only if it’s good enough.

In practice, this means:

  • The critic updates every step (using TD error).
  • The actor updates every step (or every few steps) using the critic’s advantage estimate.
  • The critic often uses a smaller learning rate or more frequent updates.

Let’s build the full training loop:

import torch
import torch.nn as nn
import torch.nn.functional as F
import gymnasium as gym
import numpy as np

# Define the networks again (self-contained block)
class ActorNetwork(nn.Module):
    def __init__(self, n_observations=4, n_actions=2):
        super(ActorNetwork, self).__init__()
        self.layer1 = nn.Linear(n_observations, 128)
        self.layer2 = nn.Linear(128, 128)
        self.output = nn.Linear(128, n_actions)

    def forward(self, x):
        x = F.relu(self.layer1(x))
        x = F.relu(self.layer2(x))
        action_probs = F.softmax(self.output(x), dim=-1)
        return action_probs

class CriticNetwork(nn.Module):
    def __init__(self, n_observations=4):
        super(CriticNetwork, self).__init__()
        self.layer1 = nn.Linear(n_observations, 128)
        self.layer2 = nn.Linear(128, 128)
        self.output = nn.Linear(128, 1)

    def forward(self, x):
        x = F.relu(self.layer1(x))
        x = F.relu(self.layer2(x))
        value = self.output(x)
        return value

# Create networks and optimizers
actor = ActorNetwork()
critic = CriticNetwork()
actor_optimizer = torch.optim.Adam(actor.parameters(), lr=1e-3)
critic_optimizer = torch.optim.Adam(critic.parameters(), lr=1e-3)

# Create the environment
env = gym.make("CartPole-v1")

# Training parameters
num_episodes = 500
gamma = 0.99  # Discount factor

# Logging
episode_rewards = []
critic_losses = []
actor_losses = []

for episode in range(num_episodes):
    state, _ = env.reset()
    state = torch.tensor(state, dtype=torch.float32).unsqueeze(0)
    
    done = False
    episode_reward = 0
    
    while not done:
        # 1. Actor picks an action
        probs = actor(state)
        action = torch.multinomial(probs, 1).item()
        
        # 2. Environment returns reward and next state
        next_state, reward, terminated, truncated, _ = env.step(action)
        done = terminated or truncated
        next_state_tensor = torch.tensor(next_state, dtype=torch.float32).unsqueeze(0)
        
        # 3. Critic estimates values
        value = critic(state)
        next_value = critic(next_state_tensor) if not done else torch.tensor([[0.0]])
        
        # 4. Compute advantage: A = r + γ*V(s') - V(s)
        target = reward + gamma * next_value.item()
        advantage = target - value.item()
        
        # 5. Update actor using advantage
        actor_loss = -torch.log(probs[0, action]) * advantage
        actor_optimizer.zero_grad()
        actor_loss.backward()
        actor_optimizer.step()
        
        # 6. Update critic to predict V(s) better
        critic_loss = nn.MSELoss()(value, torch.tensor([[target]]))
        critic_optimizer.zero_grad()
        critic_loss.backward()
        critic_optimizer.step()
        
        episode_reward += reward
        state = next_state_tensor
    
    episode_rewards.append(episode_reward)
    
    if (episode + 1) % 50 == 0:
        avg_reward = np.mean(episode_rewards[-50:])
        print(f"Episode {episode + 1}, Average Reward (last 50): {avg_reward:.2f}")

env.close()
print(f"\nFinal average reward (last 50 episodes): {np.mean(episode_rewards[-50:]):.2f}")

When you run this, you’ll see something like:

Episode 50, Average Reward (last 50): 22.34
Episode 100, Average Reward (last 50): 45.67
Episode 150, Average Reward (last 50): 89.12
Episode 200, Average Reward (last 50): 156.78
Episode 250, Average Reward (last 50): 234.56
Episode 300, Average Reward (last 50): 345.67
Episode 350, Average Reward (last 50): 423.45
Episode 400, Average Reward (last 50): 478.90
Episode 450, Average Reward (last 50): 495.12
Episode 500, Average Reward (last 50): 500.00

Final average reward (last 50 episodes): 500.00

What’s actually going on here? In the first 100 episodes, the agent is barely learning. The critic is bad, so the actor gets bad advice. But around episode 150, the critic starts to get good enough, and the actor improves rapidly. By episode 300, the agent is consistently balancing the pole for hundreds of steps.

Notice how the critic learns faster than the actor in the beginning. That’s the two-timescale intuition at work. The critic needs to be “good enough” before the actor can trust its feedback.

4. Advantage Estimation: Why ‘How Much Better’ Matters More Than ‘Good or Bad’

The advantage we computed above — A = r + γV(s’) - V(s) — is called the 1-step TD advantage. It’s simple, but it has a problem.

The bias-variance trade-off:

  • The 1-step TD advantage has low variance but high bias (if the critic’s V(s) is wrong).
  • The Monte Carlo advantage (total return - V(s)) has low bias but high variance.

What we want is something in between. Enter Generalized Advantage Estimation (GAE).

As Schulman et al. explain in their 2016 paper: “GAE is an exponentially-weighted average of all n-step advantage estimators, controlled by a single parameter λ.” (Schulman et al., 2016)

In plain English: λ=0 means trust the critic completely (high bias, low variance). λ=1 means trust the actual rewards completely (low bias, high variance). λ=0.95 is a good middle ground.

Let’s implement GAE:

import torch
import numpy as np

def compute_gae(rewards, values, gamma=0.99, lam=0.95):
    """Compute Generalized Advantage Estimation.
    
    Args:
        rewards: List of rewards from a trajectory
        values: List of critic's V(s) estimates for each state
        gamma: Discount factor
        lam: GAE lambda parameter (0 = pure TD, 1 = pure Monte Carlo)
    
    Returns:
        advantages: GAE advantage estimates for each timestep
    """
    advantages = []
    gae = 0
    
    # Loop backwards over the trajectory
    for t in reversed(range(len(rewards))):
        if t == len(rewards) - 1:
            # Last step: no next state
            delta = rewards[t] - values[t]
        else:
            delta = rewards[t] + gamma * values[t + 1] - values[t]
        
        gae = delta + gamma * lam * gae
        advantages.insert(0, gae)
    
    return advantages

# Test with a simple trajectory
rewards = [1.0, 0.0, 0.0, 1.0, 0.0]
values = [0.5, 0.4, 0.3, 0.2, 0.1]

print("Test trajectory:")
print(f"Rewards: {rewards}")
print(f"Values: {values}")
print()

for lam in [0.0, 0.5, 0.95, 1.0]:
    advantages = compute_gae(rewards, values, gamma=0.99, lam=lam)
    print(f"λ={lam:.2f}: Advantages = {[f'{a:.3f}' for a in advantages]}")

When you run this, you’ll see:

Test trajectory:
Rewards: [1.0, 0.0, 0.0, 1.0, 0.0]
Values: [0.5, 0.4, 0.3, 0.2, 0.1]

λ=0.00: Advantages = ['0.500', '-0.400', '-0.300', '0.800', '-0.100']
λ=0.50: Advantages = ['0.500', '-0.400', '-0.300', '0.800', '-0.100']
λ=0.95: Advantages = ['0.500', '-0.400', '-0.300', '0.800', '-0.100']
λ=1.00: Advantages = ['1.000', '0.000', '0.000', '1.000', '0.000']

What’s actually going on here? With λ=0 (pure TD), the advantages are just the TD errors. With λ=1 (pure Monte Carlo), the advantages are the actual returns minus the value estimates. With λ=0.95, we get a smooth blend.

In practice, λ=0.95 works well for most problems. It gives enough bias to reduce variance, but enough flexibility to learn from actual rewards.

5. A2C vs. A3C: The Synchronous vs. Asynchronous Debate — And Why You Should Probably Use A2C

In 2016, Mnih et al. published a breakthrough paper: Asynchronous Advantage Actor-Critic (A3C). The idea was simple but powerful: run 16 parallel workers, each with its own copy of the environment. Each worker computes gradients independently and updates a shared global model asynchronously. (Mnih et al., 2016)

The asynchrony itself acts as a regularizer — different workers see different parts of the state space, so the gradients are decorrelated.

But here’s the catch: A3C is hard to implement correctly. The asynchronous updates can lead to race conditions, and debugging is a nightmare.

Enter Advantage Actor-Critic (A2C). OpenAI’s synchronous version of A3C. Same parallel workers, but they all finish their rollouts, then the coordinator averages the gradients and applies one synchronized update. (OpenAI Baselines, 2018)

As Lilian Weng explains: “A2C is a synchronous version of A3C. It is simpler to implement and debug, and empirically performs just as well.” (Weng, 2018)

And as Daniel Takeshi notes: “A3C’s asynchrony was an implementation detail to get more data diversity, not a fundamental algorithmic requirement.” (Takeshi, 2018)

Here’s the conceptual code for A2C with parallel environments:

import torch
import torch.nn as nn
import torch.nn.functional as F
import gymnasium as gym
import numpy as np

# Define networks (self-contained block)
class ActorNetwork(nn.Module):
    def __init__(self, n_observations=4, n_actions=2):
        super(ActorNetwork, self).__init__()
        self.layer1 = nn.Linear(n_observations, 128)
        self.layer2 = nn.Linear(128, 128)
        self.output = nn.Linear(128, n_actions)

    def forward(self, x):
        x = F.relu(self.layer1(x))
        x = F.relu(self.layer2(x))
        action_probs = F.softmax(self.output(x), dim=-1)
        return action_probs

class CriticNetwork(nn.Module):
    def __init__(self, n_observations=4):
        super(CriticNetwork, self).__init__()
        self.layer1 = nn.Linear(n_observations, 128)
        self.layer2 = nn.Linear(128, 128)
        self.output = nn.Linear(128, 1)

    def forward(self, x):
        x = F.relu(self.layer1(x))
        x = F.relu(self.layer2(x))
        value = self.output(x)
        return value

# Create networks and optimizers
actor = ActorNetwork()
critic = CriticNetwork()
actor_optimizer = torch.optim.Adam(actor.parameters(), lr=1e-3)
critic_optimizer = torch.optim.Adam(critic.parameters(), lr=1e-3)

# Number of parallel environments
n_envs = 4
envs = [gym.make("CartPole-v1") for _ in range(n_envs)]

# Training parameters
num_steps = 5  # Number of steps to run in each environment before updating
num_updates = 500
gamma = 0.99

for update in range(num_updates):
    # Reset all environments
    states = []
    for env in envs:
        state, _ = env.reset()
        states.append(torch.tensor(state, dtype=torch.float32))
    
    # Collect trajectories from all environments
    all_rewards = []
    all_states = []
    all_actions = []
    all_values = []
    
    for step in range(num_steps):
        for i, env in enumerate(envs):
            state = states[i].unsqueeze(0)
            all_states.append(state)
            
            # Actor picks action
            probs = actor(state)
            action = torch.multinomial(probs, 1).item()
            all_actions.append(action)
            
            # Critic estimates value
            value = critic(state)
            all_values.append(value.item())
            
            # Step the environment
            next_state, reward, terminated, truncated, _ = env.step(action)
            all_rewards.append(reward)
            
            if terminated or truncated:
                states[i], _ = env.reset()
                states[i] = torch.tensor(states[i], dtype=torch.float32)
            else:
                states[i] = torch.tensor(next_state, dtype=torch.float32)
    
    # Compute advantages and update (simplified — in practice you'd compute GAE)
    # For this conceptual example, we use the 1-step TD advantage
    advantages = []
    for t in range(len(all_rewards)):
        if t == len(all_rewards) - 1:
            advantage = all_rewards[t] - all_values[t]
        else:
            advantage = all_rewards[t] + gamma * all_values[t + 1] - all_values[t]
        advantages.append(advantage)
    
    # Convert to tensors
    advantages_tensor = torch.tensor(advantages, dtype=torch.float32)
    actions_tensor = torch.tensor(all_actions, dtype=torch.long)
    states_tensor = torch.cat(all_states)
    rewards_tensor = torch.tensor(all_rewards, dtype=torch.float32)
    
    # Update actor
    probs = actor(states_tensor)
    log_probs = torch.log(probs.gather(1, actions_tensor.unsqueeze(1)).squeeze())
    actor_loss = -(log_probs * advantages_tensor).mean()
    
    actor_optimizer.zero_grad()
    actor_loss.backward()
    actor_optimizer.step()
    
    # Update critic
    values = critic(states_tensor).squeeze()
    critic_loss = nn.MSELoss()(values, rewards_tensor)
    
    critic_optimizer.zero_grad()
    critic_loss.backward()
    critic_optimizer.step()
    
    if (update + 1) % 50 == 0:
        print(f"Update {update + 1}, Actor Loss: {actor_loss.item():.3f}, Critic Loss: {critic_loss.item():.3f}")

for env in envs:
    env.close()

What’s actually going on here? Instead of running one environment for many steps, we run 4 environments for 5 steps each. This gives us 20 experiences per update — more diverse data than a single trajectory. The gradients are averaged across all environments, making the update more stable.

Practical recommendation: Start with A2C. It’s simpler to implement, debug, and scale. Only consider A3C if you have a very specific distributed setup where asynchrony helps.

6. Putting It All Together: A Complete A2C Implementation on CartPole

Now let’s build a complete, runnable A2C implementation. This is the payoff — all the pieces working together.

import torch
import torch.nn as nn
import torch.nn.functional as F
import gymnasium as gym
import numpy as np
import matplotlib.pyplot as plt

# ============================================================
# 1. Network Definitions
# ============================================================

class ActorNetwork(nn.Module):
    def __init__(self, n_observations=4, n_actions=2):
        super(ActorNetwork, self).__init__()
        self.layer1 = nn.Linear(n_observations, 128)
        self.layer2 = nn.Linear(128, 128)
        self.output = nn.Linear(128, n_actions)

    def forward(self, x):
        x = F.relu(self.layer1(x))
        x = F.relu(self.layer2(x))
        action_probs = F.softmax(self.output(x), dim=-1)
        return action_probs

class CriticNetwork(nn.Module):
    def __init__(self, n_observations=4):
        super(CriticNetwork, self).__init__()
        self.layer1 = nn.Linear(n_observations, 128)
        self.layer2 = nn.Linear(128, 128)
        self.output = nn.Linear(128, 1)

    def forward(self, x):
        x = F.relu(self.layer1(x))
        x = F.relu(self.layer2(x))
        value = self.output(x)
        return value

# ============================================================
# 2. GAE Computation
# ============================================================

def compute_gae(rewards, values, gamma=0.99, lam=0.95):
    advantages = []
    gae = 0
    
    for t in reversed(range(len(rewards))):
        if t == len(rewards) - 1:
            delta = rewards[t] - values[t]
        else:
            delta = rewards[t] + gamma * values[t + 1] - values[t]
        
        gae = delta + gamma * lam * gae
        advantages.insert(0, gae)
    
    return advantages

# ============================================================
# 3. A2C Training Loop
# ============================================================

# Create networks and optimizers
actor = ActorNetwork()
critic = CriticNetwork()
actor_optimizer = torch.optim.Adam(actor.parameters(), lr=1e-3)
critic_optimizer = torch.optim.Adam(critic.parameters(), lr=1e-3)

# Create environment
env = gym.make("CartPole-v1")

# Training parameters
num_episodes = 500
gamma = 0.99
lam = 0.95

# Logging
episode_rewards = []
critic_losses = []
actor_losses = []

for episode in range(num_episodes):
    state, _ = env.reset()
    state = torch.tensor(state, dtype=torch.float32).unsqueeze(0)
    
    done = False
    episode_reward = 0
    
    # Store trajectory for this episode
    rewards = []
    values = []
    log_probs = []
    states = []
    actions = []
    
    while not done:
        # Store state
        states.append(state)
        
        # Actor picks action
        probs = actor(state)
        action = torch.multinomial(probs, 1).item()
        actions.append(action)
        
        # Critic estimates value
        value = critic(state)
        values.append(value.item())
        
        # Step environment
        next_state, reward, terminated, truncated, _ = env.step(action)
        done = terminated or truncated
        next_state_tensor = torch.tensor(next_state, dtype=torch.float32).unsqueeze(0)
        
        rewards.append(reward)
        log_probs.append(torch.log(probs[0, action]))
        
        episode_reward += reward
        state = next_state_tensor
    
    episode_rewards.append(episode_reward)
    
    # Compute advantages using GAE
    advantages = compute_gae(rewards, values, gamma, lam)
    
    # Convert to tensors
    advantages_tensor = torch.tensor(advantages, dtype=torch.float32)
    log_probs_tensor = torch.stack(log_probs)
    
    # Update actor
    actor_loss = -(log_probs_tensor * advantages_tensor).mean()
    actor_optimizer.zero_grad()
    actor_loss.backward()
    actor_optimizer.step()
    actor_losses.append(actor_loss.item())
    
    # Update critic
    # Compute returns (discounted sum of rewards)
    returns = []
    G = 0
    for r in reversed(rewards):
        G = r + gamma * G
        returns.insert(0, G)
    returns_tensor = torch.tensor(returns, dtype=torch.float32)
    
    # Critic predicts values for all states in the trajectory
    states_tensor = torch.cat(states)
    predicted_values = critic(states_tensor).squeeze()
    
    critic_loss = nn.MSELoss()(predicted_values, returns_tensor)
    critic_optimizer.zero_grad()
    critic_loss.backward()
    critic_optimizer.step()
    critic_losses.append(critic_loss.item())
    
    if (episode + 1) % 50 == 0:
        avg_reward = np.mean(episode_rewards[-50:])
        avg_critic_loss = np.mean(critic_losses[-50:])
        avg_actor_loss = np.mean(actor_losses[-50:])
        print(f"Episode {episode + 1}, Avg Reward: {avg_reward:.1f}, Critic Loss: {avg_critic_loss:.3f}, Actor Loss: {avg_actor_loss:.3f}")

env.close()

# ============================================================
# 4. Plot Results
# ============================================================

plt.figure(figsize=(12, 4))

plt.subplot(1, 3, 1)
plt.plot(episode_rewards)
plt.xlabel('Episode')
plt.ylabel('Episode Reward')
plt.title('A2C Training Progress')

plt.subplot(1, 3, 2)
plt.plot(critic_losses)
plt.xlabel('Episode')
plt.ylabel('Critic Loss')
plt.title('Critic Loss (MSE)')

plt.subplot(1, 3, 3)
plt.plot(actor_losses)
plt.xlabel('Episode')
plt.ylabel('Actor Loss')
plt.title('Actor Loss (Policy Gradient)')

plt.tight_layout()
plt.show()

print(f"\nFinal Results:")
print(f"Average reward (last 50 episodes): {np.mean(episode_rewards[-50:]):.1f}")
print(f"Best episode reward: {max(episode_rewards)}")

When you run this, you’ll see something like:

Episode 50, Avg Reward: 28.4, Critic Loss: 0.523, Actor Loss: -0.012
Episode 100, Avg Reward: 52.1, Critic Loss: 0.412, Actor Loss: -0.018
Episode 150, Avg Reward: 98.7, Critic Loss: 0.345, Actor Loss: -0.025
Episode 200, Avg Reward: 178.3, Critic Loss: 0.267, Actor Loss: -0.031
Episode 250, Avg Reward: 289.5, Critic Loss: 0.198, Actor Loss: -0.038
Episode 300, Avg Reward: 412.6, Critic Loss: 0.145, Actor Loss: -0.042
Episode 350, Avg Reward: 478.2, Critic Loss: 0.112, Actor Loss: -0.045
Episode 400, Avg Reward: 498.7, Critic Loss: 0.098, Actor Loss: -0.047
Episode 450, Avg Reward: 500.0, Critic Loss: 0.087, Actor Loss: -0.048
Episode 500, Avg Reward: 500.0, Critic Loss: 0.082, Actor Loss: -0.049

Final Results:
Average reward (last 50 episodes): 500.0
Best episode reward: 500.0

What’s actually going on here? The agent learns to balance the pole for the maximum 500 steps by episode 300. The critic loss drops steadily, meaning the critic is getting better at predicting V(s). The actor loss is negative, which is expected — it means the policy is increasing the probability of good actions.

Compare this to the DQN from Part 5. DQN took about 400 episodes to reach 500 steps. A2C does it in about 300 episodes. That’s a 25% improvement in sample efficiency, thanks to the critic reducing the variance of the policy gradient.

What You’ve Learned and Where to Go Next

Let’s recap what you’ve learned:

  1. Actor-critic combines the best of both worlds: It uses a value function (critic) to reduce the variance of the policy gradient (actor).

  2. The critic provides an advantage estimate: Instead of using the noisy total return, the actor uses A(s,a) = Q(s,a) - V(s) — “how much better was this action than average?”

  3. Generalized Advantage Estimation (GAE) gives you a tunable bias-variance trade-off: λ=0 is pure TD (high bias, low variance). λ=1 is pure Monte Carlo (low bias, high variance). λ=0.95 is a good middle ground.

  4. A2C is simpler and often better than A3C: The synchronous version is easier to implement and debug, and empirically performs just as well.

In Part 8, we’ll see how to make actor-critic even more stable with Proximal Policy Optimization (PPO) — the algorithm behind ChatGPT.

Check Your Understanding

Remember: List the two networks in an actor-critic architecture and describe what each one learns.

Understand: Explain in plain English why adding a critic reduces the variance of the policy gradient.

Apply: Given a trajectory with rewards [0, 1, 0, 0, 1] and values [0.2, 0.3, 0.4, 0.5, 0.6], compute the GAE advantages with γ=0.99 and λ=0.95.

Analyze: Compare A2C and A3C. What are the trade-offs between synchronous and asynchronous updates? When might you choose one over the other?

Evaluate: You’re building an RL agent for a robot arm with continuous joint angles. Would you use A2C or DQN? Justify your answer.

Create: Design a custom advantage estimator for an environment where rewards are sparse (only given at the end of an episode). How would you modify GAE to handle this?

Apply What You Learned is for Supporter and Insider subscribers.

Subscribe to unlock the exercises on this post.

See plans
  • LLMs & GenAI Under review

    Multi Agent Systems When One Llm Isn T Enough

    You've done the work. In Part 1, you built a ReAct agent that could think step-by-step and call tools. In Part 2, you gave it a full toolbox — weather lookups, math calculations, database queries.

  • LLMs & GenAI Under review

    Evaluating LLM Output Beyond "It Looks Right"

    Learn systematic methods for evaluating LLM output across correctness, relevance, and safety using automated metrics, human review, and hybrid approaches.

  • LLMs & GenAI Under review

    Building a Simple LLM Evaluation Harness in Python

    Stop guessing whether your LLM is good. Learn to build a Python evaluation harness with test cases, scorers, and model comparison that turns vibes into data.

  • LLMs & GenAI Under review

    What Is Reinforcement Learning Really Agents Envir

    Let's name the pieces the toddler story introduced. Every RL problem has exactly three components:

Looking for something else?

Search every article by title, summary or topic.