πŸ“š Study Notes / Home / Neural Nets / Session 13
Session 13 Β· LSTMs, GRUs & Gating

Teaching a network to remember (and forget) on purpose

Last session we built plain RNNs and watched them choke on long sequences β€” the dreaded vanishing gradient. Today we fix that with one beautiful idea: gates. We'll open up the famous LSTM cell piece by piece, see why its "conveyor belt" lets gradients flow far back in time, meet its leaner cousin the GRU, and finish by stacking and reversing RNNs in real PyTorch. We assume you've studied none of this before β€” every topic starts with a tiny everyday story before the math.

⏱ 21 min readπŸ“– 5 topics

1 The gating idea


Explain like I'm 5

Imagine you're listening to a long story so you can answer a question at the end. You don't try to memorise every single word β€” that's impossible. Instead you keep a little notepad: when something important happens ("the key is under the mat") you write it down, when something stops mattering you cross it out, and when someone asks you a question you read out only the relevant bits. A "gate" is just a tiny decision: let this through, or not? Today's networks learn to make those write / cross-out / read-out decisions all by themselves.

First, a quick recap of the problem

Recall from Session 12 that a plain RNN (Recurrent Neural Network) processes a sequence one step at a time, carrying a hidden state h_t from each step to the next. At every step it does roughly:

# plain RNN update (from Session 12)
h_t = tanh(W_h Β· h_{t-1} + W_x Β· x_t + b)

The trouble is in that little h_{t-1} term. To learn from a clue 100 steps ago, the training signal (the gradient) has to travel back through 100 of those multiplications. Each step multiplies by roughly the same weight matrix and a tanh derivative that's less than 1. Multiply 100 numbers that are each < 1 together and you get something microscopic β€” the vanishing gradient problem. The opposite can also happen (numbers > 1 multiplied many times blow up) β€” the exploding gradient problem.

Why this hurts

A vanished gradient means the network literally cannot learn long-range dependencies. By the time the error signal travels back to step 1, it's so faint that the early weights barely change. The network forgets the start of the sentence β€” exactly when "remembering the start" is the whole point (e.g. matching a pronoun to a noun mentioned paragraphs earlier).

The insight: stop forcing it, let it learn

A plain RNN completely rewrites its memory at every single step β€” it has no choice. The hidden state is squashed through a tanh and replaced. That constant rewriting is exactly what destroys the gradient.

The big idea

Don't hard-code when to remember. Give the network small, learnable gates β€” values between 0 and 1 produced by the network itself β€” that decide how much information to keep, throw away, or read out at each step. The network learns the right remembering policy from data, just like it learns everything else.

What exactly is a gate?

A gate is a vector of numbers between 0 and 1, produced by a tiny neural layer with a sigmoid activation (Οƒ, which always outputs 0–1). You then multiply it element-wise (the βŠ™ symbol) against some information:

  • A gate value near 0 = "block this" (multiply by ~0 β†’ information disappears).
  • A gate value near 1 = "let it all through" (multiply by ~1 β†’ information passes untouched).
  • Values in between = "let some fraction through" β€” gates are soft, not on/off switches, which is what makes them trainable by gradient descent.
Why sigmoid for gates, tanh for content?

Gates use Οƒ because we want a clean 0-to-1 "how much" knob. Actual content (the new candidate memory) usually uses tanh, which outputs βˆ’1 to +1, so memory can move in either direction. Watch for this pairing β€” it shows up in every gated cell below.

Worked example: a one-number memory with a gate

Suppose our memory is a single number c, currently c = 5.0 (it means "the subject is plural"). A new word arrives. The network computes a forget gate from the input and gets f = 0.9 (mostly keep), and an input gate i = 0.2 with new candidate content g = βˆ’1.0. The updated memory is:

c_new = f βŠ™ c + i βŠ™ g
      = 0.9 Γ— 5.0 + 0.2 Γ— (βˆ’1.0)
      = 4.5 βˆ’ 0.2
      = 4.3

The memory was mostly preserved (because f was high) with a small nudge from the new word. Nobody told it to keep 90% β€” it learned that gate value because keeping plural-ness around was useful for the loss.

Recap Plain RNNs rewrite their memory every step, which makes gradients vanish over long sequences. The fix is gating: learnable sigmoid-valued knobs (0–1) that the network multiplies against information to decide how much to keep, add, or read out. The network learns its own remembering policy. Next we'll see the most famous design that uses this idea β€” the LSTM.

2 The LSTM cell anatomy


Explain like I'm 5

