The encoder-decoder architecture
26 min read
This block starts with the architecture already built. The previous lesson, on sequence-to-sequence models, chained two networks together: one reads the input and summarises it in a vector, and the other writes the output from that summary. That lesson trained them end to end on a toy task. This block looks closely at the point where the two meet, but first the task has to change. The toy let the architecture off the hook on three counts, and translation lets it off on none.
Take a sentence and its translation. The source is Spanish, but you don't need to read Spanish to follow along, because the words line up with the English ones: ayer is yesterday, leí is i read, un libro is a book and muy bueno is very good. So ayer leí un libro muy bueno becomes yesterday i read a very good book. Six tokens go in and seven come out. The extra one is i, a pronoun that appears nowhere in the input (Spanish carries it in the verb's ending, while English has to write it out). Then muy bueno, which follows the noun in Spanish, moves in front of it in English. And that isn't the translation, only one of them: yesterday i read a really good book works equally well. A different length, a different order, and no single right answer: that's three things.
All three come down to the same thing: the toy task answered, on the model's behalf, questions that a real task makes the model answer for itself.
The first is the vocabulary. When the output is written in the same symbols as the input, there's a single vocabulary, and each entry serves equally well for reading and for writing. Translation needs two, one per language, and the two needn't be the same size: the libro that goes in and the book that comes out aren't the same entry of the same table, and the decoder never has to produce a Spanish word.
The second is the length, and it's the one that costs the most. A loop that writes as many tokens as the input had works only while the task guarantees that input and output are the same length. Once that guarantee goes, the length of the output is no longer given: it becomes an unknown. It has to come from somewhere, and the only place left is the model itself. So the output vocabulary gains a token meaning the sentence is over, and writing that token is what stops generation.
The third changes how you read the decoder's output. If a sentence has several acceptable translations, committing to a single token isn't the honest answer; spreading the probability across all of them is. The decoder already did that (a distribution hangs off each state, not a single token), so what changes isn't the network but what its output means. At each step the output is a column of probabilities over the output vocabulary, and the sentence the decoder writes is a path through those columns.
Two vocabularies and an output that stops on its own
Fix two vocabularies: for the input and for the output. The input is a sequence of tokens , each an entry of . The output is another sequence, , over , and the two lengths are independent of each other. The encoder is unchanged from the previous lesson: it reads the input from start to finish with the usual recurrence and hands over its last state,
where is the one-hot vector of the -th token and has one column for each entry of . The decoder's recurrence doesn't change either, and it starts where the encoder finished, . What does change is its output, which now lives in the other language:
with , which has one row for each entry of and is the only shape in the model that knows how many entries the target language has. Nothing requires and to match.
Where the end of the sentence lives
Now for the missing token. To , and only to , we add one more entry, <EOS>, the end-of-sequence token, and every translation in the training data ends with it: if the correct output has tokens, the training sequence has and its last token is . The loss is the one from the lesson on sequence-to-sequence models, unchanged,
and that small piece of bookkeeping is the whole mechanism. The last position sits inside the sum, so it contributes its own term, and gradient descent pushes the probability of <EOS> up exactly there (and, by the same route, down at the earlier positions, where the correct token is a different one). Learning to stop is learning to predict one more token.
That delivers what the task needed: stops being something the loop is handed and becomes
something that comes out of the model. When generating, the model writes one token at a time and
stops as soon as the token it picks is <EOS>. The code will also have a cap, max_len, so
that a badly trained model doesn't write forever. That cap is a safety net in the implementation, not
part of the model, and it's worth keeping the two apart.
The decoder defines a distribution over translations
We can write any distribution over sequences, with no assumptions at all, as a product of distributions over one token each. That's the chain rule of probability, applied position by position:
where is the prefix : everything written before step . So far there's no architecture at all, since the equality holds for any way of assigning probabilities to sequences. The architecture enters when it decides what each factor gets to look at, and the encoder-decoder's answer is this:
Read it slowly, because the whole block hangs on it. On the left, step may look at the entire input; on the right, it sees a single vector. Each of the two conditioning terms on the right has a specific home in the model: is the initial state , and the prefix is fed back in token by token, so both reach step already summarised in . And the factor's probabilities are the coordinates we've already computed: .
Generating is walking the product
Writing a translation means choosing a path through those columns. The cheapest path of all takes the largest coordinate at each step,
feeds the chosen token back in and carries on until <EOS>. That's greedy decoding, the same as in the lesson on the character-level language model, and it costs one step per output token.
The catch is that this path isn't, in general, the most probable one. Nothing guarantees that the product of the maxima is the maximum of the products: a token that narrowly wins its column can leave the decoder in a state from which everything that follows is bad, and greedy decoding never revisits that decision. Searching for the best path properly would mean scoring sentences, which nobody does. Between greedy and exhaustive there are intermediate searches (the usual one is beam search) that this course doesn't cover.
See a case where greedy decoding loses
Say the first step has two candidate tokens, with probabilities and (I made the numbers up, and only four are needed). Greedy decoding keeps the . Suppose the best continuation from that branch is worth , and the best from the other is worth . Then the two complete paths are worth
and the better one is the one greedy decoding discarded at the first step, when it had no way of knowing what came next. Over more steps the effect compounds, which is why real translation systems don't decode this way.
A toy translator in NumPy
Time to train a tiny translator: a toy language with six Spanish words, translated into one with
seven English words. It's a toy on purpose (real machine translation doesn't fit in the browser), but
it has all three things this lesson has put on the table: two different vocabularies, an output whose
length the model decides, and a reordering that forces the model to wait before writing. The Spanish
sentences are a verb, a noun and, sometimes, an adjective. Their translation starts with i and
moves the adjective in front of the noun. The cell's TO_EN pairs each Spanish word with its English
counterpart, so you can read both sides of every pair. Run the cell and look at the pairs.
# Toy language. Two different vocabularies: one to read, one to write.
VX = ["leo", "vendo", "libros", "coches", "rojos", "nuevos"] # V_x, the input
VY = ["i", "read", "sell", "red", "new", "books", "cars", "<EOS>"] # V_y, the output
TO_EN = {"leo": "read", "vendo": "sell", "libros": "books",
"coches": "cars", "rojos": "red", "nuevos": "new"}
EOS = VY.index("<EOS>") # the end of the sequence is one more entry of V_y
GO = len(VY) # start: it only enters the decoder, never predicted
nx, ny = len(VX), len(VY)
data_rng = np.random.default_rng(0) # fixed seed: you'll see these sentences
def sentence(): # a Spanish sentence and its translation, as indices
es = [VX[data_rng.integers(0, 2)], VX[data_rng.integers(2, 4)]] # verb + noun
if data_rng.random() < 0.67:
es.append(VX[data_rng.integers(4, 6)]) # ... + adjective, sometimes
en = ["i", TO_EN[es[0]]] + [TO_EN[w] for w in es[2:]] + [TO_EN[es[1]]] # the adjective moves forward
return [VX.index(w) for w in es], [VY.index(w) for w in en] + [EOS]
print("sentence translation + <EOS> T_x T_y")
for _ in range(5):
x, y = sentence()
print(" %-20s %-30s %3d %4d" % (
" ".join(VX[i] for i in x), " ".join(VY[i] for i in y),
len(x), len(y)))
The first run downloads the Python interpreter (~15 MB). After that it stays in the browser cache and is reused across every lesson.
Compare the two length columns on the right. The translation has one more word than the sentence (the i that Spanish doesn't write), and the sequence the decoder has to produce is one longer still, because <EOS> occupies a position like any other token and has to be predicted too: . There are twelve possible sentences in all, so the model isn't going to learn Spanish; it's going to learn those twelve. It's the mechanism that's on trial, not the translator.
The next cell builds the two networks and trains them. Its train_step is the previous lesson's, line
for line: the encoder's forward pass, the decoder's forward pass with teacher forcing, the decoder's
backward pass and, hanging off the gradient at , the encoder's. The only differences are
the two vocabularies (nx columns at the input, ny rows at the output) and the <EOS> that
sentence already put at the end of every translation.
# Needs the previous cell: VX, VY, EOS, GO, nx, ny, sentence.
d_h, eta, kappa, iters = 64, 0.30, 5.0, 1200
init_rng = np.random.default_rng(1); sc = 0.1 # its own generator: independent of sentence
Wxh_e = init_rng.normal(size=(d_h, nx)) * sc; Whh_e = init_rng.normal(size=(d_h, d_h)) * sc; bh_e = np.zeros(d_h)
Wxh_d = init_rng.normal(size=(d_h, ny + 1)) * sc; Whh_d = init_rng.normal(size=(d_h, d_h)) * sc; bh_d = np.zeros(d_h)
Why = init_rng.normal(size=(ny, d_h)) * sc; by = np.zeros(ny) # ny rows: one per entry of V_y
weights = [Wxh_e, Whh_e, bh_e, Wxh_d, Whh_d, bh_d, Why, by]
def train_step(x, y): # x over V_x; y over V_y, with <EOS> at the end
Lx, Ly = len(x), len(y)
He = np.zeros((d_h, Lx)); h = np.zeros(d_h) # --- encoder: reads and summarises ---
for j in range(Lx):
h = np.tanh(Wxh_e[:, x[j]] + Whh_e @ h + bh_e); He[:, j] = h
c = h # context vector: the last state
dec_in = np.concatenate(([GO], y[:-1])) # teacher forcing: GO, y1, ..., y_{Ty-1}
S = np.zeros((d_h, Ly)); P = np.zeros((ny, Ly)); s = c.copy(); loss = 0.0
for i in range(Ly): # --- decoder: writes from c ---
s = np.tanh(Wxh_d[:, dec_in[i]] + Whh_d @ s + bh_d); S[:, i] = s
o = Why @ s + by; o -= o.max(); p = np.exp(o); p /= p.sum(); P[:, i] = p
loss -= np.log(p[y[i]] + 1e-12)
gWhy = np.zeros_like(Why); gby = np.zeros_like(by) # --- decoder backward pass ---
gWxh_d = np.zeros_like(Wxh_d); gWhh_d = np.zeros_like(Whh_d); gbh_d = np.zeros_like(bh_d)
ds = np.zeros(d_h)
for i in reversed(range(Ly)):
dO = P[:, i].copy(); dO[y[i]] -= 1.0
gWhy += np.outer(dO, S[:, i]); gby += dO
dp = (1 - S[:, i] ** 2) * (Why.T @ dO + ds)
s_prev = S[:, i - 1] if i > 0 else c
gWhh_d += np.outer(dp, s_prev); gWxh_d[:, dec_in[i]] += dp; gbh_d += dp
ds = Whh_d.T @ dp
gWxh_e = np.zeros_like(Wxh_e); gWhh_e = np.zeros_like(Whh_e); gbh_e = np.zeros_like(bh_e)
dh = ds # the only link: the gradient at c
for j in reversed(range(Lx)): # --- encoder backward pass ---
dp = (1 - He[:, j] ** 2) * dh
h_prev = He[:, j - 1] if j > 0 else np.zeros(d_h)
gWhh_e += np.outer(dp, h_prev); gWxh_e[:, x[j]] += dp; gbh_e += dp
dh = Whh_e.T @ dp
return loss / Ly, [g / Ly for g in (gWxh_e, gWhh_e, gbh_e, gWxh_d, gWhh_d, gbh_d, gWhy, gby)]
smooth = None
for it in range(iters):
x, y = sentence()
loss, grads = train_step(x, y)
for g in grads:
nrm = np.linalg.norm(g)
if nrm > kappa: g *= kappa / nrm # gradient clipping, from the previous block
for Pm, g in zip(weights, grads):
Pm -= eta * g
smooth = loss if smooth is None else 0.99 * smooth + 0.01 * loss
if it % 300 == 0 or it == iters - 1:
print("iter %4d loss/token %.3f" % (it, smooth))
The first run downloads the Python interpreter (~15 MB). After that it stays in the browser cache and is reused across every lesson.
The loss starts near (the probability spread evenly over the eight entries of
, counting <EOS>) and falls almost to zero. Now for the part that matters: generating.
The loop below is never given the length of the output. It writes a token, checks whether it was
<EOS> and, if so, stops. max_len is there only for safety.
# Needs the previous cells: the trained weights, VX, VY, TO_EN, EOS, GO.
def translate(x, max_len=8): # nobody tells it how many tokens to write
h = np.zeros(d_h)
for j in range(len(x)): # the encoder reads the whole sentence
h = np.tanh(Wxh_e[:, x[j]] + Whh_e @ h + bh_e)
s = h; ix = GO; out = []
for _ in range(max_len):
s = np.tanh(Wxh_d[:, ix] + Whh_d @ s + bh_d)
ix = int((Why @ s + by).argmax()) # the most probable: greedy decoding
if ix == EOS: # the model says it has finished
break
out.append(ix)
return out
examples = [["leo", "libros"], ["vendo", "coches"], ["leo", "coches", "rojos"],
["vendo", "libros", "nuevos"], ["vendo", "libros"], ["leo", "coches", "nuevos"]]
print("sentence translation in out")
for es in examples:
got = translate([VX.index(w) for w in es])
want = ["i", TO_EN[es[0]]] + [TO_EN[w] for w in es[2:]] + [TO_EN[es[1]]]
print(" %-20s %-24s %3d %5d %s" % (
" ".join(es), " ".join(VY[i] for i in got), len(es), len(got),
"OK" if [VY[i] for i in got] == want else "X"))
The first run downloads the Python interpreter (~15 MB). After that it stays in the browser cache and is reused across every lesson.
Two things stand out in that table. First, the length columns: two or three tokens go in and three or
four come out, always one more, and the model decided that number itself when it placed
<EOS> (there's no range(len(x)) anywhere in the loop). Second, the order: when the sentence
has an adjective, the model writes it before the noun even though it read it after. To do that it
had to hold on to the noun while writing the adjective, and the only thing it had to hold it in was
.
Test your intuition
Four questions: why an end-of-sequence token is needed, exactly which distribution the decoder defines, whether greedy decoding is optimal, and where the ability to stop in the right place comes from.
The previous lesson's model generated with a fixed-length loop, writing as many tokens as the input had. Why does that loop fail for translation?
Which of these expressions does the decoder define?
Taking the most probable token at each step always yields the most probable sequence.
The decoder ends up writing <EOS> in the right place, neither earlier nor later. Where does that ability come from?
With the architecture stated precisely, an uncomfortable line comes into view: . That equality isn't a theorem but a design decision, and it claims something strong: that numbers are enough to carry everything the input has to contribute to any position of the output. In a three-token toy nobody would notice. What happens to that claim when the input is a real sentence?
You can measure the answer, and that's what the next lesson, on the context-vector bottleneck, is for. The input keeps getting longer while you watch what happens to the output, until you see where the equality above stops holding and why the breaking point doesn't depend on how much you train.
Further reading2 sources · 1 paper, 1 book
Where this lesson comes from, and where to go next. None of it is needed to carry on with the course.
- Learning Phrase Representations using RNN Encoder-Decoder for Statistical Machine Translation
Its §2 states the factorisation the block rests on, P(y|x) as a product of P(y_i | prefix, c) with c the encoder's last state, plus the end-of-sequence token. Its decoder feeds c in at every step; yours only starts from it.
- Speech and Language Processing, ch. 13: Machine Translation
Its §13.4 covers the greedy decoding and beam search this lesson names without developing, along with teacher forcing and cross-entropy. One caveat: it presents all of this with a Transformer, not an RNN.