Home / Deep Learning

What is RNN?

By Updated

Take a microscopic look inside a single Recurrent Neural Network block. Visualize how it simply merges past memory with new input data to generate a new context state.

Waiting for Data...

Internal Flow

$H_{t-1} \to H_t$ (Hidden State)
$X_t$ (New Input Data)
Neural Network Layer (W)
Mathematical Operations
$+$
Vector Concatenation / Add

Overview

Take a microscopic look inside a single Recurrent Neural Network block.

In this guide
  1. Unrolling
  2. Counting the parameters
  3. Backpropagation through time
  4. The shapes a recurrent network can take
  5. The encoder-decoder, and the bottleneck that motivated attention
  6. Depth, and how to add it
  7. One weight matrix, applied over and over
  8. Things to try
  9. The sequence shapes an RNN can be wired into
  10. Where this goes wrong
  11. In one line
  12. Building one end to end
  13. Training notes
  14. Questions people ask
  15. Recap in one screen

RNN Architecture: A Practical Guide

Unrolling a recurrent cell across time gives a network as deep as the sequence is long - sharing one set of weights the whole way. That is what makes RNNs efficient, and what makes them hard to train.

Unrolling

An RNN is often drawn as a cell with an arrow looping back to itself. That is compact and slightly misleading. To understand training, unroll it: draw one copy of the cell per timestep, with the hidden state flowing left to right between copies.

Unrolled, an RNN over a 50-token sentence is a 50-layer deep network. Each layer is the same layer — the same weights — but the compute graph really is that deep. And the gradient really does have to travel all the way back through it. Weight sharing is the structural edge: a single set of parameters handles any sequence length. So a model trained on 20-token sentences can process 200-token ones at inference with no extra parameters. But the gradient that must travel back through 50 (or 200) the same matrix products either explodes or vanishes. This is why plain RNNs struggle with dependencies longer than about 10-20 steps. LSTMs and GRUs add gating methods that create an additive path for the gradient, mitigating the vanishing problem at the cost of more parameters per cell. The hidden state has a fixed size (the hidden dimension), no matter how long the sequence is. All data about the past must be compressed into that one vector. This is the basic bottleneck. Attention removes it by letting the decoder look right at each encoder state. This page unrolls an RNN across a sequence, shows the hidden-state flow, and covers how gradient size changes with depth.

Counting the parameters

A recurrent cell with input size d and hidden size h has:

Wx: d × h    Wh: h × h    b: h

total = dh + h² + h

With d = 100 and h = 128 that is 12,800 + 16,384 + 128 = 29,312 parameters. And that number does not change whether the sequence is 5 tokens or 5,000. The h² term often rules, which is why hidden size is the costly dimension and doubling it roughly quadruples the recurrent weights.

Backpropagation through time

Training unrolls the network, runs a normal forward pass. Then backpropagates from the loss all the way back to timestep 1. Because the same weights appear at each step, the gradient for Wh is the sum of its gradient shares from all timesteps.

This is called backpropagation through time. And it has two real results. Memory grows linearly with sequence length, since each middle hidden state must be kept for the backward pass. And the gradient reaching early timesteps is a long product. So it vanishes or explodes just as depth causes it to in a feedforward network.

Truncated BPTT is the standard mitigation. Backpropagate only k steps back, often 20 to 50, and treat a thing earlier as constant. It bounds memory and gradient depth at the cost of never learning dependencies longer than k.

The shapes a recurrent network can take

The cell is the same in each case. What changes is where inputs enter and where outputs are taken. And that decides what the network can do.

ShapeInput → outputExample
One-to-manyOne vector → sequenceImage captioning
Many-to-oneSequence → one valueSentiment classification
Many-to-many, alignedSequence → same-length sequencePart-of-speech tagging
Many-to-many, unalignedSequence → different-length sequenceTranslation

Many-to-one takes an output only at the end. Use pooled hidden states rather than only the final one. Since the final state over-weights the end of the input.

