Skip to content
AI360Xpert
Beta

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.

A recurrent policy unrolls one cell across three steps, carrying the hidden state forward so each action depends on the observation history, not just the current frame.
A recurrent policy unrolls one cell across three steps, carrying the hidden state forward so each action depends on the observation history, 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 oto_t, a shadow of the true state sts_t, 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 hth_t, 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 hth_t, 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 oto_t:

ht+1=tanh⁡(Whht+Wxot+b)h_{t+1} = \tanh(W_h h_t + W_x o_t + b)

Here WhW_h is the recurrent weight (how much old memory survives), WxW_x is the input weight (how much the new observation matters), bb is a bias, and tanh⁡\tanh 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:

at∼π(at∣ot,ht)a_t \sim \pi(a_t \mid o_t, h_t)

The policy π\pi 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 tanh⁡\tanh 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 hh 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 WhW_h 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, ht+1=tanh⁡(0.8 ht+1.0 ot)h_{t+1} = \tanh(0.8\,h_t + 1.0\,o_t), starting from a reset h0=0h_0 = 0, watching observations o=[+1.0,−0.5,+0.5]o = [+1.0, -0.5, +0.5]:

  • Step 0: z0=0.8×0+1.0×1.0=1.0000z_0 = 0.8 \times 0 + 1.0 \times 1.0 = 1.0000. Then h1=tanh⁡(1.0000)=0.7616h_1 = \tanh(1.0000) = 0.7616. The strong positive sighting fills the memory.
  • Step 1: z1=0.8×0.7616+1.0×(−0.5)=0.6093−0.5=0.1093z_1 = 0.8 \times 0.7616 + 1.0 \times (-0.5) = 0.6093 - 0.5 = 0.1093. Then h2=tanh⁡(0.1093)=0.1088h_2 = \tanh(0.1093) = 0.1088. The negative observation nearly wipes the slate, but 0.10880.1088 of history survives.
  • Step 2: z2=0.8×0.1088+1.0×0.5=0.0870+0.5=0.5871z_2 = 0.8 \times 0.1088 + 1.0 \times 0.5 = 0.0870 + 0.5 = 0.5871. Then h3=tanh⁡(0.5871)=0.5278h_3 = \tanh(0.5871) = 0.5278.

Notice step 1 and step 2 both faced middling inputs, yet the memory differs (0.10880.1088 versus 0.52780.5278) 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 0.820=0.01150.8^{20} = 0.0115, 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.0115

Watch 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 hh 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 hh 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 hth_t updated by ht+1=tanh⁡(Whht+Wxot+b)h_{t+1} = \tanh(W_h h_t + W_x o_t + b), 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 0.80.8 keeps only about one percent of a clue after 20 silent steps.