Picture a factory conveyor belt running straight through a room. Boxes (memories) ride along it. As the belt passes three little workers, each worker can act on it: the first worker can toss boxes off the belt (forget), the second can place new boxes on (add), and the third decides which boxes to show to the next room (output). The belt itself barely changes as it rolls along β€” that's the secret. An LSTM is exactly this: a memory belt plus three gates.

The LSTM (Long Short-Term Memory) cell was introduced by Hochreiter & Schmidhuber in 1997. Its key innovation is keeping two things flowing through time instead of one:

  • The cell state c_t β€” the long-term memory "conveyor belt." It's edited only by gentle additions and removals.
  • The hidden state h_t β€” the short-term, "what I output right now" state (same role as in a plain RNN).

The three gates at a glance

GateSymbolQuestion it answersConveyor-belt job
Forget gatef_tWhat in the old memory should we erase?Tosses boxes off the belt
Input gatei_tWhat new info should we write to memory?Places new boxes on the belt
Output gateo_tWhat part of memory do we reveal right now?Decides which boxes to show next room

The data flow, as a diagram

πŸ“₯
Inputs
x_t and h_{t-1} arrive
β†’
πŸ—‘οΈ
Forget
f_t erases part of c_{t-1}
β†’
βž•
Input
i_t adds new content g_t
β†’
🎞️
Cell state
c_t = the updated belt
β†’
πŸ“€
Output
o_t reveals h_t from c_t

The equations, one by one

At each timestep the LSTM first glues together the previous hidden state h_{t-1} and the current input x_t, then runs five small computations. Don't be scared β€” they're all the same shape: a weighted sum followed by an activation. Let's read each line like a sentence.

# Οƒ = sigmoid (outputs 0..1, a "how much" knob)
# βŠ™ = element-wise multiply

f_t = Οƒ( W_f Β· [h_{t-1}, x_t] + b_f )      # 1. forget gate
i_t = Οƒ( W_i Β· [h_{t-1}, x_t] + b_i )      # 2. input gate
g_t = tanh( W_g Β· [h_{t-1}, x_t] + b_g )   # 3. candidate memory
c_t = f_t βŠ™ c_{t-1} + i_t βŠ™ g_t           # 4. update the cell state
o_t = Οƒ( W_o Β· [h_{t-1}, x_t] + b_o )      # 5. output gate
h_t = o_t βŠ™ tanh( c_t )                    # 6. new hidden state

Line by line, in plain English:

  • 1 β€” Forget gate f_t: looks at the new input and old hidden state and outputs a number 0–1 per memory slot. 0 means "erase this slot," 1 means "keep it."
  • 2 β€” Input gate i_t: decides how much of the brand-new candidate we'll actually write.
  • 3 β€” Candidate memory g_t: the new information we could add, squashed to βˆ’1..+1 by tanh so it can push memory up or down.
  • 4 β€” The update (the heart of it): c_t = f_t βŠ™ c_{t-1} + i_t βŠ™ g_t. Read it as: "keep the fraction f_t of the old belt, then add the fraction i_t of the new content." This is the famous additive update β€” remember it for Topic 3.
  • 5 β€” Output gate o_t: decides which parts of the (now updated) memory to expose to the outside world this step.
  • 6 β€” Hidden state h_t: the cell state passed through tanh and filtered by the output gate. This is what leaves the cell and feeds the next layer / the prediction.
Notation note: [h_{t-1}, x_t]

The square brackets just mean "stack the two vectors into one long vector," then one weight matrix acts on the whole thing. In code you often see this as two separate matrices (WΒ·h + UΒ·x) β€” mathematically identical, just split. Each gate has its own learned weights, so the cell has 4 sets of weights total.

Worked example: remembering grammatical number

Sentence: "The dogs that chased the cat across the yard ... were tired." The verb must be "were" (plural) to match "dogs," even though many words intervene.

  • At "dogs," the input gate opens (i_t β‰ˆ 1) and writes "subject = plural" into a memory slot via g_t.
  • Through "that chased the cat across the yard," the forget gate stays near 1 for that slot (f_t β‰ˆ 1), so plural-ness rides the conveyor belt untouched while other slots churn.
  • At the verb, the output gate opens for that slot, revealing "plural" so the network predicts "were," not "was."

No human wrote those gate values β€” they emerged from training to minimise prediction error.

Recap An LSTM carries two states: the long-term cell state c_t (the conveyor belt) and the short-term hidden state h_t. Three gates control it β€” forget (erase old memory), input (write new memory via candidate g_t), and output (reveal memory as h_t). The cell state is updated additively: c_t = f_t βŠ™ c_{t-1} + i_t βŠ™ g_t.

3 Why LSTMs fix gradient flow


Explain like I'm 5

