Actor-Critic — Combining Value Methods with Policy Gradients
REINFORCE taught the policy directly, but at the cost of waiting for whole episodes and tolerating noisy returns. DQN taught a value function, but only for discrete actions. Actor-Critic unifies both ideas: an actor picks actions like REINFORCE, while a critic estimates returns like DQN. This unit walks from the variance problem in REINFORCE all the way to a complete A2C implementation — the algorithmic backbone of PPO that you have been running in gdrl since Unit 2.
← Policy Gradients · Course home
Prerequisites
- Policy Gradients — REINFORCE, baselines, the variance problem
- Q-Learning unit — the critic is a value function; Bellman should feel familiar
- RL Essentials — discount factor, return, MDP loop
- PyTorch comfort (forward pass, optimizer,
loss.backward()) for §5
Time
Reading: ~35 min · Training: ~20 min GPU / ~1 h CPU
Three ways to see your AI
Python console (actor / critic loss per update) · matplotlib (actor_loss, critic_loss, ep_rew_mean) · gym.make("CartPole-v1", render_mode="human") for a live cart-pole window
Concepts before code
The PyTorch listing in Section 5 only makes sense after you understand the advantage function in Section 3. Read top to bottom — do not jump straight to the code.
1 · The problem REINFORCE left us with
REINFORCE works, but it pays a price for being a pure Monte Carlo method:
- It needs complete episodes. The update rule uses the discounted return
G_t = r_t + γ r_{t+1} + γ² r_{t+2} + …. You cannot computeG_tuntil you have seen every reward after stept. Long episodes mean slow learning. - It has high variance.
G_tis the outcome of one rollout. In CartPole an unlucky gust pushes the pole over at step 17 even though the action at step 3 was perfect — REINFORCE still punishes that step 3 action. Across thousands of episodes the noise averages out, but it takes a lot of samples. - It throws away information. DQN learned a value network
Q(s,a)that estimates the return from any state without rolling out an episode. REINFORCE ignores that idea entirely.
What if we asked DQN's trick to help REINFORCE? Use a neural network to estimate the return from a state, so the actor does not have to wait for the episode to end. That estimator is called the critic.
Mental model: REINFORCE is a student who only knows their grade after finals. Actor-Critic is a student who gets an estimated grade after every quiz, courtesy of a TA (the critic) who has been watching all term.
2 · Two networks, one goal
Actor-Critic uses two function approximators that work together:
| Network | Symbol | Job | Analogue |
|---|---|---|---|
| Actor (policy) | π_θ(a \| s) |
Pick an action given a state | REINFORCE's policy |
| Critic (value) | V_φ(s) |
Estimate the expected return from s |
DQN's Q, but over states only |
θare the actor's parameters,φare the critic's. Each network has its own gradient signal, but in practice they share most of the layers — one trunk processing observations, two small heads on top. This saves parameters and helps the actor learn from features the critic discovered (and vice versa).- The actor is trained with a policy gradient, just like REINFORCE — except the noisy
G_tis replaced by a less-noisy signal that uses the critic. - The critic is trained with TD learning, exactly the bootstrapping idea from Q-Learning and DQN: predict the return, then nudge the prediction toward the observed reward plus the next state's prediction.
┌────── shared backbone ──────┐
│ (e.g. 2× Linear+Tanh, 128) │
└──────────────┬──────────────┘
│
┌────────────┴────────────┐
│ │
actor head (logits) critic head (V(s))
→ action → scalar return estimate
3 · The advantage function
REINFORCE's gradient pushes up actions whose return G_t is large. But "large" relative to what? An episode in CartPole might return +200 because the state was easy, not because the action was clever. We want to credit the action, not the state.
That is exactly the advantage:
In plain English:
"How much better is action
athan the average action I would have picked from states?"
- A > 0 — this action is better than average → increase its probability
- A < 0 — this action is worse than average → decrease its probability
- A = 0 — this action is exactly average → leave it alone
We do not have access to the true Q(s, a). But we have a critic that estimates V(s), and we know one real reward and the next state from interacting with the env. Bootstrapping (the same trick DQN used) gives:
Substituting back:
This is the headline result of the whole unit: the critic's TD error is an (unbiased) estimate of the advantage. The same number that the critic uses to correct itself is the signal the actor uses to update its policy. One scalar per step, two networks updated.
Term by term:
| Term | What it is |
|---|---|
r |
The reward you actually received after taking a in s |
γ V(s') |
The critic's discounted prediction of everything that happens after the next state |
V(s) |
The critic's prediction of total return from s before you acted |
δ = r + γV(s') - V(s) |
The "surprise" — better or worse than expected? |
4 · A2C: Advantage Actor-Critic
A2C ("Advantage Actor-Critic", the synchronous cousin of A3C) is the cleanest algorithm built on the idea above. Here is the full loop:
- Collect an
n-step rollout by running the current policy in the env:(s_0, a_0, r_0), (s_1, a_1, r_1), …, (s_n, a_n, r_n) - Compute advantage estimates for every step:
A_t = r_t + γ V(s_{t+1}) - V(s_t)(Or the multi-step generalization in Section 7.) - Actor loss — same shape as REINFORCE, but with
A_tinstead ofG_t:L_actor = - Σ_t A_t · log π_θ(a_t | s_t) - Critic loss — squared TD error, like DQN's regression target:
L_critic = Σ_t (r_t + γ V(s_{t+1}) - V(s_t))² - Entropy bonus — keep the policy from collapsing too early:
L_entropy = - β · H(π_θ) = β · Σ_t π_θ log π_θ - Total loss, summed across the rollout:
L = L_actor + c · L_critic - β · H(π_θ)withc = 0.5(critic weight) andβ = 0.01(entropy coefficient) as common defaults. - Backprop once through the shared network. Adam (or RMSProp) updates both actor and critic in a single step.
Compared to REINFORCE this is wildly more efficient: we update every n steps instead of every episode, and we use a learned baseline (V(s)) instead of the raw return.
5 · Complete A2C PyTorch implementation
Below is a self-contained A2C agent that solves CartPole-v1 in a few hundred updates on CPU. It uses a shared backbone, n-step rollouts, advantage normalization, an entropy bonus, and gradient clipping — every standard trick you will see again in PPO.
import torch
import torch.nn as nn
import torch.optim as optim
import numpy as np
import gymnasium as gym
class ActorCritic(nn.Module):
def __init__(self, obs_dim, act_dim):
super().__init__()
self.shared = nn.Sequential(
nn.Linear(obs_dim, 128), nn.Tanh(),
nn.Linear(128, 128), nn.Tanh(),
)
self.actor_head = nn.Linear(128, act_dim)
self.critic_head = nn.Linear(128, 1)
def forward(self, x):
h = self.shared(x)
logits = self.actor_head(h)
value = self.critic_head(h).squeeze(-1)
return logits, value
def get_action(self, obs):
logits, value = self(obs)
dist = torch.distributions.Categorical(logits=logits)
action = dist.sample()
return action, dist.log_prob(action), dist.entropy(), value
env = gym.make("CartPole-v1")
model = ActorCritic(obs_dim=4, act_dim=2)
optimizer = optim.Adam(model.parameters(), lr=3e-4)
gamma = 0.99
vf_coef = 0.5
ent_coef = 0.01
n_steps = 128 # steps per update
max_updates = 500
obs, _ = env.reset()
for update in range(max_updates):
# 1. Collect n_steps of experience
obs_list, act_list, rew_list, val_list, logp_list, done_list = [], [], [], [], [], []
for _ in range(n_steps):
obs_t = torch.tensor(obs, dtype=torch.float32).unsqueeze(0)
action, log_prob, entropy, value = model.get_action(obs_t)
next_obs, reward, terminated, truncated, _ = env.step(action.item())
done = terminated or truncated
obs_list.append(obs_t.squeeze(0))
act_list.append(action)
rew_list.append(reward)
val_list.append(value)
logp_list.append(log_prob)
done_list.append(done)
obs = next_obs if not done else env.reset()[0]
# 2. Compute returns and advantages (reverse pass)
returns, advantages = [], []
G = 0.0
for r, v, d in zip(reversed(rew_list), reversed(val_list), reversed(done_list)):
G = r + gamma * G * (1 - d)
adv = G - v.item()
returns.insert(0, G)
advantages.insert(0, adv)
returns = torch.tensor(returns, dtype=torch.float32)
advantages = torch.tensor(advantages, dtype=torch.float32)
advantages = (advantages - advantages.mean()) / (advantages.std() + 1e-8)
obs_t = torch.stack(obs_list)
logp_t = torch.stack(logp_list)
# 3. Recompute logits/values with current params (for entropy + critic loss)
logits, values = model(obs_t)
dist = torch.distributions.Categorical(logits=logits)
entropy = dist.entropy().mean()
actor_loss = -(advantages * logp_t).mean()
critic_loss = (returns - values.squeeze()).pow(2).mean()
loss = actor_loss + vf_coef * critic_loss - ent_coef * entropy
# 4. Single backward pass through the shared network
optimizer.zero_grad()
loss.backward()
torch.nn.utils.clip_grad_norm_(model.parameters(), 0.5)
optimizer.step()
if update % 50 == 0:
print(f"Update {update:4d} | actor_loss={actor_loss.item():+.3f} "
f"critic_loss={critic_loss.item():.3f} entropy={entropy.item():.3f}")
Run it. Entropy will decrease from ~0.69 (the maximum for two equally-likely actions, ln 2) toward something like 0.3 as the policy commits.
Done when
ep_rew_mean climbs past 200 within ~150 updates and keeps rising toward the CartPole-v1 cap of 500. CartPole-v1 counts as solved at an average return ≥ 475 over 100 consecutive episodes — if your curve plateaus far below that while entropy sits near 0, you are looking at entropy collapse (Section 8), not a converged agent.
Compare with REINFORCE
Plug the same network into your REINFORCE script from the previous unit (drop the critic head, use raw G_t instead of A_t). You will see A2C reach the same reward in roughly an order of magnitude fewer environment steps — the same observation that pushed the field from REINFORCE to A2C in the first place.
6 · Why gradient clipping?
The single line torch.nn.utils.clip_grad_norm_(model.parameters(), 0.5) is doing surprisingly heavy lifting.
- The advantage can occasionally be very large (a rare big reward, an unusually wrong critic prediction).
- A large advantage multiplied by a
log πterm creates a huge gradient → Adam takes a huge step → the policy distribution swings hard → on the next rollout most actions become absurd → reward collapses → recovery is slow or impossible. - Clipping the gradient norm to 0.5 caps how far the policy can move per update.
Foreshadowing PPO
Gradient clipping is the blunt version of "do not let the policy move too far per update". PPO's clipped surrogate objective (next unit) is the principled version: instead of capping the gradient after computing it, PPO redefines the loss so that updates that would shift the policy too much get zero gradient automatically.
7 · N-step returns vs 1-step TD
There is a whole spectrum of ways to estimate the return for the critic and the advantage for the actor:
| Estimator | Formula | Bias | Variance |
|---|---|---|---|
| 1-step TD | r_t + γ V(s_{t+1}) |
high (uses critic's biased estimate) | low |
| n-step | r_t + γ r_{t+1} + … + γ^{n-1} r_{t+n-1} + γ^n V(s_{t+n}) |
medium | medium |
| Monte Carlo (REINFORCE) | G_t = r_t + γ r_{t+1} + … (to end of episode) |
none (unbiased) | high |
Larger n uses more real rewards and less of the critic's prediction → less bias, more variance. Smaller n does the opposite. n_steps=128 in the code above is a middle ground that PPO-family algorithms favour.
This is exactly the n_steps parameter you tuned in Unit 4 when calling gdrl. Bigger n_steps means longer rollouts, fewer updates, more environment data per gradient step. PPO's "advantages" are computed by a generalization called GAE (Generalized Advantage Estimation) that smoothly interpolates between 1-step and Monte Carlo via a parameter λ — but the spirit is identical to what you see here.
8 · Entropy bonus for exploration
Look at the loss again:
That last term is the entropy bonus. Subtracting entropy from the loss is the same as adding it as a reward.
H(π_θ) = - Σ_a π(a|s) log π(a|s)measures how spread-out the action distribution is.- For two equally likely actions,
H = ln 2 ≈ 0.693. For a deterministic policy,H = 0. - Without this bonus, A2C frequently collapses: very early in training one action happens to look slightly better, the actor pushes its probability to 1.0, and the agent stops exploring forever.
ent_coef = 0.01is the typical default. If you watch your run and see entropy crash to 0 in the first few updates while reward is still flat, raise it to0.05.
Entropy collapse looks like a stuck reward
A flat reward curve with very low entropy is the classic signature. The policy has committed early and is no longer trying anything new. Increase ent_coef, or lower the learning rate, or both.
This is the same knob as the --ent_coef argument in gdrl from Unit 4. It is not a magic number — it is the weight in the loss you just read.
9 · Shared vs separate networks
Two reasonable architectures, two trade-offs:
- Shared backbone, two heads (the code in Section 5):
- Faster, fewer parameters, the actor benefits from features the critic learns.
- Risk: the critic's huge gradients (squared error can be much larger than the policy gradient) can overwhelm the actor's. The
vf_coef = 0.5weight exists to soften that. - Two separate networks:
- More stable, easier to tune actor and critic learning rates independently.
- Slower, more memory, no feature sharing.
Stable-Baselines3's PPO uses separate policy and value heads on top of a shared feature extractor by default, which is a sensible compromise. The policy_kwargs={"net_arch": [...]} argument lets you switch.
Practical rule of thumb: use shared for low-dimensional observations (CartPole, simple Godot scenes); use separate when you have image inputs or wildly different scales between actor and critic.
10 · A2C vs PPO: the single remaining problem
A2C is a complete, working algorithm. So why does anyone use PPO?
- A2C performs one gradient update per rollout. The data is discarded immediately afterwards.
- With expensive simulators (Godot at scale, robotics, anything with images), every rollout step is precious. We would like to reuse the same rollout for multiple gradient steps.
- But here is the catch: after the first gradient step, the policy has shifted. The actions we took during the rollout are no longer drawn from the current policy — they were drawn from the old policy. The advantage estimates that worked for the first update become biased for the second.
- Naïvely doing multiple epochs over the rollout makes A2C unstable. The policy can diverge far from the data-generating distribution and everything breaks.
PPO's clipped objective is the fix. It introduces a probability ratio r_t(θ) = π_new(a|s) / π_old(a|s) and clips it to a small interval around 1.0, so updates that would push the new policy too far from the old one get zero gradient. That makes it safe to run multiple epochs over a single rollout, which is exactly what n_epochs=10 does in your Unit 4 gdrl command.
11 · Where A2C appears in the course
You have actually been running A2C the whole time, dressed up as PPO:
gdrluses SB3's PPO under the hood. PPO is A2C plus a clipped surrogate objective plus multi-epoch updates plus GAE.- When you set
n_steps=512, you are choosing A2C's rollout length from Section 7. - When you set
batch_size=256, you are choosing the minibatch size that PPO uses to chop up a rollout for multiple gradient steps. - When you set
n_epochs=10, you are deciding how many times to reuse the same rollout — the thing A2C cannot do safely but PPO can. ent_coefis theβfrom Section 8.vf_coefis thecfrom Section 4.clip_rangeis PPO's principled replacement for the gradient clipping in Section 6.
Now when you stare at a PPO config file you can name every line.
12 · Stretch goals
For students who want to dig deeper before moving on to PPO:
- Separate actor and critic networks. Refactor
ActorCriticinto two classes with two optimizers. Compare training curves on CartPole. You will probably see slightly more stable but slower learning. - Try LunarLander-v2. A more challenging env where A2C typically needs ~2M steps to solve. Watch the entropy curve carefully — entropy collapse is much more common here.
- Visualize what the critic learns. Sample a grid of observations, run them through the critic, plot
V(s)as a heatmap (for 2D state spaces) or as a 1D curve (for cart position, pole angle). Compare to the rollout returns at those states. - Replace 1-step TD with GAE-λ. Implement Generalized Advantage Estimation with
λ ∈ {0.9, 0.95, 1.0}and watch how variance and bias trade off in practice. This is the exact code path that ships in SB3's PPO. - Plug the policy back into Godot. Re-export the agent as ONNX and load it in a Godot scene the way Unit 5 did, but using your own A2C training script instead of
gdrl.
13 · The Actor and Critic Inside SB3's PPO (optional on a first read)
First pass? Skim or skip this section.
The core path is Sections 1–11 — the advantage function, the A2C loop, the CartPole implementation, and the bridge to PPO; come back here once you have a trained Godot agent to inspect.
SB3's PPO is an Actor-Critic method — it has exactly the two heads you built in Section 5. The ActorCritic class you wrote maps directly onto model.policy in a trained SB3 model.
Inspecting the actor and critic on a trained Godot agent
from stable_baselines3 import PPO
from godot_rl.wrappers.stable_baselines_wrapper import StableBaselinesGodotEnv
import torch, numpy as np
env = StableBaselinesGodotEnv(env_path="./JumperHard.x86_64", n_parallel=1, speedup=1)
model = PPO.load("logs/sb3/jumper_baseline/best_model", env=env)
obs, _ = env.reset()
obs_tensor = torch.tensor(obs, dtype=torch.float32).unsqueeze(0)
with torch.no_grad():
# Actor: get action distribution
dist = model.policy.get_distribution(obs_tensor)
action_mean = dist.distribution.loc # mean of Gaussian (continuous actions)
action_std = dist.distribution.scale # std (exploration amount)
# Critic: get value estimate
value = model.policy.predict_values(obs_tensor)
print(f"Action mean: {action_mean.numpy()}")
print(f"Action std: {action_std.numpy()}")
print(f"State value: {value.item():.3f}")
env.close()
The action_mean is what the actor recommends; action_std reflects how much uncertainty (exploration) remains — a well-trained agent has lower std. The value is the critic's estimate of expected return from this state.
Mapping unit variables to SB3 internals
Unit variable → SB3 PPO equivalent
──────────────────────────────────────────────────
actor_head → model.policy.action_net
critic_head → model.policy.value_net
shared backbone → model.policy.mlp_extractor
advantage A_t → computed in rollout buffer
ent_coef → model.ent_coef
vf_coef → model.vf_coef
n_steps → model.n_steps (rollout length)
TensorBoard connection
Every loss term from Section 4's combined loss formula has a TensorBoard counterpart:
train/policy_gradient_loss= L_actor from this unit — the actor improving on advantage estimatestrain/value_loss= L_critic from this unit — the critic minimizing squared TD errortrain/entropy_loss= L_entropy — the entropy bonus keeping exploration alive
The explained variance diagnostic
train/explained_variance (shown in SB3's TensorBoard) is the most useful single metric for diagnosing your critic. It measures how well V(s) predicts the actual returns:
- Close to 1.0 — the critic has learned a good value function. The actor is getting accurate advantage estimates, and the training signal is clean.
- Near 0 or negative — the critic is useless. The actor is essentially running REINFORCE with high variance — exactly the problem this unit was designed to solve. If you see this, the critic is undertrained: try a higher
vf_coef, moren_steps, or a lower learning rate.
Watching explained_variance climb from near-zero toward 0.9+ during a Godot training run is seeing the critic learn in real time — the same process you implemented in the CartPole code above, just at scale.
What's next
You now have every conceptual ingredient PPO needs. The next unit takes A2C's loss, swaps A_t · log π_θ(a_t | s_t) for a clipped probability ratio, allows multiple epochs over one rollout, and walks through the full PPO update — the algorithm behind every gdrl command you have run.
Self-check before you move on
Can you answer these in your own words?
- What does the advantage A(s, a) represent, and why is it lower-variance than the raw return G?
- What goes wrong if the critic is much worse than the actor — what does that look like on TensorBoard?
- Why does Actor-Critic update during an episode while REINFORCE has to wait for the end?
- What does entropy bonus prevent, and what happens if it's too large?
- What is the one remaining problem with A2C that PPO is specifically designed to fix?
If you can answer all five — you're ready for the PPO deep dive.
Self-check answers
- The advantage
A(s, a) = Q(s, a) - V(s)measures how much better actionais than the average action from states. It is lower-variance than the raw returnGbecause subtracting the baselineV(s)removes the part of the return that came from the state being easy or hard — only the action's contribution remains. - A weak critic produces noisy advantage estimates, so the actor is effectively running REINFORCE with high variance again. The TensorBoard signature is
train/explained_variancenear 0 or negative — fix it with a highervf_coef, moren_steps, or a lower learning rate. - Because the critic bootstraps: the one-step estimate
A_t ≈ r + γ V(s') - V(s)needs only one real reward and the next state's prediction. REINFORCE'sG_tliterally cannot be computed until every reward after stepthas been observed. - The entropy bonus prevents premature policy collapse — one action's probability getting pushed to 1.0 early so the agent stops exploring forever. Too large, and the policy stays near-uniform (entropy stuck near
ln 2) and never commits, so reward stays low. - A2C can safely perform only one gradient update per rollout — after that update the data is off-policy and the advantage estimates become biased. PPO's clipped probability ratio makes multiple epochs over the same rollout safe, which is what
n_epochs=10does ingdrl.