Lesson 4 - The Backward Pass for Attention

Welcome to The Backward Pass for Attention

You have built the forward pass of a self-attention head: project the input into a query, a key, and a value; score every position against every other with QK/hs Q K^\top / \sqrt{hs} ; softmax those scores into weights; and read back a weighted average of the values. That gives you out, but it does not yet give you a way to train the projections Wq,Wk,Wv W_q, W_k, W_v . Training needs gradients, and gradients need a backward pass. This lesson derives that backward pass in full and, crucially, proves it is correct with a numerical gradient check.

This is the crux of building a trainable transformer by hand. Every other piece of your mini-GPT reuses gradient rules you already met in the Deep Learning Foundations course, matrix-multiply gradients, elementwise gradients, sums over the batch. Attention adds exactly one genuinely new and subtle step: differentiating through the softmax, whose Jacobian couples every entry of a row to every other. Get that one right and the rest is careful bookkeeping. We will use tiny dimensions and float64 so that a finite-difference check can confirm, digit by digit, that the analytic gradients match reality.

By the end of this lesson, you will be able to:

  • Cache the forward pass of attention so the backward pass has everything it needs
  • Derive dA and dValue through the value multiply out = A @ V
  • Apply the softmax Jacobian to turn dA into dS with the vectorized per-row formula
  • Push the gradient through the scale and the score matrix to get dS_scaled, dQuery, and dKey
  • Finish at the parameters and input: dWq, dWk, dWv, and dX
  • Verify every gradient against a numerical estimate and read a real max relative error

You should be comfortable with the attention forward pass from Lessons 1 through 3 and with the backpropagation pattern (an incoming gradient times a local derivative) from the foundations course. Let’s begin.


Setup: A Tiny Head in float64

To prove gradients numerically we want dimensions small enough that a finite-difference check over every entry runs instantly, and float64 so the check has enough precision to trust. We use one attention head with a batch of B = 2, sequence length T = 4, model width C = 8, and head size hs = 8. Everything is seeded so the run is fully reproducible.

The forward pass is the same scaled dot-product attention you already built, but rewritten to return a cache: the intermediate tensors the backward pass will need. Caching is not optional. The backward pass through out = A @ V needs A and V; the softmax backward needs A; the score gradients need Q and K; and the parameter gradients need X.

import numpy as np

np.random.seed(42)
B, T, C, hs = 2, 4, 8, 8      # tiny dims, a single attention head

def softmax(x, axis=-1):
    x = x - x.max(axis=axis, keepdims=True)   # stable: subtract row max
    e = np.exp(x)
    return e / e.sum(axis=axis, keepdims=True)

# float64 everywhere (default), small init
X  = np.random.randn(B, T, C) * 0.5
Wq = np.random.randn(C, hs) * 0.1
Wk = np.random.randn(C, hs) * 0.1
Wv = np.random.randn(C, hs) * 0.1
G  = np.random.randn(B, T, hs)   # fixed random output directions (defines the loss)

def attention_forward(X, Wq, Wk, Wv):
    Q = X @ Wq                                  # (B, T, hs)
    K = X @ Wk                                  # (B, T, hs)
    V = X @ Wv                                  # (B, T, hs)
    S = Q @ K.transpose(0, 2, 1) / np.sqrt(hs)  # (B, T, T)
    A = softmax(S, axis=-1)                     # (B, T, T)
    out = A @ V                                 # (B, T, hs)
    cache = (X, Wq, Wk, Wv, Q, K, V, A)
    return out, cache

out, cache = attention_forward(X, Wq, Wk, Wv)
print("X:", X.shape, " out:", out.shape, " dtype:", out.dtype)
X: (2, 4, 8)  out: (2, 4, 8)  dtype: float64

The head maps an input of shape (2, 4, 8) to an output of the same shape (because here C == hs), and everything is float64. The cache tuple carries the five tensors we will differentiate against.


A Scalar Loss So dout Is Known

Backpropagation starts from a scalar. In a real transformer that scalar is the cross-entropy loss many layers downstream, but to isolate and test just the attention backward pass we invent the simplest possible scalar whose gradient with respect to out we can write by hand. Take a fixed random tensor G G the same shape as out and define

