Sequence-to-Sequence with Attention
Map one sequence to another. An encoder reads the source, a decoder writes the target, and cross-attention lets every output step look back at the whole input. This guide builds an encoder-decoder in Flax NNX, trains it with teacher forcing on a copy-and-reverse task, and shows how attention learns the source-to-target alignment.
This guide assumes you have met recurrent cells and attention already. Read recurrent networks for the GRU encoder/decoder, and simple transformer for how multi-head attention works.
- The encoder-decoder architecture: compress a source sequence into per-step states, then generate a target from them
- How cross-attention forms a context vector over the encoder states with
nnx.MultiHeadAttention - Teacher forcing β feeding the ground-truth previous token so the decoder trains in parallel
- Why this is a conditional source β target model, unlike a decoder-only GPT
- How to score sequence output with per-token accuracy and a token-level cross-entropy
See the full implementation: examples/sequence/seq2seq_attention.py
Why Encoder-Decoder?β
A recurrent classifier collapses a whole sequence into a single label. But many problems map a sequence to another sequence of possibly different content and length: translation, summarization, speech-to-text. The encoder-decoder (seq2seq) design splits the job in two:
- an encoder reads the source and produces a stack of hidden states ;
- a decoder generates the target one token at a time, conditioned on those states and on what it has already emitted.
The original seq2seq crammed the entire source into the encoder's final state β a fixed-size bottleneck that forgets long inputs. Attention removes the bottleneck: at each output step the decoder builds a fresh, weighted read over all encoder states.
The task: copy-and-reverseβ
Our benchmark maps a source sequence to its reverse:
It is a clean attention probe: to emit output position correctly, the decoder must attend to source position and copy that token. There is no fixed offset the recurrence can memorize β the alignment reverses across the sequence β so a model that solves it has genuinely learned to point with attention. Everything is generated in memory; there is nothing to download.
Attention: a differentiable lookupβ
At decoder step we have a query (from the decoder state) and, for every source position , a key and value (from the encoder states). Attention scores each source position, normalizes with a softmax, and returns the value average:
The weights form a soft, differentiable alignment: a distribution over source positions that says where to look. For the reverse task, a well-trained model puts almost all of on position . Multi-head attention runs of these in parallel over projected subspaces and concatenates the results, so different heads can track different alignment cues.
Because keys/values come from the encoder and queries come from the decoder, this is cross-attention β distinct from the self-attention a GPT applies within a single stream.
Building the model in Flax NNXβ
Encoderβ
The encoder is exactly the recurrent stack from the recurrent networks guide: embed the tokens, scan a GRU across time with nnx.RNN, and keep all per-step states (not just the last), because the decoder attends over the whole stack.
import jax
import jax.numpy as jnp
from flax import nnx
import optax
class Encoder(nnx.Module):
def __init__(self, vocab, embed, hidden, *, rngs: nnx.Rngs):
self.embed = nnx.Embed(vocab, embed, rngs=rngs)
self.rnn = nnx.RNN(nnx.GRUCell(embed, hidden, rngs=rngs))
def __call__(self, src):
h = self.embed(src) # (B, T) -> (B, T, embed)
return self.rnn(h) # (B, T, embed) -> (B, T, hidden) encoder states
Decoder with cross-attentionβ
The decoder embeds the (teacher-forced) previous tokens, runs its own GRU to get a per-step decoder state, then uses nnx.MultiHeadAttention as cross-attention: the query is the decoder state, the keys and values are the encoder states. The context vector is concatenated with the decoder state and projected to the vocabulary.
class Decoder(nnx.Module):
def __init__(self, vocab, embed, hidden, num_heads, *, rngs: nnx.Rngs):
# vocab + 1: the extra id is the <bos> start token fed at step 0.
self.embed = nnx.Embed(vocab + 1, embed, rngs=rngs)
self.rnn = nnx.RNN(nnx.GRUCell(embed, hidden, rngs=rngs))
self.cross_attn = nnx.MultiHeadAttention(
num_heads=num_heads,
in_features=hidden, # query dim (decoder state)
in_kv_features=hidden, # key/value dim (encoder states)
qkv_features=hidden,
decode=False,
rngs=rngs,
)
self.out = nnx.Linear(2 * hidden, vocab, rngs=rngs)
def __call__(self, dec_in, enc_states):
d = self.embed(dec_in) # (B, T) -> (B, T, embed)
d = self.rnn(d) # (B, T, embed) -> (B, T, hidden) decoder states
# Cross-attention: query = decoder states, key/value = encoder states.
ctx = self.cross_attn(d, enc_states, enc_states) # (B, T, hidden) context c_t
combined = jnp.concatenate([d, ctx], axis=-1) # (B, T, 2*hidden)
return self.out(combined) # (B, T, vocab) logits
Passing three arguments to nnx.MultiHeadAttention(inputs_q, inputs_k, inputs_v) is what makes it cross-attention: inputs_q comes from the decoder while inputs_k and inputs_v come from the encoder. (Calling it with a single argument would be ordinary self-attention.)
Wiring it togetherβ
class Seq2SeqAttention(nnx.Module):
def __init__(self, vocab, embed, hidden, num_heads, *, rngs: nnx.Rngs):
self.encoder = Encoder(vocab, embed, hidden, rngs=rngs)
self.decoder = Decoder(vocab, embed, hidden, num_heads, rngs=rngs)
def __call__(self, src, dec_in):
enc_states = self.encoder(src) # (B, T, hidden)
return self.decoder(dec_in, enc_states) # (B, T_out, vocab)
Teacher forcing and the dataβ
During training we feed the decoder the ground-truth previous target token rather than its own prediction β this is teacher forcing. It lets the decoder process all steps in one parallel pass and gives a stable learning signal early on. The decoder input is the target shifted right by one, with a special <bos> (begin-of-sequence) id in the first slot:
def make_dataset(synthetic=True, *, n=1024, seq_len=8, vocab=12, seed=0):
key = jax.random.key(seed)
src = jax.random.randint(key, (n, seq_len), 0, vocab).astype(jnp.int32)
tgt = src[:, ::-1] # reversed sequence
bos = jnp.full((n, 1), vocab, dtype=jnp.int32) # start-of-sequence id
dec_in = jnp.concatenate([bos, tgt[:, :-1]], axis=1) # shift-right teacher forcing
return {"src": src, "dec_in": dec_in, "tgt": tgt}
The training stepβ
The output is a full sequence of logits (B, T, vocab), so we flatten to (B*T, vocab) and score every token position with cross-entropy. Per-token accuracy is the fraction of positions predicted correctly.
from shared.training_utils import compute_accuracy, compute_cross_entropy_loss
@nnx.jit
def train_step(model, optimizer, batch):
def loss_fn(model):
logits = model(batch["src"], batch["dec_in"]) # (B, T, vocab)
B, T, V = logits.shape
flat_logits = logits.reshape(B * T, V)
flat_tgt = batch["tgt"].reshape(B * T)
loss = compute_cross_entropy_loss(flat_logits, flat_tgt)
acc = compute_accuracy(flat_logits, flat_tgt) # per-token accuracy
return loss, acc
(loss, acc), grads = nnx.value_and_grad(loss_fn, has_aux=True)(model)
optimizer.update(model, grads)
return loss, acc
Build the model and optimizer explicitly, then loop:
model = Seq2SeqAttention(vocab=12, embed=32, hidden=64, num_heads=4, rngs=nnx.Rngs(0))
optimizer = nnx.Optimizer(model, optax.adam(3e-3), wrt=nnx.Param)
for step in range(...):
loss, acc = train_step(model, optimizer, batch)
Results / What to Expectβ
The reverse task is fully learnable with attention. On CPU, per-token accuracy climbs past 90% within a dozen epochs and reaches 100% as the cross-attention locks onto the reversed alignment:
$ python sequence/seq2seq_attention.py
seq2seq+attention samples=1024 seq_len=8 vocab=12 epochs=30 batch=128
epoch 0 loss 2.3614 token_acc 0.1953
epoch 5 loss 1.6474 token_acc 0.4102
epoch 8 loss 0.7278 token_acc 0.7129
epoch 12 loss 0.1573 token_acc 0.9648
epoch 17 loss 0.0294 token_acc 0.9980
epoch 20 loss 0.0144 token_acc 1.0000
epoch 29 loss 0.0046 token_acc 1.0000
Environment knobs EPOCHS, BATCH, and SYNTHETIC let you scale the run; SYNTHETIC=0 generates a larger, longer (harder) dataset.
Common Pitfallsβ
-
β Calling
nnx.MultiHeadAttention(x)with one argument and expecting cross-attention. β Cross-attention needs three inputs:attn(query, key, value)=attn(decoder_states, encoder_states, encoder_states). -
β Attending over only the encoder's last state (the fixed-size bottleneck). β Keep all per-step states from
nnx.RNNβ attention reads the full(B, T, hidden)stack. -
β Feeding the target itself as the decoder input, leaking the answer at each step. β Shift right by one and prepend
<bos>:dec_in = [<bos>, y_1, ..., y_{U-1}]. -
β Sizing the output head from
hiddenwhen you concatenate the context. β The decoder state and context are concatenated, so the head isnnx.Linear(2 * hidden, vocab). -
β Setting
qkv_featuresso it is not divisible bynum_heads. β Multi-head attention splitsqkv_featuresacross heads; keepqkv_features % num_heads == 0(here64 % 4 == 0).
Next stepsβ
- Time series β apply sequence models to forecasting continuous signals.
- GPT β drop the encoder and use masked self-attention for a decoder-only generative model.
Complete Exampleβ
Full runnable script with the encoder, cross-attention decoder, teacher-forcing data, and the training loop: examples/sequence/seq2seq_attention.py.
Referencesβ
- Sutskever, Vinyals & Le (2014), Sequence to Sequence Learning with Neural Networks β arXiv:1409.3215.
- Bahdanau, Cho & Bengio (2014), Neural Machine Translation by Jointly Learning to Align and Translate β arXiv:1409.0473.
- Cho et al. (2014), Learning Phrase Representations using RNN EncoderβDecoder (GRU) β arXiv:1406.1078.
- Luong, Pham & Manning (2015), Effective Approaches to Attention-based Neural Machine Translation β arXiv:1508.04025.
- Vaswani et al. (2017), Attention Is All You Need β arXiv:1706.03762.