📚 Study Notes / Home / Neural Nets / Session 14
Session 14 · Seq2Seq & Attention

Teaching a network to translate — and to look back

Last few sessions we built up RNNs, LSTMs and GRUs — networks that read sequences one step at a time. Today we put them to real work: turning one sequence into another (English → French, article → summary). We'll meet the elegant encoder–decoder idea, watch it choke on long sentences, and then discover the single trick — attention — that fixed it and quietly set the stage for the Transformer you'll meet next session. We assume you've studied none of this; every topic starts with a tiny story before the real machinery.

⏱ 17 min read📖 4 topics

1 The sequence-to-sequence problem


Explain like I'm 5

Imagine two friends who don't speak the same language, with a translator sitting between them. The first friend whispers a whole sentence to the translator. The translator listens to the entire thing, nods, holds the idea in their head — and only then starts speaking it out loud in the other language, one word at a time. Listen fully first, then speak. That "listen, then speak" dance is exactly what today's network does.

So far our networks did things like "read a sentence, output one label" (is this review positive or negative?) or "read numbers, predict the next number." But a huge family of real problems is different: the input is a sequence and the output is a sequence — and the two can be different lengths. This is the sequence-to-sequence (often written seq2seq) problem.

Where it shows up

TaskInput sequenceOutput sequence
Machine translation"I am hungry" (English)"J'ai faim" (French)
SummarizationA 500-word articleA 2-sentence summary
Chatbot / dialogueA user's messageThe bot's reply
Speech-to-textAudio framesWords
Code generationA descriptionLines of code

Notice the key wrinkle: "I am hungry" is 3 words but "J'ai faim" is 2. The input and output don't line up one-to-one, and we don't know the output length in advance. A plain RNN that emits one output per input step can't handle that. We need an architecture that reads the whole input first, then generates an output of whatever length it needs.

The encoder–decoder architecture

