Bahdanau's attention (additive)

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 Wch\mathbf{W}_{ch}. What that lesson didn't write down is what produces the weights. Before the softmax there's a function, aa, 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 0.740.74 and 0.810.81. The next row, for a, gives that same position 0.050.05. The state hˉ2\bar{\mathbf{h}}_2 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.

The three rows of the example are 2, 3 and 4. The weights are still the ones I set by hand; what this lesson adds is where they would come from if nobody had set them.

A small network that scores pairs

The additive score projects the two states into a space of its own, with dad_a coordinates, and adds them there:

pij=Wsasi−1+Whahˉj∈Rda,\mathbf{p}_{ij} = \mathbf{W}_{sa}\mathbf{s}_{i-1} + \mathbf{W}_{ha}\bar{\mathbf{h}}_j \in \mathbb{R}^{d_a},

with Wsa,Wha∈Rda×dh\mathbf{W}_{sa}, \mathbf{W}_{ha} \in \mathbb{R}^{d_a \times d_h}, one for each side. Both have dhd_h 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 dad_a rows; the number of columns in each is its own side's business. If the decoder states had dsd_s coordinates, Wsa\mathbf{W}_{sa} would be da×dsd_a \times d_s 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.

eij=a(si−1,hˉj)=va⊤tanh⁡(pij)∈R,e_{ij} = a\left(\mathbf{s}_{i-1}, \bar{\mathbf{h}}_j\right) = \mathbf{v}_a^{\top}\tanh\left(\mathbf{p}_{ij}\right) \in \mathbb{R},

