Inside the LSTM Cell: Four Gates With Actual Numbers
The LSTM answers the vanishing-gradient problem with a structure change: it adds a cell state c_t — a highway running lengthwise through the chain — and puts gates on-ramps onto it. The step:
f_t = sigmoid(W_f [h_{t-1}, x_t] + b_f) forget gate: keep fraction of old memory
i_t = sigmoid(W_i [h_{t-1}, x_t] + b_i) input gate: admit fraction of new memory
g_t = tanh (W_g [h_{t-1}, x_t] + b_g) candidate: proposed content in (-1, 1)
o_t = sigmoid(W_o [h_{t-1}, x_t] + b_o) output gate: expose fraction of memory
c_t = f_t ⊙ c_{t-1} + i_t ⊙ g_t (elementwise, the memory update)
h_t = o_t ⊙ tanh(c_t) (what the next layer actually sees)
Run it with numbers. Suppose c_{t-1} = [1.0, 0.5], the cell decides f_t = [0.9, 0.2] (keep most of slot 1, drop slot 2) and i_t ⊙ g_t = [0.1, 0.3]:
c_t = [0.9*1.0 + 0.1, 0.2*0.5 + 0.3] = [1.0, 0.4]
h_t = [0.6, 0.8] ⊙ tanh([1.0, 0.4]) = [0.6*0.762, 0.8*0.380] = [0.457, 0.304]
The gradient story is the point. Backpropagating the loss from c_T to c_1 gives a product of forget-gate values, ∂c_t/∂c_{t-1} = diag(f_t) — and since 0 ≤ f_t ≤ 1, the product is bounded: when the network learns f ≈ 1, the gradient passes through 50 steps essentially undiminished, the "constant error carousel" from the original LSTM paper. Compare the vanilla RNN's W_hh-product from the previous chapter: no gate, no way to switch the decay off. The model learns when to hold memory (a closing quotation mark or a topic set 200 tokens back) and when to erase it (a sentence boundary) — that is what "gates control what to remember, forget, and emit" means mechanically.
PyTorch usage mirrors nn.RNN, with the cell state returned alongside:
lstm = nn.LSTM(input_size=8, hidden_size=16, batch_first=True)
out, (h_n, c_n) = lstm(x) # x: (4, 30, 8); c_n: (1, 4, 16)
cell = nn.LSTMCell(8, 16) # one timestep, weights exposed
# the four gates are fused into one matrix multiply per side:
print(lstm.weight_ih_l0.shape) # torch.Size([64, 8]) = (4*hidden, input)
print(lstm.weight_hh_l0.shape) # torch.Size([64, 16]) = (4*hidden, hidden)
The cost is arithmetic: four gates instead of two means 4(H+F)H weights against 2(H+F)H — an LSTM has exactly twice the recurrent matrix parameters of a vanilla RNN (plus two bias vectors per gate in PyTorch, b_ih and b_hh). At F=8, H=16 that is 4*8*16 + 4*16*16 + 8*16 = 512 + 1,024 + 128 = 1,664 parameters — verify it against sum(p.numel() for p in nn.LSTM(8, 16).parameters()) and you have the arithmetic proof.
Keep the two states straight, because interviewers use them as a discriminator. c_t is the raw long-term memory: values ride it unmodified except by the forget gate, which is why the gradient highway lives there. h_t is the filtered read-out — what the next layer and the next step are allowed to see. A cell can be holding crucial information in c while o ≈ 0 keeps h quiet: stored but not currently spoken. The gradient defenses from the vanilla cell apply unchanged — clipping handles explosion through the gates as well, and the bounded sigmoid/tanh derivatives keep the products tame.
Whether that buys anything on your data depends on sequence length and dataset size, and the trimmed-down cell that wins the comparison more often than people expect is the subject of the final chapter.
GRU or LSTM: Three Matrices Against Four, and How to Decide in Practice
The GRU (Cho et al., 2014) keeps the LSTM's premise — gated memory — and rebuilds the cell with fewer parts:
z_t = sigmoid(W_z [h_{t-1}, x_t]) update gate: blend old vs new
r_t = sigmoid(W_r [h_{t-1}, x_t]) reset gate: how much past to consult
h̃_t = tanh (W [x_t, r_t ⊙ h_{t-1}]) candidate from reset history
h_t = (1 - z_t) ⊙ h_{t-1} + z_t ⊙ h̃_t one vector, no separate cell state
Two structural differences matter for interviews. First, the cell state disappears — memory lives in h_t itself, so there is no separate highway or output gate deciding what to expose. Second, the update coefficients are tied: (1 - z) and z sum to 1, so the gate cannot amplify — it allocates a fixed budget between preserving the past and admitting the candidate. z_t ≈ 1 means "rewrite"; z_t ≈ 0 means "hold", which is the LSTM's forget-and-input pair compressed into one degree of freedom. The reset gate has its own job: it decides how much of the previous state the candidate is allowed to consult, so after a boundary token (a sentence end, a new topic marker) the model can build the next representation from the current input alone instead of the stale past.
Practical advice carries over from the LSTM unchanged: stack with num_layers, add dropout between stacked layers (PyTorch's dropout= argument applies only between layers, not within the last one), pack padded batches with pack_padded_sequence, and clip gradients regardless of which cell you chose.
Parameter accounting decides cost, and you can compute it exactly for F = 8, H = 256:
| Cell | Weight matrices | Total parameters |
|---|---|---|
| Vanilla RNN | W_ih (H,F), W_hh (H,H) |
67,584 + 512 bias = 68,096 |
| GRU | (3H, F) + (3H, H) + 2 biases |
202,752 + 1,536 = 204,288 |
| LSTM | (4H, F) + (4H, H) + 2 biases |
270,336 + 2,048 = 272,384 |
def counts(F, H):
return {
"rnn": (F*H + H*H + 2*H),
"gru": (3*F*H + 3*H*H + 6*H),
"lstm": (4*F*H + 4*H*H + 8*H),
}
print(counts(8, 256)) # {'rnn': 68096, 'gru': 204288, 'lstm': 272384}
print(counts(8, 256)["gru"] / counts(8, 256)["lstm"]) # 0.75 — GRU is 25% smaller
Decision guidance you can defend in an interview, because there is no universal winner — the original comparison paper found them within noise of each other on most benchmarks:
- Start with the GRU on small or medium datasets: fewer parameters, one less gate to tune, typically faster per epoch, and on short windows (tens of steps) its quality matches.
- Reach for the LSTM when sequences run long or data is large: the separate cell state and output gate give you two independent knobs for "how long to hold" and "how much to show", and the long-range-dependency benchmarks tilt its way.
- Neither, if
Tis hundreds or thousands. Every step of either cell is sequential, GPU utilization is poor, and both must compress history into one vector. The alternative — let every position attend directly to every other position — is exactly what the next topic builds: Attention Mechanisms.
x = torch.randn(4, 30, 8)
for name, cell in [("GRU", nn.GRU(8, 16, batch_first=True)),
("LSTM", nn.LSTM(8, 16, batch_first=True))]:
out, h_n = cell(x)
print(name, out.shape, sum(p.numel() for p in cell.parameters()))
# GRU (4, 30, 16) 1248 # LSTM (4, 30, 16) 1664 (H=16 here)
One capability note: both cells accept num_layers= for stacking and bidirectional=True for reading the sequence forward and backward and concatenating states — bidirectionality is free accuracy when the full input is available at once (classification, tagging) and forbidden in streaming, where the future has not arrived. The bigger structural limitation — one vector cannot serve as an addressable memory of a 500-token history — is what attention removes, and it is where this batch's sequence arc ends up.
Quiz
1. An LSTM cell has c_prev = [1.0, 0.5], forget gate f = [0.9, 0.2], and input gate times candidate i*g = [0.1, 0.3]. What is c_t?
2. Why can an LSTM propagate a dependency from timestep 1 to timestep 50 when a vanilla RNN cannot?
3. With input size F=8 and hidden size H=256, how do the parameter counts compare?
4. Training a deep RNN, gradients explode and loss goes NaN by epoch 3. What is the standard first response?
Flashcards
Question
State the vanilla RNN update and one consequence of reusing the same weights at every timestep.
Click to reveal answer
Answer
h_t = tanh(W_hh h_{t-1} + W_xh x_t + b), identical at all t. Sharing across time means gradients accumulate through T products of W_hhᵀ·diag(tanh′) — signal from step 1 arrives scaled by that product (0.95 over 50 steps ≈ 0.077), so long dependencies vanish (or explode when the factor exceeds 1).
Question
What gradient defenses exist for vanilla RNNs?
Click to reveal answer
Answer
clip_grad_norm_(params, 1.0) for explosion; tanh (bounded derivative) instead of unbounded activations; orthogonal W_hh initialization so the product stays near-isometric; truncated backpropagation through time (chunks of ~200 steps, h detached between chunks); pack_padded_sequence so padding never enters the recurrence.
Question
Write the LSTM cell equations and identify the gradient highway.
Click to reveal answer
Answer
f,i,o = sigmoid(W[h_{t-1}, x_t]+b), g = tanh(...); c_t = f ⊙ c_{t-1} + i ⊙ g; h_t = o ⊙ tanh(c_t). The highway is c_t: its gradient is a product of forget gates, ≤ 1 by construction and held near 1 when the network learns to remember.
Question
What structurally distinguishes GRU from LSTM?
Click to reveal answer
Answer
No separate cell state or output gate; two gates — update z and reset r — with h_t = (1-z) ⊙ h_prev + z ⊙ h̃ where the coefficients are tied to sum to 1 (a hold-vs-rewrite budget). Fewer matrices: 3H(F+H) vs 4H(F+H) — 204,288 vs 272,384 at F=8, H=256.
Question
When would you pick GRU, LSTM, or neither?
Click to reveal answer
Answer
GRU: default on smaller/shorter data — fewer params, fewer knobs, faster epochs. LSTM: long sequences or large data where a separate memory and output gate help. Neither when T runs to hundreds+ and inputs are all available: recurrence is sequential, O(T) gradient paths, and both squeeze history into one vector — use attention.
Revision Notes
Key Takeaways
- 1. One step, one set of weights reused across time: h_t = tanh(W_hh h_{t-1} + W_xh x_t + b), and nn.RNN’s loop is just that equation chained (verifiable by hand with matmul)
- 2. Gradients through time are products: 0.95^50 ≈ 0.077 explains vanishing long dependencies, 1.03^100 ≈ 19x explains NaNs — countered by clipping, tanh, orthogonal init, TBPTT
- 3. The LSTM splits memory from output and gates both: c_t = f ⊙ c_{t-1} + i ⊙ g, and the forget-gate product is the bounded gradient path vanilla RNNs lack
- 4. Compute with numbers: GRU 204,288 vs LSTM 272,384 at F=8, H=256 (three vs four gate matrices, GRU 25% smaller); the LSTM costs exactly 2x a vanilla RNN’s recurrent matrices
- 5. Either cell must compress all history into one H-vector and runs sequentially — the O(T) path and single-vector bottleneck are what attention removes
Interview Tips
- • "Write the RNN update" — one equation, then immediately unroll it verbally and give the gradient product argument (0.95^50 ≈ 0.077); follow with two defenses (clip, tanh/orthogonal init) to show the full picture
- • "Why does an LSTM work where an RNN fails?" — c_t = f ⊙ c_{t-1} + i ⊙ g and the gradient being a product of forget gates bounded by 1; a worked example (f=[0.9,0.2] -> c=[1.0,0.4]) makes it concrete
- • "GRU or LSTM?" — say no universal winner, give the parameter counts, then decide from evidence: shorter/smaller data -> GRU, long-range/large data -> LSTM, very long sequences -> attention
- • "How do you train on variable-length or very long sequences?" — pack_padded_sequence with sorted lengths and enforce_sorted=False, plus truncated BPTT for long inputs; naming both shows hands-on experience
Cheat Sheet
RNNs, LSTMs, and GRUs — Cheat Sheet
Vanilla step: h_t = tanh(W_hh h_{t-1} + W_xh x_t + b) — same weights every timestep. PyTorch: nn.RNN(8, 16, batch_first=True); out (B,T,H), h_n (layers,B,H).
Gradient to step 1 = product of W_hh^T diag(tanh'): factor 0.95 over 50 steps = 0.077 (vanishes); 1.03 over 100 = 19x (explodes). Defenses: clip_grad_norm_(params, 1.0), tanh, orthogonal W_hh, truncated BPTT, pack_padded_sequence.
LSTM:
f,i,o = sigmoid(W [h_{t-1}, x_t] + b); g = tanh(W [...])
c_t = f ⊙ c_{t-1} + i ⊙ g; h_t = o ⊙ tanh(c_t)
Gradient along state = product of forget gates (≤1); learn f≈1 to carry memory hundreds of steps. Worked: c=[1.0,.5], f=[.9,.2], i⊙g=[.1,.3] -> c'=[1.0,.4]; o=[.6,.8] -> h'=[0.457,0.304]. LSTM = 2x vanilla recurrent params.
GRU: z update, r reset, h̃ = tanh(W[x, r⊙h]), h = (1-z)⊙h_prev + z⊙h̃. No cell state; tied coefficients (hold vs rewrite budget).
Params (F=8, H=256): RNN 9,280 | GRU 204,288 | LSTM 272,384 — GRU 25% smaller.
Choosing: GRU default on smaller/shorter data (faster, fewer knobs); LSTM for long sequences / large data / when you must expose sub-state; both bad at T in the hundreds — sequential, O(T) path, one-vector bottleneck -> attention.
Extras: num_layers stacks; bidirectional=True for offline inputs only (streaming sees no future).