Stochastic Gradient Descent in RL
In RL, stochastic gradient descent chases a target that moves with the very weights being updated, so practical methods use semi-gradients that pretend the target stands still for one step.
Why Does This Exist?
Big state spaces rule out lookup tables, so the value function becomes a parameterized approximation with weights . The natural objective weights squared errors by how often each state is visited:
Here is the fraction of time spent in and is the true value. Stochastic gradient descent samples one transition at a time instead of summing over all states, which is the only affordable option when states are innumerable.
Three things break the supervised-learning story. The true value is unknown, so the target is a bootstrapped guess that moves with . Transitions arrive along correlated trajectories, not as independent draws. And as the policy improves, the visitation distribution itself shifts. This page covers how SGD adapts: semi-gradient updates plus decaying step sizes. The linear features used below are taught in linear methods, and the Monte Carlo alternative with true gradients is gradient Monte Carlo.
Think of It Like This
Rowing toward a buoy tied to your own boat
You row toward a bright buoy to hold position in a current. In the supervised version the buoy is chained to the seabed: every stroke toward it is progress, and short steady strokes get you there.
In the RL version someone tied the buoy rope to the stern of your own boat. Each stroke still moves you toward where the buoy is right now, but the buoy slides forward as you pull. If you stop to compute exactly how your rowing drags the buoy, you will never row. So you cheat productively: aim at the buoy as if it were fixed, take one stroke, look again, and repeat.
The stroke is the gradient step, the rope is the target's dependence on your weights, and ignoring the rope is the semi-gradient. The cheat works because each single stroke barely moves the buoy.
Where it stops: a buoy slides in three dimensions and you can see it. Weights live in thousands of dimensions, the target surface can deform violently rather than slide, and with the wrong step size the boat can circle forever instead of settling.
How It Actually Works
Differentiating the objective gives an ideal update that needs the unknown true value:
Here is the step size and the bracket is the prediction error. Practice replaces with a target . Monte Carlo uses the realized return , which is unbiased and weight-free, so that is true gradient descent. One-step TD uses , whose second term depends on . A full gradient would differentiate through that term too. The semi-gradient skips it:
With linear features the gradient is just the feature vector , so the update is with scalar error . Convergence needs shrinking steps satisfying the Robbins-Monro conditions and : large enough in total to reach far optima, decaying fast enough to drown out sampling noise. A constant step size never settles; it jitters forever in a noise ball around the fixed point.
Worked example
Linear model with and .
Step 1: features , target . Prediction , error . Update: .
Step 2: features , target . Prediction , error . Update: .
Check both states under : state 1 gives against target (error down from to ), state 2 gives against target (error down from to ). Shared weights improved both predictions at once, which is exactly what generalization buys and what tables cannot do.
Code
def predict(w: list[float], x: list[float]) -> float: """Linear value estimate w dot x.""" return w[0] * x[0] + w[1] * x[1]
def sgd_step(w: list[float], x: list[float], target: float, alpha: float) -> float: """One semi-gradient step; returns the prediction error.""" error = target - predict(w, x) w[0] += alpha * error * x[0] w[1] += alpha * error * x[1] return error
w: list[float] = [0.0, 0.0]alpha: float = 0.1e1 = sgd_step(w, [1.0, 2.0], 5.0, alpha)e2 = sgd_step(w, [2.0, 1.0], 4.0, alpha)print(f"{w[0]:.1f} {w[1]:.1f}") # -> 0.9 1.2print(f"{e1:.1f} {e2:.1f}") # -> 5.0 2.0print(f"{predict(w, [1.0, 2.0]):.1f}") # -> 3.3print(f"{predict(w, [2.0, 1.0]):.1f}") # -> 3.0Watch Out For
Keeping a constant step size and calling the jitter convergence
With a fixed the weights orbit the fixed point forever instead of landing on it, because every sample injects fresh noise that a constant step never damps. The symptom is a loss curve that falls, then flatlines into a fuzzy band, plus evaluation scores that wobble run to run. People respond by training longer, which changes nothing.
The fix is a schedule with decaying roughly like , or an adaptive optimizer with decay, and to confirm the fix by watching the update magnitudes shrink toward zero late in training. If they do not shrink, the schedule is the bug.
Feeding correlated trajectory order straight into SGD
Updates drawn from one long trajectory pull the weights along whatever corridor the agent happens to be in, and the approximator quietly forgets distant states. The symptom is values that are accurate for recently visited regions and stale everywhere else, plus learning curves that sawtooth as the agent revisits areas.
The fix is an experience replay buffer: store transitions and sample mini-batches uniformly at random, which breaks the temporal correlation and restores something close to the independent sampling SGD assumes.
The Quick Version
- Function approximation replaces tables with weights, and SGD updates them one sampled transition at a time.
- Bootstrapped targets move with the weights, so TD methods use semi-gradients that differentiate the prediction but not the target.
- Monte Carlo targets are weight-free, so Monte Carlo is true gradient descent while TD(0) is not.
- Step sizes must satisfy the Robbins-Monro conditions; constant steps jitter forever instead of converging.
- Correlated trajectories violate the independent-sampling assumption, which is why replay buffers shuffle transitions.
- In the worked example two steps move weights from zero to [0.9, 1.2] and cut both prediction errors.