Linear algebra of modern ML: autodiff, LoRA/GaLore, tensor decompositions, attention
Before this lesson: lesson 01 (matrices as maps and the views of matrix multiplication), lesson 02 (rank) and lesson 08 (the SVD and Eckart–Young–Mirsky). Time: about 85 minutes, plus the notebook.
Why this matters #
Four central tools of modern ML are linear algebra in disguise. Every gradient is a chain of transposed Jacobians applied to a vector (VJPs). LoRA and GaLore exploit low rank, which is Eckart–Young at work. CP and Tucker extend the SVD to data with more than two indices, such as climate fields over space × time × variable × ensemble member. An attention head is a low-rank bilinear form (which token to read from) plus a low-rank linear map (what to copy), acting on a residual stream that is a vector space. The same ideas decide memory and compute when you fine-tune or train a scientific foundation model.
1. Predict first #
2. The picture #
A derivative is a linear map. Near , , with the Jacobian : the matrix of all partial derivatives . For a chain the chain rule gives a product . You never need itself, only times a vector, and the product can be evaluated from either end:
- right to left with an input direction : . This is forward mode (JVP);
- left to right with an output weighting : . This is reverse mode (VJP), and backpropagation is its special case for a scalar loss.
Each order costs only matrix-vector products. Which one wins depends on the shape of .
Build the Jacobian one pass at a time. Press Run all passes in both modes. A forward pass (JVP) with gives column of ; a reverse pass (VJP) with gives row . For a scalar loss () one reverse pass gives the whole gradient. Switch to the simulator shape ( inputs, outputs) and forward mode wins.
Why reverse mode wins for a scalar loss: the multiplication order. Matrix multiplication is associative, so is the same matrix whatever pair you multiply first. The cost is not the same. A product of an matrix and a matrix needs multiplications. Take a loss after two hidden layers of width 1000 on an input of size 1000: is , and are .
- From the left (reverse). is : multiplications, and the result is again a row. Then : another . Total .
- From the right (forward). is : multiplications. Then : . Total about , 500 times more.
Starting at the scalar loss keeps every intermediate result a single row vector. Starting at the input carries one column per input, so the work grows with the number of inputs.
Choose the order and press Next product. The blocks are drawn to scale (height = rows, width = columns); the highlighted pair is multiplied next. With the loss shape, reverse needs 224 multiplications and forward 1632. Switch to the simulator shape (2 inputs, 12 outputs): now forward is cheaper. The readout also gives the cheapest order of all.
with of size and , of size . You multiply from the left: . How many multiplications?
costs and gives a row; multiplying that row by costs another . The tempting is the cost of , the first step of the other order (forward mode).
Low rank = few outer products. A rank- matrix is stored with numbers instead of . LoRA restricts a weight update to this form.
Spend a rank budget. Move the rank slider . The middle map is the best rank- matrix (the truncated SVD), and the right map is what it misses. When the singular values of the target update decay fast, a small captures almost all of it with parameters. Choose flat and low rank fails: the relative error falls only like .
A tensor is an array with more indices. The simplest tensor is a rank-1 tensor with entries . A sum of of them is a CP model; a small core with a basis per index is a Tucker model.
Attention moves information between tokens. Token forms a weighted average of (projected) other tokens. The weights come from a bilinear form "query of against key of "; the projection decides what is copied and where it is written.
3. Definitions and main results #
JVP and VJP #
Let be differentiable at , with Jacobian .
- The JVP (Jacobian-vector product) maps a tangent to .
- The VJP (vector-Jacobian product) maps a cotangent to (the row vector ).
Notation: for a scalar loss , a bar denotes its gradient with respect to a variable: . If , the chain rule gives : a VJP.
- Forward mode carries (value, tangent) pairs through the computation, in the same order. Each primitive operation has a JVP rule.
- Reverse mode runs the computation once and records the graph and its intermediate values (the tape). A backward pass then applies each primitive's VJP rule in reverse order, starting from . Where a variable feeds several operations, the contributions add.
Theorem (VJP and JVP of a linear layer) #
Let with , , and . Then
Proof.
- Product rule: . This is the JVP with .
- The loss changes by .
- First term: , so .
- Second term: , with the Frobenius inner product . So .
Check every shape. The safest way to remember these formulas is to write the shape next to every symbol. A gradient always has the shape of its variable, and in each case only one product of the available matrices has that shape.
| quantity | shape | formula | shape check |
|---|---|---|---|
| , | |||
| , | |||
| , (batch of columns) | |||
| , (batch) |
For a batch with examples as columns, the weight gradient is a sum
of outer products, so its rank is at most the batch size . For any product :
and . PyTorch's nn.Linear uses rows, ;
the same rules hold, transposed.
with of size and of size (5 examples as columns). The upstream gradient is (). Which expression is ?
must have the shape of , . is . has the right numbers but is : it is the transpose. () is , and does not exist ( times ).
Cost of training. The forward product costs flops per example; the backward pass needs and , another each. So a training step costs about three forward passes, which gives the usual estimate "training flops parameters tokens".
Forward or reverse mode? #
The full Jacobian needs JVPs (one per column) or VJPs (one per row). Each pass costs a small constant times the cost of : below 6 and typically 2–3 (Griewank and Walther, cited by Baydin et al.).
| forward mode (JVP) | reverse mode (VJP) | |
|---|---|---|
| one pass gives | , one input direction | , one output weighting |
| full Jacobian | passes | passes |
| extra memory | a tangent per value, no tape | all intermediates of the forward pass |
| wins when | few inputs, many outputs | few outputs, many inputs |
Training has (a scalar loss) and up to parameters, so one reverse pass gives the whole gradient. The price is memory: reverse mode stores the activations. Checkpointing stores only some of them and recomputes the rest in the backward pass. A Hessian-vector product is the JVP of the gradient (forward over reverse): it costs a few gradient evaluations and never forms the Hessian, which is what Lanczos needs (lesson 14).
LoRA #
LoRA (low-rank adaptation; Hu et al., 2021) freezes a pretrained and learns a low-rank update:
In two plain sentences. starts at zero, so and the model at step 0 is exactly the pretrained model; fine-tuning starts from what the model already knows. The fixed factor scales the update so that, with Adam, the first updates have about the same size for every rank , and you do not need to tune the learning rate again when you change .
The details:
- Parameters: instead of . Gradients and Adam states exist only for these.
- Rank: by the outer-product view. Both and are trained, so the column space of moves during training. Only the rank bound is fixed; LoRA does not fix one low-rank subspace.
- Initialization: random Gaussian, , so and training starts exactly at . Let be the gradient with respect to the effective weight . Then and give
At the start but , so moves first. If both were zero, both gradients would stay zero forever.
- Scaling keeps the size of the early updates, and so the best learning rate, roughly independent of . Hu et al. set once and do not tune it.
- Inference: merge : no extra latency. One frozen base can serve many adapters, and it can be stored quantized (QLoRA).
Why low-rank updates are plausible #
- Structure of the gradient. By the theorem above, one example gives a rank-1 gradient , and a batch of tokens a gradient of rank at most . In large batches exceeds , so this bound alone proves little; but the singular values of gradients and of fine-tuning updates decay fast in practice.
- Intrinsic dimension. Fine-tuning RoBERTa inside a random 200-dimensional subspace of parameter space reached 90% of full fine-tuning performance on MRPC (Aghajanyan et al., 2020).
- The LoRA paper (§7). Very small ranks work ( for GPT-3 when adapting and ); the update amplifies directions that already contains but does not emphasize.
- LoRA Without Regret (Thinking Machines, 2025). LoRA applied to all layers, especially the MLPs, matches full fine-tuning on small and medium supervised datasets, and in reinforcement learning even at rank 1 (policy-gradient RL gives only bits of information per episode). It falls behind when the dataset exceeds the adapter's capacity. Its best learning rate is about 10 times that of full fine-tuning.
These are empirical findings, not theorems.
GaLore: full-rank training with a low-rank optimizer state
GaLore (Zhao et al., 2024) trains the full () but keeps Adam's state in a low-rank space. Every steps ( in the paper) it computes the SVD of the current gradient and sets to the top left singular vectors. At every step:
Adam's two moments have size instead of . Each update has rank , but changes, so the sum of updates can have full rank.
| LoRA | GaLore | |
|---|---|---|
| what is low rank | the update | the gradient, for the optimizer state |
| trainable parameters | all | |
| saves | gradients, optimizer state, per-task storage | optimizer state |
| total update | rank | can be full rank |
| typical use | fine-tuning, many adapters | memory-limited pretraining or full fine-tuning |
Tensors: fibers, unfoldings, CP and Tucker #
A third-order tensor is an array with entries . The outer product has entries (a rank-1 tensor).
- A mode- fiber fixes all indices but the -th (for : the vector ).
- The mode- unfolding is the matrix whose columns are the mode- fibers: for . We order the columns with the first remaining index varying fastest (Kolda and Bader). The column order is a convention; it changes neither the rank nor the left singular vectors.
- The mode- product multiplies every mode- fiber by : .
One small tensor, step by step. Let have the two frontal slices ( and )
- Mode-1 fibers (index varies): the columns of each slice. In the order they are , , , .
- Mode-2 fibers (index varies): the rows of each slice. In the order they are , , , .
- Mode-3 fibers (index varies): from the front slice to the back slice. In the order they are , , , .
- Put the fibers side by side as columns:
- A mode product. With (), adds the two entries of every mode-1 fiber: . The result is a tensor with slices and .
Choose a mode and press Next fiber. The coloured lines in the cube are the mode- fibers; the black one is the selected fiber, and it becomes the outlined column of the unfolding below. With counting 1…24, the mode-1 columns read the numbers in order. Choose ranks differ: the three unfoldings have ranks , so the entries of the multilinear rank need not be equal.
CP (CANDECOMP/PARAFAC) of rank :
with factor matrices (), (), (): parameters (plus weights if the columns are normalized). The CP rank is the smallest with equality.
Tucker with core size :
with parameters. CP is Tucker with a superdiagonal core (, all other entries 0). For a matrix ( indices), Tucker is and the SVD is the case with a diagonal core. The multilinear rank is ; unlike row and column rank of a matrix, its entries can differ.
is a tensor. What is the shape of its mode-2 unfolding ?
The rows of are indexed by mode (here ) and every other index pair gives one column ( fibers). is , is , and is the transpose.
Truncated HOSVD. For each mode, = the top left singular vectors of . Then
with the orthogonal projectors . It is the analogue of for a matrix. The core is not diagonal in general: a tensor cannot be diagonalized by one orthogonal change of basis per mode.
Theorem (truncated HOSVD is quasi-optimal). For an -th order tensor, let be the best approximation with multilinear rank . Then
Proof for (telescoping and Eckart–Young per mode)
- Telescope: .
- The three terms are orthogonal: the first has mode-1 fibers in the range of , the other two in the range of ; the second and third differ in the same way in mode 2. So the squared norms add.
- Mode products in different modes commute, and an orthogonal projection does not increase the norm. So, for example, .
- , because projects on the top left singular vectors (Eckart–Young, lesson 08). This gives the first inequality.
- Any with multilinear rank has , so by Eckart–Young for every . Take and add over .
So the truncated HOSVD is within a factor of the best Tucker model, but in general not optimal: there is no Eckart–Young theorem for tensors. HOOI (alternating least squares on the factors, started from the HOSVD) usually improves it.
CP rank is harder than matrix rank. It can depend on the field (a real tensor can have rank 3 over and 2 over ), and computing it is NP-hard. A best rank- approximation may not exist (rank-2 tensors can converge to a rank-3 tensor: degeneracy), and the best rank-1 term need not be part of the best rank-2 model, so greedy deflation fails. But CP is often essentially unique (Kruskal's condition), so its factors can be interpreted; a matrix factorization is not unique.
Attention in matrix form #
We use the column convention of Elhage et al. (2021). Token is the residual-stream vector , and . One head of width has and . With , , :
A causal mask sets for . The layer adds to the residual stream: (then an MLP adds its output in the same way).
- (the QK circuit) is a matrix of rank . The score is a bilinear form: the query side reads the subspace of the residual stream, the key side reads .
- (the OV circuit) is a map of rank . In its SVD , the right singular vectors say what is read from the source token and the left singular vectors say where it is written in the destination token.
- Only these products matter. For any invertible , replacing , , , changes no output. Queries, keys and values are intermediate results of two low-rank matrices.
- Linear once is fixed. In vectorized form (lesson 16 proves the rule ): mixes tokens, acts on each token's vector. The softmax inside is the only nonlinear part.
- Heads are independent and additive. "Concatenate the head results , then multiply by " equals , where is the block of columns of for head . Every head reads from the residual stream with linear maps and writes back into a subspace of dimension at most (usually for heads).
- Low rank is in the logits, not in . () has rank , but the entrywise exponential makes full rank in general.
- Why . If and have independent entries with mean 0 and variance 1, then has variance . Dividing by keeps the logits of order 1, so the softmax does not saturate.
One head, six tokens, . The readout shows but : the low rank is in the logits, not in . Move the scale (an inverse temperature) to 0: every row of becomes uniform, has rank 1, and the output is the mean of the values. Move it up: the rows become nearly one-hot and the output jumps to one value vector. Turn on the causal mask and pick : the first token can only attend to itself.
One head has , and the sequence has tokens. What can you say about the ranks of the logit matrix and of the attention matrix (row by row)?
and . The softmax applies entry by entry and then normalizes the rows; this nonlinear step does not keep the rank, so is usually full rank. The low rank is in the logits.
Code uses rows. With and (what nn.Linear computes),
the logits are : the same . The output rows are
: the map is the same, written as a right product with the transpose.
Answers to section 1. (1) : reverse mode, one pass (). : forward mode, 3 passes (), instead of reverse passes. (2) . The logit matrix also has rank , but itself can have full rank 1000.
4. Worked example #
A. One linear layer, both modes. (), , upstream gradient .
- Forward: .
- VJP for : . Shape: , like .
- VJP for : , shape like , rank 1 (every row is a multiple of ).
- Check: for , increasing by increases by .
- JVP with , : , the first column of . The full Jacobian takes 2 JVPs or 3 VJPs.
B. LoRA and GaLore memory. A attention projection with : LoRA trains numbers instead of (0.78%), and Adam keeps moment entries instead of . GaLore with on a MLP matrix trains all weights, but its Adam moments have entries, plus for : 3.7% of the entries of full Adam.
LoRA with rank adapts one weight matrix. How many trainable numbers does it add?
is and is , so , about 0.39% of the entries of the full update. Counting only one factor gives the first option.
C. A tensor. For the tensor of section 3, each of the three unfoldings has rank 2, so the multilinear rank is . For counts, take a climate data set with 1000 grid points × 365 days × 10 variables ( numbers). CP with needs parameters; Tucker with core needs .
D. One attention head by hand. Take , , two tokens ("has feature 1") and ("looks for feature 1"), and a causal mask. Let , , , . Then
both of rank . The logits are , , ( is masked), so
and , so and . Token 2 found the token with feature 1 and copied it into coordinate 3 of its own residual vector. (Token 1 can only attend to itself, so it copies its own feature.) The logit matrix (before the mask) has rank 1, while : has rank 2.
5. In code #
The worked example A, both modes, and a Hessian-vector product, with NumPy and hand-written JVP
and VJP rules. An autodiff library (torch.func.vjp, jax.vjp, jax.jvp) returns the same numbers.
import numpy as np
# Worked example A: y = W x, with the upstream gradient ybar
W = np.array([[2., 1.], [0., 1.], [1., 3.]])
x = np.array([1., 2.])
ybar = np.array([1., -1., 1.])
y = W @ x
xbar = W.T @ ybar # VJP with respect to x
Wbar = np.outer(ybar, x) # VJP with respect to W: rank 1, shape of W
print(y, xbar) # [4. 2. 7.] [3. 3.]
print(Wbar.tolist(), np.linalg.matrix_rank(Wbar)) # [[1.0, 2.0], [-1.0, -2.0], [1.0, 2.0]] 1
# finite-difference check of xbar for L = ybar^T W x
L = lambda W, x: ybar @ (W @ x)
h = 1e-6
print(round((L(W, x + h * np.array([1., 0.])) - L(W, x)) / h, 6)) # 3.0 = xbar[0]
# forward mode: one JVP with dx = e1, dW = 0 gives the first column of J = W
print(W @ np.array([1., 0.]) + np.zeros_like(W) @ x) # [2. 0. 1.]
# a small nonlinear map f: R^2 -> R^3 and its Jacobian, by columns (JVPs) or rows (VJPs)
W1 = np.array([[1., -1.], [0.5, 2.]]); W2 = W
def f(x): return W2 @ np.tanh(W1 @ x)
def jvp(x, v): # forward mode: push v through the chain
a = W1 @ x
return W2 @ ((1 - np.tanh(a) ** 2) * (W1 @ v))
def vjp(x, u): # reverse mode: pull u back through the chain
a = W1 @ x
return W1.T @ ((1 - np.tanh(a) ** 2) * (W2.T @ u))
J_fwd = np.column_stack([jvp(x, e) for e in np.eye(2)]) # n = 2 JVPs
J_rev = np.vstack([vjp(x, e) for e in np.eye(3)]) # m = 3 VJPs
print(np.allclose(J_fwd, J_rev)) # True
# Hessian-vector product without forming H: the JVP of the gradient.
# An AD library does it exactly (forward over reverse); here a central difference of the gradient.
S = np.array([[3., 1.], [1., 2.]])
grad = lambda z: S @ z + np.array([np.cos(z[0]), 0.]) # gradient of 0.5 z^T S z + sin(z0)
v = np.array([1., -1.])
eps = 1e-5
Hv = (grad(x + eps * v) - grad(x - eps * v)) / (2 * eps)
H = S + np.diag([-np.sin(x[0]), 0.])
print(np.allclose(Hv, H @ v, atol=1e-8)) # True
The cost of the two multiplication orders for the chain of section 2:
import numpy as np
def chain_cost(shapes, from_left):
"""Multiplications to multiply a chain of matrices (list of (rows, cols)) from one end."""
shapes = list(shapes)
total = 0
while len(shapes) > 1:
if from_left: # reverse mode: start at the output
(a, b), (_, c) = shapes[0], shapes[1]
shapes[:2] = [(a, c)]
else: # forward mode: start at the input
(a, b), (_, c) = shapes[-2], shapes[-1]
shapes[-2:] = [(a, c)]
total += a * b * c
return total
chain = [(1, 1000), (1000, 1000), (1000, 1000)] # J3, J2, J1
print(chain_cost(chain, from_left=True)) # 2000000
print(chain_cost(chain, from_left=False)) # 1001000000
LoRA gradients at initialization, and why GaLore's total update is not low rank:
import numpy as np
rng = np.random.default_rng(0)
d, k, r, alpha, n_tok = 64, 48, 4, 8.0, 32
W0 = rng.standard_normal((d, k)) # frozen
A = rng.standard_normal((r, k)) / np.sqrt(k) # random init
B = np.zeros((d, r)) # zero init
X = rng.standard_normal((k, n_tok)) # 32 input tokens as columns
T = rng.standard_normal((d, n_tok)) # targets
# loss 0.5 ||W X - T||_F^2 with W = W0 + (alpha/r) B A; at init W = W0
G = (W0 @ X - T) @ X.T # dL/dW: a sum of 32 outer products
Bbar = (alpha / r) * G @ A.T # dL/dB
Abar = (alpha / r) * B.T @ G # dL/dA
print(np.abs(Abar).max(), np.abs(Bbar).max() > 0) # 0.0 True: only B moves at the first step
print(np.linalg.matrix_rank(G)) # 32: rank <= number of tokens (here < min(d, k) = 48)
print(r * (d + k), d * k) # 448 trainable numbers instead of 3072
# check dL/dB with a finite difference in one entry
def loss(B):
W = W0 + (alpha / r) * B @ A
return 0.5 * np.sum((W @ X - T) ** 2)
E = np.zeros_like(B); E[3, 1] = 1e-6
print(np.isclose((loss(B + E) - loss(B - E)) / 2e-6, Bbar[3, 1])) # True
# GaLore-style: each update has rank <= r, but the subspace changes, so the sum does not
updates = []
for t in range(4):
Gt = rng.standard_normal((d, k)) # stand-in for the gradient at refresh step t
P = np.linalg.svd(Gt, full_matrices=False)[0][:, :r] # top-r left singular vectors
updates.append(P @ (P.T @ Gt)) # project, (optimizer step), project back
print([int(np.linalg.matrix_rank(u)) for u in updates]) # [4, 4, 4, 4]
print(np.linalg.matrix_rank(sum(updates))) # 16: the total update is not rank 4
CP, unfoldings and the truncated HOSVD (no library needed beyond the SVD):
import numpy as np
rng = np.random.default_rng(0)
I, J, K = 30, 20, 10
def unfold(X, n):
"""Mode-n unfolding: mode-n fibers as columns, first remaining index fastest."""
return np.moveaxis(X, n, 0).reshape(X.shape[n], -1, order="F")
# the 2x2x2 example of section 3 (x[i, j, k], slices along the last index)
X2 = np.zeros((2, 2, 2)); X2[:, :, 0] = [[1, 2], [3, 4]]; X2[:, :, 1] = [[5, 6], [7, 8]]
print([unfold(X2, n).astype(int).tolist() for n in range(3)])
# [[[1, 2, 5, 6], [3, 4, 7, 8]], [[1, 3, 5, 7], [2, 4, 6, 8]], [[1, 3, 2, 4], [5, 7, 6, 8]]]
# A CP tensor of rank 3: x_ijk = sum_r a_ir b_jr c_kr
A, B, C = rng.standard_normal((I, 3)), rng.standard_normal((J, 3)), rng.standard_normal((K, 3))
X_cp = np.einsum("ir,jr,kr->ijk", A, B, C)
print([int(np.linalg.matrix_rank(unfold(X_cp, n))) for n in range(3)]) # [3, 3, 3]
print(3 * (I + J + K), I * J * K) # 180 parameters vs 6000 entries
# Truncated HOSVD of a noisy tensor with multilinear rank (3, 3, 3)
X = X_cp + 0.1 * rng.standard_normal((I, J, K))
ranks = (3, 3, 3)
U = [np.linalg.svd(unfold(X, n), full_matrices=False)[0][:, :ranks[n]] for n in range(3)]
G = np.einsum("ijk,ip,jq,ks->pqs", X, *U) # core G = X x1 U1ᵀ x2 U2ᵀ x3 U3ᵀ
X_hat = np.einsum("pqs,ip,jq,ks->ijk", G, *U) # X_hat = G x1 U1 x2 U2 x3 U3
err = np.linalg.norm(X - X_hat)
tails = [np.sum(np.linalg.svd(unfold(X, n), compute_uv=False)[ranks[n]:] ** 2) for n in range(3)]
lower = np.sqrt(max(tails)) # Eckart-Young per mode: no Tucker model of this size does better
print(G.shape, round(lower, 3), round(err, 3), round(np.sqrt(sum(tails)), 3), round(np.sqrt(3) * lower, 3))
# (3, 3, 3) 7.263 7.606 12.04 12.58: lower <= err <= sqrt(sum of tails) <= sqrt(3) * lower
Attention: multi-head attention computed the way a library does it (rows, heads concatenated, then ), checked against , and the sum over heads:
import numpy as np
rng = np.random.default_rng(0)
d, H, n = 16, 4, 6 # model width, heads, tokens
dh = d // H
Wq, Wk, Wv, Wo = (rng.standard_normal((d, d)) / np.sqrt(d) for _ in range(4))
Xr = rng.standard_normal((n, d)) # library layout: tokens as rows
def softmax_rows(S):
E = np.exp(S - S.max(axis=1, keepdims=True))
return E / E.sum(axis=1, keepdims=True)
# how a library computes multi-head attention: rows, split into heads, concatenate, then W_O
Qr, Kr, Vr = Xr @ Wq.T, Xr @ Wk.T, Xr @ Wv.T
heads = []
for h in range(H):
c = slice(h * dh, (h + 1) * dh)
A_h = softmax_rows(Qr[:, c] @ Kr[:, c].T / np.sqrt(dh))
heads.append(A_h @ Vr[:, c])
out = np.concatenate(heads, axis=1) @ Wo.T # n x d
# the same layer in the column convention: a sum over heads of W_OV X Aᵀ
X = Xr.T # d x n, column i = token i
total = np.zeros((d, n))
for h in range(H):
rows = slice(h * dh, (h + 1) * dh)
W_QK = Wq[rows].T @ Wk[rows] # d x d, rank <= dh
W_OV = Wo[:, rows] @ Wv[rows] # d x d, rank <= dh
S = X.T @ W_QK @ X / np.sqrt(dh) # logits s_ij = x_iᵀ W_QK x_j / sqrt(dh)
A = softmax_rows(S)
total += W_OV @ X @ A.T # head output, d x n
if h == 0:
rk = np.linalg.matrix_rank
print(rk(W_QK), rk(W_OV), rk(S), rk(A)) # 4 4 4 6
vec = lambda M: M.reshape(-1, order="F") # vec stacks columns
print(np.allclose(np.kron(A, W_OV) @ vec(X), vec(W_OV @ X @ A.T))) # True
M = rng.standard_normal((dh, dh)) # reparametrize Q and K
print(np.allclose((M @ Wq[rows]).T @ (np.linalg.inv(M).T @ Wk[rows]), W_QK)) # True
print(np.allclose(total, out.T)) # True: multi-head = sum of the heads' W_OV X Aᵀ
6. Where it shows up #
- Autodiff in science. Variational data assimilation (4D-Var) in weather prediction runs a tangent-linear model (JVPs) and an adjoint model (VJPs): reverse mode, written by hand long before deep learning. Adjoint methods give gradients of a PDE solution with respect to many parameters at the cost of one extra solve. Forward mode fits the sensitivities of a simulator to a few physical parameters.
- Fine-tuning foundation models. Adapting a pretrained Earth-system model to a new region or variable with LoRA keeps one frozen backbone and one small adapter per task.
- Tensors in ML for science. Ensemble forecasts (member × variable × grid point × time) are
compressed with Tucker/HOSVD, with an error you can choose. The TFNO model of the
neuraloperatorlibrary stores the spectral weights of a Fourier neural operator in Tucker form. CP gives interpretable components because it is often essentially unique. - Attention in practice. The KV cache stores the keys and values of every past token; multi-query and grouped-query attention share them across heads, and DeepSeek's multi-head latent attention (2024) caches a low-rank latent vector instead. Interpretability work reads heads through the SVDs of and .
7. Common mistakes #
- "Backprop computes the Jacobian." One reverse pass computes one VJP, . The full Jacobian needs passes.
- Wrong shapes in the VJP. has the shape of (); is its transpose. In row convention (): and .
- Initializing both LoRA factors to zero. Both gradients vanish and nothing trains.
- "GaLore reduces the trainable parameters." It reduces optimizer memory; all weights train.
- Treating the truncated HOSVD as optimal, or truncating a CP model and expecting the best lower-rank CP model. Neither holds for tensors. (Also, CP rank is one number; multilinear rank is one number per mode.)
- "The attention matrix is low rank." The logits are (rank ); after the softmax is usually not.
- Mixing conventions. (weights act on columns) and (row-convention weights of shape ) are the same written two ways; check shapes before you transpose.
- Row-major unfolding. NumPy's default
reshapeis row-major. Withorder="C"the columns of come in another order; the rank and the left singular vectors stay the same, but the matrix differs from the Kolda–Bader convention used in papers.
8. Check yourself #
- Give the VJP of (, ) and check the shapes.
Answer
( times = ) and ( times = ). From . - How do you get for the Hessian of a scalar loss without forming ? What does it cost?
Answer
Take the JVP of the gradient function in direction (forward over reverse): . It costs a small constant times one gradient evaluation, with memory like one backward pass. - LoRA with on a matrix: how many trainable parameters, and what
fraction of the matrix?
Answer
of : 1.25%. - Why does LoRA set and random, and not ?
Answer
makes training start at . The gradients are and . With random , ; with both gradients are zero and stay zero. - Show that CP is a special case of Tucker, and compare the parameter counts for
, CP rank 5, Tucker core , and the full tensor.
Answer
A superdiagonal core (, other entries 0) turns into . Counts: CP , Tucker , full . - Show that does not change when and for an invertible
. What is the rank of the logit matrix for tokens and ?
Answer
. The logit matrix has rank at most 64.
What do a JVP and a VJP compute for with Jacobian ?
A JVP maps an input direction to (one column of for ). A VJP maps an output weighting to (one row of for ).
Why does training use reverse mode?
The loss is a scalar (), so one VJP gives the gradient for all parameters at a few times the cost of the forward pass. Forward mode would need passes.
What does reverse mode cost in memory, and how do you reduce it?
It stores the intermediate values (activations) of the forward pass. Checkpointing stores only some and recomputes the others during the backward pass.
Give and for .
and , a rank-1 matrix with the shape of .
How many trainable numbers does LoRA with rank add to a matrix?
: is and is .
Why does LoRA initialize and random?
, so training starts exactly at . With random the gradient is nonzero; with both gradients stay zero.
What does GaLore make low rank, and what does it save?
The gradient, projected on its top- left singular vectors, so Adam's moments are instead of . It saves optimizer memory; all weights still train.
Define the mode- unfolding of a tensor.
The matrix whose columns are the mode- fibers (index varies, the others are fixed). For and it is .
Write a rank- CP model and its parameter count for an tensor.
, with parameters.
What are and of an attention head, and what is their largest possible rank?
(where a token looks) and (what it copies and where it writes). Both are with rank at most .
9. Interview angle #
- "Why does deep learning use reverse mode?" — the loss is a scalar, so one VJP gives all gradients at a few times the forward cost; forward mode would need one pass per parameter. The cost is memory for the activations.
- "Estimate the training compute of a 7B model on tokens." — flops (2 per parameter per token forward, 4 backward).
- "LoRA or GaLore for pretraining under a memory limit?" — GaLore: it trains all weights and the total update can have full rank; LoRA's update is limited to rank .
- "What are the QK and OV circuits?" — decides where a token looks, decides what is moved and where it is written; both have rank ; with fixed the head is linear, .
- "CP or Tucker for a 4-D climate data set?" — Tucker/HOSVD for compression (orthonormal factors from SVDs, error controlled, within of optimal); CP for interpretable components (unique), with harder fitting and no guarantee that a best model exists.
10. Practice #
The notebook for this lesson runs in Colab with NumPy only. It has five exercises, each with a check cell and a folded solution:
- Write the VJP of a matrix product and check it against finite differences.
- Build the Jacobian of a small two-layer map from JVPs (columns) and from VJPs (rows), and count the passes.
- Compute the LoRA gradients , from , and see that only moves at the first step.
- Write the mode- unfolding and the truncated HOSVD, and check the quasi-optimality bounds.
- Write one attention head in the column convention, reproduce worked example D, and check that multi-head attention is a sum of heads.
11. Watch and read #
- Baydin, Pearlmutter, Radul, Siskind, Automatic Differentiation in Machine Learning: a Survey (JMLR 2018), §3.1–3.2 (forward mode, reverse mode, their costs).
- Hu et al., LoRA: Low-Rank Adaptation of Large Language Models (2021), §4.1 (the parameterization and initialization) and §7 (understanding the low-rank updates). Follow-ups: GaLore (Zhao et al., 2024, §3–4) and LoRA Without Regret (Thinking Machines, 2025).
- Kolda and Bader, Tensor Decompositions and Applications (SIAM Review 2009), §2.4–2.5 (unfoldings, mode- product), §3.1–3.3 (CP rank, degeneracy), §4.1–4.2 (Tucker, HOSVD, HOOI).
- Elhage et al., A Mathematical Framework for Transformer Circuits (Anthropic, 2021), sections "Transformer Overview" (residual stream, heads independent and additive, information movement) and "One-Layer Attention-Only Transformers" (QK and OV circuits).
Runs in your browser with nothing to install. The notebook repeats the key demos and has exercises with checks; solutions are inside, folded.
No Google account needed: Python runs inside your browser. The first start downloads about 40 MB; your changes are saved in this browser only.