Skip to content

Jacobians, multivariable chain rule, numeric JVP/VJP

ModuleM04.2 · build · Python · Pass 2 · 2 to 3 h
You buildpython/tinyllm/num/jacobian.py: jacobian, jvp_numeric (JvJv), vjp_numeric (u⊤Ju^\top J)
Contractcourse/contracts/py/tinyllm/num/jacobian.pyi
Testscourse/tests/M04.2/test_jacobian.py (what they check: section 4)
NeedsM04.1 numerical_grad, on which vjp_numeric is built (or --ref-deps). Reading: M03.1 (matrices and their product)
Used byM08.1 checks dual-number JVPs against jvp_numeric · M08.2 and M08.3 check reverse-mode VJPs against vjp_numeric and jacobian · later L3.1 checks manual backpropagation through time
MilestoneMS-P2 (the Pass 2 gate)
Optional depthOpenStax, Calculus Volume 3 (free), section 4.5 (the chain rule for several variables); Baydin et al., “Automatic Differentiation in Machine Learning: a Survey” (2018), sections 3.1 and 3.2 (forward and reverse mode)
  • For f:Rn→Rmf: \mathbb{R}^n \to \mathbb{R}^m the Jacobian JJ is the m×nm \times n matrix of partial derivatives, Jij=∂fi/∂xjJ_{ij} = \partial f_i / \partial x_j: row ii is the gradient of output ii, column jj is how every output moves with input jj (test_hand_example).
  • The chain rule in several variables is a matrix product, outer function on the left: Jg∘f(x)=Jg(f(x)) Jf(x)J_{g \circ f}(x) = J_g(f(x))\,J_f(x) (test_chain_rule_is_a_matrix_product).
  • Autodiff never forms JJ. Forward mode pushes a direction through, JvJv; reverse mode pulls a cotangent back, u⊤Ju^\top J, which is the gradient of the scalar u⋅f(x)u \cdot f(x) (test_hand_example_products).
  • The two products are consistent: u⊤(Jv)=(u⊤J)vu^\top(Jv) = (u^\top J)v for every uu and vv (test_jvp_and_vjp_agree).
  • The JVP step must scale with vv: h=10−6/max⁡∣v∣h = 10^{-6}/\max|v|, so the input moves by 10−610^{-6} whatever the size of vv (test_jvp_scales_its_step_to_v).
Terminal window
ol start M04.2 # stubs python/tinyllm/num/jacobian.py into your repo
ol tests M04.2 # read the test catalog first: rung R0, you write no tests here
ol check M04.2 # exit code is the verdict
ol check M04.2 --ref-deps # only if your M04.1 is not passing yet
ol diff M04.2 # after passing: your code against the reference

M04.1 checks the gradient of a scalar loss. But your autodiff engine never handles the loss as one formula: it handles a chain of layers, each mapping arrays to arrays, and its backward pass multiplies through them one at a time. The objects being multiplied are Jacobians, or rather products with Jacobians, because the matrices themselves are far too large to form (a layer from 4096 to 4096 numbers has a Jacobian of 16 million entries per token). The next three modules build automatic differentiation three ways: dual numbers (M08.1, forward mode), a scalar Value graph (M08.2, reverse mode), and closed-form VJPs of matrix expressions (M08.3). Each needs an independent numeric oracle for JvJv and u⊤Ju^\top J. This module defines the Jacobian and the chain rule from partial derivatives and builds those oracles.

SymbolMeaningType / shape
f:Rn→Rmf: \mathbb{R}^n \to \mathbb{R}^ma function from nn numbers to mm numbersCallable[[NDArray], ArrayLike]
xxthe input; any shape, flattened row-major to nn numbersfloat64[...]
fif_ioutput ii of ff (row-major order of f(x)f(x))float
J=Jf(x)J = J_f(x)the Jacobian, Jij=∂fi/∂xjJ_{ij} = \partial f_i / \partial x_jfloat64[m, n]
v∈Rnv \in \mathbb{R}^na tangent: a direction to move the inputshape of xx
u∈Rmu \in \mathbb{R}^ma cotangent: a weighting of the outputsshape of f(x)f(x)
JvJvthe Jacobian-vector product (JVP)shape of f(x)f(x)
u⊤Ju^\top Jthe vector-Jacobian product (VJP)shape of xx
ϵ\epsilon, hhfinite-difference stepsfloat

