← Back to the learning map

DEEP LEARNING / WEEK 06

Recurrent Neural Networks and Memory

A sequence model must decide what to carry forward. Trace a recurrent state through time, derive its gradient problems, and see how gates create a more controllable memory path.

7 lessonsInteractive labPython experimentPractice + answers

Bring with you: Matrix shapes, the chain rule, gradient clipping, and residual connections.

BY THE END OF THIS WEEK

  • Calculate a recurrent hidden-state trace.
  • Explain backpropagation through time and its gradient problems.
  • Compare RNN, LSTM, and GRU updates.
  • Classify variable-length sequences without treating padding as observations.

LESSON 6.1

Reuse one cell across time

A recurrent neural network updates a hidden state from the current input and previous state. The same parameters are used at every step. Parameter count does not grow with sequence length, although computation and saved activations do.

The hidden state is a learned summary, not an archive of every earlier input. A many-to-one model makes one decision about a sequence; a many-to-many model makes position-wise predictions. Whether the state retains useful information must be tested.

hβ‚œ = tanh(Wxh xβ‚œ + Whh hβ‚œβ‚‹β‚ + bβ‚•)
logitsβ‚œ = Why hβ‚œ + bα΅§

LESSON 6.2

Backpropagation through the unrolled graph

Backpropagation through time is ordinary reverse-mode differentiation on the unrolled recurrent computation. Because each weight is reused, its gradient accumulates contributions from every use.

Long-range sensitivity contains a product of Jacobians. Saturated tanh derivatives and small gains can shrink a signal; large gains can amplify it. Matrix directions and singular values matter, so a scalar multiplier is an illustration rather than a complete stability diagnosis.

Jβ‚œ = βˆ‚hβ‚œ/βˆ‚hβ‚œβ‚‹β‚ = diag(1 βˆ’ hβ‚œΒ²) Whh
βˆ‚hT/βˆ‚hβ‚– = JT JT₋₁ … Jβ‚–β‚Šβ‚

Keep this idea. A long sequence creates a deep computation graph even when the cell is small.

LESSON 6.3

Control explosions and bound the graph

Global norm clipping scales down a gradient when its norm exceeds a threshold. It preserves direction for that update but cannot recover a gradient that has already vanished. Clip after backward and before the optimizer step.

Truncated BPTT detaches a carried state at selected boundaries. Detaching preserves the numerical state while cutting its gradient history, reducing memory and limiting credit assignment. Resetting changes the value too; it is a different operation.

gclipped = g Β· min(1, c / ||g||β‚‚)
loss.backward()
nn.utils.clip_grad_norm_(model.parameters(), 5.0)
optimizer.step()
# LSTM state at a truncation boundary:
h, c = h.detach(), c.detach()

LESSON 6.4

An additive memory with gates

An LSTM separates cell state c from exposed hidden state h. The forget gate controls retention, the input gate controls writing a candidate, and the output gate controls exposure. Sigmoid gates are learned functions of the current input and previous hidden state.

Holding the gate values fixed, the direct cell-state derivative is the forget gate. The total derivative also includes dependencies through gates and hidden states. Long retention is possible, not guaranteed: multiplying forget gates below one still attenuates the direct path.

fβ‚œ, iβ‚œ, oβ‚œ = sigmoid(affine functions of [xβ‚œ,hβ‚œβ‚‹β‚])
gβ‚œ = tanh(Wg[xβ‚œ,hβ‚œβ‚‹β‚] + bg)
cβ‚œ = fβ‚œ βŠ™ cβ‚œβ‚‹β‚ + iβ‚œ βŠ™ gβ‚œ
hβ‚œ = oβ‚œ βŠ™ tanh(cβ‚œ)

INTERACTIVE / CHANGE ONE ASSUMPTION

Retain, write, expose

Start with cell state 0.8. Change fixed gate values for one update, then inspect the direct retention path across repeated steps without new writing.

Updated cell c
0.8200
Exposed hidden h
0.4725
Direct retention f^20
0.12158
0.00.51.0Direct retentionstartend

The plot holds gates fixed and isolates one path. It is not the full LSTM Jacobian, which also includes learned gate dependencies.

LESSON 6.5

GRU: a compact gated alternative

A GRU has one state with reset and update gates. The reset gate changes how the past enters the candidate, while the update gate blends old state and candidate. It has no separate LSTM-style cell state.

Here z weights the new candidate, matching the lecture convention. PyTorch calls the old-state retention weight z and uses a particular reset placement. Translate conventions before comparing a hand calculation to library output.

