Skip to content
Machine Learning and Neural Networks

Sequences: recurrent networks, vanishing gradient, LSTM, attention and transformers

Recurrent networks and backpropagation through time, the vanishing gradient proved and measured, the LSTM, attention with the proof of the division by the square root of d_k, the transformer and positional encoding.

Texts, audio signals, sensor time series, trajectories: many data are sequences, in which order matters and length varies. This lesson presents the two families of networks for sequences. Recurrent networks (RNNs) read one element at a time and carry a state with them; it is proved why their gradient vanishes on long sequences and how LSTMs avoid this. Transformers (2017) look at the whole sequence together with the attention mechanism; it is proved why the dot product must be divided by \sqrt{d_k}. Transformers are today the basis of language models and are increasingly used for images and signals too.

Recurrent networks

An RNN updates a hidden state \mathbf h_t at each element \mathbf x_t of the sequence, with the same weights at every step:

    \[ \mathbf h_t = \tanh(W\mathbf h_{t-1} + U\mathbf x_t + \mathbf b) \]

It is a non-linear discrete-time dynamical system, like those of Systems Theory, with learned parameters. To train it the network is “unrolled” in time (one copy per step) and backpropagation is applied: this is backpropagation through time (BPTT).

Theorem (vanishing or exploding gradient). \dfrac{\partial\mathbf h_T}{\partial\mathbf h_0} = \prod_{t=1}^{T}\text{diag}\big(\tanh'(\mathbf z_t)\big)\,W, so \Big\|\dfrac{\partial\mathbf h_T}{\partial\mathbf h_0}\Big\| \le \big(\|W\|\cdot\max|\tanh'|\big)^T.