Each output fif_i is a scalar function of xx, so it has a gradient (M04.1). Stack those gradients as rows and you get the Jacobian:

J=(∂f1/∂x1⋯∂f1/∂xn⋮⋮∂fm/∂x1⋯∂fm/∂xn).J = \begin{pmatrix} \partial f_1/\partial x_1 & \cdots & \partial f_1/\partial x_n \\ \vdots & & \vdots \\ \partial f_m/\partial x_1 & \cdots & \partial f_m/\partial x_n \end{pmatrix} .

It is the best linear approximation of ff near xx: f(x+Δ)≈f(x)+JΔf(x + \Delta) \approx f(x) + J\Delta for small Δ\Delta, the several-variable version of the tangent line. Column jj is how the whole output moves per unit move of input jj, which is also how to compute it numerically:

J:,j≈f(x+ϵej)−f(x−ϵej)(xj+ϵ)−(xj−ϵ),J_{:, j} \approx \frac{f(x + \epsilon e_j) - f(x - \epsilon e_j)}{(x_j + \epsilon) - (x_j - \epsilon)} ,

2n2n evaluations of ff, dividing by the step actually taken (M01.1). Three examples recur. A linear map f(x)=Axf(x) = Ax has J=AJ = A everywhere. An elementwise map like tanh⁡\tanh has a diagonal Jacobian, diag⁡(1−tanh⁡2x)\operatorname{diag}(1 - \tanh^2 x): output ii depends only on input ii. Softmax, pi=ezi/∑kezkp_i = e^{z_i}/\sum_k e^{z_k}, couples everything: ∂pi/∂zj=pi(δij−pj)\partial p_i / \partial z_j = p_i(\delta_{ij} - p_j), so J=diag⁡(p)−pp⊤J = \operatorname{diag}(p) - pp^\top, symmetric with columns summing to 0 (the probabilities always add to 1). M08.3 derives that one by hand. When xx or f(x)f(x) is a matrix, flatten both in row-major order (M03.1): the derivative with respect to X[1,2]X[1, 2] of a 2×32 \times 3 input is column 1⋅3+2=51 \cdot 3 + 2 = 5.

If y=f(x)y = f(x) and z=g(y)z = g(y), then a small change Δ\Delta in xx moves yy by about JfΔJ_f \Delta, which moves zz by about Jg(JfΔ)J_g (J_f \Delta). So

Jg∘f(x)=Jg(f(x)) Jf(x),J_{g \circ f}(x) = J_g\bigl(f(x)\bigr)\, J_f(x) ,

a product of a p×mp \times m and an m×nm \times n matrix, the outer function’s Jacobian on the left, evaluated at the inner function’s output. Entry by entry this is ∂zk∂xj=∑i∂zk∂yi∂yi∂xj\frac{\partial z_k}{\partial x_j} = \sum_i \frac{\partial z_k}{\partial y_i}\frac{\partial y_i}{\partial x_j}: add up every path from xjx_j to zkz_k. The one-variable chain rule is the 1×11 \times 1 case. A deep network is a long composition, and its Jacobian is a long product.

Forming JJ costs nn forward passes and mnmn memory. Two products avoid it:

  • JVP, JvJv: the derivative of t↦f(x+tv)t \mapsto f(x + tv) at t=0t = 0, the rate at which the output moves when the input moves in direction vv. One central difference computes it: f(x+hv)−f(x−hv)2h\frac{f(x + hv) - f(x - hv)}{2h}. Forward-mode autodiff (M08.1) computes it exactly by carrying a tangent alongside every value. Along a chain, JgJfv=Jg(Jfv)J_g J_f v = J_g (J_f v): push vv through one layer at a time.
  • VJP, u⊤Ju^\top J: the gradient of the scalar u⋅f(x)=∑iuifi(x)u \cdot f(x) = \sum_i u_i f_i(x), since ∂∂xj∑iuifi=∑iuiJij\frac{\partial}{\partial x_j}\sum_i u_i f_i = \sum_i u_i J_{ij}. vjp_numeric computes it with M04.1’s numerical_grad. Reverse-mode autodiff (backpropagation, M08.2, L0.1) computes it exactly by pulling uu back one layer at a time: u⊤JgJf=(u⊤Jg)Jfu^\top J_g J_f = (u^\top J_g) J_f. With u=1u = 1 and a scalar loss, the VJP is the gradient; that is why training uses reverse mode.

