Skip to content

Seq2seq, Bahdanau Attention, and Why Recurrence Hit a Wall

11 min read

How do you translate a sentence from English to French using a neural network?

The input and output may have different lengths and word orders. "I am going to the store" becomes the shorter "Je vais au magasin." In "the black cat" → "le chat noir," the adjective moves after the noun. A translation model has to learn these correspondences without assuming one output token per input token.

In 2014, Cho et al. introduced an RNN encoder-decoder that scored phrase pairs for a statistical translation system. Sutskever et al. demonstrated end-to-end translation with a deep LSTM encoder-decoder on WMT'14 English–French. Both pass information from an encoder to a decoder; the design question here is how much of the source the decoder can access.


The encoder-decoder architecture

You take two RNNs (or LSTMs, in practice). The first one, the encoder, reads the source sentence one token at a time and builds up a hidden state. When it reaches the end of the input, its final hidden state is supposed to represent the "meaning" of the entire source sentence. That single vector is the context.

The second RNN, the decoder, takes that context vector as its initial hidden state and generates the target sentence one token at a time, feeding each predicted token back as input for the next step.

So the information flow looks like: source tokens →\rightarrow encoder RNN →\rightarrow single fixed-length vector cc →\rightarrow decoder RNN →\rightarrow target tokens.

The context vector cc has a fixed dimension regardless of source length. This creates a bottleneck: the final encoder state must preserve everything the decoder will need, even for a long input.

The effect of that bottleneck differed across systems. Cho et al. observed sharply degrading performance on longer sources in their basic RNN encoder-decoder; Bahdanau et al. saw the same pattern in their RNNencdec baseline. Sutskever et al. reported that their deep LSTM with reversed source sentences "did not have difficulty on long sentences." Attention offered a way to access source positions directly instead of relying on one final state.

How much of the encoder output can the decoder revisit?

Fixed context
One final source summary of width d, whatever the source length.
Attention over encoder states
20 source states of width d. Each decoding step computes a weighted sum of those states.

The diagram compares how many source representations are available to the decoder. Attention compares nn source positions at each of mm decoder steps, costing O(nm)O(nm) with vector dimensions held fixed.


Bahdanau attention: learning to align

In 2015, Bahdanau, Cho, and Bengio proposed a mechanism they called "attention." The core insight was to let the decoder, at each generation step, compute a weighted combination over all of the encoder's hidden states, rather than relying only on the final one.

Bahdanau's encoder is bidirectional. Each annotation hjh_j concatenates forward and backward recurrent states, giving source position jj context from both directions. The decoder attends over these annotations.

The encoder computes the annotations once for the source sequence. At decoder step tt, using hidden state st−1s_{t-1}, we compute:

Alignment scores (how relevant is each source position to this decoder step):

etj=va⊤tanh⁡(Wast−1+Uahj)e_{tj} = v_a^\top \tanh(W_a s_{t-1} + U_a h_j)

where WaW_a, UaU_a, and vav_a are learned parameters. This is called "additive" attention because we're adding transformed versions of the decoder state and encoder state, then passing through a tanh nonlinearity.

Attention weights (normalize scores into a distribution):

αtj=exp⁡(etj)∑k=1Txexp⁡(etk)\alpha_{tj} = \frac{\exp(e_{tj})}{\sum_{k=1}^{T_x} \exp(e_{tk})}

That's just a softmax over the source positions. Each αtj\alpha_{tj} tells us how much the decoder should "attend to" source position jj when generating target token tt.

Context vector (the weighted sum):

ct=∑j=1Txαtjhjc_t = \sum_{j=1}^{T_x} \alpha_{tj} h_j

The context vector ctc_t can differ at each decoder step. When generating "chat," attention may concentrate on "cat"; for "noir," it may concentrate on "black." The weights let the decoder combine source information differently for each prediction.

The decoder then uses both ctc_t and st−1s_{t-1} to compute the next hidden state and predict the next target token.

Alignment scores → weights → context

Each row supplies a toy score and encoder state. Softmax is computed over the four scores; the context is their weighted sum.

SourceScoreWeightEncoder state
the3.200.823[0.3, -0.2, 0.5, 0.1]
black0.200.041[-0.1, 0.7, -0.3, 0.4]
cat0.800.075[0.6, 0.1, -0.2, 0.5]
sleeps0.600.061[0.2, 0.4, 0.3, -0.4]

Σ weights = 1.000
Context = [0.300, -0.104, 0.403, 0.112]

The score values are authored, not the output of displayed alignment-network parameters.

Select a target step to inspect the scores, softmax, and weighted sum in this hand-built example.


Attention as soft alignment

Collecting αtj\alpha_{tj} across target steps tt and source positions jj gives an alignment matrix. Each row shows the source weights used for one target prediction. In the paper, the network learned these alignments from parallel translation data without separate alignment labels.

Here's what that looks like for a simple English-to-French translation:

English (encoder)French (decoder)theblackcatsleeps<eos>lechatnoirdort<eos>.65.70.65.70.70dist.
Tap or hover a cell to inspect its weight. Darker cells have larger weights on a fixed 0–1 scale.
Tap or hover cells to inspect weights. Select a cell or row to see the weighted sum.