L=b,t,ioutb,t,i  Gb,t,i. L = \sum_{b,t,i} \text{out}_{b,t,i} \; G_{b,t,i}.

Because L L is a plain sum of out weighted by constants G G , its derivative with respect to each entry of out is exactly the matching entry of G G . So the upstream gradient entering attention is simply dout = G, no chain rule required. This is a standard trick for unit-testing a layer: pick a loss whose dout you know exactly, then check everything the layer computes from it.

def loss_from_out(out):
    return np.sum(out * G)          # a scalar; dL/dout is therefore exactly G

L = loss_from_out(out)
dout = G                            # upstream gradient dL/dout
print("L:", round(float(L), 6), " dout:", dout.shape)
L: -0.292686  dout: (2, 4, 8)

Throughout, we follow the course convention that a name like dX means L/X \partial L / \partial X , the gradient of the loss with respect to X, and it always has the same shape as X. That shape rule is your first line of defense against transpose bugs: if a gradient comes out shaped differently from its quantity, something is wrong before you even run the numerical check.

A two-lane diagram of scaled dot-product attention. The top blue lane shows the forward pass flowing left to right: X of shape (B,T,C) into Q,K,V of shape (B,T,hs), into scores S of shape (B,T,T), into attention weights A of shape (B,T,T), into out of shape (B,T,hs), which feeds the scalar loss L. The bottom orange lane shows gradients flowing right to left: dout equals G, into dA and dValue, into dS through the softmax Jacobian, into dQuery and dKey, into dWq, dWk, dWv and finally dX. Dashed vertical lines mark the cached forward values that feed each backward step.
The forward pass (top, blue) flows left to right building X → Q,K,V → S → A → out → L. The backward pass (bottom, orange) flows right to left: dout → dA,dValue → dS (through the softmax Jacobian) → dQuery,dKey → dWq,dWk,dWv → dX. The dashed lines show which cached forward tensors (A, K, Q, V, X) each backward step reuses. Shapes for the tiny head: B=2, T=4, C=8, hs=8.

That picture is the whole lesson on one page. Now we derive each backward arrow, right to left, in exactly the order the code runs.


Step 1: Through the Value Multiply, dA and dValue

The last forward operation was out = A @ V, a batched matrix multiply where, for each item in the batch, A A is (T, T) and V V is (T, hs). This is the same matrix-multiply rule you know from dense layers, applied per batch element. For a product out=AV \text{out} = A V , the gradient on each factor is the incoming gradient multiplied by the other factor, transposed:

LA=Lout  V,LV=A  Lout. \frac{\partial L}{\partial A} = \frac{\partial L}{\partial \text{out}} \; V^\top, \qquad \frac{\partial L}{\partial V} = A^\top \; \frac{\partial L}{\partial \text{out}}.

The only new detail versus a 2D layer is that these are batched, so “transpose” means swapping the last two axes, V.transpose(0, 2, 1), not V.T (which would also flip the batch axis and give the wrong shape). Check the shapes: dout is (B, T, hs) and V V^\top is (B, hs, T), so dA is (B, T, T), matching A. And A A^\top is (B, T, T) times dout (B, T, hs) gives dValue of shape (B, T, hs), matching V.

dA = dout @ V.transpose(0, 2, 1)   # (B, T, T)   dout @ V^T
dValue = A.transpose(0, 2, 1) @ dout   # (B, T, hs)  A^T @ dout

dValue is already a finished gradient on a forward tensor; it will flow all the way back to dWv and dX later. dA, the gradient on the attention weights, is the input to the trickiest step in the whole course: the softmax.


Step 2: Through the Softmax, dS

Each row of A A is a softmax of the matching row of S S . Softmax is not elementwise, so its gradient is not a simple elementwise multiply. Within one row, changing a single score Sj S_j changes every output probability Ai A_i , because the normalizing sum in the denominator ties them together. For a single row, the derivative of output Ai A_i with respect to input Sj S_j is the softmax Jacobian

AiSj=Ai(δijAj), \frac{\partial A_i}{\partial S_j} = A_i (\delta_{ij} - A_j),

where δij \delta_{ij} is 1 when i=j i = j and 0 otherwise. Written as a matrix over one row, that Jacobian is

J=diag(A)AA, J = \operatorname{diag}(A) - A A^\top,

