The context-vector bottleneck
27 min read
The decoder from the previous lesson, on the encoder-decoder architecture, never reads the input sentence. It reads a vector. has coordinates, that number is fixed when the network is built (before it has seen a single sentence), and it's the same for five tokens as for five hundred. That lesson flagged this for what it was: a bet on what fits in numbers, not something it had shown.
A bet can be lost in two very different ways, and telling them apart is the whole job of this lesson. A model can fail because it's undertrained, in which case more training fixes it. Or it can fail because what you ask of it doesn't fit where you ask it to fit, in which case no amount of training will help: the wall doesn't move. The lesson takes the two separately. First it counts two quantities and compares them. One is how many distinct values can take. I'll call each of them a message, since it's the only thing that gets through from the encoder to the decoder. The other is how many distinct sentences the encoder can receive, where a sentence means any combination of tokens from the input vocabulary at a given length, not only the ones that appear in the data. For the decoder to reconstruct every sentence, each one needs a message of its own. Then the lesson measures what happens to a real model as the input grows longer, using two state widths and several training budgets.
The two quantities grow in incompatible ways, and the explorable lays one over the other. The green line doesn't move with the length: it shows the most distinct messages a vector of coordinates can encode, given that levels can be told apart in each coordinate (where comes from is the next section's business). That number knows nothing about the sentence it's summarising. The amber line counts the sentences of each length, and climbs a full rung with every token you add. Widen the state or raise the levels as far as you like, then look for the point where the two lines cross.
How many messages fit, and how many sentences there are
The encoder's state comes out of a , so each coordinate of lives in the interval . On paper that's a continuum, and anything fits in a continuum. In a model trained with gradients and read by another network, it doesn't. Let be the number of levels of one coordinate that can really be told apart: two values closer together than that reach the decoder as the same number. I choose myself: eight levels, sixteen, thirty-two, depending on how optimistic you want to be. What follows doesn't depend on which you pick.
With that convention the encoder has a finite number of distinct messages to send, and the sentences it has to tell apart are all those of length over the input vocabulary:
Look at where sits. In the first count it doesn't appear: the context vector is as big for a short sentence as for a long one, which is exactly the design decision of the previous lesson. In the second, it's in the exponent.
Now for the move that turns that asymmetry into an impossibility. Once is fixed, everything the decoder writes is determined: it starts at , writes the most probable token at each step (the one with the largest coordinate in its output vector) and feeds that token back in. That's the previous lesson's greedy decoding. The complete output is therefore a function of , and a function can't return two different answers for the same argument. If there are fewer messages than sentences, two different sentences must share a message, so the decoder returns the same output for both and reconstructs at least one of them wrongly. Counting the sentences it can get right gives
The ceiling is (it says nothing) as long as the sentences fit, and from there on it's divided by with every new token. We find the point where it changes by taking logarithms in :
Plug in numbers of the kind real models use. A state of coordinates, eight levels per coordinate and a subword vocabulary of entries give tokens; ten tokens further on, the ceiling above has already fallen to . And one word appears in none of the three formulas: training. Neither the number of iterations, nor the learning rate, nor the data enters the argument, because what we counted is the most different answers a mechanism can give, not how well it chooses among them.
A ceiling far above where it breaks
That count proves the wall exists. It doesn't say where the wall is, and the difference deserves spelling out. The next section trains encoder-decoders on a toy task: read a string of letters, drawn from only four of them (a, b, c and d), and write it backwards. The best of them has , and with levels and the count gives it a ceiling of tokens. Even so, it reverses only one five-letter string in three correctly.
The gap between those two figures has an explanation, and it's material from the previous block seen from a new angle. The gradient that teaches the encoder to keep token of the input starts at the loss term for position of the output, travels back through the decoder's steps as far as , and from there continues through the encoder's steps down to position . There's no other route ( is the only point of contact between the two networks), and its length is
which is largest when , the first token of the input. The lesson on the vanishing gradient describes what happens to a signal that crosses a route like that: each step multiplies it by and by the mask, and when (the steepest slope of the times the largest singular value of ) is below one, the magnitude falls exponentially with distance. The first token of the sentence therefore receives the weakest signal of all.
So the encoder doesn't divide among the positions the way an optimal code would. It fills it mostly with what it read last, because that's all it manages to learn to keep. The tokens of the count assume a perfect encoding; the handful of letters in the experiment is what you get from training a recurrence with gradient descent. Both are true, and they say different things: counting proves the wall exists, and training shows where it is.
Two state widths and three training budgets
The task is the one from the lesson on sequence-to-sequence models: read a string of letters and write it backwards. It's deliberately impoverished (no grammar, no meaning, nothing to generalise), and that's what keeps the experiment clean: reversing a string takes nothing beyond remembering it, so every failure is a failure of memory.
The first cell builds a model of whatever width you ask for and defines its forward and backward passes, line for line the previous lesson's. The one thing to look for is that is still the only link between the two networks: a single variable, marked in the code.
# The sequence-to-sequence task from the previous block: read a string of letters and write it backwards.
V = list("abcd") # four letters: |V_x| = 4
n_sym, GO = 4, 4 # GO only enters the decoder; it's never predicted
L_MIN, L_MAX = 2, 7
eta, kappa = 0.10, 5.0
def build(d_h): # same seed: every model starts the same way
r = np.random.default_rng(1); sc = 0.1
return [r.normal(size=(d_h, 5)) * sc, r.normal(size=(d_h, d_h)) * sc, np.zeros(d_h),
r.normal(size=(d_h, 5)) * sc, r.normal(size=(d_h, d_h)) * sc, np.zeros(d_h),
r.normal(size=(n_sym, d_h)) * sc, np.zeros(n_sym)]
def train_step(weights, x, y): # forward and backward pass, as in the previous lesson
Wxh_e, Whh_e, bh_e, Wxh_d, Whh_d, bh_d, Why, by = weights
d_h = len(bh_e); L = len(x)
He = np.zeros((d_h, L)); h = np.zeros(d_h)
for j in range(L):
h = np.tanh(Wxh_e[:, x[j]] + Whh_e @ h + bh_e); He[:, j] = h
c = h # the bottleneck: everything passes through here
dec_in = np.concatenate(([GO], y[:-1])) # teacher forcing
S = np.zeros((d_h, L)); P = np.zeros((n_sym, L)); s = c.copy()
for i in range(L):
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[:, i] = p / p.sum()
gWxh_e = np.zeros_like(Wxh_e); gWhh_e = np.zeros_like(Whh_e); gbh_e = np.zeros_like(bh_e)
gWxh_d = np.zeros_like(Wxh_d); gWhh_d = np.zeros_like(Whh_d); gbh_d = np.zeros_like(bh_d)
gWhy = np.zeros_like(Why); gby = np.zeros_like(by); ds = np.zeros(d_h)
for i in reversed(range(L)): # --- decoder backward pass ---
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)
gWhh_d += np.outer(dp, S[:, i - 1] if i > 0 else c); gWxh_d[:, dec_in[i]] += dp; gbh_d += dp
ds = Whh_d.T @ dp
dh = ds # the only link between the two networks
for j in reversed(range(L)): # --- encoder backward pass ---
dp = (1 - He[:, j] ** 2) * dh
gWhh_e += np.outer(dp, He[:, j - 1] if j > 0 else np.zeros(d_h))
gWxh_e[:, x[j]] += dp; gbh_e += dp
dh = Whh_e.T @ dp
return [g / L for g in (gWxh_e, gWhh_e, gbh_e, gWxh_d, gWhh_d, gbh_d, gWhy, gby)]
The first run downloads the Python interpreter (~15 MB). After that it stays in the browser cache and is reused across every lesson.
The second trains and measures: for each length, the percentage of strings that come out exactly right (a hundred and twenty new strings behind each number, generated without teacher forcing). The output has four rows. The first three are the same narrow state, , at three points in its training; the fourth has a state four times as wide and trains for half as long as the third. The test strings are the same in all four rows and the networks start from the same seed, so the only difference between rows is the one printed at the start of each.
# Needs the previous cell: build, train_step, n_sym, GO, L_MIN, L_MAX, eta, kappa.
def write(weights, x): # greedy generation, no teacher forcing
Wxh_e, Whh_e, bh_e, Wxh_d, Whh_d, bh_d, Why, by = weights
h = np.zeros(len(bh_e))
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)
return out
def accuracy(weights): # % of strings EXACTLY right, by length
test_rng = np.random.default_rng(123) # the same test strings in every row
row = []
for L in range(L_MIN, L_MAX + 1):
right = 0
for _ in range(120):
x = test_rng.integers(0, n_sym, size=L)
right += int(write(weights, x) == list(x[::-1]))
row.append(100 * right / 120)
return row
print(" model " + "".join(" L=%d " % L for L in range(L_MIN, L_MAX + 1)))
for d_h, checkpoints in [(8, (250, 1000, 2000)), (32, (1000,))]:
weights = build(d_h); rng = np.random.default_rng(2)
for it in range(max(checkpoints)):
L = int(rng.integers(L_MIN, L_MAX + 1))
x = rng.integers(0, n_sym, size=L); y = x[::-1].copy()
grads = train_step(weights, 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
if it + 1 in checkpoints:
print(" d_h=%2d, %4d iter " % (d_h, it + 1)
+ "".join("%3.0f%% " % v for v in accuracy(weights)))
The first run downloads the Python interpreter (~15 MB). After that it stays in the browser cache and is reused across every lesson.
Read the table in pairs of rows. From the first row to the second, training does exactly what you'd expect: in 750 more iterations, the two- and three-letter strings go from never coming out right to coming out right most of the time. From the second to the third, doubling the budget, the progress continues, but only in the short columns. The two-letter strings reach a perfect score and the three- and four-letter ones climb a little further, while the five-, six- and seven-letter ones stay at floor level, where they already were. That's the finding, and it's worth stating slowly: training buys accuracy on what fits, and nothing on what doesn't.
Now the fourth row, which trains for half as long as the third but with a state four times as wide: everything that had room to rise does, and the three long columns go from anecdotal to real (one five-letter string in three, against one in twelve). That answers the question from the start of the lesson, measured rather than guessed. The width of and the training budget don't buy the same thing: training refines how well the vector is used, up to what the vector can hold, and only width moves the wall.
The most revealing thing, though, is how it gets things wrong, which is the previous section seen from the output's side. The lesson on sequence-to-sequence models already let you see it on this same task: in a long string, the start of what the decoder writes (the end of what the encoder read, and so the most recent thing in ) is usually right, and what gets scrambled is the end, which asks the decoder to remember the first token of the input. The worst-kept token is the one at the end of the longest route.
Test your intuition
Four questions: what more training fixes, how high the count's ceiling is, what widening the state buys, and which token of the input survives worst.
The narrow model in the table, with , gets a good share of the three-letter strings right and almost none of the six-letter ones. You train it for ten times as long. What do you expect to see?
An encoder has coordinates, and levels can be told apart in each one. It reads sentences over a vocabulary of entries. What's the length, in tokens, of the longest sentence that this lesson's count does not rule out?
A margin of ±0 is accepted.
By this lesson's count, doubling doubles (the maximum length the count doesn't rule out), give or take rounding to a whole number.
In an input of tokens, which one survives worst inside ?
Both halves of the lesson point at the same place, and it isn't the size of . The count weighs the messages that fit against the sentences there are because we agreed there would be a single message, and the route gets long because all the information passes through a single point. Widening moves the ceiling a few tokens and leaves both of those statements untouched. The problem underneath isn't a small vector: it's the idea of summarising.
Put like that, something stands out. The encoder computed , one state per input token, and the architecture throws away all but the last. The others were computed and then discarded. Getting them back, so that the decoder can look at the whole input instead of settling for the summary, is the job of the next lesson, on the idea of attention. Giving that backward look its formulas takes up the rest of the block.
Further reading1 source · 1 paper
Where this lesson comes from, and where to go next. None of it is needed to carry on with the course.
- On the Properties of Neural Machine Translation: Encoder-Decoder Approaches
This lesson's experiment on real translation: figure 4a shows quality plunging as sentences grow, and §5.1 blames the fixed vector. It measures the wall but doesn't explain why it's there, which is the other half of this lesson.