Hand-built English–French attention weights. Select a row to inspect its source weights.

The crossed "black"/"cat" → "chat"/"noir" pattern illustrates adjective-noun reordering. The paper showed learned alignments with similar patterns. Alammar's illustrated post provides an animated walk-through of the routing.


A quick word on Luong variants

Luong et al. compared several scoring functions in 2015, including a dot product between the decoder and encoder states.

Dot-product scoring:

etj=st⊤hje_{tj} = s_t^\top h_j

Just the raw dot product between decoder and encoder states. Fast, simple, no extra parameters. But requires the decoder and encoder hidden states to live in the same dimensionality.

General scoring:

etj=st⊤Whje_{tj} = s_t^\top W h_j

A learned matrix WW projects the encoder states before the dot product.

Concat scoring (essentially the same as Bahdanau):

etj=v⊤tanh⁡(W[st;hj])e_{tj} = v^\top \tanh(W[s_t; h_j])

Concatenate the two vectors, transform them, and project the result to a scalar. Like Bahdanau's score, this includes a learned nonlinear transformation.

Luong's work helped normalize simpler multiplicative attention forms. Their strongest reported local-attention system actually used the "general" score function with the learned WW, not raw dot product, but the multiplicative family as a whole proved competitive with additive attention while being faster. Two years later, the Transformer used a scaled dot product, mainly because it maps very efficiently to batched matrix multiplication and the scaling fixes large-dimension softmax saturation. We'll get there in Arc 4.


Worked example

For one decoder step, we can calculate the scores, weights, and context vector by hand.

Say our encoder has processed a 3-word source sentence and produced hidden states (using small 4-dimensional vectors for readability):

  • h1=[0.2,−0.1,0.5,0.3]h_1 = [0.2, -0.1, 0.5, 0.3] (encoding of "the")
  • h2=[0.8,0.4,−0.2,0.1]h_2 = [0.8, 0.4, -0.2, 0.1] (encoding of "cat")
  • h3=[0.1,0.6,0.3,−0.5]h_3 = [0.1, 0.6, 0.3, -0.5] (encoding of "sat")

The decoder is at step 2, with hidden state s1=[0.5,0.3,−0.1,0.7]s_1 = [0.5, 0.3, -0.1, 0.7], and we want to compute which source words to attend to.

Using dot-product scoring for simplicity:

e2,1=s1⊤h1=(0.5)(0.2)+(0.3)(−0.1)+(−0.1)(0.5)+(0.7)(0.3)=0.10−0.03−0.05+0.21=0.23e_{2,1} = s_1^\top h_1 = (0.5)(0.2) + (0.3)(-0.1) + (-0.1)(0.5) + (0.7)(0.3) = 0.10 - 0.03 - 0.05 + 0.21 = 0.23 e2,2=s1⊤h2=(0.5)(0.8)+(0.3)(0.4)+(−0.1)(−0.2)+(0.7)(0.1)=0.40+0.12+0.02+0.07=0.61e_{2,2} = s_1^\top h_2 = (0.5)(0.8) + (0.3)(0.4) + (-0.1)(-0.2) + (0.7)(0.1) = 0.40 + 0.12 + 0.02 + 0.07 = 0.61 e2,3=s1⊤h3=(0.5)(0.1)+(0.3)(0.6)+(−0.1)(0.3)+(0.7)(−0.5)=0.05+0.18−0.03−0.35=−0.15e_{2,3} = s_1^\top h_3 = (0.5)(0.1) + (0.3)(0.6) + (-0.1)(0.3) + (0.7)(-0.5) = 0.05 + 0.18 - 0.03 - 0.35 = -0.15

Softmax over [0.23,0.61,−0.15][0.23, 0.61, -0.15]:

α2,1=e0.23e0.23+e0.61+e−0.15=1.261.26+1.84+0.86=1.263.96≈0.318\alpha_{2,1} = \frac{e^{0.23}}{e^{0.23} + e^{0.61} + e^{-0.15}} = \frac{1.26}{1.26 + 1.84 + 0.86} = \frac{1.26}{3.96} \approx 0.318 α2,2≈1.843.96≈0.465\alpha_{2,2} \approx \frac{1.84}{3.96} \approx 0.465 α2,3≈0.863.96≈0.217\alpha_{2,3} \approx \frac{0.86}{3.96} \approx 0.217

So the attention concentrates most on h2h_2 ("cat"), which makes sense if the decoder is trying to generate the corresponding French word. The context vector is:

