Policy-gradient methods are the corner of reinforcement learning where the mathematics fits on one page but most bugs come from skipping a line of it. This post derives REINFORCE from the definition of the objective, shows where the variance comes from, proves that a baseline does not bias the estimate, and then checks each claim on a toy environment written in NumPy. No framework is involved; the whole experiment is a few dozen lines.
The objective and the trick that makes it tractable
An episode is a trajectory
We cannot differentiate through the environment, but we can differentiate the probability of a trajectory. The identity
Now expand the trajectory probability. The dynamics and the start distribution do not depend on
This is the whole reason policy gradients are model-free: the estimator needs the policy's own log-probabilities and nothing about the environment beyond samples.
Causality and the return-to-go
The product
Replacing the expectation by a single sampled episode gives REINFORCE (Williams, 1992). It is unbiased, and that is its main virtue: the variance can be enormous because
Baselines: subtracting a number without changing the expectation
Pick any function
So
A tiny environment and a tabular softmax policy
The environment is a line of 11 cells with the goal in the middle. An episode starts in a random non-goal cell; each step moves one cell left or right and costs
import numpy as np
N_POS, GOAL, MAX_T = 11, 5, 20
def reset(rng):
s = GOAL
while s == GOAL:
s = int(rng.integers(0, N_POS))
return s
def step(s, a): # a = 0: left, a = 1: right
s2 = int(np.clip(s + (1 if a == 1 else -1), 0, N_POS - 1))
done = s2 == GOAL
return s2, (10.0 if done else -1.0), done
def policy(theta, s): # tabular softmax, theta has shape (N_POS, 2)
z = theta[s] - theta[s].max()
p = np.exp(z)
return p / p.sum()
def grad_logp(theta, s, a): # d log pi(a|s) / d theta = e_a - pi(.|s), in row s only
g = np.zeros_like(theta)
g[s] = -policy(theta, s)
g[s, a] += 1.0
return g
For a softmax over a table,
-
def returns_to_go(rewards, gamma=0.99): G, g = np.zeros(len(rewards)), 0.0 for t in reversed(range(len(rewards))): g = rewards[t] + gamma * g G[t] = g return G def update(theta, S, A, Rw, lr=0.02): G = returns_to_go(Rw) grad = np.zeros_like(theta) for t, (s, a) in enumerate(zip(S, A)): grad += G[t] * grad_logp(theta, s, a) theta += lr * grad -
def update(theta, V, S, A, Rw, lr=0.02, lr_v=0.1): G = returns_to_go(Rw) grad = np.zeros_like(theta) for t, (s, a) in enumerate(zip(S, A)): grad += (G[t] - V[s]) * grad_logp(theta, s, a) # advantage-like weight for t, s in enumerate(S): # Monte Carlo value update V[s] += lr_v * (G[t] - V[s]) theta += lr * grad
Both versions were run for 500 episodes with learning rate 0.02 and five random seeds.
| Method | Mean return, last 50 episodes | Std across seeds |
|---|---|---|
| REINFORCE | 7.59 | 0.26 |
| REINFORCE + | 7.49 | 0.14 |
| optimal policy | 8.00 | — |
On a problem this small the two learning curves are hard to tell apart, which is itself a useful lesson: a baseline reduces the variance of the gradient estimate, and whether that shows up in the return curve depends on how much variance the problem had to begin with.
Measuring the variance directly
The cleaner experiment is to freeze the policy (uniform,
| Estimator | Total variance | Relative |
|---|---|---|
| plain REINFORCE ( | 550.0 | 1.00× |
| best constant baseline ( | 344.7 | 0.63× |
| state-value baseline | 336.8 | 0.61× |
The sample means of the two estimators differ by at most 0.19 in any coordinate over the 3000 episodes, consistent with the unbiasedness argument given the measured spread. The parabola is the real content of the figure: the baseline is a free parameter of the estimator, and a bad baseline (far to either side) is strictly worse than none at all.
WARNING
The derivation assumes the episodes were sampled from the current policy. Reusing old episodes after a parameter update makes the estimate biased unless you correct it with importance weights, which is exactly what off-policy and PPO-style methods do. A surprising share of "my REINFORCE does not learn" bugs are stale data.
Why the optimal constant baseline is a weighted mean of returns
For a single term
a mean of the returns weighted by the squared score. When the magnitude of the score is uncorrelated with the return this collapses to
Where this leads
Everything after REINFORCE is variance reduction plus trust: actor–critic replaces the Monte Carlo return by a bootstrapped estimate, generalized advantage estimation interpolates between the two with a
Further reading
- Sutton and Barto, Reinforcement Learning: An Introduction (2nd ed.), chapter 13 on policy-gradient methods: http://incompleteideas.net/book/the-book-2nd.html
- Williams (1992), Simple statistical gradient-following algorithms for connectionist reinforcement learning: https://link.springer.com/article/10.1007/BF00992696
- Schulman et al. (2015), High-dimensional continuous control using generalized advantage estimation: https://arxiv.org/abs/1506.02438
- Schulman et al. (2017), Proximal policy optimization algorithms: https://arxiv.org/abs/1707.06347
- Lilian Weng, Policy gradient algorithms, a survey-style blog post: https://lilianweng.github.io/posts/2018-04-08-policy-gradient/