Recurrent Neural Networks in RL
A recurrent policy gives your agent a memory: it carries a hidden state from step to step so today's action can depend on what it saw a minute ago, not just the current frame.
Why Does This Exist?
Most deep reinforcement learning policies are feedforward: they look at the current observation and pick an action. That works when the observation tells the whole story. It breaks the moment it doesn't.
Picture a maze robot whose camera sees only the wall directly ahead. Facing a blank wall at the start of a corridor and facing a blank wall at a dead end look identical, yet they demand opposite actions. A feedforward policy maps both to the same move, because it has no way to know which blank wall it faces. The formal name for this is partial observability: the agent sees an observation , a shadow of the true state , and one frame is not enough to decide.
This page covers how a recurrent policy fixes that with a running memory called the hidden state , how it trains through time, and where the memory silently breaks. If you need the value-estimation side first, read temporal difference learning; for the value-network baseline this builds on, see Deep Q-Networks.
Think of It Like This
The Waiter Who Takes Notes
A forgetful waiter treats every trip to your table as a fresh encounter. You ordered coffee ten minutes ago, but all he sees is you sitting there now, so he asks "what can I get you?" for the third time. He is a feedforward policy: current observation in, action out, no memory.
A good waiter carries a notepad. Each visit, he glances at the table (the new observation), updates his notes (the hidden state), and acts on both: he brings the coffee you ordered earlier instead of asking again. The notepad is small, so he jots summaries, not transcripts. At the end of the night he throws the page away and starts the next shift blank, because last night's orders say nothing about today's customers.
Where the analogy stops: the waiter's notes are words he chose deliberately. A recurrent policy's hidden state is a vector of numbers shaped only by gradient descent, and nobody can read it directly. You infer what it remembers from the agent's behavior, the way you'd infer what the waiter wrote down from what he brings.
How It Actually Works
The memory update
A recurrent policy keeps a vector , the hidden state: a compressed summary of everything observed so far. Each step does two things with it. First, update the memory with the fresh observation :
Here is the recurrent weight (how much old memory survives), is the input weight (how much the new observation matters), is a bias, and squashes the result into a sane range. If you want the full story on this recurrence, read the vanilla RNN page. Then act from both:
The policy sees the observation and the memory. Two identical frames with different histories now produce different actions, which is exactly what the maze robot needed. In practice the plain cell is usually swapped for a gated one, an LSTM or GRU, whose input and forget gates decide what to keep and what to drop. Same job, better plumbing over long stretches.
DRQN and recurrent PPO
Two algorithms made this standard. DRQN (Deep Recurrent Q-Network) takes a DQN and replaces its first fully connected layer with an LSTM, training on sampled observation sequences instead of single transitions so the hidden state has something to chew on. Recurrent PPO does the same for policy gradients: rollout buffers store the hidden state alongside each step, and each update replays short sequences through the cell to rebuild the memory before computing the policy loss. Both reset to zero at every episode boundary, for the waiter's end-of-shift reason: memory from a finished episode is stale context for a fresh one.
Training through time, truncated
Gradients flow backward through the unrolled steps, a procedure called backpropagation through time (BPTT). Unrolling a thousand-step episode is too expensive, so training unrolls only a short window, often 8 to 32 steps, and stops the gradient at the window's edge. That truncation is a deal with consequences: the agent can never learn a dependency longer than its window, no matter how big the hidden state is. And the same repeated multiplication by that carries memory forward also shrinks (or explodes) gradients, which is why plain cells forget over long gaps and gated cells took over.
Worked numerical example
A tiny scalar cell, , starting from a reset , watching observations :
- Step 0: . Then . The strong positive sighting fills the memory.
- Step 1: . Then . The negative observation nearly wipes the slate, but of history survives.
- Step 2: . Then .
Notice step 1 and step 2 both faced middling inputs, yet the memory differs ( versus ) because the past differs. That gap is the whole point: a feedforward net would treat similar frames identically, while this policy remembers. The diagram above draws exactly this unroll. And the vanishing side: after 20 silent steps the retention factor is , barely one percent, which is why distant clues fade without gates.
Code
A standalone replay of the worked unroll, plus the decay check:
import math
def unroll(observations: list[float], w_h: float = 0.8, w_x: float = 1.0) -> list[float]: """Run one scalar recurrent cell over an observation sequence.
Returns the hidden state after each step, starting from a reset h = 0. """ h = 0.0 states: list[float] = [] for t, o in enumerate(observations): z = w_h * h + w_x * o h = math.tanh(z) print(f"step {t}: obs={o:+.1f} z={z:+.4f} h={h:+.4f}") states.append(h) return states
if __name__ == "__main__": unroll([1.0, -0.5, 0.5]) print(f"decay over 20 steps: {0.8**20:.4f}")# -> expected output:step 0: obs=+1.0 z=+1.0000 h=+0.7616step 1: obs=-0.5 z=+0.1093 h=+0.1088step 2: obs=+0.5 z=+0.5871 h=+0.5278decay over 20 steps: 0.0115Watch Out For
Stale hidden state leaks across episodes
The symptom: evaluation scores swing wildly between runs with identical weights, or a policy trained on short episodes collapses on longer ones. The cause is memory smuggled past the episode boundary. Somewhere in the rollout code, the hidden state was not zeroed at done, so the new episode starts with the previous episode's summary still loaded. The policy then acts on ghosts: it "remembers" a corridor it never entered.
The fix is mechanical. Reset to zero wherever the environment resets, and double check the vectorized case: in a batch of parallel environments, reset only the entries whose episode ended, not the whole batch. If your framework stores hidden states in the replay buffer, confirm the stored state at each sequence start matches a true reset and not a mid-episode slice.
Truncation quietly caps your memory horizon
The symptom: the agent masters short-term reactions but never learns the one clue from fifty steps back, and widening the hidden state changes nothing. The cause is the BPTT window, not capacity. Gradients stop at the window edge, so a clue outside it cannot teach the memory, however large.
The fix starts with measuring: log the longest dependency your task actually needs, then set the unroll window past it. If the window gets too expensive, reach for burn-in (replay a few extra steps to warm up without training on them, the way DRQN implementations do) or switch the cell to an LSTM or GRU so surviving gradients travel further. Never just widen the hidden vector and hope.
The Quick Version
- A feedforward policy maps one observation to one action, so aliased views of different true states force identical moves. Partial observability is what breaks it.
- A recurrent policy carries a hidden state updated by , and acts from both the observation and the memory.
- DRQN adds an LSTM to a Q-network and trains on sequences; recurrent PPO stores hidden states in its rollout buffer and replays short windows. Both reset memory at episode ends.
- BPTT trains through a truncated window, so dependencies longer than the window are unlearnable no matter how wide the hidden state is.
- Gated cells (LSTM, GRU) exist because plain recurrences fade: a retention of keeps only about one percent of a clue after 20 silent steps.