Imagine passing a whispered secret down a line of 100 kids. In a plain RNN, every kid re-says it in their own words β€” by kid 100 the message is total nonsense. In an LSTM there's a special rule: most kids are allowed to just pass the note along unchanged. A clear note can travel all the way down the line. That "pass it along unchanged" highway is the cell state, and it's why the message (the gradient) survives.

Recall what killed the plain RNN

In a plain RNN the hidden state is transformed every step: h_t = tanh(W Β· h_{t-1} + ...). When we backpropagate, the gradient of h_t with respect to h_{t-1} includes the weight matrix W and a tanh' factor (always < 1). Chain those across many steps and you're multiplying many small numbers β€” the product shrinks toward zero. Gradient vanishes.

The additive path is the whole trick

Now look at the LSTM's cell-state update again, and ask: how does c_t depend on c_{t-1}?

c_t = f_t βŠ™ c_{t-1} + i_t βŠ™ g_t

# the part of the gradient that travels straight back in time:
βˆ‚c_t / βˆ‚c_{t-1} = f_t        # just the forget gate!

This is the magic. The connection from one cell state to the next is essentially multiplication by the forget gate β€” and nothing else. There's no weight matrix and no tanh squashing on this path. So when the gradient flows back through time along the cell state, it gets multiplied by f_t at each step.

The big idea

If the network learns to keep a forget gate open (f_t β‰ˆ 1) for a memory slot it cares about, then the gradient down that path is multiplied by β‰ˆ1 over and over β€” so it neither vanishes nor explodes. The cell state is a near-uninterrupted highway for gradients to travel far back in time. This is sometimes called the constant error carousel β€” error "rides the carousel" without decaying.

Worked example: 50 steps later

Plain RNN, with an effective per-step factor of 0.8:

gradient after 50 steps β‰ˆ 0.8^50 β‰ˆ 0.000014   # vanished

LSTM, with the network keeping the relevant forget gate near 1.0 (say 0.99):

gradient after 50 steps β‰ˆ 0.99^50 β‰ˆ 0.61       # very much alive

Same depth, wildly different survival β€” purely because the LSTM's path multiplies by a learnable gate that it can choose to hold open, instead of a fixed shrinking factor.

It's an addition, not a forced overwrite

The deeper reason this works: c_t is built by adding to the old state (+ i_t βŠ™ g_t) rather than replacing it. Additive updates have gradients that flow through unchanged β€” the very same principle behind residual / skip connections in deep CNNs and Transformers (we'll revisit those in Session 14). Gating + addition = the recurring recipe for training very deep or very long networks.

Honest caveat

LSTMs greatly reduce vanishing gradients but don't make them mathematically impossible β€” if the network learns small forget gates, memory still fades. And exploding gradients can still occur, which is why people pair LSTMs with gradient clipping (capping the gradient's size during training). LSTMs make long-range learning practical, not magic.

Recap The gradient along the cell state is multiplied only by the forget gate (βˆ‚c_t/βˆ‚c_{t-1} = f_t) β€” no weight matrix, no tanh squashing. If the network holds that gate open (β‰ˆ1), gradients survive across hundreds of steps: the "constant error carousel." This additive memory path is the same idea as residual connections, and it's why LSTMs can learn long-range dependencies that plain RNNs cannot.

4 GRUs β€” the leaner gated cell


Explain like I'm 5

The LSTM was a factory with three workers and two conveyor belts β€” powerful, but a lot of machinery. The GRU is a tidier shop: one belt and two workers. One worker decides "how much of the old memory to keep vs. replace" and the other decides "how much of the past to even look at when writing the new memory." Fewer parts, often just as good, and faster to run.

The GRU (Gated Recurrent Unit), introduced by Cho et al. in 2014, keeps the gating idea but simplifies it. The two big changes versus an LSTM:

  • It has no separate cell state β€” the hidden state h_t carries both long- and short-term memory.
  • It uses two gates instead of three: a reset gate and an update gate.

The GRU equations

# Οƒ = sigmoid, βŠ™ = element-wise multiply

r_t = Οƒ( W_r Β· [h_{t-1}, x_t] + b_r )           # 1. reset gate
z_t = Οƒ( W_z Β· [h_{t-1}, x_t] + b_z )           # 2. update gate
h~_t = tanh( W_h Β· [r_t βŠ™ h_{t-1}, x_t] + b_h ) # 3. candidate state
h_t = (1 βˆ’ z_t) βŠ™ h_{t-1} + z_t βŠ™ h~_t          # 4. blend old & new

