The encoder-decoder architecture

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.

Five columns, one per decoder step, each with three candidate tokens and a bar proportional to its probability. In the first column i wins with 0.94, ahead of read and sell. In the second sell wins with 0.88, ahead of read and new. In the third new wins with 0.71, followed by cars with 0.21 and red with 0.08: it is the least decided of the five columns. In the fourth cars wins with 0.85, ahead of books and new. In the fifth the end-of-sequence token, EOS, wins with 0.96. Below them all, the product of the five winning probabilities: 0.94 times 0.88 times 0.71 times 0.85 times 0.96 equals 0.48.
Translating "vendo coches nuevos" (I sell new cars). The length of the output is set not by the loop but by the column where "<EOS>" wins. I set the probabilities by hand for the drawing.

Two vocabularies and an output that stops on its own

Fix two vocabularies: VxV_x for the input and VyV_y for the output. The input is a sequence of tokens x1:Txx_{1:T_x}, each xjx_j an entry of VxV_x. The output is another sequence, y1:Tyy_{1:T_y}, over VyV_y, 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,

hˉj=tanh⁡(Wxhencoxj+Whhenchˉj−1+bhenc),c=hˉTx∈Rdh,\bar{\mathbf{h}}_j = \tanh\left(\mathbf{W}^{\text{enc}}_{xh}\mathbf{o}_{x_j} + \mathbf{W}^{\text{enc}}_{hh}\bar{\mathbf{h}}_{j-1} + \mathbf{b}^{\text{enc}}_h\right), \qquad \mathbf{c} = \bar{\mathbf{h}}_{T_x} \in \mathbb{R}^{d_h},

where oxj∈{0,1}∣Vx∣\mathbf{o}_{x_j} \in \{0,1\}^{\lvert V_x \rvert} is the one-hot vector of the jj-th token and Wxhenc∈Rdh×∣Vx∣\mathbf{W}^{\text{enc}}_{xh} \in \mathbb{R}^{d_h \times \lvert V_x \rvert} has one column for each entry of VxV_x. The decoder's recurrence doesn't change either, and it starts where the encoder finished, s0=c\mathbf{s}_0 = \mathbf{c}. What does change is its output, which now lives in the other language:

y^i=softmax(Whysi+by)∈R∣Vy∣,\hat{\mathbf{y}}_i = \text{softmax}\left(\mathbf{W}_{hy}\mathbf{s}_i + \mathbf{b}_y\right) \in \mathbb{R}^{\lvert V_y \rvert},

with Why∈R∣Vy∣×dh\mathbf{W}_{hy} \in \mathbb{R}^{\lvert V_y \rvert \times d_h}, which has one row for each entry of VyV_y and is the only shape in the model that knows how many entries the target language has. Nothing requires ∣Vx∣\lvert V_x \rvert and ∣Vy∣\lvert V_y \rvert to match.

Where the end of the sentence lives

Now for the missing token. To VyV_y, and only to VyV_y, 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 nn tokens, the training sequence has Ty=n+1T_y = n + 1 and its last token is yTy=<EOS>y_{T_y} = \texttt{<EOS>}. The loss is the one from the lesson on sequence-to-sequence models, unchanged,

ℓ=1Ty∑i=1Ty−log⁡y^i, yi,\ell = \frac{1}{T_y}\sum_{i=1}^{T_y} -\log \hat{y}_{i,\,y_i},

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: TyT_y 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:

P(y1:Ty∣x1:Tx)=∏i=1TyP(yi∣y<i, x1:Tx),P\left(y_{1:T_y} \mid x_{1:T_x}\right) = \prod_{i=1}^{T_y} P\left(y_i \mid y_{<i},\, x_{1:T_x}\right),

where y<iy_{<i} is the prefix y1,…,yi−1y_1, \dots, y_{i-1}: everything written before step ii. 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:

P(yi∣y<i, x1:Tx)=P(yi∣y<i, c).P\left(y_i \mid y_{<i},\, x_{1:T_x}\right) = P\left(y_i \mid y_{<i},\, \mathbf{c}\right).

Read it slowly, because the whole block hangs on it. On the left, step ii 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: c\mathbf{c} is the initial state s0\mathbf{s}_0, and the prefix y<iy_{<i} is fed back in token by token, so both reach step ii already summarised in si−1\mathbf{s}_{i-1}. And the factor's probabilities are the coordinates we've already computed: P(yi=k∣y<i,c)=y^i,kP(y_i = k \mid y_{<i}, \mathbf{c}) = \hat{y}_{i,k}.

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,

yi=arg⁡max⁡k∈Vyy^i,k,y_i = \arg\max_{k \in V_y} \hat{y}_{i,k},

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 ∣Vy∣Ty\lvert V_y \rvert^{T_y} 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 0.60.6 and 0.40.4 (I made the numbers up, and only four are needed). Greedy decoding keeps the 0.60.6. Suppose the best continuation from that branch is worth 0.50.5, and the best from the other is worth 0.90.9. Then the two complete paths are worth

0.6⋅0.5=0.30and0.4⋅0.9=0.36,0.6 \cdot 0.5 = 0.30 \qquad\text{and}\qquad 0.4 \cdot 0.9 = 0.36,

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.

import numpy as np

# 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)))
numpy

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: Ty=Tx+2T_y = T_x + 2. 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 c\mathbf{c}, 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.

import numpy as np

# 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))
numpy

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 ln⁡8≈2.08\ln 8 \approx 2.08 (the probability spread evenly over the eight entries of VyV_y, 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.

import numpy as np

# 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"))
numpy

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 c\mathbf{c}.

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: P(yi∣y<i,x1:Tx)=P(yi∣y<i,c)P(y_i \mid y_{<i}, x_{1:T_x}) = P(y_i \mid y_{<i}, \mathbf{c}). That equality isn't a theorem but a design decision, and it claims something strong: that dhd_h 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.

Was this helpful?
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.