a dense (T, T) matrix. The diagonal term Ai(1Ai) A_i(1 - A_i) is how a probability responds to its own score; the off-diagonal term AiAj -A_i A_j is how it is pulled down when a competing score rises. The gradient of the loss with respect to the row of scores is this Jacobian applied to the incoming row gradient: dSrow=JdArow dS_{\text{row}} = J^\top \, dA_{\text{row}} . Because J J is symmetric here, working that product out entry by entry collapses to a compact vectorized form that needs no explicit (T, T) Jacobian:

dSi=Ai(dAikdAkAk). dS_i = A_i \Big( dA_i - \sum_{k} dA_k \, A_k \Big).

Read it as: each score’s gradient is its probability times its own incoming gradient, minus the average incoming gradient weighted by the row’s probabilities. That subtracted term is shared by every entry in the row, which is exactly the coupling the off-diagonal Jacobian encodes. In NumPy the whole thing is one line, broadcast over every row of every batch at once by summing over the last axis with keepdims:

dS = A * (dA - np.sum(dA * A, axis=-1, keepdims=True))   # (B, T, T)

Why the softmax needs the whole row

A ReLU or a sigmoid has a diagonal Jacobian: output i i depends only on input i i , so its backward pass is a pure elementwise multiply. Softmax is different because its denominator sums over the entire row, coupling every output to every input. That is the origin of the diag(A)AA \operatorname{diag}(A) - A A^\top Jacobian and the kdAkAk -\sum_k dA_k A_k correction term. If you ever see softmax backward written as just dA * A with no subtraction, it is wrong: it drops the off-diagonal coupling and will fail a gradient check.


Step 3: Through the Scale and the Scores, dS_scaled, dQuery, dKey

Recall the forward scaling S = Q @ K.transpose(0, 2, 1) / np.sqrt(hs). Dividing by a constant hs \sqrt{hs} is a linear operation, so its backward is just the same division applied to the gradient:

L(QK)=1hsdS. \frac{\partial L}{\partial (QK^\top)} = \frac{1}{\sqrt{hs}} \, dS.
dS_scaled = dS / np.sqrt(hs)       # (B, T, T)

Now the unscaled scores are another batched matrix multiply, Sraw=QK S_{\text{raw}} = Q K^\top , with Q Q shaped (T, hs) and K K^\top shaped (hs, T) per batch element. Apply the same matrix-multiply rule as in Step 1, being careful about which factor is transposed. Differentiating QK Q K^\top :

LQ=dSscaled  K,LK=dSscaled  Q. \frac{\partial L}{\partial Q} = dS_{\text{scaled}} \; K, \qquad \frac{\partial L}{\partial K} = dS_{\text{scaled}}^{\top} \; Q.

The dQuery line reads directly: dS_scaled is (B, T, T) and K is (B, T, hs), giving (B, T, hs) to match Q. The dKey line needs the batched transpose of dS_scaled first, because K K sits on the right of the product as K K^\top ; transposing the score gradient and multiplying by Q recovers a gradient of shape (B, T, hs) that matches K.

dQuery = dS_scaled @ K                   # (B, T, hs)
dKey = dS_scaled.transpose(0, 2, 1) @ Q   # (B, T, hs)

A quick intuition check: dS_scaled measures how the loss wants each score Sij S_{ij} to move, and a score is the dot product of query i i with key j j . So the gradient on query i i is a weighted sum of the keys (weighted by how much each of its scores should change), and symmetrically the gradient on key j j is a weighted sum of the queries. The two transposes encode exactly that symmetry.


Step 4: Back to the Parameters and Input, dWq, dWk, dWv, dX

We now have dQuery, dKey, and dValue, the gradients on the three projected tensors. Each came from a projection like Q = X @ Wq, a matrix multiply of X (B, T, C) by a shared weight Wq (C, hs). The weight is shared across the batch, so its gradient sums the per-item contributions. For each batch element the local rule gives XdQuery X^\top dQuery of shape (C, hs); summing over the batch axis collapses those into one (C, hs) gradient that matches Wq:

LWq=bXbdQueryb,LWk=bXbdKeyb,LWv=bXbdValueb. \frac{\partial L}{\partial W_q} = \sum_{b} X_b^{\top} \, dQuery_b, \qquad \frac{\partial L}{\partial W_k} = \sum_{b} X_b^{\top} \, dKey_b, \qquad \frac{\partial L}{\partial W_v} = \sum_{b} X_b^{\top} \, dValue_b.
dWq = np.sum(X.transpose(0, 2, 1) @ dQuery, axis=0)   # (C, hs)
dWk = np.sum(X.transpose(0, 2, 1) @ dKey, axis=0)   # (C, hs)
dWv = np.sum(X.transpose(0, 2, 1) @ dValue, axis=0)   # (C, hs)

Finally, X fed all three projections, so gradient flows back to it along three paths and they add. From Q = X @ Wq the input gradient is dQueryWq dQuery \, W_q^\top , and likewise for the key and value paths:

LX=dQueryWq+dKeyWk+dValueWv. \frac{\partial L}{\partial X} = dQuery \, W_q^{\top} + dKey \, W_k^{\top} + dValue \, W_v^{\top}.

Here Wq is a plain (C, hs) matrix shared over the batch, so Wq.T is (hs, C) and dQuery @ Wq.T broadcasts cleanly over the batch to give (B, T, C), matching X. Summing the three contributions is the multivariate chain rule: when one quantity influences the loss through several routes, its gradient is the sum over those routes.

dX = dQuery @ Wq.T + dKey @ Wk.T + dValue @ Wv.T   # (B, T, C)

Collect the whole backward pass into one function and confirm every gradient has the shape of the quantity it belongs to.

def attention_backward(dout, cache):
    X, Wq, Wk, Wv, Q, K, V, A = cache

    dA = dout @ V.transpose(0, 2, 1)            # (B, T, T)
    dValue = A.transpose(0, 2, 1) @ dout            # (B, T, hs)

    # softmax backward, applied independently to each row
    dS = A * (dA - np.sum(dA * A, axis=-1, keepdims=True))   # (B, T, T)
    dS_scaled = dS / np.sqrt(hs)                # (B, T, T)

    dQuery = dS_scaled @ K                          # (B, T, hs)
    dKey = dS_scaled.transpose(0, 2, 1) @ Q       # (B, T, hs)

    dWq = np.sum(X.transpose(0, 2, 1) @ dQuery, axis=0)   # (C, hs)
    dWk = np.sum(X.transpose(0, 2, 1) @ dKey, axis=0)   # (C, hs)
    dWv = np.sum(X.transpose(0, 2, 1) @ dValue, axis=0)   # (C, hs)

    dX = dQuery @ Wq.T + dKey @ Wk.T + dValue @ Wv.T      # (B, T, C)
    return dWq, dWk, dWv, dX

dWq, dWk, dWv, dX = attention_backward(dout, cache)
print("dWq:", dWq.shape, " dWk:", dWk.shape, " dWv:", dWv.shape)
print("dX:", dX.shape)
dWq: (8, 8)  dWk: (8, 8)  dWv: (8, 8)
dX: (2, 4, 8)

Every parameter gradient is (C, hs) = (8, 8), matching its weight, and dX is (2, 4, 8), matching the input. The shapes are right. But shapes only prove the plumbing lines up, not that the numbers are correct. For that we need the gradient check.


Proving It: A Numerical Gradient Check

The definition of a derivative gives a way to estimate any gradient without calculus at all: nudge one number up by a tiny ϵ \epsilon , nudge it down by ϵ \epsilon , see how much the loss L L moved, and divide. This central finite difference is accurate to order ϵ2 \epsilon^2 :

LθL(θ+ϵ)L(θϵ)2ϵ. \frac{\partial L}{\partial \theta} \approx \frac{L(\theta + \epsilon) - L(\theta - \epsilon)}{2\epsilon}.

If our analytic backward pass is correct, the numerical estimate must agree with it for every entry of every parameter and for X. We measure agreement with the max relative error over all entries: the largest, across the tensor, of an/max(a,n) |a - n| \, / \, \max(|a|, |n|) , where a a is analytic and n n is numerical. In float64 with ϵ=105 \epsilon = 10^{-5} , a correct gradient lands around 106 10^{-6} or smaller; anything like 102 10^{-2} means a real bug.

