Seq2seq with Bahdanau attention
Build an encoder-decoder seq2seq model and add the Bahdanau additive attention mechanism; visualize the attention matrix on a toy translation.
Language generation tasks -- translation, summarization, question answering -- require mapping a variable-length input sequence to a variable-length output sequence. The sequence-to-sequence (seq2seq) framework (Sutskever et al., 2014[1]; Cho et al., 2014[2]) achieves this by pairing an encoder that reads the input with a decoder that generates the output one token at a time.
Encoder-decoder architecture
Encoder: an RNN (or LSTM) reads the source sequence and produces a sequence of hidden states and a final state .
Context vector: the basic seq2seq model passes only the encoder's final hidden state to the decoder. This single vector must compress the entire source.
Decoder: an RNN initialized with generates the target sequence auto-regressively:
Training: teacher forcing -- feed the gold target token at each step, not the model's own prediction.
The bottleneck problem
Compressing an entire source sentence into a single fixed-size vector is a bottleneck: longer sequences are harder to compress, and the gradient must flow through the entire decoder and back through the entire encoder. For sentences longer than ~30 tokens, BLEU scores drop sharply.
Bahdanau attention
Bahdanau et al. (2015) replace the fixed context vector with a dynamic one: at each decoding step , the decoder looks at all encoder hidden states and attends to the most relevant ones.
Alignment scores: for each encoder position , compute a scalar score measuring how relevant is to the current decoder state :
where , , are learned parameters. This is called additive (or MLP) attention.
Attention weights: normalize scores to a probability distribution:
Context vector: a weighted sum of encoder states:
Decoder step: update with the dynamic context:
The matrix is the attention matrix: it shows which source positions the decoder focuses on when generating each target token. For translation, this matrix is nearly diagonal for similar-word-order languages and shows the alignment.
A contrasting pair makes the role of the weights concrete. Take three encoder states , , and two alignment-score vectors over them. The softmax converts each into a weight row, and the context is its weighted sum:
- Peaked alignment softmaxes to , almost all mass on position 2, so . The decoder reads source position 2 and largely ignores the rest.
- Flat alignment softmaxes to , near the uniform , so , a blur of all three states.
Same encoder states, same softmax; only the score spread differs, and it decides whether the context is one sharply selected source vector or an averaged smear. A trained model learns to produce peaked rows that align target words to their source counterparts; with the random untrained states in the block below, the rows come out near-uniform (every weight close to ), which is the flat case before any alignment has been learned.
Implementation
import numpy as np
def softmax(x):
e = np.exp(x - x.max())
return e / e.sum()
class BahdanauAttention:
def __init__(self, enc_dim, dec_dim, attn_dim, seed=0):
rng = np.random.default_rng(seed)
s = 0.1
self.Ws = rng.normal(0, s, (attn_dim, dec_dim))
self.Wh = rng.normal(0, s, (attn_dim, enc_dim))
self.va = rng.normal(0, s, attn_dim)
def score(self, s_prev, h_enc):
"""s_prev: (dec_dim,), h_enc: (S, enc_dim). Returns (S,) attention weights."""
energy = np.tanh(
(self.Ws @ s_prev)[None, :] + h_enc @ self.Wh.T # (S, attn_dim)
) @ self.va # (S,)
return softmax(energy)
def context(self, s_prev, h_enc):
alpha = self.score(s_prev, h_enc)
return alpha @ h_enc, alpha # (enc_dim,), (S,)
# Toy seq2seq: encode 5 tokens, decode 4 tokens
S, T, enc_dim, dec_dim, attn_dim = 5, 4, 8, 8, 6
rng = np.random.default_rng(1)
# Random encoder hidden states (normally from a real LSTM)
h_enc = rng.normal(0, 1, (S, enc_dim))
attn = BahdanauAttention(enc_dim, dec_dim, attn_dim)
alphas = np.zeros((T, S))
s = rng.normal(0, 1, dec_dim) # initial decoder state
for t in range(T):
c, alpha = attn.context(s, h_enc)
alphas[t] = alpha
# Normally: s = decoder_lstm_step(s, c, y_{t-1})
s = rng.normal(0, 1, dec_dim) # placeholder
# Print attention matrix
print("Attention matrix (rows=decoder steps, cols=encoder positions):")
print(np.round(alphas, 3))
print("\nEach row sums to:", alphas.sum(axis=1).round(4))Why attention matters
- Removes the bottleneck: the decoder can access any encoder state directly, not just the final one.
- Provides interpretability: the attention matrix aligns source and target tokens, useful for debugging and for semi-supervised alignment learning.
- Enables longer sequences: BLEU on long translations improved dramatically.
- Inspired self-attention: in the Transformer, queries, keys, and values all come from the same sequence. Bahdanau attention between encoder and decoder becomes cross-attention in the Transformer decoder.
Exercises
Exercise 1. Three encoder states , , receive alignment scores . Compute the attention weights and the context vector by hand.
Solution
Step 1. Exponentiate the scores: , , . The normalizer is .
Step 2. Divide each by :
which sums to as a probability distribution must.
Step 3. Take the weighted sum of the encoder states:
Most mass sits on , so the context leans toward its direction; this is the source position the decoder attends to most at this step.
Exercise 2. The bottleneck of basic seq2seq is one fixed-size context vector . Argue, using the additive attention equations and , why attention removes this bottleneck and why the gradient to early encoder states is shorter.
Solution
Step 1. In basic seq2seq the only path from the source to decoder step runs through the single vector , so every source token must be compressed into and re-extracted; capacity is fixed regardless of source length.
Step 2. With attention, is a direct weighted sum over all encoder states. Each contributes to with weight , so the decoder reads any source position directly rather than through a single summary.
Step 3. The gradient includes the term , a direct edge from the loss at step to encoder state . This path does not traverse the encoder recurrence steps, so it avoids the repeated multiplications that shrink the gradient, shortening the route to early source tokens.
Exercise 3 (code). Compute softmax attention over encoder positions for a peaked score vector and a flat one. Verify each weight row sums to , that the peaked row concentrates more than of its mass on one position, and that its context vector lands near that position's encoder state.
Solution
import numpy as np
def softmax(x):
e = np.exp(x - x.max())
return e / e.sum()
rng = np.random.default_rng(0)
S = 5
h_enc = rng.normal(0, 1, (S, 3)) # encoder states
e_peak = np.array([0.1, 0.1, 4.0, 0.1, 0.1]) # strongly favors position 2
e_flat = np.array([0.1, 0.2, 0.15, 0.1, 0.05]) # nearly uniform
a_peak = softmax(e_peak)
a_flat = softmax(e_flat)
assert np.isclose(a_peak.sum(), 1.0)
assert np.isclose(a_flat.sum(), 1.0)
assert a_peak.max() > 0.9 # peaked row concentrates mass
assert a_peak.max() > a_flat.max() # flatter row spreads it out
c_peak = a_peak @ h_enc # context for the peaked row
assert np.linalg.norm(c_peak - h_enc[2]) < 0.3 # close to the attended state
print("peak max:", round(a_peak.max(), 4),
" flat max:", round(a_flat.max(), 4))The peaked row puts of its weight on position , so its context is within of ; the flat row spreads weight near each and produces a blur of all five states. Only the score spread differs.
The next subsection builds the full Transformer by replacing RNNs with self-attention, making the sequence-parallel computation that enables training at LLM scale.
References
- [1]Sutskever, I., Vinyals, O., & Le, Q. V. (2014). "Sequence to sequence learning with neural networks". NeurIPS. arXiv:1409.3215. The LSTM encoder-decoder.↩
- [2]Cho, K., et al. (2014). "Learning phrase representations using RNN encoder-decoder for statistical machine translation". EMNLP. arXiv:1406.1078. The RNN encoder-decoder and context vector.↩