Seq2seq: from sequence to sequence

Seq2seq: from sequence to sequence

25 min read

The previous lesson's character-level model can carry on a text: give it an opening and it keeps going, character by character, for as long as you let it. But carrying on is all it does. Plenty of tasks have a different shape: shortening a paragraph into a headline, correcting a line full of typos, turning a date written out in words into digits. In each of them one sequence goes in and another comes out, and the second isn't a continuation of the first, nor does it need to be the same length. That shape is what this lesson builds.

It's called a sequence-to-sequence model (seq2seq for short), and the idea fits in a sentence: two networks instead of one. The first, the encoder, reads the input sequence from start to finish and compresses it into a vector; the second, the decoder, starts from that vector and writes the output sequence one token at a time, the way the previous lesson's model wrote. Nothing passes between them except that vector: everything the encoder took from the input has to fit in there.

Take the simplest task with this shape: reversal. The input is live and the output is evil, the same string read backwards. To write the first letter of the output (the e), the decoder needs to know which letter the input ended on; to write the last (the l), it needs to remember the first. So to reverse a string, you have to have read all of it before you start writing, and that's exactly what the split into two networks reflects. The encoder reads live to the end and leaves its summary; only then does the decoder start producing evil.

Two rows of cells joined by a single green box in the centre. The left row, the encoder, has three cells labelled h bar sub one, h bar sub two and h bar sub T x; each takes an input token from below (x sub one, x sub two and x sub T x), and the cells are linked from left to right. A green arrow runs from the encoder's last cell into the central green box labelled c, with the note d sub h numbers underneath. From box c, another green arrow leads to the right row, the decoder, with three cells labelled s sub one, s sub two and s sub three, also linked from left to right. Each decoder cell takes an input from below (GO in the first, y hat sub one and y hat sub two in the next two) and produces an output above: y hat sub one, sub two and sub three. All the information crossing from the encoder to the decoder passes through that single box c.
Everything the encoder read from the input crosses to the decoder at a single point, the context vector c. Whether the input is three tokens or thirty, the decoder receives the same handful of numbers.

That summary is the encoder's last state, and it has a name: the context vector, c\mathbf{c}. It's a vector of fixed size (exactly as many coordinates as the state, not one more), and it's the only part of the input the decoder ever gets to see. The decoder never looks at live again: it starts from c\mathbf{c} and writes from there, feeding itself what it produces, the way the previous lesson's model generated text.

The encoder summarises, the decoder writes

Both networks are the vanilla recurrent neural network (RNN), with nothing new in the recurrence; the only novelty is how they're chained together. The encoder reads the input tokens x1,…,xTx\mathbf{x}_1, \dots, \mathbf{x}_{T_x} (each xj\mathbf{x}_j being the one-hot vector of the jj-th symbol, as in the previous lesson) and updates its state:

hˉj=tanh⁡(Wxhencxj+Whhenchˉj−1+bhenc),\bar{\mathbf{h}}_j = \tanh\left(\mathbf{W}^{\text{enc}}_{xh}\mathbf{x}_j + \mathbf{W}^{\text{enc}}_{hh}\bar{\mathbf{h}}_{j-1} + \mathbf{b}^{\text{enc}}_h\right),

with hˉ0=0\bar{\mathbf{h}}_0 = \mathbf{0}. The bar over hˉj\bar{\mathbf{h}}_j marks it as the encoder's state, so it can't be confused with the decoder's; the superscript enc\text{enc} does the same for its weights, and it isn't a layer index (the encoder has no layers to number) but a label saying which network the weights belong to. When the encoder finishes reading, its last state is the context vector:

c=hˉTx∈Rdh.\mathbf{c} = \bar{\mathbf{h}}_{T_x} \in \mathbb{R}^{d_h}.

Notice the size of c\mathbf{c}: dhd_h coordinates, the same as the state, and that number doesn't depend on TxT_x. A three-token input and a thirty-token input produce a c\mathbf{c} of the same size.

The decoder is another RNN, with its own weights, that picks up where the encoder left off: its initial state is s0=c\mathbf{s}_0 = \mathbf{c}. From there it produces the output step by step. At step ii it reads a token (at the first step <GO>, the symbol the decoder starts from, and after that the previous token) and updates its state:

si=tanh⁡(Wxhdecoui−1+Whhdecsi−1+bhdec),\mathbf{s}_i = \tanh\left(\mathbf{W}^{\text{dec}}_{xh}\mathbf{o}_{u_{i-1}} + \mathbf{W}^{\text{dec}}_{hh}\mathbf{s}_{i-1} + \mathbf{b}^{\text{dec}}_h\right),

where oui−1\mathbf{o}_{u_{i-1}} is the one-hot vector of the token ui−1u_{i-1} fed in at position ii. Each state produces a distribution over the next symbol, as in the previous lesson's language model:

y^i=softmax(Whysi+by),\hat{\mathbf{y}}_i = \text{softmax}\left(\mathbf{W}_{hy}\mathbf{s}_i + \mathbf{b}_y\right),

and the loss adds one cross-entropy per output position, averaged per token:

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

where yiy_i is the symbol that really belongs at position ii, and TyT_y is the length of the output, which needn't equal TxT_x. What matters is where that loss lives: only in the decoder. The encoder predicts nothing at any of its positions, so it contributes not a single term to ℓ\ell. In the previous lesson's model every position had its own output and its own loss term; here, half the network has neither.

One detail about the token ui−1u_{i-1} the decoder reads at each step. During training it's given the correct symbol (the one from the real output, even if the decoder got its own prediction wrong); this is called teacher forcing, and it makes training more stable. During generation there's no correct answer to give it, so its own output is fed back in. It's the same split between training and generating as in the previous lesson, with <GO> at the start instead of a space.

How the encoder learns with no output of its own

If the encoder has no loss, where does its gradient come from? From a single place, and that's what lets this model train end to end despite being two networks. The backward pass starts at the only loss there is, the decoder's, and step for step it's the one from the lesson on the character-level language model, without a line changed: each position starts an error from its output and transports it to the previous step. What's new happens at the end of that pass. The decoder's first state is s0=c\mathbf{s}_0 = \mathbf{c}, so once the error has finished crossing the decoder backwards, it leaves a gradient on the context vector:

∇cℓ=(Whhdec)⊤δ1,\nabla_{\mathbf{c}}\ell = \left(\mathbf{W}^{\text{dec}}_{hh}\right)^{\top}\boldsymbol{\delta}_1,

where δ1\boldsymbol{\delta}_1 is the error at the decoder's first step, the same quantity as in the lesson on the character-level language model. That ∇cℓ\nabla_{\mathbf{c}}\ell is the only gradient the encoder receives, and it's all the encoder needs: since c=hˉTx\mathbf{c} = \bar{\mathbf{h}}_{T_x}, it's exactly the gradient on the encoder's last state, and from there the encoder runs its own backpropagation through time (BPTT), as if ∇cℓ\nabla_{\mathbf{c}}\ell had come from an output at its last step. Spread backwards over its TxT_x steps, that single gradient adjusts Whhenc\mathbf{W}^{\text{enc}}_{hh}, Wxhenc\mathbf{W}^{\text{enc}}_{xh} and bhenc\mathbf{b}^{\text{enc}}_h.

The limit is already visible here. The whole decoder hangs off c\mathbf{c}, and the whole encoder is corrected through c\mathbf{c}: if some part of the input didn't make it into those dhd_h coordinates, the decoder can't use it, and the gradient can't ask for it to be kept. The longer the input, the more has to be squeezed into the same vector.

See the full backward pass, from the decoder to the encoder

The decoder's backward pass is the one from the lesson on the character-level language model, with s\mathbf{s} in place of h\mathbf{h}: the error at the pre-activation of each position is

δi=(1−si⊙si)⊙(Why⊤(y^i−yi)+(Whhdec)⊤δi+1),\boldsymbol{\delta}_i = \left(\mathbf{1} - \mathbf{s}_i \odot \mathbf{s}_i\right) \odot \left(\mathbf{W}_{hy}^{\top}\left(\hat{\mathbf{y}}_i - \mathbf{y}_i\right) + \left(\mathbf{W}^{\text{dec}}_{hh}\right)^{\top}\boldsymbol{\delta}_{i+1}\right),

with δTy+1=0\boldsymbol{\delta}_{T_y+1} = \mathbf{0}. After crossing all TyT_y steps, the transport reaches s0=c\mathbf{s}_0 = \mathbf{c} and stops there: ∇cℓ=(Whhdec)⊤δ1\nabla_{\mathbf{c}}\ell = (\mathbf{W}^{\text{dec}}_{hh})^{\top}\boldsymbol{\delta}_1. That vector kicks off the encoder's backward pass, which is the one from the lesson on BPTT but with no output term (the encoder makes no prediction at any step, so the term with y^\hat{\mathbf{y}} disappears and only the transport is left):