Lecture convention:
hΜƒβ‚œ = tanh(Wx xβ‚œ + Wh(rβ‚œ βŠ™ hβ‚œβ‚‹β‚) + b)
hβ‚œ = (1βˆ’zβ‚œ) βŠ™ hβ‚œβ‚‹β‚ + zβ‚œ βŠ™ hΜƒβ‚œ
PyTorch update: hβ‚œ = zβ‚œ βŠ™ hβ‚œβ‚‹β‚ + (1βˆ’zβ‚œ) βŠ™ nβ‚œ
CellStateTradeoff
RNNhSimple; long credit paths can be difficult.
LSTMh and cSeparate memory path; more gates.
GRUhCompact gating; task-dependent performance.

LESSON 6.6

Do not invent observations through padding

With batch_first=True, inputs have shape [batch,time,features]. An ordinary RNN keeps updating through padded zeros because recurrent weights and biases still act. Both output[:,βˆ’1] and h_n from an unpacked padded input can therefore include nonexistent time steps.

Pack inputs with their true lengths or implement masked updates. With a packed, single-direction LSTM, the final layer's h_n corresponds to the last real step. In a bidirectional model, concatenate the final layer's forward and backward final states. h_n[-1] alone gives only one direction. Bidirectionality is appropriate only when future context is available at inference.

packed = nn.utils.rnn.pack_padded_sequence(
    x, lengths.cpu(), batch_first=True, enforce_sorted=False
)
_, (h_n, c_n) = lstm(packed)
logits = head(h_n[-1])  # final layer, one direction

LESSON 6.7

Teacher forcing and the context bottleneck

Teacher forcing feeds the correct previous output token during decoder training. At inference, a generator consumes its own earlier predictions. Errors then change later inputs, creating a mismatch between training and generation contexts.

An encoder–decoder can compress a source sequence into a final context state. Long inputs stress this fixed-size channel. Attention will allow a decoder to retrieve different information from multiple source positions.

Compare recurrent cells under fixed data splits and tuning budgets. Report results by sequence length, clipping frequency, and padding policy. Split related sequences by source or time when random rows would leak correlated information.

WHEN THE MODEL SURPRISES YOU

Mistakes worth catching early

Zero padding is harmless

Biases and recurrence still change state. Pack or mask using true lengths.

Clipping fixes vanishing gradients

Clipping limits large norms; it cannot restore a tiny long-range signal.

Detach means reset

Detach keeps values and removes gradient history; reset changes values.

Bidirectional means online-ready

The backward direction uses future observations, which may be unavailable in deployment.

FROM EQUATIONS TO A RUNNING MODEL

Remember the first observation

Compare RNN, GRU, and LSTM on variable-length sequences whose first observation determines the label. Later observations are distractors. Each model uses packed inputs, validation checkpoint selection, and a reserved test set.

Download the script, create a Python environment, and run the commands below in the folder containing the download. The example uses PyTorch on CPU and generates its own small dataset. No dataset or model download is required.

python -m venv .venv
# macOS / Linux: source .venv/bin/activate
# Windows PowerShell: .venv\Scripts\Activate.ps1
python -m pip install torch
python dl-week-6.py
# Fast execution check (not a convergence result):
python dl-week-6.py --smoke
Download Week 6 Python ↓
Read the complete, runnable experiment
"""Week 6: packed RNN/GRU/LSTM memory study. CPU, synthetic data only.
Install: python -m pip install torch
Run: python dl-week-6.py [--smoke]
"""
import argparse
import copy
import torch
from torch import nn
from torch.nn import functional as F
from torch.utils.data import DataLoader, TensorDataset

torch.set_num_threads(1)


def dataset(n, seed, longest=10):
    g = torch.Generator().manual_seed(seed)
    lengths = torch.randint(4, longest + 1, (n,), generator=g)
    y = torch.randint(0, 2, (n,), generator=g)
    x = .2 * torch.randn(n, longest, 1, generator=g)
    x[:, 0, 0] = 2 * y.float() - 1
    x[torch.arange(longest)[None, :] >= lengths[:, None]] = 0
    return TensorDataset(x, lengths, y)


class MemoryModel(nn.Module):
    def __init__(self, kind):
        super().__init__()
        self.cell = getattr(nn, kind)(1, 24, batch_first=True)
        self.head = nn.Linear(24, 2)

    def forward(self, x, lengths):
        packed = nn.utils.rnn.pack_padded_sequence(
            x, lengths.cpu(), batch_first=True, enforce_sorted=False)
        _, state = self.cell(packed)
        hidden = state[0] if isinstance(state, tuple) else state
        return self.head(hidden[-1])


@torch.no_grad()
def evaluate(model, data):
    model.eval()
    x, lengths, labels = data.tensors
    logits = model(x, lengths)
    return F.cross_entropy(logits, labels).item(), (logits.argmax(1) == labels).float().mean().item()