def numerical_grad(param):
    eps = 1e-5
    grad = np.zeros_like(param)
    it = np.nditer(param, flags=["multi_index"])
    while not it.finished:
        idx = it.multi_index
        original = param[idx]
        param[idx] = original + eps
        Lp = loss_from_out(attention_forward(X, Wq, Wk, Wv)[0])
        param[idx] = original - eps
        Lm = loss_from_out(attention_forward(X, Wq, Wk, Wv)[0])
        param[idx] = original          # always restore the entry
        grad[idx] = (Lp - Lm) / (2 * eps)
        it.iternext()
    return grad

def max_rel_err(analytic, numeric):
    denom = np.maximum(np.abs(analytic), np.abs(numeric)) + 1e-12
    return np.max(np.abs(analytic - numeric) / denom)

print("max relative error (analytic vs. numerical):")
print("  dWq:", max_rel_err(dWq, numerical_grad(Wq)))
print("  dWk:", max_rel_err(dWk, numerical_grad(Wk)))
print("  dWv:", max_rel_err(dWv, numerical_grad(Wv)))
print("  dX :", max_rel_err(dX,  numerical_grad(X)))
max relative error (analytic vs. numerical):
  dWq: 1.4008950364789722e-07
  dWk: 3.4212993729037537e-08
  dWv: 4.2107753701537897e-10
  dX : 1.517927265799911e-09

Every error is between 107 10^{-7} and 1010 10^{-10} , far below the 104 10^{-4} threshold that separates “correct” from “buggy.” That is the proof: the analytic backward pass, including the subtle softmax Jacobian, computes the same gradients that a brute-force perturbation of the loss produces. Run the whole script a second time and the numbers are byte-for-byte identical, because the seed fixes every random draw and the finite-difference loop is deterministic. dWv and dX are the smallest errors because their gradients flow through the fewest nonlinear steps; dWq is largest because it flows through the full softmax coupling, yet even it is essentially perfect.

Perturb, evaluate, and always restore

The classic gradient-check bug is forgetting to restore the perturbed entry before moving to the next one, which quietly corrupts every later estimate. Notice the loop sets param[idx] = original after each measurement. Also note we perturb the same arrays the forward pass reads, and re-run attention_forward from scratch each time, so no stale cache leaks in. If a check fails, suspect a missing transpose or a dropped term in the softmax before you suspect the numerics.


Practice Exercises

Try these before checking the hints. They reuse attention_forward, attention_backward, loss_from_out, numerical_grad, and the tensors defined above.

Exercise 1: Break the Softmax on Purpose

Replace the softmax backward line with the naive (wrong) version dS = dA * A, which drops the kdAkAk -\sum_k dA_k A_k correction term. Recompute dWq and gradient-check it. Confirm the max relative error explodes, then restore the correct line.

# Your code here: temporarily use dS = dA * A inside a copy of attention_backward,
# recompute dWq, and print max_rel_err(dWq_wrong, numerical_grad(Wq))

Hint

Copy attention_backward into a broken_backward and change only the dS line to dS = dA * A. Run it, take its dWq, and compare against numerical_grad(Wq). The error will jump from about 1e-7 to a number near 1 (order 100%), because dropping the off-diagonal Jacobian term is a real mathematical error, not a rounding issue. This is exactly the kind of bug a gradient check is designed to catch.

Exercise 2: Check the Softmax Jacobian Directly

For a single row a = A[0, 0] (a length-T probability vector), build the full Jacobian matrix J=diag(a)aa J = \operatorname{diag}(a) - a a^\top explicitly with np.diag and an outer product, then confirm that applying it to a random row gradient, J @ g, equals the vectorized formula a * (g - (g * a).sum()).

a = A[0, 0]                      # one softmax row, shape (T,)
g = np.random.randn(T)           # a pretend incoming row gradient dA_row
# Your code here: build J, compare J @ g to the vectorized dS formula

Hint

Build J = np.diag(a) - np.outer(a, a), which is (T, T). Then J @ g should match a * (g - (g * a).sum()) to within floating-point tolerance, check with np.allclose(J @ g, a * (g - (g * a).sum())). This is the whole point of the vectorized line: it applies the dense Jacobian without ever materializing the (T, T) matrix, which matters when T is hundreds or thousands.