δˉj=(1−hˉj⊙hˉj)⊙(Whhenc)⊤δˉj+1,\bar{\boldsymbol{\delta}}_j = \left(\mathbf{1} - \bar{\mathbf{h}}_j \odot \bar{\mathbf{h}}_j\right) \odot \left(\mathbf{W}^{\text{enc}}_{hh}\right)^{\top}\bar{\boldsymbol{\delta}}_{j+1},

starting at the last step with δˉTx=(1−c⊙c)⊙∇cℓ\bar{\boldsymbol{\delta}}_{T_x} = \left(\mathbf{1} - \mathbf{c} \odot \mathbf{c}\right) \odot \nabla_{\mathbf{c}}\ell. That single entry point, c\mathbf{c}, is the encoder's only contact with the loss.

Reversing sequences in NumPy

Now to train a seq2seq on the toy task: reversing strings of symbols. It's a toy on purpose (real translation doesn't fit in the browser), but it has the whole shape of the problem: two networks, a context vector between them, and an output that isn't a continuation of the input. The alphabet is six letters, plus a <GO> symbol that only ever enters the decoder, to get it started. Each example is a random string of 3 to 7 letters, paired with its reverse. The cell builds the two networks, chains the two backward passes (the decoder's and, hanging off c\mathbf{c}, the encoder's) and trains. Watch the loss per character it prints every few hundred steps.

import numpy as np

# Toy task: read a string of letters and write it backwards.
V = list("abcdef") # the 6 letters: the encoder's input and the decoder's output
GO = 6 # start symbol; it only enters the decoder, never predicted
n_v, n_sym = 7, 6 # 7 inputs (letters + GO); 6 output classes
d_h, eta, kappa, iters = 64, 0.10, 5.0, 1200

rng = np.random.default_rng(0) # fixed seed: you'll see these numbers
sc = 0.1
Wxh_e = rng.normal(size=(d_h, n_v)) * sc; Whh_e = rng.normal(size=(d_h, d_h)) * sc; bh_e = np.zeros(d_h)
Wxh_d = rng.normal(size=(d_h, n_v)) * sc; Whh_d = rng.normal(size=(d_h, d_h)) * sc; bh_d = np.zeros(d_h)
Why = rng.normal(size=(n_sym, d_h)) * sc; by = np.zeros(n_sym)
weights = [Wxh_e, Whh_e, bh_e, Wxh_d, Whh_d, bh_d, Why, by]


def train_step(x, y): # x: the source; y: the source reversed
L = len(x)
He = np.zeros((d_h, L)); h = np.zeros(d_h) # --- encoder: reads and summarises ---
for j in range(L):
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])) # decoder with teacher forcing: GO, y1, ..., y_{L-1}
S = np.zeros((d_h, L)); P = np.zeros((n_sym, L)); s = c.copy(); loss = 0.0
for i in range(L): # --- 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(L)):
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(L)): # --- 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 / L, [g / L for g in (gWxh_e, gWhh_e, gbh_e, gWxh_d, gWhh_d, gbh_d, gWhy, gby)]