Reading it as a story:

  • Reset gate r_t: when computing the new candidate memory, how much of the past should we let influence it? Near 0 = "ignore the past, this is a fresh start" (great at sentence/clause boundaries).
  • Update gate z_t: the star of the GRU. It does the job of the LSTM's forget and input gates at once. Line 4 says: keep fraction (1 βˆ’ z_t) of the old state and take fraction z_t of the new candidate. They're tied together β€” what you don't keep, you replace.
  • Candidate h~_t: the proposed new memory, computed using a reset-filtered view of the past.
Where's the additive highway?

Line 4 still has the precious additive structure: when the update gate z_t β‰ˆ 0, we get h_t β‰ˆ h_{t-1} β€” memory passes through nearly unchanged, so gradients flow back just like the LSTM's cell state. The GRU keeps the gradient-friendly skip path while using fewer parameters.

LSTM vs GRU β€” side by side

AspectLSTMGRU
Gates3 (forget, input, output)2 (reset, update)
Memory states2 (cell c_t + hidden h_t)1 (hidden h_t only)
Parameters per cellMore (4 weight sets)~25% fewer (3 weight sets)
Speed / memorySlower, heavierFaster, lighter
Exposes full memory?No β€” output gate filters itYes β€” whole state is the output
Best onVery long / complex sequences; when you have lots of dataSmaller datasets, shorter sequences, when compute is tight
Worked example: which would you pick?
  • Long documents, big dataset, accuracy is king (e.g. document-level sentiment): start with an LSTM β€” its extra control sometimes squeezes out more performance on long dependencies.
  • Modest dataset, want fast training, mobile/edge deployment (e.g. a small on-device text classifier): start with a GRU β€” fewer parameters means faster training and less overfitting risk.

In practice their accuracy is usually very close. The honest advice: try both and let your validation set decide. Neither is universally "better."

Key takeaway

A GRU is a streamlined LSTM: it merges the cell and hidden states into one, and merges forget+input into a single update gate. You lose a little fine-grained control but gain speed and fewer parameters β€” and you keep the all-important additive gradient highway.

Recap The GRU uses two gates β€” reset (how much past to use when forming the candidate) and update (how much to keep vs. replace) β€” and a single hidden state. It's lighter and faster than an LSTM with usually comparable accuracy. Choose GRU when data/compute is limited, LSTM for large data and the longest dependencies; in doubt, benchmark both.

5 Bidirectional & stacked RNNs


Explain like I'm 5

Two simple upgrades. First: when you fill in a missing word in a sentence, you read both the words before and after the blank β€” a bidirectional RNN does the same by reading the sequence forward and backward. Second: one pair of eyes is good, but a team passing notes catches more β€” a stacked RNN piles several RNN layers on top of each other so deeper layers spot higher-level patterns.

Bidirectional RNNs β€” reading both directions

A normal RNN reads left-to-right, so its understanding of word t only uses words before it. But meaning often depends on what comes after. A bidirectional RNN (BiRNN) runs two independent RNNs: one forward (start→end) and one backward (end→start). At each position it concatenates both hidden states, so every output knows about the entire sequence on both sides.

▢️
Forward RNN
reads x₁…xβ‚œ
+
◀️
Backward RNN
reads xβ‚œβ€¦x₁
β†’
πŸ”—
Concatenate
[hβ†’ , h←] per step
β†’
🎯
Output
uses full context
Worked example: why both directions help

Tag the word "book":

  • "I want to book a flight" β†’ verb. You only know this from the word after ("a flight").
  • "I read a book" β†’ noun.

A forward-only RNN at "book" hasn't seen "a flight" yet. A BiRNN's backward pass has, so it disambiguates correctly.

When you cannot use bidirectional

BiRNNs need the whole sequence up front, so they're great for tasks like tagging or classification on complete texts. But for real-time generation or forecasting (predicting the next word/value as data streams in) you can't peek at the future β€” there is none yet. Use a forward-only model there.

Stacked (deep) RNNs β€” layers on layers

A stacked RNN (or deep RNN) feeds the sequence of hidden states from one RNN layer as the input sequence to the next RNN layer. Lower layers capture local patterns (letters→words), higher layers capture abstract ones (phrases→meaning) — the same "hierarchy of features" idea you saw with CNNs in earlier sessions.

How deep?

2–4 layers is the common sweet spot. More layers add capacity but cost compute and can overfit; people add dropout between layers to regularise. You can absolutely combine ideas: a stacked bidirectional LSTM is a very common workhorse for sequence labelling.

Real PyTorch: it's basically free

The wonderful part: PyTorch's nn.LSTM gives you stacking and bidirectionality with two arguments. You don't hand-write any of the gate equations from Topic 2 β€” they're built in.