Proof. By the chain rule \frac{\partial\mathbf h_T}{\partial\mathbf h_0} = \prod_t\frac{\partial\mathbf h_t}{\partial\mathbf h_{t-1}}, and differentiating \mathbf h_t = \tanh(\mathbf z_t) with \mathbf z_t = W\mathbf h_{t-1} + \dots gives \frac{\partial\mathbf h_t}{\partial\mathbf h_{t-1}} = \text{diag}(\tanh'(\mathbf z_t))\,W. The norm of a product is at most the product of the norms, and |\tanh'| \le 1. ∎

If \|W\|\cdot\max|\tanh'| \lt  1 the gradient decreases exponentially with distance: the network cannot learn that an input far in the past determines today’s output. If it is greater than 1 it can explode (it is limited by clipping the gradient norm, as in PPO).

The LSTM

The LSTM (long short-term memory, Hochreiter and Schmidhuber, 1997) adds a memory cell \mathbf c_t controlled by three gates (values between 0 and 1 given by sigmoids):

    \[ \mathbf f_t,\ \mathbf i_t,\ \mathbf o_t = \sigma(\dots),\qquad \mathbf c_t = \mathbf f_t\odot\mathbf c_{t-1} + \mathbf i_t\odot\tanh(\dots),\qquad \mathbf h_t = \mathbf o_t\odot\tanh(\mathbf c_t) \]

The forget gate \mathbf f decides how much of the memory to keep, the input gate \mathbf i how much to write into it, the output gate \mathbf o how much of it to show. The key point is that the cell is updated by addition: \partial\mathbf c_t/\partial\mathbf c_{t-1} = \text{diag}(\mathbf f_t), with no multiplications by W or derivatives of saturating functions. If the network learns \mathbf f \approx 1, the gradient crosses many steps almost intact. The GRU (2014) is a simpler variant with two gates.

The measurement. For freshly initialised networks (32-neuron state, average over 10 initialisations) automatic differentiation computes how much the output at the last step depends on the first input:

length TRNNLSTM
14.6\cdot 10^{-1}8.1\cdot 10^{-2}
102.0\cdot 10^{-3}1.9\cdot 10^{-2}
201.4\cdot 10^{-5}2.9\cdot 10^{-3}
501.4\cdot 10^{-11}3.3\cdot 10^{-5}
1003.6\cdot 10^{-21}4.6\cdot 10^{-8}
Sensitivity of the final output to the first input as a function of sequence length
On a logarithmic scale the RNN, red, falls like a steep straight line down to 10 to the minus 21 after 100 steps; the LSTM, blue, falls much more slowly and after 100 steps is still at 10 to the minus 8.

In the RNN the signal shrinks by a factor of about 0.6 at each step (the straight line on a logarithmic scale); after 100 steps it is 10^{13} times weaker than in the LSTM.

Attention

Even the LSTM struggles beyond a few hundred steps, and above all it works sequentially: step t waits for step t - 1, and it cannot be parallelised on the GPU. Attention directly connects every position with all the others. Each element produces three vectors: a query \mathbf q, a key \mathbf k and a value \mathbf v, with learned weight matrices. The output for a position is an average of the values of all positions, weighted by how much its query “resembles” each key:

    \[ \text{Attention}(Q, K, V) = \text{softmax}\Big(\frac{QK^T}{\sqrt{d_k}}\Big)V \]

where the rows of Q, K, V are the queries, keys and values of the n positions, and d_k is the key dimension.

Theorem (why divide by \sqrt{d_k}). If the components of \mathbf q and \mathbf k are independent with mean 0 and variance 1, the dot product \mathbf q^T\mathbf k has mean 0 and variance d_k.

Proof. \mathbf q^T\mathbf k = \sum_{i=1}^{d_k}q_ik_i. Each term has mean \mathbb E[q_i]\mathbb E[k_i] = 0 and variance \mathbb E[q_i^2]\mathbb E[k_i^2] = 1; the terms are independent, so the variances add: d_k. ∎

With d_k = 512 the scores would have a standard deviation of about 23: the softmax of such large numbers is almost “one only, all the others zero”, and its gradient is almost zero. Dividing by \sqrt{d_k} brings the variance back to 1. In simulation (code at the end): variance 4.0, 64.0 and 509 for d_k = 4, 64, 512, and 1.00, 1.00, 0.99 after the division.

An attention matrix
Each row is a position that “asks”, each column a position that “answers”; the colour shows the weight given by the softmax. The rows sum to 1: each output is a weighted average of the values of all positions.

The transformer

The transformer (Vaswani et al., 2017, “Attention is all you need”) stacks identical blocks, each with:

  • multi-head attention: several attentions in parallel, with different matrices, which can specialise (one follows syntax, another references);
  • a small dense network applied to each position separately;
  • residual connections and normalisation (layer norm) around both, as in ResNets.

Attention knows nothing about order (permuting the positions permutes the outputs in the same way), so a positional encoding is added to the inputs: in the original work sinusoids of different frequencies, today often rotations (RoPE). The cost of attention grows as n^2 with the sequence length n, and it is the main limit of long-context models. In exchange all positions are computed in parallel, and this, more than anything else, is what made it possible to train models with hundreds of billions of parameters. To generate text a causal mask is used: each position can only look at the previous ones, and the model learns to predict the next word.

The code

import numpy as np
import torch, torch.nn as nn

# 1) attention in NumPy
def attention(Q, K, V):
    scores = Q @ K.T / np.sqrt(K.shape[1])
    weights = np.exp(scores - scores.max(1, keepdims=True))
    weights /= weights.sum(1, keepdims=True)               # row-wise softmax
    return weights @ V, weights

g = np.random.default_rng(0)
X = g.normal(0, 1, (6, 16))                                # 6 positions, 16 dimensions
Wq, Wk, Wv = (g.normal(0, 0.25, (16, 8)) for _ in range(3))
output, weights = attention(X @ Wq, X @ Wk, X @ Wv)
print("output", output.shape, "- the weight rows sum to", np.round(weights.sum(1), 6))

# 2) variance of the dot product before and after dividing by sqrt(d_k)
for dk in (4, 64, 512):
    q, k = g.normal(0, 1, (20000, dk)), g.normal(0, 1, (20000, dk))
    s = (q * k).sum(1)
    print(f"d_k = {dk:3d}: variance {s.var():6.1f}, after division {(s / np.sqrt(dk)).var():.3f}")

# 3) sensitivity of the last output to the first input, freshly initialised RNN versus LSTM
def sensitivity(kind, T, H=32, inits=10):
    values = []
    for s in range(inits):
        torch.manual_seed(s)
        net = (nn.RNN if kind == "rnn" else nn.LSTM)(1, H, batch_first=True)
        if kind == "lstm":                                 # forget gate initially open (bias 1)
            for name, p in net.named_parameters():
                if "bias_ih" in name:
                    p.data[H:2 * H] = 1.0
        x = torch.randn(1, T, 1, requires_grad=True)
        outputs, _ = net(x)
        outputs[0, -1].sum().backward()
        values.append(x.grad[0, 0].abs().item())
    return sum(values) / len(values)

for T in (1, 10, 20, 50, 100):
    print(f"T = {T:3d}: RNN {sensitivity('rnn', T):.1e}, LSTM {sensitivity('lstm', T):.1e}")
Subscribe
Notify of
guest
0 Comments
Oldest
Newest Most Voted
0
Would love your thoughts, please comment.x
()
x