What it actually does
Every value-based method so far has produced a policy as a by-product: learn the values, take the argmax. That works until the action space is continuous or combinatorial, at which point the argmax itself becomes an optimization problem you cannot afford to solve at every step. REINFORCE takes the other route and parameterizes the policy directly, then asks how to improve it. The obstacle is that expected return is an expectation over trajectories the policy itself generates, so changing the parameters changes the distribution being averaged over, and there is no obvious way to differentiate through that. The score-function identity resolves it in one line: the gradient of an expectation over a parameterized distribution equals the expectation of the outcome times the gradient of the log-density. Both sides are expectations you can sample. So the algorithm is embarrassingly simple — run an episode, compute the return, and take a gradient step that makes every action taken more likely in proportion to how good the return was. Good episodes get reinforced, bad ones get suppressed, and nothing needs to be known about the environment. What you pay for that generality is variance. The estimator is unbiased and it is also so noisy that the raw form is essentially unusable on anything real, which is why every practical policy-gradient method is REINFORCE plus a stack of variance reductions. The failure mode is distinctive too: with no exploration mechanism outside the policy itself, a policy that becomes deterministic early has no way back, because the gradient for an action it never takes is never sampled.
- The policy is differentiable in its parameters and assigns non-zero probability to every action it might need to take — a zero probability is an action whose gradient is never sampled
- Episodes terminate, because the Monte Carlo return does not exist until the episode ends; a continuing task must be truncated, which introduces bias
- Trajectories come from the current policy, which is what makes the estimator valid and what forbids replay
- Enough episodes can be collected per update to average the estimator down to something usable, which in practice means a simulator
- Rewards are on a bounded or normalized scale, since reward magnitude directly sets gradient magnitude here with nothing in between
Loss function
The identity underneath is worth stating plainly, because it recurs far outside reinforcement learning: to differentiate an expectation with respect to the parameters of the distribution being averaged over, multiply the outcome by the gradient of the log-density. Nothing about the environment has to be differentiable, or even known — only the policy does. That is what makes the method work on discrete actions, on sampled text, and on any pipeline with a non-differentiable step in the middle. Three readings of the formula pay off. First, it is weighted maximum likelihood: the update makes the actions that were taken more likely, scaled by how well things went, so a policy gradient step is a supervised imitation step on your own behaviour weighted by outcome. Second, the surrogate that gets differentiated in code — the sum of log-probabilities times returns — has a value that means nothing at all. It is constructed so that its gradient is right, not so that its value is; watching it go down is watching noise, and this is one of the most common misreadings in applied RL. Third, the baseline subtraction is free in expectation. Any function of state, including a constant, leaves the mean of the estimator unchanged because the expected score is zero, so it can be chosen purely to reduce variance. That is the opening every practical improvement walks through: subtract a learned value function and you have actor-critic, constrain how far the policy moves per step and you have TRPO and PPO.
- the score — the direction in parameter space that makes the action actually taken more likely, which is all this method ever computes
- the return from time t onward, not from the episode start: an action cannot have caused rewards that preceded it
- a baseline, any function of state and not of action; it changes the variance of the estimator and provably not its mean
- trajectories drawn from the current policy, which is why every gradient step needs fresh data and none of it can be reused
How it is minimized
Stochastic gradient ascent on the score-function estimate of the policy gradient, computed from complete Monte Carlo episodes
- the advantage estimate: how much better this action turned out than the baseline expected, which is the signed weight on the score
- the discount on the step index itself, which the theory requires and almost every implementation quietly omits
- the step size, and the most dangerous hyperparameter here — a policy that moves too far in one step collects worse data and cannot recover from it
- the episode end; the return is not available until it arrives, which is why this cannot be applied to a continuing task without truncation
The raw estimator is unbiased and unusable, so the engineering is entirely in variance reduction, and each step of it is worth knowing separately. Reward-to-go comes first: weighting an action by the whole episode return credits it with rewards that preceded it, which is pure noise, and restricting to the return from that step onward removes it without bias. A baseline comes second, and it is the single largest win — subtracting any function of state leaves the mean untouched because the expected score is zero, so a good baseline can cut variance by an order of magnitude for free. The optimal one is the state value function, which is exactly what actor-critic introduces, and the crude version of running-mean normalization of returns within a batch is what most implementations actually use. Batching multiple episodes per update comes third, and it is not optional: a single-episode gradient is so noisy that the policy performs a random walk. An entropy bonus comes fourth, and it addresses the one failure the other three do not — because the policy is the only source of exploration, a distribution that sharpens early stops sampling the alternatives entirely, and the gradient for an action that is never taken is never computed. Adding a small entropy term to the objective keeps the distribution from collapsing before it has evidence. What none of this fixes is sample efficiency. The estimator is valid only for data from the current policy, so every gradient step discards its data, and that is the structural reason PPO exists: it is the same estimator with a trust region that makes several steps per batch safe.
batch size (episodes per update)
The primary variance control. A single-episode gradient makes the policy random-walk; the estimator only becomes usable once many trajectories are averaged (10 to 100 episodes, or 2,000 to 10,000 steps)
learning rate
The most dangerous knob here, because a policy that steps too far collects worse data and the damage compounds through the distribution it then samples from (1e-4 to 3e-4 with Adam)
baseline choice
Constant, batch-mean, or a learned value function. Free in expectation and the largest single variance reduction available, which is why the learned version became actor-critic (batch-normalized returns, or a learned V)
entropy coefficient
The only thing preventing premature determinism, since the policy is the sole source of exploration and a collapsed distribution stops sampling alternatives permanently (0.0 to 0.01, higher for discrete actions)
discount (gamma)
Sets the horizon and also acts as a variance control — a shorter horizon truncates the noisy tail of the return at the cost of ignoring genuinely delayed reward (0.99, lower when returns are very noisy)
advantage normalization
Standardizing advantages within a batch. Technically introduces bias since the statistics are sample-dependent, and is almost universal anyway because the stabilization is worth more (on, per batch)
gradient clipping
Bounds the damage from an outlier episode whose return is far outside the batch distribution, which is the policy-gradient analogue of Huber loss (global norm 0.5 to 10)
The estimator is unbiased, so under standard stochastic-approximation conditions and a step size satisfying Robbins-Monro, REINFORCE converges to a local optimum of expected return. Read that carefully: local, and of a non-concave objective in a distribution the optimizer is itself moving. In practice the guarantee delivers much less than it sounds like. The dominant obstacle is variance: the estimator scales badly with horizon and with the dimension of the action space, and a naive implementation on a long-horizon task produces gradients whose signal-to-noise ratio is close to zero, so training looks like a random walk with a slight drift. The characteristic failure mode, and the one to recognize on sight, is entropy collapse. There is no exploration mechanism outside the policy, so if the distribution sharpens onto one action before there is evidence for it — which a few lucky episodes are enough to cause — the alternatives stop being sampled, their scores stop being computed, and the policy is stuck at a deterministic solution it can never leave. This appears as a return curve that rises quickly, plateaus well below what is achievable, and never moves again, and it is why an entropy bonus is standard rather than optional. A second failure worth naming is that a large step collects worse data, which produces a worse gradient, which motivates another bad step; unlike supervised learning, a bad update here corrupts the future training distribution, and that feedback is precisely what trust-region methods were invented to bound. And as with every method in this category, seed variance is large enough that single-seed comparisons are uninformative.
Per episode: one forward pass per step to sample the action, then one backward pass over the stored log-probabilities at episode end. The return computation is a single reversed pass, O(T). Compute per environment step is roughly one network forward, which makes this cheaper per step than DQN, whose extra target-network pass and minibatch replay dominate. Memory is small and bounded by the batch rather than by a replay buffer — there is no buffer, because on-policy data cannot be reused. The real cost is sample complexity: with no reuse, every gradient step consumes a full batch of fresh trajectories, so total environment interaction is typically several times what an off-policy method of comparable quality needs. That trade — cheaper per step, far more steps — is the defining cost profile of on-policy learning.
How, where, and why it is used
Time-Series Forecasting
Not applicableThere is no policy to parameterize and no distribution over actions whose log-density could be differentiated, since a forecaster does not act and does not influence what it observes next; the moment a forecast drives a decision the problem is a control problem instead.
Anomaly Detection
Not applicableDetection scores observations without taking actions, so the score-function estimator has no action distribution to attach to — and a policy gradient carries no notion of how unusual an observation was, only of how well the episode containing it turned out.
Optimization
Primary fitOptimize a parameterized policy directly by sampling trajectories and weighting the gradient of each action's log-probability by how well the episode went. Nothing about the environment or the objective needs to be differentiable — only the policy.
- Objectives with a non-differentiable component, where backpropagation stops and the score-function estimator does not
- Continuous or structured action spaces, where a value-based argmax is itself an intractable optimization
- Variance reduction as a first-class design problem: reward-to-go, baselines, batching and entropy each removing a nameable source of noise
- The foundation every modern policy-gradient method builds on — actor-critic replaces the baseline with a learned critic, PPO adds a trust region
The conceptual pivot of this whole category, and the identity at its core is the most transferable thing in it: an expectation over a distribution you control can be differentiated by weighting outcomes by the gradient of the log-density, with no requirement that anything downstream be differentiable. That single fact is why policy gradients reach places backpropagation cannot — discrete sampling, black-box simulators, human preference scores, non-differentiable evaluation metrics. Three further lessons transfer. The baseline result is the clean one: subtracting any function of state changes variance and provably not the mean, which is a general template for building estimators that are cheaper without being wrong. The surrogate-value warning is the practical one: the quantity differentiated in code is constructed so its gradient is correct, and its value is meaningless — a reported policy loss that goes down tells you nothing, and misreading it is endemic. And the exploration lesson is the structural one: when the policy is the only source of stochasticity, losing entropy is losing the ability to learn at all, permanently, which has no analogue in a value-based method with an epsilon outside the policy. Where REINFORCE is the wrong choice is easy to state — if the actions are discrete and small and a simulator is available, a value-based method will get there with far fewer samples, and if the actions are continuous, PPO or SAC dominate it on every axis except simplicity.
- Use reward-to-go rather than the full episode return; crediting an action with rewards that preceded it adds variance and no signal
- Subtract a baseline, even a batch mean — it is free in expectation and it is the largest single variance reduction available
- Batch many episodes per update, because a single-episode gradient is noise with a slight drift
- Keep an entropy term, since the policy is the only exploration mechanism and a collapsed distribution never recovers
Median return over at least five seeds with the interquartile range shown; policy-gradient runs vary enough across seeds that a single-run comparison measures the seed. Track policy entropy on the same plot as return — a return curve that plateaus while entropy has already collapsed is diagnosing itself. And never report the surrogate objective as a training loss: its value carries no information, only its gradient does.
- Reading the surrogate objective as a loss, when it was constructed only so its gradient would be correct
- Weighting actions by the full episode return, which credits them for rewards that happened first
- Updating from one episode at a time, which produces a policy that random-walks
- Letting entropy collapse before there is evidence, after which the unexplored actions are never sampled or updated again
Natural Language
ViableTreat generation as a sequence of sampled token decisions and optimize a sequence-level score — a metric, a reward model, a human preference — that no differentiable loss can express. The sampling step blocks backpropagation; the score-function estimator does not need it.
- Optimizing non-differentiable sequence-level metrics directly rather than the token-level cross-entropy that stands in for them
- Self-critical sequence training, where the baseline is the model's own greedy decode — a baseline that costs one extra forward pass and needs no learned critic
- Preference optimization from a learned reward model, which is REINFORCE-style estimation with a KL penalty to the reference policy
- Any generation pipeline with a sampling or search step in the middle that gradients cannot pass through
The place where the score-function estimator earns its keep outside control, and the argument is about what cross-entropy cannot express. Token-level maximum likelihood trains a model to predict the next token given a ground-truth prefix and then deploys it on prefixes it generated itself, and it cannot score a sequence as a whole at all — so any objective defined over the finished output is unreachable by backpropagation. A policy gradient reaches it, because it only needs to sample and to score. Two practical notes decide whether it works. The baseline matters more here than anywhere else: sequence-level rewards have enormous variance, and the self-critical trick of using the model's own greedy decode as the baseline is both cheap and unusually effective, since it directly asks whether sampling beat being deterministic. And the reward is almost always a proxy — a metric, or a learned preference model — which means the optimizer will find its defects, so a KL penalty toward the original model is standard and is a constraint rather than a regularizer. Where this goes wrong is well documented: unconstrained optimization against a learned reward produces fluent text that scores well and reads badly, which is reward hacking doing exactly what the objective asked.
- Start from a supervised-pretrained model; policy gradients refine a competent policy and cannot discover language from scratch
- Use the model's own greedy decode as the baseline when no critic is available — cheap, and it directly measures whether sampling beat determinism
- Keep a KL penalty to the reference policy, since an unconstrained optimizer will find the reward model's defects rather than the behaviour it was meant to encode
- Normalize rewards within a batch, because sequence-level scores are unbounded and their scale sets the gradient scale
The target metric on held-out prompts, alongside a measure of how far the policy has moved from the reference — a large reward gain with a large KL divergence is usually reward hacking rather than improvement. Human or held-out judgement decides in the end, because the proxy being optimized is exactly the thing that stops being trustworthy once it is optimized against.
- Optimizing a learned reward without a KL constraint, which produces text that scores well and reads badly
- Training from scratch rather than refining a supervised model, which policy gradients are far too sample-inefficient to do
- Unnormalized sequence rewards, whose scale silently becomes the learning rate
- Reporting the proxy metric as the result when the proxy was the thing being gamed
Recommendation & Ranking
ViableParameterize a policy over a narrowed candidate set and optimize long-horizon session value directly, with logged impressions reweighted by importance ratios so that data generated by the previous serving policy can still be used.
- Long-horizon objectives — session value, retention — that a per-impression supervised loss structurally cannot represent
- Off-policy correction on logged traffic, which is what makes an on-policy estimator usable on data a previous policy generated
- Top-K slate recommendation, where the correction has to account for presenting several items rather than one
- Surfaces already running deliberate exploration, which supplies the action-probability logging every correction needs
One of the few settings where a policy-gradient method has a well-documented production track record, and the reason is structural: the action is a choice among candidates, the objective is long-horizon, and a policy that outputs a distribution over candidates is exactly the object a ranking system already serves. The obstacle is that the estimator is on-policy and the data is historical, which is resolved with importance weighting — reweighting each logged action by the ratio of the new policy's probability to the logged one. That correction is unbiased and has variance that grows sharply as the two policies diverge, so ratios are clipped in practice, which trades the bias back in. Two requirements decide feasibility before anything else. The logged action probabilities must have been recorded at serving time, because they cannot be reconstructed afterwards and every correction needs them. And the candidate set has to come from a retrieval stage, since a policy over a full catalogue is not something a softmax can represent. Where it works, the gain is the one supervised ranking cannot reach: optimizing what a session is worth rather than what the next click is worth.
- Log the serving policy's action probabilities at request time; no off-policy correction is possible without them and they cannot be recovered later
- Narrow to a candidate set with retrieval first, since a policy distribution over a catalogue is not representable
- Clip importance ratios, accepting the bias — unclipped ratios have variance that explodes exactly when the new policy is most different
- Define the horizon explicitly, because the discount is choosing what the system optimizes rather than tuning it
Online experiments decide. Off-policy estimates are for triage, and their variance grows with the divergence between the candidate and logged policies, which is precisely the regime where a decision is needed. Report the long-horizon objective the system was built for, since improving click-through was never the reason to take on this machinery.
- Propensities never logged, which makes every correction unavailable after the fact
- Unclipped importance ratios, whose variance makes the estimate useless where it matters most
- Treating an off-policy estimate as a decision rather than as triage
- A policy that collapses onto a few popular items, which is entropy collapse wearing a business-metric disguise
Control & Operations
AdaptedLearn a stochastic control policy over a continuous action space directly from simulated interaction, using sampled trajectories and returns rather than any value function or model of the dynamics.
- Continuous actions where a value-based argmax has nothing tractable to maximize over
- Black-box or non-differentiable simulators, which the score-function estimator does not need to differentiate through
- A conceptual baseline against which actor-critic and PPO can be measured, rather than a method to deploy
- Small problems where the simplicity of the implementation genuinely outweighs the sample cost
Marked adapted rather than primary because the plain method is almost never the right deployment choice here, and saying so is more useful than claiming otherwise. The structural fit is real — continuous actions are exactly where value-based methods run out, and operations problems are full of them. But the variance of the Monte Carlo estimator scales badly with horizon, and operations problems have long horizons, so the sample requirement becomes impractical against a simulator that is itself expensive to run. Every property that makes REINFORCE attractive here is retained by PPO, which adds a learned critic and a trust region and dominates it on sample efficiency by a wide margin. The honest recommendation is to understand REINFORCE as the thing PPO is made of, and to deploy PPO. Two cautions apply to the whole family regardless of which member is chosen: a stochastic policy takes random-ish actions by construction, which is a liability in a physical system and means the deployed policy should be the distribution's mode rather than a sample; and a reward penalty is not a constraint, so anything genuinely unsafe needs an interlock the policy cannot negotiate with.
- Bound and normalize continuous actions, since an unbounded Gaussian policy will eventually sample something the actuator cannot do
- Normalize observations and returns; both set gradient scale, and neither is stable across operating regimes without it
- Deploy the mode rather than a sample from the policy, because a stochastic controller is a liability in a physical system
- Reach for PPO instead unless the simplicity of this implementation is genuinely the deciding factor
Simulated return against the incumbent controller on matched scenarios, with constraint violations counted separately rather than averaged into the return. Evaluate the deterministic policy that will actually be deployed, not the stochastic one that was trained, since they are different controllers and only one of them ships.
- Long horizons, where the Monte Carlo return's variance makes the gradient nearly pure noise
- Deploying the stochastic policy, so a production controller samples its actions
- Expressing a hard constraint as a reward penalty, which an optimizer treats as a price rather than a limit
- Choosing this over PPO for anything beyond a study exercise, when PPO retains every advantage and removes the main cost
What it costs to run
Cheap per environment step — one forward pass to sample an action and one backward pass per batch — and expensive in total, because nothing is reused. Every gradient step consumes a fresh batch of complete episodes, so interaction requirements typically run several times those of an off-policy method reaching comparable quality. Memory is small and bounded by the batch rather than by any replay buffer, which does make it the easiest method here to run on modest hardware. Budget for at least five seeds, because the variance across them frequently exceeds the effect being measured. Illustrative, not a measured benchmark.
One forward pass producing a distribution, then either a sample or its mode. Serving is trivial, and the decision that matters is which of the two is deployed: the trained object is stochastic, the shipped controller usually should not be, and a deterministic deployment of a policy trained stochastically is a different controller whose performance must be measured separately rather than assumed.
Retrained in batches rather than updated continuously, since the estimator is only valid for data from the current policy and continuous online updating against live traffic means a live system taking sampled actions. The usual arrangement is periodic offline training against a simulator or corrected logs, followed by offline evaluation and a staged rollout. Cadence follows how fast the environment moves, and in practice the simulator goes stale before the policy does.
- Policy entropy, which is the leading indicator of the characteristic failure — a collapsed distribution has stopped exploring permanently and the return curve will flatten shortly after
- Realized return under the deployed policy against the incumbent on matched scenarios, not in aggregate
- KL divergence between successive policy versions, since a large step collects worse data and the damage compounds through the distribution it then samples from
- Gradient norm and its variance, which is the direct read on whether the estimator still carries signal at the current batch size
- Action distribution over time: mass concentrating on a few actions is entropy collapse showing up in production
- Where a reward model stands in for the true objective, the gap between proxy score and the outcome it proxies for — that gap is what reward hacking looks like
- The surrogate objective's value is meaningless. It is constructed so its gradient is correct, and reporting it as a training loss — or tuning against it — is one of the most common errors in applied policy gradients
- Entropy collapse is permanent. Once the policy stops sampling an action its score is never computed again, and no amount of further training recovers it, because there is no exploration mechanism outside the policy
- Weighting an action by the full episode return instead of the reward-to-go credits it with rewards that preceded it, which is pure added variance and looks like a harmless simplification
- The theoretically required gamma^t factor on the step index is omitted in nearly every implementation, which makes the standard version a biased estimator of a slightly different objective — worth knowing before debugging a discrepancy against the derivation
- Single-episode updates produce a random walk. The estimator is only usable once many trajectories are averaged, and batch size is a correctness-adjacent parameter here rather than a throughput one
- A stochastic policy deployed as-is samples its actions in production. Ship the mode, and measure the deterministic policy separately, because it is not the one that was trained
- On-policy means no replay buffer and no learning from historical logs without importance correction, and the correction has variance that grows precisely as the policies diverge
- Seed variance routinely exceeds the difference between method variants, so a comparison run on one seed each is measuring the seed
Pros and cons, in context
Nothing downstream of the policy needs to be differentiable
The score-function estimator requires only the ability to sample and to score, which is why it reaches non-differentiable metrics, discrete sampling, black-box simulators and human preference signals that backpropagation cannot touch
Handles continuous and structured action spaces natively
There is no argmax over actions anywhere, which is the exact wall every value-based method hits. A Gaussian policy over a continuous action is as easy as a softmax over a discrete one
Unbiased, with a clean theoretical account of what can be changed for free
The baseline result — any function of state changes variance and not the mean — is unusually clean, and it is the doorway every practical improvement walks through, actor-critic and PPO included
Optimizes the policy that will be used, including its stochasticity
No detour through a value function whose argmax may be a policy the agent never actually follows. What is improved is precisely what is deployed, modulo the decision to ship the mode
Simple enough to implement correctly in an afternoon
Sample, compute returns, weight log-probabilities, step. That simplicity makes it the right thing to build first when debugging a new environment, because almost nothing can go wrong in the algorithm itself
Variance high enough that the raw estimator is unusable
It scales badly with horizon and action dimension, and on a long-horizon task the gradient is close to pure noise. Every practical policy-gradient method is this estimator plus a stack of variance reductions, none of which is optional
On-policy, so every gradient step throws away its data
No replay buffer, no reuse, no learning from logs without importance correction. Total interaction is typically several times what an off-policy method needs for the same result, which is the structural reason PPO exists
Entropy collapse is unrecoverable
The policy is the only source of exploration, so a distribution that sharpens before the evidence supports it stops sampling the alternatives and never computes their gradients again. A value-based method with an epsilon outside the policy has no equivalent failure
Monte Carlo returns require episodes to end
A continuing task has to be truncated, and truncation biases the return. High-variance returns from long episodes are also exactly where the estimator is weakest, so the constraint and the weakness compound
A bad step corrupts the data that trains the next one
Unlike supervised learning, the optimizer controls its own training distribution, so an overlarge step produces worse trajectories and therefore a worse gradient. Bounding that feedback is the entire content of trust-region methods
Dominated in practice by its own descendants
Actor-critic replaces the baseline with a learned critic and PPO adds a trust region with multiple epochs per batch; both retain every advantage listed above and remove the main cost. REINFORCE is what to understand, rarely what to deploy
Intuitive, idiomatic, optimized
The same algorithm in three languages, each progressing through Kent Beck's three stages. Every optimization names the lever it pulls and the trade-off it accepts — cost profiles are illustrative, not measured benchmarks.
The math, transcribed literally. No dependencies.
"""REINFORCE - the policy gradient, transcribed.
A linear softmax policy, chosen because its score has a closed form you can
read straight off the code:
d/dtheta_b log pi(a | s) = (1[b == a] - pi(b | s)) * s
No autodiff, so the identity at the centre of the method is visible rather
than delegated.
"""
import math
import random
def softmax(scores):
# Subtract the max before exponentiating. Without it a large score
# overflows, and the overflow is silent right up until it is a nan.
largest = max(scores)
exponentials = [math.exp(score - largest) for score in scores]
total = sum(exponentials)
return [value / total for value in exponentials]
def policy_probabilities(theta, state):
"""theta[a][i] is the weight on feature i for action a."""
scores = []
for action_weights in theta:
score = 0.0
for weight, feature in zip(action_weights, state):
score += weight * feature
scores.append(score)
return softmax(scores)
def sample_action(probabilities, uniform_draw):
cumulative = 0.0
for action, probability in enumerate(probabilities):
cumulative += probability
if uniform_draw < cumulative:
return action
return len(probabilities) - 1
def run_episode(env, theta, max_steps=1_000):
"""The return is not available until the episode ends, which is why this
method cannot be applied to a continuing task without truncating it."""
states, actions, rewards = [], [], []
state = env.reset()
for _ in range(max_steps):
probabilities = policy_probabilities(theta, state)
action = sample_action(probabilities, random.random())
next_state, reward, done = env.step(action)
states.append(state)
actions.append(action)
rewards.append(reward)
state = next_state
if done:
break
return states, actions, rewards
def returns_to_go(rewards, gamma):
"""G_t = r_t + gamma * G_(t+1), computed backwards in a single pass.
Reward-to-GO, not the whole episode return. An action cannot have caused a
reward that arrived before it, so including earlier rewards adds variance
and contributes no signal - the first variance reduction, and free.
"""
out = [0.0] * len(rewards)
running = 0.0
for step in range(len(rewards) - 1, -1, -1):
running = rewards[step] + gamma * running
out[step] = running
return out
def train(env, n_features, n_actions, episodes=2_000, batch_episodes=10,
alpha=0.01, gamma=0.99):
theta = [[0.0] * n_features for _ in range(n_actions)]
for _ in range(episodes // batch_episodes):
# Gradients accumulate over a BATCH of episodes. A single-episode
# gradient is noisy enough that the policy random-walks, so batching
# here is what makes the estimator usable rather than a throughput
# optimization.
gradient = [[0.0] * n_features for _ in range(n_actions)]
trajectories = []
every_return = []
for _ in range(batch_episodes):
states, actions, rewards = run_episode(env, theta)
discounted = returns_to_go(rewards, gamma)
trajectories.append((states, actions, discounted))
every_return.extend(discounted)
# The baseline. Subtracting ANY function of state - a constant
# included - leaves the gradient's mean untouched, because the
# expected score is zero. It is free, and it is the single largest
# variance reduction available.
baseline = sum(every_return) / len(every_return)
for states, actions, discounted in trajectories:
for state, action, total_return in zip(states, actions, discounted):
advantage = total_return - baseline
probabilities = policy_probabilities(theta, state)
# The score, for every action's weights at once: taking action
# a pushes a's weights up and every other action's down, in
# proportion to how likely they were.
for candidate in range(n_actions):
indicator = 1.0 if candidate == action else 0.0
coefficient = advantage * (indicator - probabilities[candidate])
for index, feature in enumerate(state):
gradient[candidate][index] += coefficient * feature
# ASCENT, not descent: expected return is being maximized, and the sign
# error here is the easiest one in the method to make.
for candidate in range(n_actions):
for index in range(n_features):
theta[candidate][index] += alpha * gradient[candidate][index] / batch_episodes
return thetaO(|A| x d) per step for the score, in the interpreter. No library anywhere, including for the softmax.
How this language actually wants it written.
"""REINFORCE - typed, batched, entropy-regularized, advantages normalized."""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import NamedTuple, Protocol
import torch
from torch import Tensor, nn
from torch.distributions import Categorical
class Trajectory(NamedTuple):
states: Tensor # (T, features)
actions: Tensor # (T,)
rewards: Tensor # (T,)
class Environment(Protocol):
def reset(self) -> Tensor: ...
def step(self, action: int) -> tuple[Tensor, float, bool]: ...
@dataclass(frozen=True)
class ReinforceConfig:
n_features: int
n_actions: int
hidden: tuple[int, ...] = (64, 64)
learning_rate: float = 3e-4
gamma: float = 0.99
episodes_per_batch: int = 16
# The only thing standing between this policy and premature determinism.
# The policy is the sole source of exploration, so a distribution that
# sharpens before the evidence supports it stops sampling the alternatives
# and never computes their gradients again.
entropy_coefficient: float = 0.01
grad_clip_norm: float = 0.5
normalize_advantages: bool = True
def __post_init__(self) -> None:
if not 0.0 <= self.gamma < 1.0:
raise ValueError(f"gamma must be in [0, 1), got {self.gamma}")
if self.episodes_per_batch < 2:
raise ValueError("a single-episode gradient random-walks; batch at least two")
if self.entropy_coefficient < 0.0:
raise ValueError("a negative entropy coefficient rewards collapse")
@dataclass
class BatchDiagnostics:
mean_return: float
# Entropy is the leading indicator of the characteristic failure. A return
# curve that plateaus is diagnosed by the entropy curve that collapsed a
# few hundred updates earlier.
policy_entropy: float
grad_norm: float
history: list[float] = field(default_factory=list)
def build_policy(config: ReinforceConfig) -> nn.Module:
layers: list[nn.Module] = []
width = config.n_features
for size in config.hidden:
layers += [nn.Linear(width, size), nn.Tanh()]
width = size
layers.append(nn.Linear(width, config.n_actions))
return nn.Sequential(*layers)
def discounted_returns_to_go(rewards: Tensor, gamma: float) -> Tensor:
"""Reward-to-go: an action is credited only with what followed it."""
out = torch.zeros_like(rewards)
running = torch.zeros((), dtype=rewards.dtype)
for step in range(rewards.shape[0] - 1, -1, -1):
running = rewards[step] + gamma * running
out[step] = running
return out
def collect_batch(env: Environment, policy: nn.Module, config: ReinforceConfig) -> list[Trajectory]:
"""Rollouts run under no_grad: the graph is not needed here.
Storing a per-step autograd graph across a whole batch of episodes holds
memory proportional to total steps for no reason - the log-probabilities
are recomputed in one batched pass at update time instead.
"""
trajectories: list[Trajectory] = []
with torch.no_grad():
for _ in range(config.episodes_per_batch):
states, actions, rewards = [], [], []
state = env.reset()
done = False
while not done:
distribution = Categorical(logits=policy(state))
action = distribution.sample()
next_state, reward, done = env.step(int(action))
states.append(state)
actions.append(action)
rewards.append(reward)
state = next_state
trajectories.append(
Trajectory(
states=torch.stack(states),
actions=torch.stack(actions),
rewards=torch.tensor(rewards, dtype=torch.float32),
)
)
return trajectories
def update(
policy: nn.Module,
optimizer: torch.optim.Optimizer,
trajectories: list[Trajectory],
config: ReinforceConfig,
) -> BatchDiagnostics:
states = torch.cat([t.states for t in trajectories])
actions = torch.cat([t.actions for t in trajectories])
advantages = torch.cat(
[discounted_returns_to_go(t.rewards, config.gamma) for t in trajectories]
)
if config.normalize_advantages:
# Standardizing within the batch. Technically this biases the
# estimator, because the statistics depend on the sample - and it is
# near-universal anyway, because the stabilization is worth more than
# the bias costs.
advantages = (advantages - advantages.mean()) / (advantages.std() + 1e-8)
distribution = Categorical(logits=policy(states))
log_probabilities = distribution.log_prob(actions)
# This quantity's VALUE means nothing. It is constructed so that its
# gradient equals the policy gradient, and nothing about its magnitude is
# interpretable. Logging it as a training loss - or tuning against it - is
# the most common misreading in applied policy gradients.
surrogate = -(log_probabilities * advantages).mean()
entropy = distribution.entropy().mean()
objective = surrogate - config.entropy_coefficient * entropy
optimizer.zero_grad(set_to_none=True)
objective.backward()
grad_norm = nn.utils.clip_grad_norm_(policy.parameters(), config.grad_clip_norm)
optimizer.step()
return BatchDiagnostics(
mean_return=float(torch.stack([t.rewards.sum() for t in trajectories]).mean()),
policy_entropy=float(entropy),
grad_norm=float(grad_norm),
)What changed: Four changes, each removing a nameable failure. Episodes are batched before any update, because a single-episode gradient is noise with a drift rather than a descent direction. Advantages are the reward-to-go standardized within the batch, which is the free baseline in its usual crude form. An entropy term is added, because the policy is the only source of exploration and a collapsed distribution never recovers. And rollouts run under no_grad with log-probabilities recomputed in one batched pass at update time, which avoids holding a per-step autograd graph across the whole batch — the surrogate is also named rather than reported, since its value carries no information at all.
One batched forward over all batch steps per update; no autograd graph retained during rollout.
Faster, and the justification for each change.
"""REINFORCE - parallel rollouts, returns computed without a Python loop.
The structural fact that makes this method cheap to accelerate: the episodes
in a batch are INDEPENDENT. Unlike an off-policy method whose updates are
sequentially dependent, on-policy data collection within one batch is
embarrassingly parallel, and collection is where nearly all the wall-clock is.
"""
from __future__ import annotations
import numpy as np
import torch
from numpy.typing import NDArray
from torch import Tensor
def batched_returns_to_go(
rewards: NDArray[np.float32],
episode_starts: NDArray[np.bool_],
gamma: float,
out: NDArray[np.float32],
) -> NDArray[np.float32]:
"""Reward-to-go for a whole flattened batch, one reversed pass.
The recursion G_t = r_t + gamma * G_(t+1) is genuinely sequential in time,
so it cannot be vectorized along the time axis. What it can do is run once
over the concatenated batch instead of once per episode, with the running
total reset at each episode boundary - which turns B separate Python loops
into one, and writes into a buffer that was allocated once.
"""
running = np.float32(0.0)
for position in range(len(rewards) - 1, -1, -1):
# A boundary resets the accumulator, so returns never leak across
# episodes. Getting this wrong credits one episode's actions with the
# next episode's rewards, and the result still trains - just worse.
if episode_starts[position]:
running = np.float32(0.0)
running = rewards[position] + gamma * running
out[position] = running
return out
class RolloutBuffer:
"""Pre-allocated storage for one batch. Sized once, written in place.
Appending to Python lists and stacking at the end allocates on every step
and copies the whole batch at the boundary; a fixed buffer does neither.
"""
def __init__(self, capacity: int, n_envs: int, n_features: int) -> None:
self.states = np.zeros((capacity, n_envs, n_features), dtype=np.float32)
self.actions = np.zeros((capacity, n_envs), dtype=np.int64)
self.rewards = np.zeros((capacity, n_envs), dtype=np.float32)
self.starts = np.zeros((capacity, n_envs), dtype=np.bool_)
self._cursor = 0
def add(
self,
states: NDArray[np.float32],
actions: NDArray[np.int64],
rewards: NDArray[np.float32],
starts: NDArray[np.bool_],
) -> None:
index = self._cursor
self.states[index] = states # in place; nothing allocated per step
self.actions[index] = actions
self.rewards[index] = rewards
self.starts[index] = starts
self._cursor += 1
def flatten(self) -> tuple[Tensor, Tensor, NDArray[np.float32], NDArray[np.bool_]]:
"""Collapse (T, N) into (T*N,) with one reshape and no copy.
Environment-major or time-major does not matter to the estimator: the
gradient is a sum over independent (state, action, advantage) triples,
so the batch can be flattened however keeps memory contiguous.
"""
steps = self._cursor
states = self.states[:steps].reshape(-1, self.states.shape[-1])
return (
torch.from_numpy(states),
torch.from_numpy(self.actions[:steps].reshape(-1)),
self.rewards[:steps].reshape(-1),
self.starts[:steps].reshape(-1),
)
@torch.no_grad()
def act_vectorized(policy: torch.nn.Module, states: Tensor, generator: torch.Generator) -> Tensor:
"""Sample an action for every environment from one forward pass.
Gumbel-max rather than a Categorical object: sampling from a softmax is
argmax(logits + gumbel noise), which needs no normalization, no per-env
distribution object, and no exponentials at all.
"""
logits = policy(states)
uniform = torch.rand(logits.shape, generator=generator, device=logits.device)
gumbel = -torch.log(-torch.log(uniform + 1e-20) + 1e-20)
return (logits + gumbel).argmax(dim=1)
def surrogate_gradient_step(
policy: torch.nn.Module,
optimizer: torch.optim.Optimizer,
states: Tensor,
actions: Tensor,
advantages: Tensor,
entropy_coefficient: float,
) -> None:
"""One forward over the entire flattened batch.
Log-probabilities are recomputed here rather than retained from rollout,
which is both faster and dramatically cheaper in memory: no autograd graph
is held across thousands of environment steps.
"""
logits = policy(states)
log_probabilities = torch.log_softmax(logits, dim=1)
# gather, not a Python loop over samples: one indexed read per row.
chosen = log_probabilities.gather(1, actions.unsqueeze(1)).squeeze(1)
entropy = -(log_probabilities.exp() * log_probabilities).sum(dim=1).mean()
objective = -(chosen * advantages).mean() - entropy_coefficient * entropy
optimizer.zero_grad(set_to_none=True)
objective.backward()
optimizer.step()What changed: Wall-clock here is rollout collection, and the episodes in an on-policy batch are independent — so environments step in lockstep under one forward pass, with Gumbel-max sampling replacing a per-environment distribution object. The return recursion is genuinely sequential in time and cannot be vectorized along it, so it instead runs once over the concatenated batch with a reset at each episode boundary rather than once per episode. Storage is a buffer sized once and written in place, and the surrogate is computed from one forward over the whole flattened batch, which keeps no autograd graph alive during rollout.
Batch work to amortize interpreter overhead
Every environment acts from a single forward pass and the whole batch updates from a single one, so no Python-level loop over environments or samples remains in either path.
Trade-off: Environments must step in lockstep, so an episode that ends early leaves its slot idle until the batch boundary unless auto-reset is handled explicitly.
Eliminate Python-level loops over samples
The per-sample log-probability lookup becomes one gather and the entropy one reduction, replacing a loop that would dominate at batch sizes in the thousands.
Trade-off: The return recursion still needs its reversed pass — it is sequential in time by definition, and pretending otherwise is the one vectorization that cannot be done here.
Pre-allocate output arrays and use in-place operations
The rollout buffer and the returns buffer are sized once and written in place, so a batch of thousands of steps performs no allocation and no end-of-batch stacking copy.
Trade-off: Capacity is fixed at construction, so an episode longer than the buffer must be truncated — which biases the return, quietly.
Fuse adjacent operations so intermediates are never materialized
Gumbel-max sampling skips normalization and any per-environment distribution object, since argmax of logits plus Gumbel noise is already a softmax sample.
Trade-off: The sampled probabilities are never formed, so anything downstream needing them — an importance ratio, a logged propensity — has to recompute them.
One forward per batch step across all environments; no graph retained during rollout. Illustrative, not a measured benchmark.