Derivatives, gradients, and how backprop actually flows through attention
The chain rule as multiplied rates, gradients as vectors of partial derivatives, and why a linear layer's Jacobian is just its own weight matrix, transposed for the backward pass.
You already know how to find the slope of a line: rise over run, and it’s the same number everywhere on that line. A derivative asks the same question of a curve, where the honest answer is “it depends where you’re standing.” Zoom in far enough on any point of a smooth curve and it starts looking straight; the derivative at that point is the slope of the straight line it turns into. Every neural network learns by asking exactly this question, once per weight: if I nudge this weight slightly, does the error go up or down, and by how much? Gradient descent, the algorithm underneath essentially all of deep learning training, is nothing but “compute that for every weight, then step in the direction that decreases error.”
A curve doesn’t have a slope, it has infinitely many
Take . Between and it rises from to , slope . Between and it rises from to , slope . Same curve, different interval, different slope: steepness here is a local property, not one number. To pin it down at a single point, pick a second point very close by, compute the ordinary slope between them, then ask what that slope approaches as the gap shrinks toward zero.
Concretely, at with a small gap :
As , this approaches exactly, not approximately, because the algebra let you cancel from the denominator before ever setting it to zero. That cancellation is the one trick underneath every derivative you’ll ever compute, and doing it symbolically for any instead of one specific point gives the general definition:
Run that same expansion-and-cancellation trick on and every term of the binomial expansion except the first still carries a leftover , so every one of them vanishes in the limit, leaving : the power rule, as a consequence rather than a rule to memorize.
Worked check. , so , and at that’s .
def numerical_derivative(f, x, h=1e-5):
return (f(x + h) - f(x)) / h
f = lambda x: (3*x + 1)**2
print(numerical_derivative(f, x=1.0)) # ~24.00002, converging on the exact answer
The chain rule: rates multiply, they don’t add
If , a function of a function, how fast does change as changes? As moves a little, moves by some rate, ; as moves a little, of it moves by a further rate, but that rate is evaluated at , not at , because ‘s steepness genuinely differs depending on where you’re standing on , and you’re standing at . The two rates compose by multiplication, the same way a 2x zoom followed by a 3x zoom gives 6x total, not 5x:
Checking at the chain-rule way: inner , , ; outer , , so . Total: . Matches the direct expansion exactly, which is the whole point: evaluating at the wrong place is the single most common chain-rule mistake, and it’s worth checking against a case you can also verify directly until it stops being tempting.
Gradients: the same derivative, once per weight, packaged into a vector
A neural network doesn’t have one input, it has millions or billions of weights. The gradient asks the derivative question of every one of them simultaneously: nudge weight one, holding all the others fixed, how does the loss change? Then weight two. Then every other weight. Package all those answers into a single vector and you have . Nothing new happens mathematically here, it’s the ordinary single-variable derivative from above, computed one variable at a time while treating every other variable as a temporary constant, a move called a partial derivative.
For : (treat as a constant, power rule on ; the term has no in it, contributes zero), and (treat as a constant coefficient). At : .
The gradient isn’t just a bookkeeping convenience, it has a specific geometric meaning worth deriving rather than accepting: it points in the direction of steepest increase. For a small unit step away from a point, the change in is approximately , a dot product. Recall from the linear algebra side of this that ; here that’s , maximized exactly when , meaning points the same way does. Steepest ascent isn’t a separate fact about gradients, it’s a direct consequence of dot-product geometry.
import numpy as np
def f(x, y):
return x**2 * y + 3*y
def numerical_gradient(f, x, y, h=1e-5):
df_dx = (f(x + h, y) - f(x, y)) / h
df_dy = (f(x, y + h) - f(x, y)) / h
return np.array([df_dx, df_dy])
grad = numerical_gradient(f, 2.0, 1.0) # ~[4, 7]
grad_dir = grad / np.linalg.norm(grad)
def directional_change(f, x, y, direction, step=1e-3):
dx, dy = direction * step
return f(x + dx, y + dy) - f(x, y)
# the gradient's own direction should beat every other direction tried
for d in [grad_dir, np.array([1.,0.]), np.array([0.,1.]), np.array([-1.,1.])/np.sqrt(2)]:
print(directional_change(f, 2.0, 1.0, d))
Running this doesn’t just compute the gradient, it empirically confirms the steepest-ascent claim: the change measured along grad_dir comes out larger than along any of the other directions tried.
Jacobians: gradients for layers that output vectors
Most layers inside a network don’t output one number, they output a vector, so the gradient needs a further generalization. The Jacobian of is the matrix whose row is : how much every output responds to every input, all at once.
| Object | Rate of change of | Shape | Where it shows up in a transformer |
|---|---|---|---|
| Derivative | one output, one input | scalar | never directly, nothing in an LLM has a single scalar input |
| Gradient | one output, many inputs | vector | with respect to all weights, what the optimizer consumes |
| Jacobian | many outputs, many inputs | matrix | every linear layer and attention block, what backprop chains through |
Here’s the fact that makes backprop through a transformer tractable at all: for a linear layer , the Jacobian is exactly itself. Entry , so (every other term in the sum has no in it and drops out), which is just ‘s own entries, unchanged. A linear transformation’s “slope” is the same everywhere, because it’s linear, so the matrix that defines it and the matrix that describes its rate of change are the same object.
Backprop through a linear layer: the transpose is forced, not chosen
Suppose the loss depends on , and a later layer has already handed you , a vector with one entry per output. You need , a vector with one entry per input. The chain rule, generalized to vectors, says , and since :
The transpose isn’t a memorized rule, it’s the only option the shapes allow: is , has length , and multiplying an matrix by a length- vector doesn’t typecheck under ordinary matmul, inner dimensions have to match. Transposing to is the one arrangement that both typechecks and lands you back at length , matching .
Worked check. , , forward: . Given : .
def backward_linear(W, grad_output):
return W.T @ grad_output
W = np.array([[2.,0.],[1.,3.]])
print(backward_linear(W, np.array([1.,1.]))) # [3, 3]
Attention’s backward pass: three chained rules, nothing new
is three operations chained together, a matmul, a softmax, then another matmul, which is exactly why backpropagating through attention gets asked in interviews: not because it’s new math, but because it’s three applications of the rule above, composed carefully with the right shapes at each step. The one genuinely new piece is softmax’s own Jacobian. For output , the diagonal entries work out to and the off-diagonal entries to , which as a full matrix is simply diag(alpha) - outer(alpha, alpha).
def softmax(z):
z = z - np.max(z, axis=-1, keepdims=True) # more on this line below
e = np.exp(z)
return e / np.sum(e, axis=-1, keepdims=True)
def attention_forward(Q, K, V, d_k):
z = (Q @ K.T) / np.sqrt(d_k)
alpha = softmax(z)
return alpha @ V, alpha, z
Chaining backward through this is: apply the transpose rule through alpha @ V to get , apply the diag-minus-outer softmax Jacobian to get , then apply the transpose rule again through to get and . Building that out fully and checking grad_Q against a finite-difference estimate (perturb every entry of , measure the change, compare) matches to within about , small enough to be floating-point noise rather than a sign error or a transpose in the wrong place, which is the actual failure mode this kind of derivation invites.
Two failure modes that only show up at this layer
The z - np.max(z, ...) line above isn’t decoration. Softmax involves , and raw attention scores can be large before the scaling fully tames them; exponentiating a large value overflows a floating-point format’s range, the exact same range-limit issue as FP32 versus BF16, showing up again three layers deeper in the stack. Subtracting the max before exponentiating doesn’t change softmax’s output, it’s a scale-invariance property of the formula, but it keeps every intermediate value inside a safe range. Skip it and training doesn’t slow down, it produces NaNs.
The second failure mode is readable directly off the Jacobian formula above: if one attention score saturates toward , its diagonal gradient term . If a score saturates toward , that term is too. Either extreme kills the local gradient, not because of some separate instability, but because it’s the direct arithmetic consequence of the formula you already have.
| Failure mode | Cause | Fix |
|---|---|---|
| NaN loss during training | Softmax input overflows before the max-subtraction | The z_shifted trick above, standard in every framework |
| Vanishing attention gradients | An attention score saturates near 1 or 0 | Better initialization, gradient clipping, architectural changes |
| Backward pass memory blowup | Materializing the full attention matrix | Fused kernels (FlashAttention-style) that never store it in full |
Common mistakes
Memorizing “bring down the exponent” without being able to reconstruct why: the binomial-expansion derivation above is what survives being combined with other rules later, memorized facts alone don’t.
Evaluating the chain rule’s outer derivative at instead of at , the single most common chain-rule error, and the reason the worked check above exists: it’s cheap to verify against a direct expansion until the habit sticks.
Treating the gradient as a categorically new object instead of what it actually is: a vector of ordinary derivatives, one per input, each computed by freezing every other input.
Conflating gradient (defined for functions with one scalar output) with Jacobian (needed the moment a function outputs a vector): an LLM’s individual layers need Jacobians, only the final scalar loss has a gradient in the strict sense, and backprop’s whole job is chaining Jacobians together until you land back at that one gradient.
Try it yourself
Beginner. For , compute symbolically, then verify it at using numerical_gradient.
Intermediate. For , , , hand-derive and check it against backward_linear.
Advanced. Derive the off-diagonal softmax Jacobian term yourself using the quotient rule on , differentiated with respect to where . This is the one piece above stated rather than derived; closing that gap is what makes the rest of this note something you’ve actually verified rather than taken on trust.
The one-sentence version: a derivative is a slope you have to approach rather than compute directly, the chain rule is just those rates multiplying, a gradient is that same chain rule run once per weight and packed into a vector that provably points toward steepest increase, and backpropagation through attention is nothing but three applications of two rules you already have, chained through the exact matmuls and shapes from vectors and matrices and hitting the exact numerical range limits from bits, floats, and quantization along the way. That softmax Jacobian from attention’s backward pass turns out to have one more job: paired with cross-entropy loss, its messy terms cancel into the single cleanest gradient in this entire series.