Exercise 3: Gradient-Check a Single dWk Entry by Hand

Instead of the full-tensor numerical_grad, perturb just Wk[0, 0] by ±ϵ \pm \epsilon , estimate that one partial derivative with the central difference, and compare it to dWk[0, 0]. This is the smallest possible gradient check and the fastest way to sanity-test one entry.

eps = 1e-5
# Your code here: perturb Wk[0, 0] up and down, recompute the loss each time,
# form the central difference, and compare to dWk[0, 0]

Hint

Save original = Wk[0, 0]. Set Wk[0, 0] = original + eps and compute Lp = loss_from_out(attention_forward(X, Wq, Wk, Wv)[0]); set it to original - eps for Lm; then restore Wk[0, 0] = original. The estimate (Lp - Lm) / (2 * eps) should match dWk[0, 0] to about six or seven decimal places. It is the same computation numerical_grad runs, just for one index.


Summary

You derived and verified the complete backward pass through scaled dot-product self-attention, the single hardest piece of building a transformer by hand. Let’s review.

Key Concepts

The Setup

  • Cache the forward tensors (X, Q, K, V, A) so the backward pass can reuse them
  • Use tiny dims and float64 so a finite-difference check is fast and trustworthy
  • Define a scalar loss L=outG L = \sum \text{out} \cdot G so the upstream gradient is exactly dout = G

The Backward Pass, In Order

  • dA = dout @ V^T, dValue = A^T @ dout — the matrix-multiply rule through out = A @ V (batched transpose on the last two axes)
  • dS = A * (dA - sum(dA * A, axis=-1, keepdims=True)) — the softmax Jacobian diag(A)AA \operatorname{diag}(A) - A A^\top applied per row
  • dS_scaled = dS / sqrt(hs) — the constant scale passes straight through
  • dQuery = dS_scaled @ K, dKey = dS_scaled^T @ Q — the matrix-multiply rule through QK Q K^\top
  • dWq/dWk/dWv = sum over batch of X^T @ dQuery/dKey/dValue — shared weights sum their gradient over the batch
  • dX = dQuery @ Wq^T + dKey @ Wk^T + dValue @ Wv^T — three projection paths add at the input

Proving It

  • Every gradient has the same shape as its quantity; a shape mismatch is a bug you can catch before running
  • A central finite difference (L(θ+ϵ)L(θϵ))/2ϵ (L(\theta+\epsilon) - L(\theta-\epsilon)) / 2\epsilon estimates any gradient with no calculus
  • Real max relative errors were 1e-7 (dWq) down to 1e-91e-10 (dX, dWv), all far below the 1e-4 bar, and identical on re-run

Why This Matters

The softmax Jacobian you implemented here is not a detail; it is the mathematical heart of what makes attention trainable. Everything else in a transformer, the projections, the residual adds, the layer norms, the output head, reuses gradient rules you already knew. Attention’s one new idea, differentiating through a normalized similarity, is the step that lets gradient descent reshape which positions attend to which. When you later watch your mini-GPT learn that a letter should attend to the letters that predict it, this backward pass is the machinery doing the learning.

Just as important, you now know how to prove a hand-derived gradient rather than hope it is right. The perturb-and-compare gradient check is the professional standard for any custom layer: whenever you write a backward pass a framework did not give you, this is how you verify it before trusting it in a training run. A wrong gradient does not crash; it quietly trains the wrong model. The check you wrote is the difference between guessing and knowing.


Continue Building Your Skills

You have every gradient for a single attention head, derived by hand and confirmed against the numerical truth. What remains is to package it. In the next lesson, the module’s guided project, you will fold this forward and backward pass into one reusable SelfAttention layer class with forward and backward methods, an object that holds its own Wq, Wk, Wv, stashes its cache during the forward call, and hands back dWq, dWk, dWv, and dX during the backward call, gradient-checked end to end. That class is the building block you will stack into multi-head attention and, eventually, the full transformer block that your char-level GPT is made of. The hard derivation is behind you; from here on, attention is a component you can drop in and trust.

Sponsor

Keep DATATWEETS free. Help fund practical data, AI, and engineering lessons for learners worldwide.

Buy Me a Coffee at ko-fi.com