Module 4. Policy gradients: from REINFORCE to PPO
Try each exercise first. Expand worked answers or project guidance when needed; their open state is shared between RU/EN.
Download course files (.zip) 382.1 KBThe archive preserves the source folder structure, both languages, and supporting files.
What the log-derivative identity actually provides
First select every correct statement about
-
It makes a discrete random sample differentiable.
-
It permits a sample gradient estimate without differentiating .
-
It eliminates Monte Carlo variance.
-
It permits a non-differentiable reward when the sampled outcome's log-probability is differentiable in .
-
It applies without any regularity conditions.
Then let depend directly on . What term must be added, and what does it represent?
Show answer and solution
Statements 2 and 4 are correct.
- 2. The identity moves the derivative onto , so only needs to be evaluated on the sampled outcome.
- 4. A program or human can supply reward. The required differentiable object is the log-probability of the sampled outcome.
Statement 1 is false: sampling remains discrete; the identity constructs a different gradient estimator. Statement 3 is false: Monte Carlo noise remains and may be large. Statement 5 is false: differentiation must be interchangeable with the sum or integral.
With direct dependence, the product rule gives
The score term accounts for the changing distribution; the direct term accounts for the integrand changing at a fixed outcome. Classical REINFORCE assumes environment reward has no direct policy-parameter dependence. A differentiable KL penalty or another parametric regularizer must either retain the direct term or deliberately stop that gradient and thereby define a different computational objective.
When environment dynamics disappear from the derivative
For each scenario, state whether the corresponding part of the environment disappears from the explicit derivative , and explain why.
-
A fixed non-differentiable simulator does not depend on .
-
The same parameter controls both the policy and transition probabilities.
-
A frozen reward model does not depend on but defines the scalar reward.
-
A known differentiable world model is used by a different estimator that backpropagates through transitions.
Also explain why “disappears from the derivative” does not mean “has no effect on learning.”
Show answer and solution
-
Yes. A fixed simulator appears in , but its log-probability has zero derivative with respect to .
-
No. If transitions share parameter , an extra term appears.
-
Yes, from the explicit derivative. A frozen reward model need not be differentiated, but its output multiplies the score and determines the update direction.
-
No in that estimator. If we deliberately backpropagate through a differentiable world model, transition derivatives are part of the pathwise gradient.
The environment remains in the trajectory distribution. It controls which states and rewards are observed, so “the transition derivative is zero” does not mean “transitions do not affect the gradient estimate through the data.”
Causality and two discounting conventions
Explain why rewards before action can be removed from its score multiplier. Then compare
with the weight .
Which derivation produces the outer ? Why is the form without it still correct under a different convention?
Show answer and solution
Conditioned on the history before , past rewards are fixed. Their contribution has the form
Only rewards that the action could influence remain.
If the objective is literally
regrouping the reward terms gives the score weight . A formula containing only commonly uses a discounted occupancy measure or another normalization of the objective. It is not the identical algebraic line under an unchanged convention.
Valid and invalid baselines
Which quantities can be subtracted from without an additional correction while preserving the expected policy gradient? Assume is fixed.
-
The constant .
-
.
-
The mean reward of independent responses to the same prompt, excluding the current response.
-
, which depends on the current action.
-
The same-group mean including the current reward.
Place each choice in one of three categories: “directly valid,” “invalid without correction,” or “known finite-sample scaling rather than literal unbiasedness.”
Show answer and solution
Directly valid: 1, 2, and 3.
- A constant and a state-only function do not depend on the current action.
- The mean of other independent responses is conditionally independent of the current response.
Invalid without correction: 4. depends on the current action, so the zero-mean score proof does not apply.
Known finite-sample scaling: 5. The same-group mean contains the current reward. Under conditional independence it scales the expected gradient by rather than preserving it exactly.
Same-group mean versus leave-one-out
Let conditionally independent responses to one prompt have rewards .
-
Why is not independent of the current response?
-
What is the expected gradient scale when using ?
-
Why does a leave-one-out mean restore the correct expectation?
-
How are and related?
-
What happens when all rewards are equal?
Show answer and solution
-
contains , and depends on the current response .
-
The expected gradient scale is .
-
The mean of the other responses is conditionally independent of , so its product with the score has zero expectation.
-
Algebraically,
- Both advantage constructions return zero. There is no within-group ranking signal.
Bootstrap and the boundary of the GAE recursion
Analyze two cases.
Case I. The agent reaches a time limit without reaching a terminal state. The environment returns a final observation and then resets.
Case II. A fixed-size rollout buffer fills up. The episode continues and the environment does not reset, but the next advantage is absent from the current array.
For each case answer:
-
Should enter the TD residual?
-
Should the backward recursion continue into the next element of the current array?
-
What are
terminatedandtrace_cut? -
Why is one
doneflag insufficient?
Show answer and solution
Case I — external truncation followed by reset.
-
Yes. The mathematical process can continue, so bootstrap from the final observation before reset.
-
No. The next array record after reset belongs to another episode.
-
terminated=False,trace_cut=True.
Case II — a local rollout-buffer boundary.
-
Yes. The same episode continues, so the next value is needed for bootstrap.
-
Not inside this array when the following fragment is not joined to the GAE computation. A streaming implementation could carry recursion state across fragments.
-
terminated=False,trace_cut=Trueon the final local record.
A single done flag conflates whether continuation value exists with whether the next advantage belongs to the current backward recursion. Zeroing bootstrap at every done biases targets near time limits and buffer boundaries: removing positive continuation lowers the target, while removing negative continuation raises it.
Four regions of the clipped PPO objective
For , complete the table. State whether the clipped branch is active and compute .
| Does clipping change the objective? | Pessimistic term | ||
|---|---|---|---|
| 1.4 | 2 | ? | ? |
| 0.6 | 2 | ? | ? |
| 1.4 | -2 | ? | ? |
| 0.6 | -2 | ? | ? |
Then explain how ratio_outside_fraction can equal 1 while active_clip_fraction equals 0.5.
Show answer and solution
With , the interval is .
| Does clipping change the objective? | Pessimistic term | ||
|---|---|---|---|
| 1.4 | 2 | yes | |
| 0.6 | 2 | no | |
| 1.4 | -2 | no | |
| 0.6 | -2 | yes |
All four ratios are outside the interval, so ratio_outside_fraction=1. The clipped branch becomes the pessimistic minimum only in rows one and four, hence active_clip_fraction=2/4=0.5.
RLOO, GRPO, and token-level credit
State:
-
one idea shared by RLOO and GRPO;
-
two substantive differences between their basic formulations;
-
why a whole-response coefficient broadcast to all tokens gives a valid gradient for whole-response reward;
-
why the same operation does not solve token-level credit assignment.
Show answer and solution
-
Both use several responses to one prompt and form a relative within-group signal without requiring a separate critic.
-
Basic RLOO uses a leave-one-out mean and a REINFORCE objective. Canonical GRPO uses PPO-like probability ratios and clipping, usually normalizes the group signal, and adds regularization toward a reference policy.
-
The response log-probability is a sum of token log-probabilities, so a whole-response coefficient multiplying that sum is the score function of the full sequence.
-
The coefficient is identical for every token and does not identify which local choice caused the reward. It optimizes the whole-response objective but does not localize causal credit.
Gradient of a softmax log-probability
For logits
and sampled action , compute
Report at least six decimal places and verify that the components sum to zero.
Show answer and solution
First,
For action 2,
The components sum to zero up to rounding.
Reward-to-go and the outer discount
For rewards
and , compute:
-
the vector ;
-
the vector for .
Explain which objective the second vector corresponds to.
Show answer and solution
Backward recursion gives
Thus
Applying the outer discount:
The second vector is the direct score weight for the literal objective whose rewards are discounted from the start of the episode.
State value and the variance-minimizing scalar baseline
A policy has logits and action values .
-
Compute the action probabilities.
-
Compute .
-
For each action, compute .
-
Compute
Why do and differ?
Show answer and solution
The action probabilities are
The state value is
Score vectors:
Squared norms:
Substitution yields
The two baselines differ because weights action values by squared score norm. The rarer second action has a much larger score vector and therefore receives much more weight than it does in .
Group advantages
For rewards
compute:
-
the group mean;
-
centered advantages ;
-
leave-one-out baselines;
-
RLOO advantages;
-
the proportionality factor between the two advantage vectors;
-
the expected scale of the same-group estimator for group size 4.
Show answer and solution
The group mean is
Centered advantages:
Leave-one-out baselines:
RLOO advantages:
They are times the centered advantages. The expected scale of the same-group estimator is
GAE at a time-limit truncation
Two independent transitions appear consecutively in an array. The first ends an episode because of a time limit; the second belongs to another episode:
Masks:
With and , compute the TD residuals and GAE advantages. Explain why reward 100 does not enter the first transition's advantage.
Show answer and solution
The first transition is truncated but not terminal:
The second is terminal:
Because episode_end[0]=1, the recursion does not carry backward:
Reward 100 belongs to another episode after reset(). It is adjacent only in storage, not in the trajectory.
PPO terms and a sample KL diagnostic
Let
Compute:
-
the unclipped terms ;
-
the clipped terms;
-
the pessimistic terms;
-
the mean PPO objective;
-
the fraction of ratios outside the interval;
-
the fraction of samples for which clipping changes the objective;
-
the sample diagnostic
Show answer and solution
The unclipped terms are
The clipped ratios are , producing
The elementwise pessimistic minimum is
The mean objective is
Every ratio lies outside the interval, so ratio_outside_fraction=1. Clipping changes the minimum in two of four cases, so active_clip_fraction=0.5.
Each contributes , while each contributes . Hence
This is a nonnegative sample diagnostic. Its old-policy expectation equals the reverse KL under the required support conditions, but a finite batch does not make it an exact KL constraint.
Stable softmax and the score function
Implement:
import numpy as np
def stable_softmax(logits: np.ndarray) -> np.ndarray:
...
def grad_log_softmax(logits: np.ndarray, action: int) -> np.ndarray:
...Requirements:
- input is one-dimensional;
[1000, 999]is handled stably;- gradient components sum to zero within absolute tolerance ;
- an invalid action index raises
ValueError.
Open checks:
np.testing.assert_allclose(stable_softmax([1000.0, 999.0]), [0.7310585786, 0.2689414214], rtol=2e-10)Show answer and solution
import numpy as np
def stable_softmax(logits: np.ndarray) -> np.ndarray:
z = np.asarray(logits, dtype=float)
if z.ndim != 1:
raise ValueError("logits must be one-dimensional")
shifted = z - np.max(z)
exp_z = np.exp(shifted)
return exp_z / np.sum(exp_z)
def grad_log_softmax(logits: np.ndarray, action: int) -> np.ndarray:
p = stable_softmax(logits)
if not 0 <= action < len(p):
raise ValueError("action is out of range")
result = -p
result[action] += 1.0
return resultSubtracting the maximum leaves softmax unchanged while preventing exponential overflow.
Reward-to-go and two discounting conventions
Implement:
def reward_to_go(rewards: np.ndarray, gamma: float) -> np.ndarray:
...
def discounted_score_weights(rewards: np.ndarray, gamma: float) -> np.ndarray:
...The first function returns ; the second returns . The algorithm must run in time without nested loops.
Open checks:
np.testing.assert_allclose(reward_to_go([1.0, 2.0, 3.0], 0.5), [2.75, 3.5, 3.0])Show answer and solution
import numpy as np
def reward_to_go(rewards: np.ndarray, gamma: float) -> np.ndarray:
r = np.asarray(rewards, dtype=float)
if r.ndim != 1:
raise ValueError("rewards must be one-dimensional")
if not 0.0 <= gamma <= 1.0:
raise ValueError("gamma must lie in [0, 1]")
result = np.empty_like(r)
running = 0.0
for t in range(len(r) - 1, -1, -1):
running = r[t] + gamma * running
result[t] = running
return result
def discounted_score_weights(rewards: np.ndarray, gamma: float) -> np.ndarray:
g = reward_to_go(rewards, gamma)
return (gamma ** np.arange(len(g), dtype=float)) * gOne backward pass gives time and constant additional storage beyond the result.
Mean, LOO, and within-group normalization
Implement:
def leave_one_out_baselines(rewards: np.ndarray) -> np.ndarray:
...
def group_mean_advantages(rewards: np.ndarray) -> np.ndarray:
...
def group_zscore_advantages(rewards: np.ndarray, eps: float = 1e-8) -> np.ndarray:
...LOO must raise ValueError for a group of size one. Equal rewards must produce zero z-scored advantages rather than NaN.
Open checks:
np.testing.assert_allclose(leave_one_out_baselines([1.0, 3.0, 8.0, 2.0]), [13/3, 11/3, 2.0, 4.0])Show answer and solution
import numpy as np
def leave_one_out_baselines(rewards: np.ndarray) -> np.ndarray:
r = np.asarray(rewards, dtype=float)
if r.ndim != 1 or len(r) < 2:
raise ValueError("at least two rewards are required")
return (np.sum(r) - r) / (len(r) - 1)
def group_mean_advantages(rewards: np.ndarray) -> np.ndarray:
r = np.asarray(rewards, dtype=float)
if r.ndim != 1 or len(r) == 0:
raise ValueError("rewards must be a non-empty vector")
return r - np.mean(r)
def group_zscore_advantages(rewards: np.ndarray, eps: float = 1e-8) -> np.ndarray:
centered = group_mean_advantages(rewards)
scale = float(np.sqrt(np.mean(centered ** 2)))
if scale < eps:
return np.zeros_like(centered)
return centered / scaleThis uses the within-group population variance with denominator , a common convention for normalized advantages.
GAE with separate bootstrap and recursion masks
Implement the backward recursion:
def gae_advantages(
rewards: np.ndarray,
values: np.ndarray,
next_values: np.ndarray,
terminated: np.ndarray,
trace_cuts: np.ndarray,
gamma: float,
gae_lambda: float,
) -> np.ndarray:
...terminated disables bootstrap in the TD residual. trace_cuts prevents recursion into the next element of the current array. The last record of a fixed rollout buffer may have terminated=False, trace_cuts=True.
Open checks:
rewards = np.array([1.0, 2.0])
values = np.array([0.5, 0.7])
next_values = np.array([0.7, 0.0])
term = np.array([False, True])
cuts = np.array([False, True])
result = gae_advantages(rewards, values, next_values, term, cuts, gamma=0.9, gae_lambda=0.8)
delta1 = 2.0 - 0.7
delta0 = 1.0 + 0.9 * 0.7 - 0.5
np.testing.assert_allclose(result, [delta0 + 0.9 * 0.8 * delta1, delta1])
# Nonterminal buffer boundary: keep bootstrap but prevent leakage from row 1.
result = gae_advantages(
np.array([1.0, 100.0]), np.array([2.0, 0.0]), np.array([10.0, 0.0]),
np.array([False, True]), np.array([True, True]), 0.9, 1.0,
)
np.testing.assert_allclose(result, [8.0, 100.0])Show answer and solution
import numpy as np
def gae_advantages(
rewards: np.ndarray,
values: np.ndarray,
next_values: np.ndarray,
terminated: np.ndarray,
trace_cuts: np.ndarray,
gamma: float,
gae_lambda: float,
) -> np.ndarray:
r = np.asarray(rewards, dtype=float)
v = np.asarray(values, dtype=float)
nv = np.asarray(next_values, dtype=float)
term = np.asarray(terminated, dtype=bool)
cuts = np.asarray(trace_cuts, dtype=bool)
if not (r.shape == v.shape == nv.shape == term.shape == cuts.shape) or r.ndim != 1:
raise ValueError("all inputs must be aligned one-dimensional arrays")
if not (0.0 <= gamma <= 1.0 and 0.0 <= gae_lambda <= 1.0):
raise ValueError("gamma and gae_lambda must lie in [0, 1]")
deltas = r + gamma * (~term).astype(float) * nv - v
result = np.empty_like(deltas)
running = 0.0
for t in range(len(r) - 1, -1, -1):
running = deltas[t] + gamma * gae_lambda * (not cuts[t]) * running
result[t] = running
return resultThe two masks answer different questions. At a truncation or local buffer boundary, terminated=False preserves next-state bootstrap, while trace_cuts=True stops the current batch recursion.
Clipped PPO terms and diagnostics
Implement:
def ppo_clipped_terms(ratio, advantage, clip_epsilon=0.2):
...
def ppo_diagnostics(new_log_prob, old_log_prob, advantage, clip_epsilon=0.2):
...The diagnostics must return:
ratio_outside_fraction;active_clip_fraction;approx_kl_nonnegative = mean((ratio - 1) - log_ratio);mean_log_ratio.
Compute ratios from log-probability differences.
Open checks:
ratio = np.array([0.75, 1.00, 1.30, 1.10])
advantage = np.array([1.0, 1.0, 1.0, -1.0])
unclipped, clipped, pessimistic = ppo_clipped_terms(ratio, advantage, 0.2)
assert np.allclose(unclipped, [0.75, 1.0, 1.3, -1.1])
assert np.allclose(clipped, [0.8, 1.0, 1.2, -1.1])
assert np.allclose(pessimistic, [0.75, 1.0, 1.2, -1.1])Show answer and solution
from dataclasses import dataclass
import numpy as np
@dataclass(frozen=True)
class PPODiagnostics:
ratio_outside_fraction: float
active_clip_fraction: float
approx_kl_nonnegative: float
mean_log_ratio: float
def ppo_clipped_terms(ratio, advantage, clip_epsilon=0.2):
r = np.asarray(ratio, dtype=float)
a = np.asarray(advantage, dtype=float)
if r.shape != a.shape or np.any(r <= 0):
raise ValueError("positive aligned ratios are required")
unclipped = r * a
clipped = np.clip(r, 1 - clip_epsilon, 1 + clip_epsilon) * a
pessimistic = np.minimum(unclipped, clipped)
return unclipped, clipped, pessimistic
def ppo_diagnostics(new_log_prob, old_log_prob, advantage, clip_epsilon=0.2):
log_ratio = np.asarray(new_log_prob, dtype=float) - np.asarray(old_log_prob, dtype=float)
ratio = np.exp(log_ratio)
a = np.asarray(advantage, dtype=float)
unclipped, _, pessimistic = ppo_clipped_terms(ratio, a, clip_epsilon)
outside = np.mean((ratio < 1 - clip_epsilon) | (ratio > 1 + clip_epsilon))
active = np.mean(pessimistic < unclipped - 1e-15)
approx_kl = np.mean((ratio - 1) - log_ratio)
return PPODiagnostics(float(outside), float(active), float(approx_kl), float(np.mean(log_ratio)))active_clip_fraction checks whether the pessimistic minimum changed, not merely whether a ratio lies outside the interval.
Response mask, sequence probability, and token advantages
Implement three functions:
def response_token_mask(input_ids, prompt_lengths, pad_token_id, eos_token_id):
...
def sequence_log_probability(token_log_probs, mask=None):
...
def broadcast_sequence_advantages(sequence_advantages, token_mask):
...The first returns a [batch, T-1] mask aligned with labels input_ids[:, 1:]: prompt and PAD tokens are excluded, the first generated EOS is included, and every later position is excluded. The second sums response-token log-probabilities. The third broadcasts one whole-response advantage to unmasked positions.
Open check:
ids = np.array([
[1, 10, 11, 20, 21, 2, 0],
[1, 12, 30, 2, 99, 0, 0],
])
mask = response_token_mask(ids, np.array([3, 2]), pad_token_id=0, eos_token_id=2)
np.testing.assert_array_equal(mask, [
[0, 0, 1, 1, 1, 0],
[0, 1, 1, 0, 0, 0],
])Show answer and solution
import numpy as np
def response_token_mask(input_ids, prompt_lengths, pad_token_id, eos_token_id):
ids = np.asarray(input_ids)
lengths = np.asarray(prompt_lengths, dtype=int)
if ids.ndim != 2 or lengths.shape != (ids.shape[0],):
raise ValueError("input_ids must be [B, T] and prompt_lengths [B]")
mask = np.zeros((ids.shape[0], ids.shape[1] - 1), dtype=float)
for b in range(ids.shape[0]):
active = True
for pos in range(1, ids.shape[1]):
token = int(ids[b, pos])
if pos < lengths[b] or token == pad_token_id or not active:
continue
mask[b, pos - 1] = 1.0
if token == eos_token_id:
active = False
return mask
def sequence_log_probability(token_log_probs, mask=None):
lp = np.asarray(token_log_probs, dtype=float)
if mask is None:
return np.sum(lp, axis=-1)
m = np.asarray(mask, dtype=float)
if m.shape != lp.shape:
raise ValueError("mask must match token_log_probs")
return np.sum(lp * m, axis=-1)
def broadcast_sequence_advantages(sequence_advantages, token_mask):
a = np.asarray(sequence_advantages, dtype=float)
m = np.asarray(token_mask, dtype=float)
if a.ndim != 1 or m.ndim != 2 or m.shape[0] != a.shape[0]:
raise ValueError("advantages must be [B] and token_mask [B, T]")
return a[:, None] * mThe mask is shifted relative to the original tokens because logits at position score the label at position . A generated EOS is a policy action and is included; prompt, PAD, and the post-EOS tail are excluded. Broadcasting one advantage implements a whole-response score-function objective, but it does not become a local explanation of token credit.