Many-to-many aligned takes an output at each step, and a bidirectional layer often helps because right context disambiguates.

Many-to-many unaligned needs two networks: an encoder that reads the input and a decoder that gives the output. This is where attention was invented.

The encoder-decoder, and the bottleneck that motivated attention

For translation, an encoder reads the source sentence and its final hidden state is passed to a decoder. This makes the target one token at a time.

That final state is a fixed-size vector. 512 numbers, say. And it must hold all of it about the source sentence. For five words that is comfortable. For fifty it is a severe compression, and translation grade was observed to fall sharply with sentence length.

Attention removed the bottleneck: instead of passing only the final state, keep all the encoder's hidden states, and let the decoder compute a weighted combination at each output step, choosing what to look at.

That change — added for just this problem — improved translation at once, and within a few years the attention method had displaced the recurrence it was invented to assist. "Attention is all you need" is a literal description of what happened next.

Depth, and how to add it

Stacking recurrent layers means feeding one layer's output sequence into the next:

lstm = nn.LSTM(300, 256, num_layers=2, batch_first=True, dropout=0.2)

The dropout input applies between layers, not within the recurrence. Applying dropout to the recurrent connection with a different mask each step damages the memory. This is why variational dropout (the same mask at each step) exists.

Two layers is the real limit for most tasks. Deep recurrent stacks are hard to train: the gradient must travel back through both depth and time. And the two decays compound. Residual connections between layers help and are less standard here than in transformers.

Two other structural choices:

Bidirectional layers run a second pass backwards and concatenate. This doubles the output width. Excellent for tagging and classification, unusable for generation or streaming.

Where the output head goes. On the final layer, always. But note that in a stacked bidirectional model, extracting the right final states needs care. Since each way's final state corresponds to a different end of the sequence.

One weight matrix, applied over and over

An RNN is a loop with shared weights. Unrolling it shows why that sharing is the whole idea and also the whole problem.

example_01.pyNumPy
Output

Building on the same setup, the next block shows the left cases.

example_02.pyNumPy
Output

The below block covers the left steps.

example_03.pyNumPy
Output

The next block goes on with the left demonstrations.

example_04.pyNumPy
Output

Things to try

  1. See where the parameters live. Press Parameters and read the breakdown. The recurrent matrix is the largest block, because it is h × h while the input matrix is only d × h.
  2. Grow the hidden state. Raise Hidden State from 10 to 40 and check the parameter count again. It grows roughly with the square, not linearly. The h² term taking over.
  3. Grow the input instead. Raise Input Vector by the same amount. The parameter count rises far less, because that dimension only appears in the d × h term.
  4. Watch one step execute. Press Simulate Cell Step and follow the two matrix products combining and passing through tanh. That single step is what gets repeated once per token, with the same weights each time.

The sequence shapes an RNN can be wired into

  • Many-to-one — read the whole sequence, use only the final hidden state. Sentiment classification.
  • Many-to-many, aligned — one output per input. Part-of-speech tagging and named entity recognition.
  • Many-to-many, unaligned — an encoder reads the input into a state and a decoder makes a different-length output. Translation, and the design attention was invented to improve.
  • One-to-many — a single input makes a sequence. Image captioning.

Where this goes wrong

  • Not clipping gradients. The repeated product makes explosion common. Clipping the global norm at around 1.0 is close to mandatory for recurrent models.
  • Padding without masking. Batching needs equal lengths, so short sequences are padded. And unless those spots are masked, the model learns from the padding and the loss is computed over meaningless tokens.
  • Running out of memory on long sequences. Each hidden state is retained for the backward pass. Truncate the BPTT window rather than reducing the batch to one.
  • Expecting parallelism. Timestep t needs the state from t−1, so an RNN is inherently sequential and cannot use a GPU the way a transformer can. That, more than precision, is why transformers displaced them.

In one line