with va∈Rda\mathbf{v}_a \in \mathbb{R}^{d_a}. This is a multilayer perceptron (MLP) from Block 2 at its smallest size: one hidden layer of dad_a units with tanh⁡\tanh, and a linear output of a single neuron. That neuron is the one from the lesson on the artificial neuron, z=w⊤x+bz = \mathbf{w}^{\top}\mathbf{x} + b: its input is the hidden layer, tanh⁡(pij)\tanh(\mathbf{p}_{ij}), and its weights are va\mathbf{v}_a, one per hidden unit. We call va\mathbf{v}_a the read-out vector, because it turns the dad_a 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 tanh⁡\tanh 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: 2dadh+da2 d_a d_h + d_a. Neither TxT_x nor TyT_y appears, and neither does the pair. There's a single set of weights, and it scores the Tx⋅TyT_x \cdot T_y pairs of this sentence and of every other, which is the same economy as the previous block's recurrence (one Whh\mathbf{W}_{hh} 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 tanh⁡\tanh and look at what's left. The read-out vector distributes over the sum:

eij=va⊤Wsasi−1+va⊤Whahˉj,e_{ij} = \mathbf{v}_a^{\top}\mathbf{W}_{sa}\mathbf{s}_{i-1} + \mathbf{v}_a^{\top}\mathbf{W}_{ha}\bar{\mathbf{h}}_j,

two numbers added together without either having looked at the other. The first, va⊤Wsasi−1\mathbf{v}_a^{\top}\mathbf{W}_{sa}\mathbf{s}_{i-1}, depends on step ii but not on position jj. So in row ii, that is, among the TxT_x scores ei1,…,eiTxe_{i1}, \dots, e_{iT_x} that step ii'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:

αij=exp⁡(va⊤Whahˉj)∑k=1Txexp⁡(va⊤Whahˉk).\alpha_{ij} = \frac{\exp\left(\mathbf{v}_a^{\top}\mathbf{W}_{ha}\bar{\mathbf{h}}_j\right)}{\sum_{k=1}^{T_x} \exp\left(\mathbf{v}_a^{\top}\mathbf{W}_{ha}\bar{\mathbf{h}}_k\right)}.

The index ii has disappeared from the right-hand side. All TyT_y 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 tanh⁡\tanh 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 αij\alpha_{ij} multiplies a known vector:

∂ℓ∂αij=(∇ciℓ)⊤hˉj,\frac{\partial \ell}{\partial \alpha_{ij}} = \left(\nabla_{\mathbf{c}_i}\ell\right)^{\top}\bar{\mathbf{h}}_j,

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:

∂ℓ∂eij=αij(∇ciℓ)⊤(hˉj−ci).\frac{\partial \ell}{\partial e_{ij}} = \alpha_{ij}\left(\nabla_{\mathbf{c}_i}\ell\right)^{\top}\left(\bar{\mathbf{h}}_j - \mathbf{c}_i\right).
The step through the softmax, with its two cases

With ii fixed, each weight in the row depends on all TxT_x 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 αij(1−αij)\alpha_{ij}\left(1 - \alpha_{ij}\right), and when they don't, −αikαij-\alpha_{ik}\alpha_{ij}. Separating the term k=jk = j from the rest gives

∂ℓ∂eij=αij∂ℓ∂αij−αij∑k=1Txαik∂ℓ∂αik.\frac{\partial \ell}{\partial e_{ij}} = \alpha_{ij}\frac{\partial \ell}{\partial \alpha_{ij}} - \alpha_{ij}\sum_{k=1}^{T_x} \alpha_{ik}\frac{\partial \ell}{\partial \alpha_{ik}}.

The sum is (∇ciℓ)⊤∑kαikhˉk\left(\nabla_{\mathbf{c}_i}\ell\right)^{\top}\sum_k \alpha_{ik}\bar{\mathbf{h}}_k, and that weighted sum is ci\mathbf{c}_i. Substituting ∂ℓ/∂αij\partial \ell / \partial \alpha_{ij} and factoring out αij\alpha_{ij} 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 eije_{ij} grows a little, position jj gains weight at the expense of the others, and ci\mathbf{c}_i shifts towards hˉj\bar{\mathbf{h}}_j. Precisely, ∂ci/∂eij=αij(hˉj−ci)\partial \mathbf{c}_i / \partial e_{ij} = \alpha_{ij}\left(\bar{\mathbf{h}}_j - \mathbf{c}_i\right), which is the same calculation as in the drop-down, with hˉk\bar{\mathbf{h}}_k in place of ∂ℓ/∂αik\partial \ell / \partial \alpha_{ik}. The second part is ∇ciℓ\nabla_{\mathbf{c}_i}\ell, 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 ci\mathbf{c}_i towards hˉj\bar{\mathbf{h}}_j takes it where it ought to go.

That tells us what training does with the score. If moving the mixture towards hˉj\bar{\mathbf{h}}_j makes the loss go down, the score goes up; if it makes the loss go up, the score goes down; and if hˉj\bar{\mathbf{h}}_j coincides with ci\mathbf{c}_i, 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 11, 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 tanh⁡(pij)\tanh(\mathbf{p}_{ij}), the tanh⁡\tanh contributes its derivative 1−tanh⁡21 - \tanh^{2}, and each matrix collects the outer product with the vector it multiplied:

∇vaℓ=∑i,j∂ℓ∂eijtanh⁡(pij),∇pijℓ=∂ℓ∂eij va⊙(1−tanh⁡2(pij)),\nabla_{\mathbf{v}_a}\ell = \sum_{i,j} \frac{\partial \ell}{\partial e_{ij}}\tanh\left(\mathbf{p}_{ij}\right), \qquad \nabla_{\mathbf{p}_{ij}}\ell = \frac{\partial \ell}{\partial e_{ij}}\,\mathbf{v}_a \odot \left(1 - \tanh^{2}\left(\mathbf{p}_{ij}\right)\right), ∇Whaℓ=∑i,j(∇pijℓ)hˉj⊤,∇Wsaℓ=∑i,j(∇pijℓ)si−1⊤.\nabla_{\mathbf{W}_{ha}}\ell = \sum_{i,j} \left(\nabla_{\mathbf{p}_{ij}}\ell\right)\bar{\mathbf{h}}_j^{\top}, \qquad \nabla_{\mathbf{W}_{sa}}\ell = \sum_{i,j} \left(\nabla_{\mathbf{p}_{ij}}\ell\right)\mathbf{s}_{i-1}^{\top}.

All Tx⋅TyT_x \cdot T_y 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 hˉj\bar{\mathbf{h}}_j appears: in the mixture, as one of the terms, and in the TyT_y scores, as an argument of aa. That's two places, so the gradient reaches it along two routes, and the two contributions have to be added:

∇hˉjℓ=∑i=1Tyαij∇ciℓ+∑i=1TyWha⊤∇pijℓ.\nabla_{\bar{\mathbf{h}}_j}\ell = \sum_{i=1}^{T_y} \alpha_{ij}\nabla_{\mathbf{c}_i}\ell + \sum_{i=1}^{T_y} \mathbf{W}_{ha}^{\top}\nabla_{\mathbf{p}_{ij}}\ell.

On top of those two comes the one the previous block already had, the gradient that comes down from hˉj+1\bar{\mathbf{h}}_{j+1} 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 ii of the decoder straight to position jj of the input, without travelling the stretch of recurrence that separates that position from the end of the sentence. They don't multiply by Whhenc\mathbf{W}^{\text{enc}}_{hh} even once, and their length doesn't grow as jj 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 tanh⁡\tanh.

import numpy as np

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])))
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 freshly initialised map says nothing: the largest weight in each row is around 0.180.18 when an even share would be 0.1670.167, 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 tanh⁡\tanh, row 11 and row 88 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 ci\mathbf{c}_i is a given vector), which means the mechanism is the only thing being measured.

