Bahdanau's attention (additive)
30 min read
A model with attention needs one weight for each input position at every output step. The previous lesson, on the idea of attention, set up everything around those weights: they come out of a softmax over the positions of the source, they mix the encoder states into a context vector of each step's own, and that vector enters the decoder's recurrence through the matrix . What that lesson didn't write down is what produces the weights. Before the softmax there's a function, , that takes a pair (where the decoder is, and what's at one input position) and returns a score. So far it has only had to do two things: return a number, and be differentiable. This lesson fills it in with the form Bahdanau gave it in 2014.
The question is small and concrete: what function takes two vectors and returns a number saying how well they correspond? Bahdanau's answer is a tiny neural network with a single hidden layer. But the network isn't what teaches the most; the constraint that decides its shape is. The score has to be a function of the pair. A function that gives an opinion on each vector separately and adds the two opinions up doesn't score a correspondence, only two unrelated things, and further down you'll see that this destroys the whole mechanism.
Go back to the previous lesson's map and look at three rows. The rows for i and read take nearly everything from the same position, the one holding leí, which says i read in a single word, with weights of and . The next row, for a, gives that same position . The state hasn't changed between those three rows (the encoder computed it once, and there it stays), and yet it receives two high scores and one low one. The only thing that differs is which decoder state it was compared with each time.
A small network that scores pairs
The additive score projects the two states into a space of its own, with coordinates, and adds them there:
with , one for each side. Both have columns because the encoder and the decoder are the same width in this block, but the sum doesn't require that. All it asks is that the two projections land in the same space, that is, that the two matrices have rows; the number of columns in each is its own side's business. If the decoder states had coordinates, would be and the rest of the score would stay as it is. In fact, in Bahdanau's paper the two widths do differ: its encoder reads the sentence in both directions and concatenates the two states, so its encoder states are twice as wide as the decoder's.
That sum is the pre-activation of a hidden layer, and what comes next is what any layer already does: squash it and read it out.
with . This is a multilayer perceptron (MLP) from Block 2 at its smallest size: one hidden layer of units with , and a linear output of a single neuron. That neuron is the one from the lesson on the artificial neuron, : its input is the hidden layer, , and its weights are , one per hidden unit. We call the read-out vector, because it turns the numbers of the hidden layer into the single number that is the score.
Neither layer has a bias, for different reasons. The output's would be redundant, because it would add the same number to the whole row, and a constant per row cancels out in the softmax. The hidden layer's wouldn't be: inside the it shifts the point where each unit operates, closer to saturation or further from it, and that does change the scores. Bahdanau leaves it out and many implementations put it in; here I follow the paper's formula.
Count the weights: . Neither nor appears, and neither does the pair. There's a single set of weights, and it scores the pairs of this sentence and of every other, which is the same economy as the previous block's recurrence (one for every position), applied to a grid instead of a row.
Without the non-linearity, every step would look at the same place
Remove the and look at what's left. The read-out vector distributes over the sum:
two numbers added together without either having looked at the other. The first, , depends on step but not on position . So in row , that is, among the scores that step 's softmax turns into weights, it's the same in every score: a constant added to the whole row. And a constant like that doesn't change the softmax. It's the shift invariance from the lesson on loss functions, the same property that lets you subtract the maximum before exponentiating, and here it makes the first number cancel between numerator and denominator:
The index has disappeared from the right-hand side. All steps receive the same row of weights, and therefore the same context vector, so the model is back to being the fixed summary of the earlier lessons: a learned alignment, yes, but still frozen, only now it sits wherever training puts it instead of on the last token. The is what prevents that split. It squashes the sum before the read-out, and a non-linear function of a sum doesn't distribute over its terms, so the score can't come apart again into one opinion about the decoder plus another about the encoder.
How the gradient reaches it
Now there are parameters to train, and the question is whether the loss reaches them. The chain starts at the mixture, where multiplies a known vector:
and continues through the softmax, which makes each score affect every weight in its row. Out the other side comes the derivative with respect to a score:
The step through the softmax, with its two cases
With fixed, each weight in the row depends on all scores, so the derivative of the loss with respect to one score sums over the whole row. The softmax's derivative has two cases, the ones from the lesson on loss functions: when the weight and the score share an index it's , and when they don't, . Separating the term from the rest gives
The sum is , and that weighted sum is . Substituting and factoring out gives the derivative above.
Read it slowly, because it says how attention learns where to look. It has two parts. The first is what happens to the mixture when the score goes up: if grows a little, position gains weight at the expense of the others, and shifts towards . Precisely, , which is the same calculation as in the drop-down, with in place of . The second part is , whose opposite points to where the loss goes down, that is, where gradient descent would want to take the mixture. The derivative is the dot product of the two, so it measures whether moving towards takes it where it ought to go.
That tells us what training does with the score. If moving the mixture towards makes the loss go down, the score goes up; if it makes the loss go up, the score goes down; and if coincides with , raising the score doesn't move the mixture, so the score gets no correction at all. A score, then, isn't corrected according to what its state is worth on its own, but according to what that state adds to the mixture the row has already built. It makes sense for this to be a comparison and not a grade: the weights sum to , so raising one means lowering the others, and there's no way to reward one position without saying which one pays for it.
From there back, it's the chain rule as you already know it. The read-out hands back , the contributes its derivative , and each matrix collects the outer product with the vector it multiplied:
All pairs add into the same three arrays, which is what sharing weights means.
There's one more consequence, and it isn't in the parameters. Look at where appears: in the mixture, as one of the terms, and in the scores, as an argument of . That's two places, so the gradient reaches it along two routes, and the two contributions have to be added:
On top of those two comes the one the previous block already had, the gradient that comes down from through the encoder's recurrence. And here is where it gets interesting: the two routes above do not go through that recurrence. They run from step of the decoder straight to position of the input, without travelling the stretch of recurrence that separates that position from the end of the sentence. They don't multiply by even once, and their length doesn't grow as moves further away. The product of matrices that the lesson on the vanishing gradient identified as the cause of the problem doesn't appear on either of them.
The score, in NumPy
The first cell computes the whole grid. It stores the states by rows (one row per step, one row per position), which is why the matrices appear transposed relative to the equations. There are two things to look at: how closely the sharing-out that freshly initialised weights produce resembles a map, and what happens when you ask for the version without the .
T_y, T_x, d_h, d_a = 8, 6, 5, 4
rng = np.random.default_rng(4)
S = rng.normal(size=(T_y, d_h)) * 0.6 # row i: the state s_{i-1} that scores step i
H = rng.normal(size=(T_x, d_h)) * 0.6 # row j: the encoder state at position j
Wsa = rng.normal(size=(d_a, d_h)) * 0.5 # untrained: nobody has corrected this yet
Wha = rng.normal(size=(d_a, d_h)) * 0.5
va = rng.normal(size=d_a) * 0.5
def softmax_rows(E):
Z = np.exp(E - E.max(axis=1, keepdims=True)) # shift invariance
return Z / Z.sum(axis=1, keepdims=True)
def score(S, H, with_tanh=True):
Ps = S @ Wsa.T # (T_y, d_a): one projection per step
Ph = H @ Wha.T # (T_x, d_a): one per position
P = Ps[:, None, :] + Ph[None, :, :] # (T_y, T_x, d_a): the sum, pair by pair
return (np.tanh(P) if with_tanh else P) @ va # (T_y, T_x)
alpha = softmax_rows(score(S, H))
assert np.allclose(alpha.sum(axis=1), 1.0), "some row doesn't sum to 1"
print("weights:", 2 * d_a * d_h + d_a, " shapes: alpha", alpha.shape)
print("row 1:", np.round(alpha[0], 3))
print("largest weight in each row:", np.round(alpha.max(axis=1), 3), " uniform:", round(1 / T_x, 3))
linear = softmax_rows(score(S, H, with_tanh=False))
print()
print("without tanh, row 1:", np.round(linear[0], 3))
print("without tanh, row 8:", np.round(linear[7], 3))
print("without tanh, all rows equal:", bool(np.allclose(linear, linear[0])))
The first run downloads the Python interpreter (~15 MB). After that it stays in the browser cache and is reused across every lesson.
The freshly initialised map says nothing: the largest weight in each row is around when an even share would be , so the eight rows are practically the mean of the sentence. The explorable's interpretable alignment doesn't come from the architecture but from training; what the architecture guarantees is a route along which to train it. And the output after the blank line is the previous section checked by hand: without the , row and row come out equal to the last decimal.
The second cell follows the two routes separately. It needs a loss, so it uses the cheapest one possible (one whose gradient with respect to each is a given vector), which means the mechanism is the only thing being measured.
# Needs the previous cell: S, H, Wsa, Wha, va, score, softmax_rows.
# Toy loss: l = sum_i G[i] . c_i, so the gradient of l with respect to c_i
# is exactly G[i], without differentiating the decoder or the cross-entropy.
G = np.random.default_rng(9).normal(size=(T_y, d_h))
def mix(H):
alpha = softmax_rows(score(S, H))
return alpha @ H, alpha
def loss(H):
C, _ = mix(H)
return float((G * C).sum())
C, alpha = mix(H)
via_value = alpha.T @ G # route 1: as a term of c_i
dl_de = alpha * (G @ H.T - (G * C).sum(axis=1, keepdims=True)) # alpha_ij * G_i . (h_j - c_i)
P = (S @ Wsa.T)[:, None, :] + (H @ Wha.T)[None, :, :]
D = dl_de[:, :, None] * va * (1 - np.tanh(P) ** 2) # (T_y, T_x, d_a)
via_score = np.einsum("ijk,kl->jl", D, Wha) # route 2: as an argument of a
num = np.zeros_like(H) # numerical probe, central difference
for j in range(T_x):
for l in range(d_h):
Hplus = H.copy(); Hplus[j, l] += 1e-6
Hminus = H.copy(); Hminus[j, l] -= 1e-6
num[j, l] = (loss(Hplus) - loss(Hminus)) / 2e-6
print("norm of the value route: %.4f" % np.linalg.norm(via_value))
print("norm of the score route: %.4f" % np.linalg.norm(via_score))
print("difference, each on its own: %.1e %.1e"
% (np.abs(via_value - num).max(), np.abs(via_score - num).max()))
print("difference, the two added: %.1e" % np.abs(via_value + via_score - num).max())
The first run downloads the Python interpreter (~15 MB). After that it stays in the browser cache and is reused across every lesson.
The last line is the check: added together, the two routes reproduce the numerical gradient to the
order of , while on its own each one is wrong in the first digit. The norms tell you
something more, and it's worth being honest about it: with untrained weights, the value route is
eleven times larger than the score route. At the start, almost all of the gradient arrives along the
route it would take even if the alignment were uniform, and the small route, the one through , is
the only one that can pull the map out of that uniformity. Change the scale of va to and run
both cells again: the scores spread apart, the weights sharpen and the two norms draw closer.
Test your intuition
Five questions: what breaks without the , how many weights the function carries, how the gradient reaches an encoder state, what the derivative of a score says, and which part of the computation can come out of the loop.
You remove the , so the score becomes . What happens to the alignment?
A decoder and an encoder with coordinates each, and an alignment model with . How many weights does the function have?
A margin of ±0 is accepted.
Which routes does the gradient of the loss take to reach from the attention mechanism? Leave out the one that arrives from through the encoder's recurrence. Tick all that apply.
Select every correct option. This is graded all-or-nothing: there is no partial credit.
The derivative of the loss with respect to a score comes out as . What does it say about when a score gets corrected?
You translate a sentence of tokens in steps. How many times does each piece of the additive score have to be computed?
Write alignment(S, H, Wsa, Wha, va). It takes the decoder states S, of shape
, the encoder states H, of shape , and the three weights of the
alignment model. Row of S is , the state that step is scored with.
It returns the matrix of shape , already normalised.
The function computes the lesson's additive score, , followed by a row-wise softmax. Don't write into the arrays you receive.
The first run downloads the Python interpreter (~15 MB); after that it stays in the browser cache. This challenge is much easier to solve on a physical keyboard: on a phone, read it and come back later.
The last question leaves a bill unsettled. The two projections come out of the loop because each depends on a single index, but between the sum and the read-out vector there's a , and it forces the grid of vectors of coordinates to be built in full. A sixty-token sentence translated into sixty tokens asks for of those vectors, and none of the operations that produce them is a large matrix product: they're sums and s over a three-dimensional block. The hardware that trains these networks is far better at one thing than at anything else, and this isn't it.
There's another way to score a pair that avoids the whole problem, and its formula fits in five symbols: Luong's attention, the multiplicative kind, the subject of the next lesson. Instead of projecting, adding and squashing, it multiplies the two states through a matrix, so the complete grid of scores becomes one matrix product. That lesson compares the two scores (what each costs, what each can do), and that's where the shape of Block 5 starts to be decided.
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.
- Neural Machine Translation by Jointly Learning to Align and Translate
This lesson's score is in its appendix A.2.2: v_a times the tanh of two summed projections. It notes that fixing the context to the last state gives the bare encoder-decoder, your frozen alignment. It never explains why the tanh.