Personalize this lesson
Adapt explanations and teaching visuals to your background and preferred voice.
An incident assistant ranks ignore, rollback, and escalate from action probabilities. One event saying "rollback requested" is useful, but it means something different after "error logged" and "reproduction confirmed" than it does by itself.
A sequence model reads ordered evidence. Recurrent neural networks (RNNs) do this by applying one learned update repeatedly and carrying a hidden state from one event to the next. LSTMs and GRUs keep the recurrent idea, then add learned gates that control what state should survive.[1][2]

An ordered history changes a decision
The order of events is part of the input. The small accumulator below isn't a trained RNN, but it exposes the requirement: a late event has more immediate effect because it's applied after earlier state has decayed.
1import numpy as np
2
3signals = {
4 "error logged": 1.0,
5 "reproduction confirmed": 0.4,
6 "rollback requested": 0.9,
7}
8
9def running_state(events):
10 state = 0.0
11 for event in events:
12 state = 0.6 * state + signals[event]
13 return state
14
15incident_order = ["error logged", "reproduction confirmed", "rollback requested"]
16reversed_order = list(reversed(incident_order))
17
18print("incident order:", round(running_state(incident_order), 3))
19print("reversed order:", round(running_state(reversed_order), 3))
20print("same events:", sorted(incident_order) == sorted(reversed_order))1incident order: 1.5
2reversed order: 1.564
3same events: TrueThe events are identical as a set, but the final state differs. A useful model must therefore accept variable-length sequences without discarding order.
A recurrent state carries earlier evidence
A basic RNN cell updates one state vector:
Here is the current event vector and is memory from earlier events. The same matrices and are reused at every timestep. A longer history causes more applications of the cell, not more parameters.
In one dimension, the update is easy to inspect:
1import numpy as np
2
3events = [1.0, 0.4, 0.9]
4w_input, w_state, bias = 0.8, 0.5, 0.0
5state = 0.0
6
7for step, event in enumerate(events, start=1):
8 state = np.tanh(w_input * event + w_state * state + bias)
9 print(f"h_{step} = {state:.3f}")1h_1 = 0.664
2h_2 = 0.573
3h_3 = 0.764The cell turns three inputs into one final value. Real cells use vectors so different dimensions can carry different kinds of evidence.
Why can an RNN read either 3 events or 30 events without changing its parameter count?
Answer
It reuses the same input and recurrent weights at each timestep. Sequence length changes the number of cell applications, not the number of learned weights.
Trace a vector RNN
Represent each incident event with three binary features:
| Event | error_seen | repro_confirmed | rollback_requested |
|---|---|---|---|
| Error logged | 1 | 0 | 0 |
| Reproduction confirmed | 0 | 1 | 0 |
| Rollback requested | 0 | 0 | 1 |
Use a two-dimensional hidden state, small enough to print at every step:
1import numpy as np
2
3events = np.array([
4 [1.0, 0.0, 0.0],
5 [0.0, 1.0, 0.0],
6 [0.0, 0.0, 1.0],
7])
8W_xh = np.array([
9 [0.8, 0.2, 0.1],
10 [0.1, 0.7, 0.4],
11])
12W_hh = np.array([
13 [0.5, 0.1],
14 [0.0, 0.6],
15])
16b_h = np.zeros(2)
17
18state = np.zeros(2)
19for step, event in enumerate(events, start=1):
20 state = np.tanh(W_xh @ event + W_hh @ state + b_h)
21 print(f"h_{step} =", np.round(state, 3))
22
23print("final shape:", state.shape)1h_1 = [0.664 0.1 ]
2h_2 = [0.494 0.641]
3h_3 = [0.39 0.655]
4final shape: (2,)
Attach a classifier to the last hidden state exactly as you attached one to a feature vector in the previous chapter:
1import numpy as np
2
3final_state = np.array([0.390, 0.655])
4W_out = np.array([
5 [0.7, -0.3], # ignore
6 [-0.4, 0.8], # rollback
7 [0.1, 0.2], # escalate
8])
9labels = ["ignore", "rollback", "escalate"]
10
11logits = W_out @ final_state
12shifted = logits - logits.max()
13probabilities = np.exp(shifted) / np.exp(shifted).sum()
14
15print("logits:", np.round(logits, 3))
16print("probabilities:", np.round(probabilities, 3))
17print("prediction:", labels[int(probabilities.argmax())])1logits: [0.076 0.368 0.17 ]
2probabilities: [0.291 0.389 0.32 ]
3prediction: rollbackWeight sharing is the core economy of recurrence. This count stays fixed as history length grows:
1input_size, hidden_size, output_size = 3, 2, 3
2rnn_parameters = hidden_size * input_size + hidden_size * hidden_size + hidden_size
3head_parameters = output_size * hidden_size + output_size
4
5for timesteps in [3, 30, 300]:
6 print(f"{timesteps:>3} events -> {rnn_parameters + head_parameters} parameters")13 events -> 21 parameters
2 30 events -> 21 parameters
3300 events -> 21 parametersThe compression is useful but severe: every past event must influence the decision through two final hidden numbers.
When early learning signals fade
Training unfolds the recurrence across time and backpropagates through every copy. This is backpropagation through time (BPTT). For a loss attached to the final hidden state, its gradient path back to the initial state contains a product of recurrent Jacobians:
If a model receives losses at several timesteps, their backward paths add together. Each long route still contains repeated factors.
In a scalar simplification, a local derivative below 1 shrinks the signal exponentially:
1for steps in [1, 4, 8, 12]:
2 gradient_factor = 0.6 ** steps
3 print(f"{steps:>2} recurrent steps: {gradient_factor:.6f}")11 recurrent steps: 0.600000
2 4 recurrent steps: 0.129600
3 8 recurrent steps: 0.016796
412 recurrent steps: 0.002177The reverse failure also occurs. Repeated factors above 1 can make gradients too large:
1import numpy as np
2
3raw_gradient = np.array([1.4 ** 12, -0.8 * (1.4 ** 12)])
4max_norm = 5.0
5norm = np.linalg.norm(raw_gradient)
6clipped = raw_gradient * min(1.0, max_norm / norm)
7
8print("raw norm:", round(float(norm), 3))
9print("clipped norm:", round(float(np.linalg.norm(clipped)), 3))
10print("clipped gradient:", np.round(clipped, 3))1raw norm: 72.604
2clipped norm: 5.0
3clipped gradient: [ 3.904 -3.123]Gradient clipping can stop an exploding update from destabilizing training. It doesn't give a plain RNN a reliable long memory; a vanished signal is already gone.
LSTM adds a controlled memory track
Long Short-Term Memory (LSTM) introduced gated memory cells designed to preserve useful learning signals over long delays.[1] The version below uses the later, common forget-gate form. Gers et al. added the adaptive forget gate so a cell could learn when to reset state in continual streams.[3] With a cell state , the model has learned controls for keeping, writing, and exposing memory:
The key structural difference is the additive cell update. If the model learns near 1 and near 0 for an important memory dimension, that value can persist instead of being replaced at every step.
That same product also governs the backward path. Along the cell-state track, treating the gates as fixed for the derivative with respect to earlier cell values,
(up to additional terms when candidate writes also depend on through the gates). If on a dimension, the local factor is near 1, so error can travel many steps without the repeated nonlinear Jacobian product that vanishes in a plain RNN . Hochreiter and Schmidhuber called this the constant error carousel: the forget gate chooses when that path stays open.[1]
One cell dimension is enough to show the mechanism:
1import numpy as np
2
3cell = 0.0
4steps = [
5 ("error recorded", 0.00, 0.95, 0.90, 0.80),
6 ("routine check", 0.98, 0.02, 0.10, 0.80),
7 ("rollback verified", 0.97, 0.15, 0.50, 0.90),
8]
9
10for label, forget, write, candidate, expose in steps:
11 cell = forget * cell + write * candidate
12 hidden = expose * np.tanh(cell)
13 print(f"{label:15} c={cell:.3f} h={hidden:.3f}")1error recorded c=0.855 h=0.555
2routine check c=0.840 h=0.549
3rollback verified c=0.890 h=0.640routine check changes little because its write gate is small and its forget gate is high. These values explain the update; during training the network learns its gates from data.
What does an LSTM forget gate value near 1 do?
Answer
It passes most of the corresponding old cell-state dimension into the next cell state. Combined with a small input gate, it preserves that stored feature across the current step.
GRU combines the keep and write choice
The Gated Recurrent Unit (GRU) uses one hidden state instead of separate cell and exposed states.[2] Using the convention in which is the fraction kept from old state:
With this notation, close to 1 keeps the prior value. The reset gate controls how much earlier state influences the candidate . Some libraries and explanations name or arrange GRU terms differently, so check the equation before interpreting a printed gate.
1old_state = 0.80
2candidate = -0.20
3
4for keep_gate in [0.95, 0.50, 0.05]:
5 new_state = keep_gate * old_state + (1.0 - keep_gate) * candidate
6 print(f"z={keep_gate:.2f} -> h={new_state:.3f}")1z=0.95 -> h=0.750
2z=0.50 -> h=0.300
3z=0.05 -> h=-0.150
Pack padding before a library GRU
A production batch often contains event histories with different lengths. Framework code pads the shorter histories to one rectangular tensor, but those padding rows aren't real events. If a final-state classifier reads a GRU after it processes padding, the filler values can silently change the decision.
PyTorch's nn.GRU accepts a packed sequence.[4] pack_padded_sequence records each history's real length so the recurrent cell stops updating that history before its padding rows.[5] Setting batch_first=True makes the input shape (batch, timesteps, features), which matches the tensor printed below:
1import torch
2from torch import nn
3from torch.nn.utils.rnn import pack_padded_sequence
4
5torch.manual_seed(7)
6gru = nn.GRU(input_size=3, hidden_size=2, batch_first=True)
7lengths = torch.tensor([3, 2])
8base = torch.tensor([
9 [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]],
10 [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 0.0]],
11])
12changed_padding = base.clone()
13changed_padding[1, 2] = torch.tensor([9.0, -9.0, 9.0])
14
15def packed_final(batch):
16 packed = pack_padded_sequence(
17 batch, lengths.cpu(), batch_first=True, enforce_sorted=False
18 )
19 _, hidden = gru(packed)
20 return hidden[-1]
21
22def unpacked_final(batch):
23 _, hidden = gru(batch)
24 return hidden[-1]
25
26packed_changed = not torch.allclose(packed_final(base), packed_final(changed_padding))
27unpacked_changed = not torch.allclose(unpacked_final(base), unpacked_final(changed_padding))
28
29print("batch shape:", tuple(base.shape))
30print("packed final shape:", tuple(packed_final(base).shape))
31print("packed state changed by padding:", packed_changed)
32print("unpacked state changed by padding:", unpacked_changed)1batch shape: (2, 3, 3)
2packed final shape: (2, 2)
3packed state changed by padding: False
4unpacked state changed by padding: TrueThe second history has only two real events. Packing makes its final state independent of the third row, while the unpacked GRU treats that row as another event. Masking can solve the same class of problem when an architecture exposes the right state control, but padding must never become evidence.
Why can padding corrupt a final-state classifier even if every padded row uses the same placeholder value?
Answer
Without packing or masking, the recurrent cell still applies another update for each padded timestep. The classifier then receives the state after filler events instead of the state after the last real event.
An encoder can compress a whole sequence
Cho et al. trained an RNN encoder-decoder in which an encoder turns a source sequence into a fixed-size representation and a decoder generates a target sequence from it.[2] Sutskever et al. demonstrated the approach with multilayer LSTMs for sequence-to-sequence translation.[6]
The fixed-size interface is easy to see: both a short claim history and a longer one leave the encoder as the same two-number state.
1import numpy as np
2
3W_xh = np.array([[0.8, 0.2, 0.1], [0.1, 0.7, 0.4]])
4W_hh = np.array([[0.5, 0.1], [0.0, 0.6]])
5
6def encode(events):
7 state = np.zeros(2)
8 for event in events:
9 state = np.tanh(W_xh @ event + W_hh @ state)
10 return state
11
12history = np.eye(3)
13for length in [1, 2, 3]:
14 context = encode(history[:length])
15 print(f"{length} event(s): shape={context.shape}, context={np.round(context, 3)}")11 event(s): shape=(2,), context=[0.664 0.1 ]
22 event(s): shape=(2,), context=[0.494 0.641]
33 event(s): shape=(2,), context=[0.39 0.655]That fixed-size context is both an interface and a bottleneck. The next chapter studies bottlenecks directly: encoders that learn compact representations and decoders that reconstruct from them.
Why attention shortens the route
An RNN has a serial dependency: can't be computed before . In the Transformer's comparison of layer types, a recurrent layer has sequential operations and an maximum path length between positions. A self-attention layer has sequential operations and an maximum path length.[7] Full self-attention compares every allowed pair of positions, so its computation and attention-matrix size grow quadratically with sequence length.
| Question | Recurrent layer | Self-attention layer |
|---|---|---|
| Can positions be computed in parallel within a layer? | No, state is serial | Yes, with the attention mask applied |
| Longest path between two positions in one layer | ||
| Must all earlier evidence fit in one running state? | Yes | No, each allowed position can be attended to directly |
This doesn't make recurrence useless. It separates two design pressures: the fixed running-state bottleneck and the long dependency route. The next lesson isolates the bottleneck with autoencoders; the Transformer lesson after it replaces the recurrent route with attention.
The comparison is about processing positions already present in a sequence. Autoregressive generation still emits one new token at a time because each next token depends on tokens generated earlier.
Debugging checks
- Print tensor shapes at the cell boundary: input feature size, hidden size, and output size should agree with the weight matrices.
- Confirm that a timestep loop reuses weights. Creating new weights inside the loop changes the model.
- Check which GRU convention your implementation uses before reading gate values.
- For padded batches, prevent padding from updating state through packing or masking. The packed-GRU example above shows the failure directly.
- Clip exploding gradients when needed, then diagnose vanishing memory separately.
Mastery check
Key concepts
- Ordered events require a state that's updated in order.
- An RNN reuses one cell's weights across all timesteps.
- The final hidden state can feed a standard logits and softmax action head.
- BPTT multiplies recurrent derivatives across time, producing vanishing or exploding gradients.
- LSTM uses a separate cell state plus forget, input, and output gates.
- GRU uses update and reset gates around a single hidden state.
- Packed recurrent inputs prevent padding rows from changing a shorter history's final state.
- Encoder-decoder recurrence exposes the fixed-size sequence bottleneck.
- Self-attention reduces the path length between distant positions within a layer.
Evaluation rubric
- Foundational: Writes the RNN recurrence and explains weight sharing across timesteps.
- Foundational: Traces the three-event forward pass and maps the final state to action logits.
- Intermediate: Explains vanishing and exploding gradients as repeated derivative products.
- Intermediate: Calculates one LSTM cell update and interprets its gates.
- Intermediate: Reads a declared GRU update-gate convention without reversing keep and write behavior.
- Intermediate: Demonstrates why a padded batch needs packing or masking before a final-state classifier.
- Advanced: Compares a recurrent encoder bottleneck with a self-attention layer's direct paths.
Common pitfalls
- Symptom: Reordering events doesn't affect the result. Cause: History was pooled as an unordered set. Fix: Process events in timestamp order and test a reversed sequence.
- Symptom: Parameter count is claimed to grow with sequence length. Cause: Unrolled computation was confused with separate parameters. Fix: Mark every timestep as sharing
W_xhandW_hh. - Symptom: Training produces unstable updates. Cause: Recurrent gradients explode. Fix: Inspect gradient norms and apply clipping while checking learning-rate and recurrence choices.
- Symptom: Early evidence is never learned. Cause: Gradients vanish through many recurrent updates. Fix: Test gated recurrence or an attention-based architecture for the required dependency length.
- Symptom: A GRU gate is explained backward. Cause: A different update-gate convention was assumed. Fix: State the exact equation before interpreting
z_t. - Symptom: Short histories change prediction when batch padding changes. Cause: Padding rows updated the recurrent state as if they were events. Fix: Pack real sequence lengths or mask state updates before classification.
Follow-up questions
Why does the final RNN state contain information from all three incident events?
Answer
Each update consumes the current event and the previous state. The second state depends on the first event through the first state, and the third state depends on both earlier updates through the second state.
Why can clipping an exploding gradient not repair a vanished gradient?
Answer
Clipping limits an update whose norm is too large. A vanished gradient has already shrunk toward zero, so limiting its norm can't restore the missing learning signal.
What extra path does an LSTM add beyond a plain RNN hidden state?
Answer
It adds a cell state updated by a gated additive rule. A forget gate near 1 and an input gate near 0 can carry a stored value across a step with little change.
Why can a forget gate near 1 keep gradients from vanishing along the cell path?
Answer
Along the cell track, ∂c_t/∂c_{t-1} equals the forget gate (on the diagonal). A gate near 1 multiplies the backward signal by about 1 at that step, so the error isn't forced through a full nonlinear Jacobian product the way a plain RNN hidden-state path is.
What bottleneck does a classic recurrent encoder-decoder create?
Answer
The encoder must place the information needed by the decoder into a fixed-size final context state, regardless of input sequence length.