Both products describe the same JJ, so for every uu and vv, u⊤(Jv)=(u⊤J)vu^\top(Jv) = (u^\top J)v: one number computed two ways, which test_jvp_and_vjp_agree checks at random uu, vv, xx. Mixing them up (returning JuJu for a VJP) gives the right shape whenever JJ is square and the wrong numbers unless it is symmetric.

Scale the JVP step to vv. hh multiplies vv, so the input moves by hmax⁡∣v∣h\max|v|. A fixed h=10−6h = 10^{-6} with ∣v∣=106|v| = 10^6 moves xx by 1 and measures a secant over a whole unit. Choosing h=10−6/max⁡∣v∣h = 10^{-6}/\max|v| keeps the largest coordinate’s move at 10−610^{-6}; then J(cv)=c JvJ(cv) = c\,Jv to rounding, and v=0v = 0 returns 0 without computing 0/00/0.

f(x,y)=(x2y,  5x+sin⁡y)f(x, y) = (x^2 y,\; 5x + \sin y) at (1,2)(1, 2). Partial derivatives:

J=(∂x(x2y)∂y(x2y)∂x(5x+sin⁡y)∂y(5x+sin⁡y))=(2xyx25cos⁡y)=(415−0.4161468).J = \begin{pmatrix} \partial_x(x^2 y) & \partial_y(x^2 y) \\ \partial_x(5x + \sin y) & \partial_y(5x + \sin y) \end{pmatrix} = \begin{pmatrix} 2xy & x^2 \\ 5 & \cos y \end{pmatrix} = \begin{pmatrix} 4 & 1 \\ 5 & -0.4161468 \end{pmatrix} .

With v=(1,0)v = (1, 0): Jv=(4,5)Jv = (4, 5), the first column: moving xx moves the outputs at rates 4 and 5. With u=(1,0)u = (1, 0): u⊤J=(4,1)u^\top J = (4, 1), the first row: the gradient of the first output. JJ is not symmetric, so the two differ, and a transposed Jacobian swaps them. Check the identity with u=(1,1)u = (1, 1) and v=(0,1)v = (0, 1): Jv=(1,−0.4161468)Jv = (1, -0.4161468), u⋅Jv=0.5838532u \cdot Jv = 0.5838532; u⊤J=(9,0.5838532)u^\top J = (9, 0.5838532), (u⊤J)⋅v=0.5838532(u^\top J) \cdot v = 0.5838532. These are test_hand_example and test_hand_example_products.

def jacobian(f, x: ArrayLike, eps: float = 1e-6) -> NDArray: ... # [f(x).size, x.size]
def jvp_numeric(f, x: ArrayLike, v: ArrayLike) -> NDArray: ... # J v, shape f(x).shape
def vjp_numeric(f, x: ArrayLike, u: ArrayLike) -> NDArray: ... # u^T J, shape x.shape

All arithmetic is float64 on a private copy of x. jacobian raises ValueError for a bad eps or when f’s output shape changes; the products raise it when v does not have x’s shape or u does not have f(x)’s.