An RNN is one cell unrolled across time, sharing weights at each step. So its parameter count is fixed at dh + h² + h no matter sequence length. Training backpropagates through the whole unrolled graph, which makes the effective depth equal to the sequence length. Hence vanishing gradients, mandatory gradient clipping, and truncated BPTT. The sequential dependency is also what stops it parallelising, which is what in time made attention the better design.

Building one end to end

class TextClassifier(nn.Module):
    def __init__(self, vocab, emb=300, hidden=256, classes=2):
        super().__init__()
        self.embed = nn.Embedding(vocab, emb, padding_idx=0)
        self.lstm = nn.LSTM(emb, hidden, num_layers=2, batch_first=True,
                            bidirectional=True, dropout=0.3)
        self.drop = nn.Dropout(0.3)
        self.head = nn.Linear(hidden * 2, classes)     # *2 for bidirectional

    def forward(self, ids, lengths):
        x = self.embed(ids)
        packed = pack_padded_sequence(x, lengths, batch_first=True,
                                      enforce_sorted=False)
        out, _ = self.lstm(packed)
        out, _ = pad_packed_sequence(out, batch_first=True)

        mask = (ids != 0).unsqueeze(-1)
        pooled = (out * mask).sum(1) / mask.sum(1).clamp(min=1)
        return self.head(self.drop(pooled))

Four details in that code are the ones that matter in practice: padding_idx=0 keeps the pad embedding at zero, packing stops the LSTM handling padding, hidden * 2 accounts for bidirectionality. And the masked mean pooling ignores padding when summarising.

Getting any of the four wrong gives a model that trains and lags without an error message.

Training notes

Clip gradients. clip_grad_norm_(params, 5.0). Recurrent models are the classic case, and omitting it in time gives NaN.

Sort or bucket by length when batching, so batches hold close lengths and less work is wasted on padding.

Watch memory against sequence length. Trigger memory grows linearly with the number of steps. So a model that fits at 200 tokens may fail at 500.

Use pretrained embeddings where available, and think of freezing them with small datasets. There is far less to overfit.

Truncate backpropagation for very long sequences, detaching the state each k steps.

Questions people ask

How many layers should I use? One or two. Deeper recurrent stacks rarely repay the training trouble.

Should I use the last hidden state or pool? Pool, for classification.

Where does dropout go? Between layers via the dropout input, and after pooling before the head. Not on the recurrent connection with a per-step mask.

Bidirectional or not? Bidirectional for classification and tagging; unidirectional for generation and streaming.

Is an encoder-decoder RNN still used? Rarely for translation. The structure survives in transformers, with attention doing the work the recurrence used to.

Why did attention replace this? It removed the fixed-size bottleneck between encoder and decoder. Then proved enough without the recurrence at all.

Recap in one screen

  • One cell, four arrangements: one-to-many, many-to-one, aligned and unaligned many-to-many.
  • Unaligned tasks need an encoder and a decoder. And the fixed-size state between them was the bottleneck attention was invented to remove.
  • Stack at most two layers; put dropout between them, not inside the recurrence.
  • Bidirectional doubles the output width and needs the complete input.
  • Pack padded batches, clip gradients, and pool over states rather than taking the last one.

Recall check

0 of 3

Say the answer out loud before you reveal it — recalling it is what makes it stick, and rereading it is not.

  1. Without scrolling back — what is the one-line takeaway from this module?

  2. What does this module say about “Unrolling”?

  3. What does this module say about “Internal Flow”?

Cheat sheet

RNN Architecture

Unroll a recurrent cell across a sequence and see a 50-token sentence become a 50-layer deep network sharing one set of weights.

NLP · vizlearn.in/natural_language_processing/rnn_architecture.html

Further reading

About the author

Ashish Jangra builds and maintains VizLearn. Every module here is written and the visualisation behind it hand-built, so the numbers in a readout come from the same code that draws the picture. Corrections are genuinely welcome and get priority over everything else — if a page states something wrong, or an animation misrepresents what the algorithm does, get in touch.