The classic 2014 solution (Sutskever et al., and Cho et al.) splits the job into two RNNs:

  • The encoder — an RNN (in practice usually an LSTM or GRU, recall Session 13) that reads the input sequence one token at a time and squeezes everything it understood into a single fixed-size summary vector.
  • The context vector — that final summary, often written c. It's the encoder's last hidden state: a fixed-length list of numbers that's supposed to capture the meaning of the whole input.
  • The decoder — a second RNN that starts from that context vector and generates the output sequence one token at a time, feeding each word it just produced back in as the next input (just like the autoregressive loop you've seen for language models).

The flow, step by step

📥
1. Encode
RNN reads input word by word
📦
2. Context
Final hidden state = one vector c
🚀
3. Init decoder
c seeds the decoder's state
🗣️
4. Decode
Emit one word, feed it back
🛑
5. Stop
Until <EOS> token

Two special tokens make this work. <EOS> ("end of sequence") tells the encoder where the input ends, and is the signal the decoder emits when it's done. A start token, often <SOS> or <BOS>, is the decoder's very first input to kick off generation.

Let's write the math compactly. The encoder runs its recurrence over input tokens x₁ … xₜ, producing hidden states h₁ … hₜ:

StepEquationIn words
Encoderhᵗ = f(hᵗ₋₁, xᵗ)Update hidden state from previous state + current input word.
Contextc = hₜThe context is just the last encoder state.
Decodersᵖ = g(sᵖ₋₁, yᵖ₋₁, c)New decoder state from previous state + previous output word + context.
OutputP(yᵖ) = softmax(W·sᵖ)Turn the decoder state into a probability over the vocabulary.
Worked example: translating "I am hungry" → "J'ai faim"

Here's a tiny PyTorch-flavoured sketch of an encoder–decoder so you can see the two RNNs and the single context hand-off concretely:

class Seq2Seq(nn.Module):
    def __init__(self, vocab_in, vocab_out, emb, hid):
        self.enc_emb = nn.Embedding(vocab_in, emb)
        self.encoder = nn.LSTM(emb, hid)        # reads the source
        self.dec_emb = nn.Embedding(vocab_out, emb)
        self.decoder = nn.LSTM(emb, hid)        # writes the target
        self.out = nn.Linear(hid, vocab_out)    # hidden -> vocab scores

    def forward(self, src, tgt):
        # 1) ENCODE: read whole source, keep only final state
        _, (h, c) = self.encoder(self.enc_emb(src))
        # 'h' is THE context vector — the bottleneck (Topic 2)

        # 2) DECODE: start decoder from the encoder's final state
        dec_in = self.dec_emb(tgt)              # <SOS> J'ai faim ...
        dec_out, _ = self.decoder(dec_in, (h, c))
        return self.out(dec_out)            # scores for each output step

Walking through it: the encoder reads I, am, hungry, <EOS> and its final hidden state h becomes the context. The decoder, seeded with that h, is fed <SOS> and predicts J'ai; that's fed back in and it predicts faim; fed back in, it predicts <EOS> and stops. Three input steps, three output steps — different content, free to differ in length.

Teacher forcing

During training, we usually feed the decoder the correct previous word (from the real translation) rather than its own guess — this is called teacher forcing. It makes training faster and more stable. At inference time there's no answer key, so the decoder must feed back its own predictions.

Recap Seq2seq maps an input sequence to an output sequence of possibly different length (translation, summarization, chat). The encoder–decoder architecture solves it: an encoder RNN reads the whole input into one fixed context vector, and a decoder RNN generates the output from it, one token at a time until <EOS>.

2 The information bottleneck


Explain like I'm 5

Imagine you have to read someone a whole storybook, but you're only allowed to write yourself one tiny sticky note first — and then close the book forever and tell the story from just that note. For a one-line story, easy. For a long fairy tale with lots of characters? That little note can't possibly hold everything, and you'll forget the beginning. The single context vector is that one sticky note.

Look back at the encoder–decoder. Everything the decoder ever sees about the input is squeezed through that single fixed-length context vector c. Whether the input is 3 words or 300, it all has to fit into the same number of slots (say 512 numbers). This is the information bottleneck, and it's the core limitation of plain seq2seq.

The big idea

A fixed-size vector cannot faithfully store an arbitrarily long input. As sentences get longer, more meaning gets crushed into the same number of slots, so detail inevitably leaks out — especially the words read earliest.

Why long sequences suffer most

  • Compression loss. 300 words of meaning forced into 512 numbers means the encoder must throw information away. The decoder simply never receives it.
  • Recency bias. Because the context is the last hidden state, the words read most recently (the end of the input) are best represented; the beginning has been overwritten through many recurrence steps — the classic vanishing-gradient / forgetting problem from Session 13, now hurting us at the architecture level.
  • No way to revisit. Once encoding is done, the original words are gone. If the decoder, halfway through, really needs that one noun from the start of the sentence, it has no way to go back and look — it only has the blurry summary.
Worked example: the BLEU score cliff

Researchers measured translation quality (BLEU score — higher is better) against input sentence length for a fixed-context seq2seq model. The pattern was stark:

Sentence lengthTranslation qualityWhat's happening
≤ 10 wordsGoodThe whole meaning fits in the context vector.
~20 wordsStill okayGetting tight; some early detail blurs.
30+ wordsDrops sharplyThe vector overflows; quality falls off a cliff.

Translate a 40-word legal sentence and the model often produces a fluent-sounding output that quietly drops or scrambles the parts it couldn't fit — a date here, a clause there. The decoder isn't lying on purpose; it genuinely never got that information.

A clever-but-limited patch

Sutskever et al. found that simply reversing the input (encoding "hungry am I" instead of "I am hungry") improved results, because it put the start of the source closer to where decoding begins. That this hack helped at all is a flashing warning sign: the bottleneck, not the vocabulary, was the problem.

The trade-off to remember

You might think "just make the context vector huge!" — but a bigger vector means more parameters, more compute, slower training, and easier overfitting, and it still can't grow with the input. Any fixed size is eventually too small. We need a fundamentally different idea, not a bigger note.

Recap Cramming an entire input into one fixed-length context vector is an information bottleneck. It works for short sequences but collapses on long ones — early words get overwritten, detail is compressed away, and the decoder can never look back at the original. This single weakness is exactly what attention was invented to fix.

3 Attention: letting the decoder look back


Explain like I'm 5

Back to our translator — but now they're allowed to keep the open book on the table. As they say each word of the translation, they glance back at the parts of the original that matter for that word. Saying the French word for "hungry"? They glance at "hungry" in the English. They don't memorize the whole book into one note; they look back, on demand, at the bits they need right now. That glancing-back is attention.

Bahdanau et al. (2014) asked: why throw away all the encoder's per-word hidden states and keep only the last one? Instead, keep all of themh₁ … hₜ, one per input word — and let the decoder build a fresh context vector at every output step, focusing on whichever input words are relevant for the word it's about to produce.

The big idea

Don't summarize the input once. At each output step, let the decoder look at all encoder states and take a weighted average of them, where the weights say "how much should I attend to each input word right now?" The context becomes dynamic — different for every word produced.

The three ingredients

At decoder step t, with the decoder's current state sᵖ₋₁, attention does three things:

StepEquationWhat it means
1. Scoreeᵖᵢ = score(sᵖ₋₁, hᵢ)For each input word i, how relevant is it to what I'm producing now? These are the alignment scores.
2. Normalizeαᵖᵢ = softmax(eᵖᵢ)Turn raw scores into attention weights that are positive and sum to 1 (a probability distribution over input words).
3. Blendcᵖ = Σᵢ αᵖᵢ · hᵢBuild a context vector for this step as the weighted sum of encoder states.

That step-specific cᵖ is then combined with the decoder state to predict the output word: sᵖ = g(sᵖ₋₁, yᵖ₋₁, cᵖ), then P(yᵖ) = softmax(W·[sᵖ; cᵖ]). Crucially, because the weights αᵖᵢ change every step, the decoder reads a different mix of the source for every word it writes. The bottleneck is gone: there's no longer a single vector that has to hold everything.

The flow with attention

📚
Keep all states
h₁ … hₜ, not just the last
📐
Score
Compare decoder state to each hᵢ
⚖️
Softmax
Weights αᵢ sum to 1
🧪
Weighted sum
Build cᵖ for this step
🗣️
Predict word
Use cᵖ + decoder state
Worked example: alignment in translation

Translating "I am hungry""J'ai faim". Suppose the encoder produced one state per English word: h(I), h(am), h(hungry). Watch the attention weights shift as the decoder writes each French word:

Decoder is producingα on "I"α on "am"α on "hungry"
"J'ai" (I have)0.70.250.05
"faim" (hunger)0.050.100.85

Each row sums to 1. When writing J'ai the model leans on "I"/"am"; when writing faim it leans almost entirely on "hungry." That learned mapping between output words and the input words they correspond to is called alignment — and here's the lovely part: nobody labelled it. The model discovered which words translate to which, purely from the attention math.

A minimal forward pass for one step:

# enc_states: (T, hid) — one row per input word
# s_prev:    (hid,)   — decoder state from the last step
scores = enc_states @ W_a @ s_prev      # e_{t,i}: relevance of each input word
alpha  = softmax(scores)                # weights, sum to 1
c_t    = (alpha.unsqueeze(1) * enc_states).sum(0)   # weighted blend
out    = predict(s_prev, y_prev, c_t)   # produce this output word
Key takeaway

Attention replaces "compress everything once" with "look back, weighted, every step." The decoder gets a direct line to every input word, so long-sequence quality stops falling off a cliff — and as a bonus, the attention weights give us a window into what the model is looking at.

Recap Attention keeps all encoder states and, at each decoder step, computes alignment scores, softmaxes them into attention weights that sum to 1, and blends the encoder states into a fresh, step-specific context vector. This removes the bottleneck and lets the model learn word alignments on its own.

4 Attention variants & intuition


Explain like I'm 5

"How relevant is this word to me?" can be measured in different ways. One way is to hand both words to a little helper who studies them and gives a relevance score (that's the additive way). A faster way is to just see how much the two words "point in the same direction" — multiply them together (the multiplicative way). Same goal, two recipes: one a bit smarter, one a lot faster.

The key choice in attention is how you compute the alignment score eᵖᵢ = score(sᵖ₋₁, hᵢ). Two famous families appeared within a year of each other.

Additive (Bahdanau) attention

Additive attention, from Bahdanau et al. (2014), feeds the decoder state and an encoder state through a tiny one-hidden-layer neural network and reads off a score:

e_ti = vᵀ · tanh( W₁·s_{t-1} + W₂·h_i )

It's called "additive" because the two inputs are added inside the tanh. The learned vectors W₁, W₂, v let the model figure out, flexibly, what "relevant" means. It's expressive and works even when the encoder and decoder states have different sizes — but the little network makes it slower.

Multiplicative (Luong) attention

Multiplicative attention (also called dot-product attention), from Luong et al. (2015), scores relevance with a simple dot product — essentially asking "how aligned are these two vectors?":

FormScoreNote
Doteᵖᵢ = sᵖ₋₁ · hᵢCheapest; needs matching sizes.
Generaleᵖᵢ = sᵖ₋₁ᵀ · W · hᵢA learned matrix W; handles different sizes.

Because a dot product is just one big matrix multiply — and GPUs love matrix multiplies — this is much faster than the additive recipe. That speed is precisely why the dot-product style won out and sits at the heart of the Transformer.

Side by side

AspectAdditive (Bahdanau)Multiplicative (Luong)
Score functionTiny feed-forward net with tanhDot product (optionally with a learned W)
SpeedSlower (extra layer)Faster (one matmul, GPU-friendly)
Different state sizesHandles naturallyNeeds W (the "general" form)
Year / paper2014, "Jointly Learning to Align and Translate"2015, "Effective Approaches…"
LegacyIntroduced attentionLed to scaled dot-product attention
One scaling detail

When vectors are long, plain dot products grow large, which pushes softmax into flat regions where gradients vanish. The Transformer's fix (next session) is scaled dot-product attention — divide the score by √d (the square root of the vector size) before softmax. Same multiplicative idea, just kept numerically calm.

Reading the weights: attention as an X-ray

Because the attention weights αᵖᵢ form a tidy grid (output words down one side, input words across the top, each cell a number 0–1), you can plot them as a heatmap and literally see what the model looked at.

Worked example: an alignment heatmap

Translating "the agreement was signed" → French "l'accord a été signé". Darker = more attention (per-row weights, each row sums to ~1):

↓ output / input →theagreementwassigned
l'accord0.300.650.030.02
a été0.050.100.800.05
signé0.020.030.050.90

The bright diagonal-ish band shows the model aligning each French word to its English source — and for languages that reorder words, this band bends, showing attention jumping around the sentence to grab the right word out of order. This visual is both a debugging tool and a confidence check.

Key takeaway

Additive and multiplicative attention are two recipes for the same "how relevant?" question. Multiplicative (dot-product) won on speed, and once you realize attention is just "query × keys → weights → weighted sum of values," you're one step from the Transformer, which throws out the RNN entirely and uses attention everywhere. That's Session 15.

Recap The score function is the dial. Additive (Bahdanau) uses a small tanh network — expressive but slower; multiplicative (Luong) uses a dot product — faster and GPU-friendly, leading to the scaled dot-product attention of the Transformer. Plotting the weights as a heatmap reveals the learned word alignments and sets up next session perfectly.

Putting it all together


Today's session is one tight story about reading and writing sequences — and the single idea that unlocked modern NLP. Here's the whole arc in a paragraph:

The sequence-to-sequence problem maps one sequence to another of possibly different length, and the encoder–decoder architecture solves it by encoding the input into a single context vector and decoding from it. But cramming everything into one fixed vector is an information bottleneck that breaks on long inputs. Attention fixes this by keeping all encoder states and, at every output step, computing alignment scores, softmaxing them into attention weights, and blending the states into a fresh per-step context — letting the decoder look back at exactly the words it needs. The score can be computed the additive (Bahdanau) way or the faster multiplicative (Luong) way, and that dot-product recipe — once scaled — is the very heart of the Transformer we'll build next session.

Quick self-check

What makes a problem "sequence-to-sequence," and why can't a plain RNN do it?

Both the input and output are sequences, often of different lengths (e.g. 3 input words → 2 output words). A plain RNN emits one output per input step, so it can't produce an output of a different, unknown length. The encoder–decoder design (read fully, then generate) handles it.

In one sentence, what is the information bottleneck?

The entire input must be squeezed into a single fixed-size context vector, so long inputs lose information — especially the earliest words — because a fixed vector can't hold an arbitrarily long sequence.

What are the three steps attention performs at each decoder step?

(1) Score each encoder state against the current decoder state (alignment scores), (2) softmax the scores into attention weights that sum to 1, and (3) take a weighted sum of the encoder states to form a fresh, step-specific context vector.

Why does the context vector with attention beat the single fixed context vector?

It's dynamic: a different weighted blend is built for every output word, giving the decoder a direct line to all input words rather than one blurry summary. So long-sequence quality no longer collapses.

Additive vs multiplicative attention — what's the practical difference?

Additive (Bahdanau) scores relevance with a small tanh network — expressive but slower. Multiplicative (Luong) uses a dot product — faster and GPU-friendly. The dot-product form, scaled by √d, became the Transformer's attention.

What does an attention heatmap show you?

The learned alignment between output and input words: each cell is how much an output word attended to an input word. A bright band reveals word correspondences the model discovered on its own, which is great for debugging and interpretability.

📚 References & Further Reading


Class material

  • SST Deep Learning handout (Session 14) — your course handout for this session.

Papers, docs & deep dives