smooth = None
for it in range(iters):
L = int(rng.integers(3, 8)) # lengths 3..7
x = rng.integers(0, n_sym, size=L); y = x[::-1].copy()
loss, grads = train_step(x, y)
for g in grads:
nrm = np.linalg.norm(g)
if nrm > kappa: g *= kappa / nrm # gradient clipping
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/char %.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⁡6≈1.79\ln 6 \approx 1.79 (the cost of spreading the probability evenly over the six letters, an echo of the previous lesson's ln⁡26\ln 26 with a six-sided die instead of a twenty-six-sided one) and falls to around 0.750.75. It doesn't reach zero, and it shouldn't: the loop is cut short to fit within the few seconds the browser allows a cell, and even fully trained, this model has a ceiling, which the next cell brings out. It takes the trained weights, asks them to reverse strings they never saw during training, and measures how many characters come out right at each length.

import numpy as np

# Needs the previous cell: the trained weights, V, n_sym.
def write(x): # generates without teacher forcing: feeds itself back
h = np.zeros(d_h)
for j in range(len(x)):
h = np.tanh(Wxh_e[:, x[j]] + Whh_e @ h + bh_e)
s = h; ix = GO; out = []
for _ in range(len(x)):
s = np.tanh(Wxh_d[:, ix] + Whh_d @ s + bh_d)
ix = int((Why @ s + by).argmax()); out.append(ix) # the most probable (greedy)
return out


rng_ex = np.random.default_rng(7)
print("A few examples, from short to long:")
for L in [3, 4, 8, 12]:
x = rng_ex.integers(0, n_sym, size=L)
got = write(x)
print(" %-9s -> want %-9s and writes %-9s %s" % (
"".join(V[i] for i in x), "".join(V[i] for i in x[::-1]),
"".join(V[i] for i in got), "OK" if list(got) == list(x[::-1]) else ""))

rng_acc = np.random.default_rng(123)
print("\nAccuracy by length (150 new strings each):")
for L in range(3, 9):
right = total = 0
for _ in range(150):
x = rng_acc.integers(0, n_sym, size=L); y = x[::-1]
right += int((np.array(write(x)) == y).sum()); total += L
print(" L=%d %.0f%% of characters" % (L, 100 * right / total))
numpy

The first run downloads the Python interpreter (~15 MB). After that it stays in the browser cache and is reused across every lesson.

Short strings come out well (three- and four-letter ones are often exactly right); long ones go wrong, and the way they go wrong tells you where the problem lies. The beginning of what the decoder writes (which is the end of what the encoder read, the most recent thing in c\mathbf{c}) is usually right; the end (which requires remembering the beginning of the input, the thing furthest back) is where it falls apart. The table puts numbers on it: close to perfect accuracy at length 3, and around half the characters on the longest strings. It isn't a lack of training. The long string and the short one reach the decoder as the same c\mathbf{c}, the same dhd_h numbers; the long one has more to fit into the same space, and something gets left out.

Test your intuition

Four questions: how this model differs from the previous lesson's, how many numbers the summary carries, how the encoder learns with no output of its own, and what training for much longer would fix (and what it wouldn't).

The previous lesson's character-level model continued a text that was already under way. What does a sequence-to-sequence model do differently?

The encoder has a state of dh=64d_h = 64 coordinates and reads a sentence of Tx=30T_x = 30 tokens. How many numbers does the decoder receive as the summary of that whole sentence?

A margin of ±0 is accepted.

The encoder has no output of its own: it predicts nothing at any of its positions. Even so, it gets trained. How does the gradient reach it? Tick everything that is true.

Select every correct option. This is graded all-or-nothing: there is no partial credit.

The reverser gets almost every character right on short strings and drops to around half on long ones. If you trained it for much longer, what would you expect?


And with that, the block is complete. The vanilla RNN read any length; backpropagation through time trained it; the gates of the long short-term memory (LSTM) and the gated recurrent unit (GRU) gave it a memory able to survive distance. The sequence-to-sequence model puts them all to work together on the course's first task that turns one sequence into a different one, rather than continuing it or hanging a verdict on it.

But the seam between the two networks is a single vector. Everything the encoder read, three tokens or thirty, has to fit into the dhd_h numbers of c\mathbf{c}, and that's why long strings fray: a whole sentence doesn't fit where a word did. That's exactly the problem the next block, the bridge to attention, opens with. Instead of squeezing the input into a single summary and never looking at it again, the idea will be to let the decoder look back over the whole sequence the encoder read and choose, at every step it writes, which part it needs. How that's done (and why it reshapes the rest of the course) is what comes next.

Was this helpful?
Further reading2 sources · 2 papers

Where this lesson comes from, and where to go next. None of it is needed to carry on with the course.

  • Sequence to Sequence Learning with Neural Networks
    paperSutskever, Vinyals and Le, 2014arXiv:1409.3215EN

    This lesson's architecture: one LSTM compresses the input into a fixed vector, and another writes the output from it. They reverse the input sentence and, unlike your reverser, report that long sentences gave them no trouble.

  • On the Properties of Neural Machine Translation: Encoder-Decoder Approaches
    paperCho, van Merriënboer, Bahdanau and Bengio, 2014arXiv:1409.1259EN

    It measures the bottleneck from your last cell: with a fixed-size context vector, quality drops sharply as the input sentence gets longer. The fix (letting the decoder look at the input again) is the next block.