Continuous Action Spaces in RL
Discrete buttons cannot steer a robot arm, so the policy outputs smooth real-valued controls instead. It predicts a Gaussian per joint, samples with a trick that keeps gradients alive, and squashes into motor limits.
Why Does This Exist?
DQN-style methods pick actions by scoring every option and taking the max:
That breaks the moment actions turn continuous. A 7-joint arm with 10 bins per joint needs scores per step, and the winning command still jumps in coarse steps that chatter real gears. Robots, cars, and valves need smooth vectors like torques and steering angles, not button presses.
Continuous methods output the vector directly. A stochastic policy predicts a distribution and samples it; a deterministic one predicts the vector itself. One forward pass, no search over actions.
This page covers the stochastic Gaussian route with bounded outputs. For the deterministic twin see deterministic policy gradients and DDPG. For the gradient theorem underneath see policy gradient.
You need one setup idea: a policy maps states to actions, and learning adjusts its weights to raise the expected return.
Think of It Like This
A ship wheel versus three gamepad buttons
Steer a yacht through a narrow channel with three buttons: hard left, center, hard right. To hold degrees you slam left, then right, then left, lurching down the channel and straining the rudder cables.
A real wheel turns to any angle and stays there. Small correction, smooth water, quiet cables.
Map it point for point: buttons are a discretized action grid, the wheel is a continuous policy output, and the lurching is the bang-bang chatter that discretization causes on real hardware.
Where it stops: a wheel is one axle, while a humanoid outputs 18 torques at once with coordination across joints, which is why the policy predicts a whole vector with per-joint spreads.
How It Actually Works
The standard stochastic choice is a diagonal Gaussian. The network predicts a mean head and a log-spread head , with spread clamped (often to ) so exploration can neither explode nor die. Predicting log-spread keeps positive for free.
Two gradient paths
Score-function methods (REINFORCE, PPO, TRPO) use the likelihood ratio:
For one Gaussian dimension the mean score is and the log-spread score is . Good actions pull the mean toward them; samples beyond one spread widen exploration, nearer ones tighten it.
Pathwise methods (SAC, DDPG-style critics) use the reparameterization trick instead. Sample noise once, then write the action as arithmetic:
Gradients now flow through into by the chain rule through the critic , with far less variance than the score route.
Squash into limits, then fix the density
Motors have hard bounds but Gaussians run to infinity. Clipping kills gradients outside the range, so modern actors squash smoothly:
Squashing compresses probability near the edges, so the logged density must shrink accordingly. The change-of-variables correction for one dimension is:
Skip that subtraction and entropy bonuses in SAC read the wrong density, which pushes pre-squash values to extremes until gradients saturate.
Worked example in 1D
Mean , spread , bound , latent sample .
Base log density:
So . Scores: mean score ; log-spread score , because the sample sits exactly one spread away.
Squash: . Slope , so . Corrected log density:
That is density . It looks startling next to , but tanh packs a wide latent range into a sliver near , so density there must be tall. The diagram traces this exact sample.
Code
The snippet below replays the worked sample with only the standard library.
import math
def gaussian_logp(u: float, mu: float, sigma: float) -> float: """Log density of a 1D Gaussian at u.""" return -0.5 * math.log(2.0 * math.pi) - math.log(sigma) - ((u - mu) ** 2) / (2.0 * sigma ** 2)
mu, sigma, u, a_max = 2.0, 0.5, 2.5, 1.0logp = gaussian_logp(u, mu, sigma)print(round(logp, 4))# -> -0.7258print(round((u - mu) / sigma ** 2, 4))# -> 2.0print(round(((u - mu) ** 2) / sigma ** 2 - 1.0, 4))# -> 0.0
a = math.tanh(u)slope = 1.0 - a ** 2print(round(a, 4))# -> 0.9866print(round(math.log(a_max * slope), 4))# -> -3.6271print(round(logp - math.log(a_max * slope), 4))# -> 2.9013Watch Out For
Forgetting the tanh Jacobian in entropy code
SAC tempers exploration with an entropy bonus computed from . Plug in the raw Gaussian and the bonus ignores the squash: near the bounds the policy looks more uncertain than it is, training shoves outward, tanh saturates, and gradients vanish.
Symptom: pre-squash magnitudes drift to large values while actions pin at the limits. Fix: always subtract (plus the scale) wherever a squashed log-probability is needed.
Letting the spread collapse before exploring
An unconstrained log-spread head can dive early on noisy gradients. Spread hits zero, the policy turns deterministic before seeing the space, and the arm masters one bad motion.
Symptom: actions repeat almost exactly across episodes while returns plateau low. Fix: clamp log-spread (such as to ), start near zero spread rather than very negative, and watch entropy per joint, not just the mean return.
The Quick Version
- Continuous policies emit real-valued control vectors in one forward pass, dodging the blowup of discretized grids.
- The network predicts per-joint Gaussian means and clamped log-spreads, keeping variance positive and bounded.
- The reparameterization routes critic gradients straight through the sample with low variance.
- Tanh squashing enforces motor bounds, and the log density must subtract the Jacobian term to stay exact.
- In the worked sample, with mean 2.0 and sigma 0.5 squashes to with corrected log density .