TestKINDChecksWhy it matters downstream
test_hand_exampleunit, smokesection 3’s JJyou and the tests agree on rows and columns
test_hand_example_productsunitJv=(4,5)Jv = (4, 5) and u⊤J=(4,1)u^\top J = (4, 1)forward and reverse mode are different products
test_shapes_flatten_row_majorunitxx of shape (2,3)(2, 3) gives JJ of shape (4,6)(4, 6) in row-major columns; product shapes; shape errorsbackward passes return gradients in the input’s shape
test_linear_map_jacobian_is_the_matrixgoldenJAx=AJ_{Ax} = AM03.1’s matmul is every layer’s first step
test_softmax_jacobian_closed_formgoldendiag⁡(p)−pp⊤\operatorname{diag}(p) - pp^\top, columns sum to 0M08.3 derives this VJP by hand
test_elementwise_jacobian_is_diagonalgoldenJtanh⁡=diag⁡(1−tanh⁡2)J_{\tanh} = \operatorname{diag}(1 - \tanh^2); its VJP is elementwiseactivation backward passes (M01.3)
test_chain_rule_is_a_matrix_productdifferentialJg∘f=Jg(f(x))Jf(x)J_{g \circ f} = J_g(f(x)) J_f(x)the rule backpropagation applies
test_jvp_and_vjp_agreeproperty, smokeJvJv, u⊤Ju^\top J, and u⊤(Jv)=(u⊤J)vu^\top(Jv) = (u^\top J)v at random pointsthe design’s property test; forward and reverse oracles
test_jvp_scales_its_step_to_vboundaryJ(106v)=106JvJ(10^6 v) = 10^6 Jv, J(10−6v)=10−6JvJ(10^{-6}v) = 10^{-6}Jv, J0=0J0 = 0tangents of any size
test_inputs_are_left_unchanged_and_float64unitthe caller’s xx is untouched; ff sees float64 even for int xxparameters are not perturbed in place
test_rejects_bad_eps_and_changing_shapesboundaryeps = 0 and an ff whose output shape changes raiseno ragged Jacobians
PitfallSymptomCaught by
1. the Jacobian transposed (columns stacked as rows)J⊤J^\top: right shape for square JJ, wrong numbers, and a chain rule product in the wrong ordertest_hand_example, test_chain_rule_is_a_matrix_product (mutant s01)
2. perturbing the caller’s xx and not restoring itthe input array changes during a checktest_inputs_are_left_unchanged_and_float64 (mutant s08)
2b. leaving coordinate jj at xj−ϵx_j - \epsilonevery later column is taken at a shifted pointtest_hand_example (mutant s09)
3. a fixed JVP step whatever the size of vvJ(106v)J(10^6 v) is a secant over a unit steptest_jvp_scales_its_step_to_v (mutant m04)
4. a VJP that computes JuJu instead of u⊤Ju^\top Jthe column (4,5)(4, 5) where the row (4,1)(4, 1) was wantedtest_hand_example_products (mutant s04)
one-sided differences for the columnserrors near 10−610^{-6}; softmax’s closed form missedtest_softmax_jacobian_closed_form (mutant s02)
dividing the JVP by hh instead of 2h2hevery JVP doubledtest_hand_example_products (mutant s03)
flattening xx through a copythe perturbations never reach ff: J=0J = 0test_shapes_flatten_row_major (mutant s05)
the column difference reversed−J-Jtest_softmax_jacobian_closed_form (mutant s06)
a VJP that ignores uuthe gradient of ∑ifi\sum_i f_i for every uutest_jvp_and_vjp_agree (mutant s07)
DirectionModuleHow it uses this
BackM04.1vjp_numeric is numerical_grad(lambda z: sum(u * f(z)), [x])
BackM03.1matrices, row-major layout, and the product (reading)
ForwardM08.1dual numbers compute JvJv exactly; its tests compare them with jvp_numeric
ForwardM08.2the scalar Value graph computes u⊤Ju^\top J by backpropagation, checked against vjp_numeric
ForwardM08.3closed-form VJPs of matmul, softmax, LayerNorm, RMSNorm, and cross-entropy, checked against jacobian and vjp_numeric
ForwardL3.1manual backpropagation through time, a chain of Jacobians over time steps
Your pieceProduction equivalentWhat it addsWhere to look
jvp_numeric, vjp_numericJAX jax.jvp, jax.vjpexact products by forward and reverse mode, composable with jit and vmapjax/_src/api.py
jacobianPyTorch torch.autograd.functional.jacobian (vectorize=True), JAX jacfwd and jacrevthe full Jacobian from nn JVPs or mm VJPs, batched; pick forward when n<mn < m, reverse when m<nm < ntorch/autograd/functional.py
the chain rulereverse-mode autodiff (backpropagation)one VJP per layer, in reverse order, storing the forward values it needsyour L0.1; Baydin et al. (2018)
u⊤(Jv)=(u⊤J)vu^\top(Jv) = (u^\top J)vPyTorch gradcheck(fast_mode=True)checks a whole backward with one random uu and vv instead of mnmn entriestorch/autograd/gradcheck.py