import numpy as np

# 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())
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 last line is the check: added together, the two routes reproduce the numerical gradient to the order of 10−1010^{-10}, 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 aa, is the only one that can pull the map out of that uniformity. Change the scale of va to 2.02.0 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 tanh⁡\tanh, how many weights the function aa 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 tanh⁡\tanh, so the score becomes eij=va⊤(Wsasi−1+Whahˉj)e_{ij} = \mathbf{v}_a^{\top}\left(\mathbf{W}_{sa}\mathbf{s}_{i-1} + \mathbf{W}_{ha}\bar{\mathbf{h}}_j\right). What happens to the alignment?

A decoder and an encoder with dh=64d_h = 64 coordinates each, and an alignment model with da=32d_a = 32. How many weights does the function aa have?

weights

A margin of ±0 is accepted.

Which routes does the gradient of the loss take to reach hˉj\bar{\mathbf{h}}_j from the attention mechanism? Leave out the one that arrives from hˉj+1\bar{\mathbf{h}}_{j+1} 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 ∂ℓ/∂eij=αij(∇ciℓ)⊤(hˉj−ci)\partial\ell/\partial e_{ij} = \alpha_{ij}\left(\nabla_{\mathbf{c}_i}\ell\right)^{\top}\left(\bar{\mathbf{h}}_j - \mathbf{c}_i\right). What does it say about when a score gets corrected?

You translate a sentence of TxT_x tokens in TyT_y 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 (Ty,dh)(T_y, d_h), the encoder states H, of shape (Tx,dh)(T_x, d_h), and the three weights of the alignment model. Row ii of S is si−1\mathbf{s}_{i-1}, the state that step ii is scored with. It returns the matrix α\alpha of shape (Ty,Tx)(T_y, T_x), already normalised.

The function computes the lesson's additive score, eij=va⊤tanh⁡(Wsasi−1+Whahˉj)e_{ij} = \mathbf{v}_a^{\top}\tanh\left(\mathbf{W}_{sa}\mathbf{s}_{i-1} + \mathbf{W}_{ha}\bar{\mathbf{h}}_j\right), 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 tanh⁡\tanh, and it forces the grid of Tx⋅TyT_x \cdot T_y vectors of dad_a coordinates to be built in full. A sixty-token sentence translated into sixty tokens asks for 3 6003\,600 of those vectors, and none of the operations that produce them is a large matrix product: they're sums and tanh⁡\tanhs 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.

Was this helpful?
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
    paperBahdanau, Cho and Bengio, 2015arXiv:1409.0473EN

    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.