import torch
import torch.nn as nn

# A 2-layer BIDIRECTIONAL LSTM
lstm = nn.LSTM(
    input_size=100,      # size of each input vector (e.g. word embedding)
    hidden_size=128,     # size of the hidden state per direction
    num_layers=2,        # STACKED: 2 LSTM layers
    batch_first=True,    # input shape is (batch, seq_len, features)
    bidirectional=True,  # BIDIRECTIONAL: read both ways
    dropout=0.2,        # dropout between stacked layers
)

# Fake batch: 32 sequences, each 10 steps long, 100 features per step
x = torch.randn(32, 10, 100)

# output: per-step hidden states (both directions concatenated)
# h_n, c_n: final hidden and cell states for each layer/direction
output, (h_n, c_n) = lstm(x)

print(output.shape)  # (32, 10, 256)  -> 128 * 2 directions
print(h_n.shape)     # (4, 32, 128)   -> 2 layers * 2 directions
Worked example: reading those shapes
  • output is (32, 10, 256): for all 32 sequences and all 10 timesteps, a 256-dim vector β€” that's 128 forward + 128 backward concatenated. Use this for per-token tasks (tagging).
  • h_n is (4, 32, 128): the final hidden state, with the first axis = num_layers Γ— num_directions = 2 Γ— 2 = 4. Use the top layer's states for whole-sequence tasks (classification).
  • Swapping to a GRU is a one-word change: nn.GRU(...) β€” and it returns just output, h_n (no cell state, as we learned in Topic 4).
Key takeaway

Bidirectionality and stacking are orthogonal upgrades you can mix freely with LSTM or GRU. In PyTorch they're just the bidirectional and num_layers flags β€” the heavy lifting is done for you.

Recap Bidirectional RNNs read the sequence forward and backward and concatenate, so each output sees full context β€” great for tagging/classification, impossible for live generation. Stacked RNNs layer cells to learn a hierarchy of features. PyTorch's nn.LSTM / nn.GRU expose both via the bidirectional and num_layers arguments.

β˜… Putting it all together


This session was really about one elegant idea applied five ways. Here's the story that ties it all together:

Plain RNNs forget the distant past because they overwrite their memory every step, making gradients vanish. The cure is gating: learnable 0–1 knobs that decide how much information to keep, add, or reveal. The LSTM bundles three gates (forget, input, output) around a long-term cell-state "conveyor belt," updated additively (c_t = f_t βŠ™ c_{t-1} + i_t βŠ™ g_t). That addition is exactly why gradients survive β€” they ride the cell state, multiplied only by the forget gate (the constant error carousel). The GRU achieves the same with fewer parts: two gates, one state. And whichever cell you choose, you can make it bidirectional (read both ways) and stacked (deep) β€” both one-line flags in PyTorch. Next session we use these recurrent encoders to build Seq2Seq models and meet attention, which eventually makes the recurrence itself optional.

Quick self-check

Why does a plain RNN's gradient vanish but an LSTM's cell-state gradient doesn't?

A plain RNN multiplies the gradient by a weight matrix and a tanh derivative (<1) at every step, so it shrinks. The LSTM's cell-state path multiplies only by the forget gate (βˆ‚c_t/βˆ‚c_{t-1} = f_t); if the network holds that gate near 1, gradients pass through nearly unchanged.

What do the three LSTM gates do?

Forget gate erases parts of the old cell state; input gate decides how much new candidate content to write; output gate decides which parts of the cell state to expose as the hidden state h_t.

How is a GRU different from an LSTM?

A GRU has only one state (the hidden state, no separate cell state) and two gates (reset and update). Its update gate does the job of the LSTM's forget+input gates together. It's lighter and faster with usually comparable accuracy.

When should you NOT use a bidirectional RNN?

When you can't see the future β€” real-time generation or forecasting, where you predict the next item as data streams in. BiRNNs need the entire sequence up front.

In nn.LSTM, you set hidden_size=128, num_layers=2, bidirectional=True. What's the last-dim size of output?

256 β€” that's hidden_size Γ— num_directions = 128 Γ— 2, because the forward and backward hidden states are concatenated at each timestep.

Why is the additive update c_t = f_t βŠ™ c_{t-1} + ... conceptually similar to a residual connection?

Both add to an existing signal rather than fully replacing it, so the gradient has an unobstructed path to flow backward. This shared principle is what lets both LSTMs and very deep residual networks train across long distances.

πŸ“š References & Further Reading


Class material

  • Your course handout for this session: "SST Deep Learning handout (Session 13)" β€” the primary reference for this class. (course page)

Papers, docs & deep dives