def main():
    p = argparse.ArgumentParser()
    p.add_argument('--smoke', action='store_true')
    args = p.parse_args()
    print('PyTorch', torch.__version__)
    train = dataset(128 if args.smoke else 640, 10)
    val, test, longer = dataset(200, 11), dataset(300, 12), dataset(300, 13, 24)
    for kind in ('RNN', 'GRU', 'LSTM'):
        torch.manual_seed(7)
        model = MemoryModel(kind)
        # Restart the same batch order for each architecture.
        loader = DataLoader(train, batch_size=64, shuffle=True,
                            generator=torch.Generator().manual_seed(8))
        optimizer = torch.optim.Adam(model.parameters(), lr=.008)
        best_loss, _ = evaluate(model, val)
        before = best_loss
        best = copy.deepcopy(model.state_dict())
        max_grad = 0.
        for _ in range(2 if args.smoke else 24):
            model.train()
            for x, lengths, labels in loader:
                optimizer.zero_grad()
                loss = F.cross_entropy(model(x, lengths), labels)
                assert torch.isfinite(loss)
                loss.backward()
                norm = nn.utils.clip_grad_norm_(model.parameters(), 5.)
                max_grad = max(max_grad, norm.item())
                optimizer.step()
            current, _ = evaluate(model, val)
            if current < best_loss:
                best_loss, best = current, copy.deepcopy(model.state_dict())
        model.load_state_dict(best)
        # Extra zero padding must not alter a packed sequence's final state.
        x, lengths, _ = test.tensors
        model.eval()
        with torch.no_grad():
            assert torch.allclose(model(x, lengths), model(F.pad(x, (0, 0, 0, 5)), lengths), atol=1e-6)
        _, acc = evaluate(model, test)
        _, stress = evaluate(model, longer)
        print(f'{kind:5} val {before:.4f}->{best_loss:.4f}; test acc={acc:.3f}; '
              f'longer acc={stress:.3f}; max pre-clip norm={max_grad:.3f}')
    print('Fixed toy split; longer set changes length distribution. Repeat seeds before ranking cells.')
    if args.smoke:
        print('Smoke run checks execution, not convergence.')


if __name__ == '__main__':
    main()

Read the output, then challenge it

  • Verify true lengths exclude the padding from recurrent computation.
  • Compare validation losses and test accuracy without assuming a universal winning cell.
  • Increase sequence length in a separate stress study and inspect performance by length.
  • Observe pre-clipping gradient norms to distinguish instability from weak long-range learning.

These are small mechanism studies, not benchmark claims. Seeds make runs easier to compare; versions and hardware can still change exact results. Decide your validation protocol before tuning and reserve final test results for reporting.

PREDICT β†’ CALCULATE β†’ EXPLAIN

Practice with worked answers

1. Calculate direct retention over 20 steps at f=0.9 and f=0.99.

Check your reasoning

The multipliers are approximately 0.1216 and 0.8179. This holds gates fixed; it is not the full recurrent Jacobian.

2. What does a bidirectional LSTM with hidden size 32 output?

Check your reasoning

There are 64 features per time step. For L layers, h_n has shape [2L,batch,32]. Concatenate the final layer's two directions for a complete final representation.

3. Why might two sequences with the same real values produce different final states?

Check your reasoning

If they have different padding lengths and run unpacked, the model executes different numbers of updates. Dropout or an incorrectly reused initial state can also change outputs.

4. Design a memory stress test.

Check your reasoning

Make a label depend on an early cue followed by distractors. Evaluate held-out length buckets, including longer sequences, and compare cells with the same split and tuning budget.

EXPLAIN IT WITHOUT THE CODE

Week 6 review questions

01Why share weights over time?

To learn a reusable transition rule and process different sequence lengths with one parameter set.

02Is BPTT a different gradient algorithm?

It is backpropagation on an unrolled recurrent graph.

03What does the LSTM output gate control?

Exposure of tanh(cell state) as the hidden state.

04Can the cell state exceed one?

Yes; the additive cell state is not bounded by tanh.

05Why do GRU equations differ?

Update-gate meaning and reset placement vary between conventions.

06Does h_n always ignore padding?

No. Appropriately packed input avoids the padded updates; ordinary padded input does not.

07What is teacher forcing?

Using reference previous outputs as decoder inputs during training.

08Why introduce attention?

To retrieve from multiple source positions instead of relying solely on one compressed final context.

RESEARCH CONNECTIONS

Follow the original ideas

Course alignment: AI Algorithms 2, lecture Week 7. This site counts from Week 0. Explanations, browser labs, and runnable experiments are authored study companions to the supplied lecture material.