c2=0.318⋅h1+0.465⋅h2+0.217⋅h3c_2 = 0.318 \cdot h_1 + 0.465 \cdot h_2 + 0.217 \cdot h_3 =0.318⋅[0.2,−0.1,0.5,0.3]+0.465⋅[0.8,0.4,−0.2,0.1]+0.217⋅[0.1,0.6,0.3,−0.5]= 0.318 \cdot [0.2, -0.1, 0.5, 0.3] + 0.465 \cdot [0.8, 0.4, -0.2, 0.1] + 0.217 \cdot [0.1, 0.6, 0.3, -0.5] =[0.064,−0.032,0.159,0.095]+[0.372,0.186,−0.093,0.047]+[0.022,0.130,0.065,−0.109]= [0.064, -0.032, 0.159, 0.095] + [0.372, 0.186, -0.093, 0.047] + [0.022, 0.130, 0.065, -0.109] ≈[0.458,0.284,0.131,0.033]\approx [0.458, 0.284, 0.131, 0.033]

That context vector is a blend of all three encoder states, weighted towards "cat." The decoder uses this, combined with its own hidden state, to predict the next output token.


Implementation: Bahdanau attention in PyTorch

This module implements additive attention. It accepts encoder states and one decoder state, then returns the context vector and attention weights.

import torch
import torch.nn as nn
import torch.nn.functional as F
 
class BahdanauAttention(nn.Module):
    def __init__(self, encoder_dim: int, decoder_dim: int, attn_dim: int = 128):
        super().__init__()
        self.W_enc = nn.Linear(encoder_dim, attn_dim, bias=False)
        self.W_dec = nn.Linear(decoder_dim, attn_dim, bias=False)
        self.v = nn.Linear(attn_dim, 1, bias=False)
 
    def forward(
        self,
        encoder_outputs: torch.Tensor,         # (batch, src_len, encoder_dim)
        decoder_hidden: torch.Tensor,          # (batch, decoder_dim)
        src_mask: torch.Tensor | None = None,  # (batch, src_len), True = real token
    ) -> tuple[torch.Tensor, torch.Tensor]:
        if encoder_outputs.size(1) == 0:
            raise ValueError("Each example needs at least one source token")
        if src_mask is not None and not src_mask.any(dim=-1).all():
            raise ValueError("Each example needs at least one unmasked source token")
 
        # Project encoder states: (batch, src_len, attn_dim)
        enc_proj = self.W_enc(encoder_outputs)
 
        # Project decoder state and broadcast over src positions: (batch, 1, attn_dim)
        dec_proj = self.W_dec(decoder_hidden).unsqueeze(1)
 
        # Additive scoring: tanh(W_enc * h_j + W_dec * s_t)
        energy = torch.tanh(enc_proj + dec_proj)
 
        # Scalar alignment scores: (batch, src_len)
        scores = self.v(energy).squeeze(-1)
 
        # Mask out padding positions before softmax so they get zero weight.
        # Without this, the decoder can attend to <pad> tokens.
        if src_mask is not None:
            scores = scores.masked_fill(~src_mask, float("-inf"))
 
        # Attention weights via softmax over source positions
        attn_weights = F.softmax(scores, dim=-1)
 
        # Context vector: weighted sum of encoder outputs
        context = torch.bmm(attn_weights.unsqueeze(1), encoder_outputs)
        context = context.squeeze(1)  # (batch, encoder_dim)
 
        return context, attn_weights

Why sequential processing hit a wall

Attention gives the decoder access to individual source states, but a recurrent encoder still computes those states in order.

An RNN processes tokens one at a time. To compute h5h_5, you need h4h_4. To compute h4h_4, you need h3h_3. This is an inherently sequential dependency chain. For a sentence with nn tokens, you need O(n)O(n) sequential steps through the encoder, and you can't parallelize them.

Dependencies within one sequence

  1. 1. the

    Starts from h₀

  2. 2. black

    Waits for h1

  3. 3. cat

    Waits for h2

  4. 4. sat

    Waits for h3

  5. 5. on

    Waits for h4

  6. 6. the

    Waits for h5

  7. 7. mat

    Waits for h6

The hidden-state recurrence creates a chain of seven dependent updates.

Autoregressive generation still has dependencies between output tokens.

RNNs can batch matrix multiplications across examples and hidden units. Within a single example, however, hth_t depends on ht−1h_{t-1}. Vaswani et al. described this as computation factored "along the symbol positions," which "precludes parallelization within training examples."

Adding hardware can process more examples together, but it does not remove this dependency within an example. The sequence still requires nn successive state updates.


Misconceptions

"Attention was invented for transformers." Bahdanau attention appeared as a preprint in 2014, three years before the transformer. The 2015 conference paper was about seq2seq translation with RNNs. The 2017 move was to keep this attention idea and drop the recurrence, and we'll cover that in Arc 4.

"Seq2seq models are obsolete and irrelevant." Transformer encoder-decoders retain the sequence-to-sequence abstraction. T5 casts tasks into text-to-text form; BART trains a denoising encoder-decoder. Their cross-attention layers let a decoder access the encoded source.

"Attention fixes the vanishing gradient problem." It adds shorter paths from decoder losses to source states. Gradients within a recurrent encoder still pass through successive state updates and can shrink or grow along that chain.

What's next

So far we've taken the input tokens as given. Arc 3 examines how text becomes those tokens, starting with bytes and Unicode and continuing through subword vocabularies, chat templates, and training batches.

The next post is Unicode, Bytes, and What Text Actually Is.


Additional reading (and watching)