Chapter 3: Automatic Differentiation with grad
Personal study notes on Chapter 3 of the course "Getting Started with JAX" (ApX Machine Learning) · Environment: JAX 0.12 (CPU) Original course: https://apxml.com/courses/getting-started-with-jax/chapter-3-automatic-differentiation-grad
Training a machine learning model almost always means computing the gradient of a loss function with respect to the parameters. Automatic differentiation (AD) is the most efficient and most accurate way to compute gradients. This chapter introduces JAX's core transformation jax.grad: give it an ordinary Python function on numeric inputs, and you get back a function that computes its gradient.
Learning goals
- Compute gradients of scalar functions with
jax.grad; - Understand the reverse-mode automatic differentiation behind
grad; - Control which arguments to differentiate with
argnums; - Compose
gradto get higher-order derivatives; - Get the function value and the gradient together with
jax.value_and_grad; - Understand how control flow interacts with differentiation, and the limits of
grad.
Sections
| Section | Original course page |
|---|---|
| 3.1 Understanding gradients | understanding-gradients |
3.2 Introducing jax.grad | introducing-jax-grad |
| 3.3 How autodiff works: reverse mode | how-autodiff-works |
| 3.4 Differentiating with respect to arguments | grad-respect-to-arguments |
3.5 Higher-order derivatives (grad of grad) | grad-higher-order-derivatives |
3.6 Value and gradient (jax.value_and_grad) | jax-value-and-grad |
| 3.7 Differentiation and control flow | grad-control-flow |
| 3.8 Limitations and caveats | grad-limitations |
| 3.9 Hands-on: computing gradients | hands-on-computing-gradients |
| Chapter 3 Quiz | quiz |
| Version notes | End of chapter |
How to read this: the code examples follow the course pages. All of them were run under JAX 0.12, and the output below each cell is the actual result.
- The text uses the recommended JAX 0.12 style throughout. Common pitfalls are marked with
> ⚠️ **Note**. When code is involved, the outdated or wrong version goes in a ❌ cell, followed right away by the ✅ recommended version, and the conclusion is checked withassert.- Cells marked "🔍 Extra" are additional material.
- These notes are collected in the Version notes at the end of the chapter.
The explanations are rewritten around the course's key points. The code examples keep the course's variable names and values where possible, and all of them have been run.
import jax
import jax.numpy as jnp
import numpy as np
print("JAX version:", jax.__version__)
print("Default backend:", jax.default_backend())
3.1 Understanding gradients¶
What is a gradient¶
Start with a function of one variable, $f(x)$. Its derivative at a point is the slope of the tangent line there. It tells you how fast $f$ changes when $x$ changes a little.
For example, $f(x)=x^2$ has $f'(x)=2x$. At $x=2$ the tangent slope is $4$, which is the derivative (gradient) at that point.
For a function with several inputs and one scalar output, $f(x_1,\dots,x_n)$, stack the partial derivatives with respect to each input into a vector. That vector is the gradient:
$$ \nabla f(x_1,\dots,x_n)=\left[\frac{\partial f}{\partial x_1},\ \frac{\partial f}{\partial x_2},\ \dots,\ \frac{\partial f}{\partial x_n}\right] $$
The gradient is a vector. It points in the direction in which the function increases fastest at that point, and its length tells you how steep that increase is.
Example: $f(x,y)=x^2+\sin(y)$
$$ \frac{\partial f}{\partial x}=2x,\qquad \frac{\partial f}{\partial y}=\cos(y),\qquad \nabla f(x,y)=[2x,\ \cos y],\qquad \nabla f(1,0)=[2,\ 1] $$
Why gradients matter for optimization¶
Since the gradient points in the direction of fastest increase, the negative gradient points in the direction of fastest decrease. Training a model means making the loss as small as possible, and gradient descent uses exactly this:
- Start from some initial parameters;
- Compute the gradient of the loss at the current parameters;
- Take a small step along the negative gradient;
- Repeat steps 2–3 until convergence.
The update rule:
$$ \theta_{\text{new}} = \theta_{\text{old}} - \eta\,\nabla f(\theta_{\text{old}}) $$
Here $\eta$ is the learning rate, a small positive number that controls the step size.
Why we need automatic differentiation¶
Deep networks easily have millions of parameters. Deriving gradients by hand is impractical and error-prone. There are three main ways to let a computer take derivatives:
| Method | How it works | Drawbacks |
|---|---|---|
| Numerical differentiation | Approximate with $\dfrac{f(x+h)-f(x)}{h}$ ($h$ small) | Truncation and rounding errors; one extra evaluation per input dimension, so $O(n)$ evaluations for $n$ dimensions |
| Symbolic differentiation | Manipulate expressions symbolically, like SymPy | Expressions can blow up, and general programs with loops and branches are hard to handle |
| Automatic differentiation | Apply the chain rule systematically at the level of elementary operations | None of the problems above: exact (to machine precision) and efficient |
JAX's grad uses reverse-mode automatic differentiation, which machine learning calls backpropagation. It is a great fit for functions with "many inputs and one scalar output (the loss)".
# Check the two examples above with JAX
# 1) The derivative (tangent slope) of f(x) = x^2 at x=2 should be 4
print("d/dx x^2 at x=2:", jax.grad(lambda x: x**2)(2.0))
# 2) The gradient of f(x, y) = x^2 + sin(y) at (1, 0) should be [2, 1]
def f_xy(v):
x, y = v
return x**2 + jnp.sin(y)
print("∇f(1, 0) =", jax.grad(f_xy)(jnp.array([1.0, 0.0])))
🔍 Extra: what exactly is wrong with numerical differentiation¶
⚪ Optional: read this if you wonder why we don't train with finite differences, or if you need to write your own gradient check.
The error of numerical differentiation has two sources. If $h$ is too large, you get truncation error ($O(h)$). If $h$ is too small, floating-point subtraction brings in rounding error (about $\varepsilon/h$). JAX uses float32 by default (machine epsilon $\varepsilon\approx 1.2\times10^{-7}$), which makes this worse. Below we differentiate $\sin$ at $x=1$ and compare forward differences, central differences and jax.grad:
x0 = 1.0
exact = np.cos(x0) # True value (computed in float64)
print(f"{'h':>8} | {'fwd diff error':>14} | {'ctr diff error':>14}")
for h in [1e-1, 1e-2, 1e-3, 1e-4, 1e-5, 1e-6]:
x = jnp.float32(x0)
fwd = (jnp.sin(x + h) - jnp.sin(x)) / h
cen = (jnp.sin(x + h) - jnp.sin(x - h)) / (2 * h)
print(f"{h:>8.0e} | {abs(float(fwd) - exact):>14.2e} | {abs(float(cen) - exact):>14.2e}")
print("\njax.grad error:", abs(float(jax.grad(jnp.sin)(jnp.float32(x0))) - exact))
In float32, the finite-difference error first drops as $h$ shrinks, then climbs back up because of rounding error. At best it reaches about $10^{-4}\sim10^{-5}$. The autodiff error is at the level of machine precision. So numerical differentiation is only good for checking gradients (see check_grads in 3.8), not for training.
3.2 Introducing jax.grad¶
jax.grad is itself a function transformation. You give it a Python numeric function that returns a scalar, and it gives you back a new function that computes the gradient of the original one.
Take $f(x)=x^3$. Its derivative is $f'(x)=3x^2$, so $f'(2.0)=3\times 2.0^2=12.0$. First define and test the original function:
import jax
import jax.numpy as jnp
def f(x):
"""Compute x cubed."""
return x**3
# Test the original function
print(f"f(2.0) = {f(2.0)}")
Use jax.grad to get the gradient function. Note that grad_f, like f, is just an ordinary Python callable:
# Get the gradient function with jax.grad
grad_f = jax.grad(f)
print(f"Type of f: {type(f)}")
print(f"Type of grad_f: {type(grad_f)}")
# Compute the gradient of f at x=2.0
gradient_at_2 = grad_f(2.0)
print(f"Gradient of f at x=2.0: {gradient_at_2}") # By hand: 3 * 2.0**2 = 12.0
Key points
- Input: a Python function that returns a single scalar;
jax.gradreturns a new function;- The new function takes the same arguments as the original and returns the gradient of the output with respect to the first positional argument.
Functions of several arguments: $g(x,y)=x^2 y$. By default jax.grad(g) differentiates only with respect to the first argument $x$: $\dfrac{\partial g}{\partial x}=2xy$, which is $12$ at $(2,3)$. Section 3.4 shows how to differentiate with respect to other arguments.
def g(x, y):
"""Compute x squared times y."""
return (x**2) * y
# Get the gradient function of g
grad_g = jax.grad(g)
# Compute the gradient at (x=2.0, y=3.0)
# By default this is the gradient with respect to the first argument (x)
gradient_g_wrt_x = grad_g(2.0, 3.0)
print(f"g(2.0, 3.0) = {g(2.0, 3.0)}")
print(f"Gradient of g w.r.t x at (2.0, 3.0): {gradient_g_wrt_x}")
Requirements on the function
- The function being differentiated must return a single scalar. Returning something non-scalar, like an array or a tuple, raises a
TypeError. For functions with several outputs, usejax.jacrev/jax.jacfwd(jax.jacobianis an alias ofjax.jacrev), or the lower-leveljax.vjp/jax.jvp. Likegrad, they are all built onjax.vjp(reverse) orjax.jvp(forward); see 3.3. - The inputs you differentiate with respect to must be floating-point (or complex). Passing an integer raises a
TypeErrorright away; JAX will not convert it to float for you. If you really need an integer argument (an index, a step count, and so on), put it in a position you don't differentiate, that is, leave it out ofargnums. If you must list it inargnums, addallow_int=True; its gradient is then a zero of typefloat0.
⚠️ Note: it's easy to assume that multi-output tools like
jax.jacobianare built on top ofjax.grad. They aren't. The source ofjax.jacobiansimply callsjacrev(the docs say Alias of jax.jacrev), and bothjacrevandgradare implemented on top ofjax.vjp.⚠️ Note: using an integer input is not just "not recommended". It raises
TypeError: grad requires real- or complex-valued inputs.
# Check: integer input raises; how allow_int behaves; jacobian is jacrev
try:
jax.grad(f)(2)
int_input_raised = False
except TypeError as e:
int_input_raised = True
print("Integer input raises:", str(e).splitlines()[0])
assert int_input_raised, "Integer input should raise TypeError"
assert jax.grad(f)(2.0) == 12.0
def scale(x, n):
return x * n
# allow_int=True: integer arguments may appear in argnums; their gradient is a float0 zero
gx, gn = jax.grad(scale, argnums=(0, 1), allow_int=True)(2.0, 3)
print("allow_int=True ->", gx, "| gradient of n has type float0:", gn.dtype == jax.dtypes.float0)
assert gn.dtype == jax.dtypes.float0
# jax.jacobian is jax.jacrev
import inspect
print("Docstring of jax.jacobian:", jax.jacobian.__doc__.strip())
assert "jacrev(fun" in inspect.getsource(jax.jacobian)
F_demo = lambda x: jnp.stack([x[0] * x[1], jnp.sin(x[1])])
x_demo = jnp.array([1.0, 2.0])
assert jnp.array_equal(jax.jacobian(F_demo)(x_demo), jax.jacrev(F_demo)(x_demo))
print("Assertion passed: jacobian and jacrev give identical results")
One more example: $h(x)=\sin x$, $h'(x)=\cos x$. At $\pi/2$ the mathematical value of the gradient is $\cos(\pi/2)=0$.
⚠️ Note:
grad_h(jnp.pi / 2.0)does not print0.0. It prints about-4.37e-08(float32 by default). The reason is that $\pi/2$ gets rounded when converted to float32, andcosat the rounded point is not exactly 0. Compare floating-point results withjnp.allclose, not==.
# ❌ Wrong: expecting the gradient cos(pi/2) to print as exactly 0.0
def h(x):
"""Compute sin(x)."""
return jnp.sin(x)
grad_h = jax.grad(h)
gradient_h_at_pi_half = grad_h(jnp.pi / 2.0)
print(f"h(pi/2) = {h(jnp.pi / 2.0)}")
print(f"Gradient of h at x=pi/2: {gradient_h_at_pi_half}") # The mathematical value is 0.0
print("Exactly equal to 0.0?", bool(gradient_h_at_pi_half == 0.0))
# ✅ Recommended: compare floating-point results with allclose
gradient_h_at_pi_half = grad_h(jnp.pi / 2.0)
assert gradient_h_at_pi_half != 0.0 # It really is not exactly 0
assert jnp.allclose(gradient_h_at_pi_half, 0.0, atol=1e-6) # But it is 0 within float32 precision
print("Assertion passed: grad_h(pi/2) ≈ 0 (atol=1e-6)")
3.3 How autodiff works: reverse mode¶
Automatic differentiation (AD) is the technique behind jax.grad. It breaks a program into elementary operations and applies the chain rule to them one by one, which gives exact derivatives. AD comes in two flavors: forward mode and reverse mode. jax.grad uses reverse mode, usually called backpropagation in machine learning.
The computation graph¶
Think of a computation as a chain of elementary operations. You can draw it as a computation graph of nodes (operations) and directed edges (data flow). Take $f(x)=\sin(x^2)$ and split it into two steps:
$$ a = x^2,\qquad y=\sin(a) $$
x ──[square]──► a ──[sin]──► y
Forward pass: computing values¶
Take $x=2.0$:
$$ a = 2.0^2 = 4.0,\qquad y=\sin(4.0)\approx -0.757 $$
During the forward pass, the AD system stores the intermediate values (here $x$ and $a$) and the structure of the graph for the backward pass.
Backward pass: computing gradients with the chain rule¶
Write $\bar v = \dfrac{\partial y}{\partial v}$ (the "sensitivity" of the output $y$ to a variable $v$, also called the adjoint). Walk back from the output:
- Initialize: the sensitivity of the output to itself is $\bar y=\dfrac{dy}{dy}=1$.
- Through the sin node: $\bar a=\bar y\cdot\dfrac{dy}{da}=\bar y\cos(a)=\cos(4.0)\approx-0.654$.
- Through the square node: $\bar x=\bar a\cdot\dfrac{da}{dx}=\bar a\cdot 2x\approx-0.654\times4.0\approx-2.615$.
$\bar x$ is the $\dfrac{dy}{dx}$ we want. Put simply, backpropagation starts from the output and applies the chain rule step by step, getting the sensitivity of the output to each intermediate variable and finally to each input.
Forward (values): x=2.0 ──► a=4.0 ──► y≈-0.757 (store x, a)
Backward (gradients): x̄≈-2.615 ◄── ā≈-0.654 ◄── ȳ=1
Why machine learning uses reverse mode¶
A loss function $L=f(W_1,\dots,W_n,x,y)$ usually has millions of inputs (parameters) but only one scalar output.
- Reverse mode: one backward pass gives the gradient with respect to all inputs. The cost is a small constant multiple of evaluating the original function (usually quoted as 2–4x), and it barely depends on the number of inputs.
- Forward mode: each pass gives the derivative along only one input direction. Getting the full gradient takes a number of passes proportional to the number of inputs, which is far too expensive for large models.
What JAX does behind the scenes¶
- Tracing: JAX runs the function once with "tracer" objects in place of the inputs and records the sequence of operations (the computation graph). With
gradalone, the tracers carry concrete values, so the forward values are computed on the spot while the operations needed for differentiation are recorded. Only insidejitare the tracers abstract values that carry just shape and dtype, and what gets recorded is the intermediate representation jaxpr (3.7 explains the difference). - Forward pass: run these operations and compute the intermediate values the gradient needs (with
value_and_gradyou also get the function value along the way). - Backward pass: from the recorded graph, automatically generate and run the backward computation to get the gradient with respect to the chosen inputs.
You only write the forward computation in NumPy-like syntax. Deriving and computing the gradient is left to JAX.
⚠️ Note: with
gradalone, tracers are not abstract. A tracer prints asGradTracer(primal=2.0, ...)and carries a concrete value. Abstract tracers that carry only shape and dtype are ajitbehavior (a cell in 3.7 checks this withassert).
# Check the hand computation above with JAX
def f_sin_sq(x):
a = x**2 # square node
y = jnp.sin(a) # sin node
return y
x = 2.0
print("y =", f_sin_sq(x))
print("ā = cos(4.0) ≈", jnp.cos(4.0))
print("x̄ = ā * 2x ≈", jnp.cos(4.0) * 2 * x)
print("jax.grad(f)(2.0) =", jax.grad(f_sin_sq)(x))
assert jnp.allclose(jax.grad(f_sin_sq)(x), jnp.cos(4.0) * 2 * x)
🔍 Extra: look at the jaxpr yourself, and how jvp/vjp relate to the two modes¶
🟢 Must-read
(1) See what grad generates. jax.make_jaxpr prints the jaxpr produced by tracing. Compare the jaxpr of the original function with that of the gradient function, and you'll see the backward computation contains cos (the derivative of sin) and a multiplication by 2x:
print("jaxpr of the original function:")
print(jax.make_jaxpr(f_sin_sq)(2.0))
print("jaxpr of the gradient function:")
print(jax.make_jaxpr(jax.grad(f_sin_sq))(2.0))
(2) Forward mode = jax.jvp, reverse mode = jax.vjp. Each of the two modes above has a low-level API in JAX. Let $f:\mathbb R^n\to\mathbb R^m$ with Jacobian $J\in\mathbb R^{m\times n}$:
| API | Mode | Computes | One call gives you |
|---|---|---|---|
jax.jvp(f, (x,), (v,)) | Forward | $J v$ (Jacobian-vector product), $v\in\mathbb R^n$ | One column of the Jacobian (the directional derivative along input direction $v$) |
jax.vjp(f, x) → vjp_fn(u) | Reverse | $u^\top J$ (vector-Jacobian product), $u\in\mathbb R^m$ | One row of the Jacobian |
jax.grad(f)(x)is essentiallyvjp_fn(1.0)(when $m=1$, $J$ has a single row, so one VJP gives the whole gradient).jax.jvpreturns the function value and the directional derivative together. Forward mode doesn't need to store intermediate values, so it uses little memory.jax.vjpreturns "the function value plus avjp_fnyou can call many times". The intermediate values stored during the forward pass live insidevjp_fn.
def F(x):
# F: R^3 -> R^2
return jnp.array([x[0] * x[1], jnp.sin(x[2]) + x[0]])
x = jnp.array([1.0, 2.0, 3.0])
# Forward mode: J @ v with v = first basis vector -> first column of the Jacobian
y, Jv = jax.jvp(F, (x,), (jnp.array([1.0, 0.0, 0.0]),))
print("F(x) =", y, "| column 1 of J =", Jv)
# Reverse mode: u^T @ J with u = first basis vector -> first row of the Jacobian
y, vjp_fn = jax.vjp(F, x)
(uJ,) = vjp_fn(jnp.array([1.0, 0.0]))
print("row 1 of J =", uJ)
print("full Jacobian J =\n", jax.jacrev(F)(x))
# grad is just vjp when the output has dimension 1
def loss(x):
return jnp.sum(x**2)
_, vjp_loss = jax.vjp(loss, x)
print("\nvjp_fn(1.0) =", vjp_loss(1.0)[0], "| grad =", jax.grad(loss)(x))
J_full = jax.jacrev(F)(x)
assert jnp.allclose(Jv, J_full[:, 0]) and jnp.allclose(uJ, J_full[0, :]) # jvp gives a column, vjp gives a row
assert jnp.allclose(vjp_loss(1.0)[0], jax.grad(loss)(x)) # grad is vjp(1.0)
🔍 Extra: choosing between jacfwd and jacrev (decision rule + timing check)¶
⚪ Optional: read this if you need a full Jacobian or Hessian and care about efficiency.
To compute the full Jacobian $J\in\mathbb R^{m\times n}$ of $f:\mathbb R^n\to\mathbb R^m$:
jacfwduses forward mode and builds it column by column, with $n$ JVPs (JAX batches them in one go withvmap);jacrevuses reverse mode and builds it row by row, with $m$ VJPs, and it has to store the forward intermediate values, so it uses more memory.
Both give the same result; they differ only in efficiency. That leads to the following rules:
| Situation | Choice | Why |
|---|---|---|
| Input dimension $n$ > output dimension $m$ ("wide" Jacobian) | jax.jacrev | Only $m$ VJPs |
| Input dimension $n$ < output dimension $m$ ("tall" Jacobian) | jax.jacfwd | Only $n$ JVPs |
| Scalar output ($m=1$, e.g. a loss) | jax.grad | Special case of jacrev, one VJP |
| $n\approx m$ | Either works; lean toward jacfwd | Forward mode stores no intermediates, so it saves memory |
| Hessian (second derivative of a scalar function) | jax.hessian = jacfwd(jacrev(f)) | The inner gradient is $n\to1$, so use reverse; the outer step is an $n\to n$ Jacobian, where forward mode saves memory |
| You only need $Jv$ or $u^\top J$, not the whole matrix | jax.jvp / jax.vjp | Never builds $J$; costs about one forward/backward pass |
Let's check this with timings on CPU (absolute times depend on the machine; only the relative sizes matter).
Note: here the weight matrix is passed as an argument to the
jit-compiled function instead of being captured by a closure. That is the style JAX recommends (arrays captured by a closure get embedded in the compiled result as constants). It also sidesteps a known issue with the CPU backend in this environment: "closure-captured large array + matmul + reduction" silently returns wrong results. See 00 · Known issues in this environment (root cause in Advanced 02 §2.3).
import time
def bench(fn, *args, n=20):
"""Return the average time of fn(*args) in milliseconds; runs once first to trigger compilation."""
jax.block_until_ready(fn(*args))
t0 = time.perf_counter()
for _ in range(n):
jax.block_until_ready(fn(*args))
return (time.perf_counter() - t0) / n * 1e3
def layer(W, x):
# Weights W are passed in as an argument; take the Jacobian with respect to x only (argnums=1)
return jnp.tanh(W @ x)
W_wide = jax.random.normal(jax.random.key(0), (4, 2000)) # R^2000 -> R^4
W_tall = jax.random.normal(jax.random.key(1), (2000, 4)) # R^4 -> R^2000
jac_fwd = jax.jit(jax.jacfwd(layer, argnums=1))
jac_rev = jax.jit(jax.jacrev(layer, argnums=1))
results = {}
for name, W, x in [("R^2000 -> R^4 (n > m)", W_wide, jnp.ones(2000)),
("R^4 -> R^2000 (n < m)", W_tall, jnp.ones(4))]:
J_f, J_r = jac_fwd(W, x), jac_rev(W, x)
# Correctness: jit vs. non-jit, and the two modes, must all agree
assert jnp.allclose(J_f, J_r, atol=1e-5)
assert jnp.allclose(J_r, jax.jacrev(layer, argnums=1)(W, x), atol=1e-5)
t_f, t_r = bench(jac_fwd, W, x), bench(jac_rev, W, x)
results[name] = (t_f, t_r)
print(f"{name}: jacfwd {t_f:7.3f} ms | jacrev {t_r:7.3f} ms | faster: {'jacfwd' if t_f < t_r else 'jacrev'}")
t_f, t_r = results["R^2000 -> R^4 (n > m)"]
print("\nRule 'n > m -> use jacrev' confirmed:", t_r < t_f)
t_f, t_r = results["R^4 -> R^2000 (n < m)"]
print("Rule 'n < m -> use jacfwd' confirmed:", t_f < t_r)
# Four ways to build a Hessian: same result, different efficiency
def scalar_fn(W, x):
return jnp.sum(jnp.tanh(W @ x) ** 2)
n = 300
W_sq = jax.random.normal(jax.random.key(2), (n, n)) / jnp.sqrt(n)
x0 = jax.random.normal(jax.random.key(3), (n,))
combos = {
"jacfwd(jacrev) [= jax.hessian]": jax.jacfwd(jax.jacrev(scalar_fn, argnums=1), argnums=1),
"jacrev(jacrev)": jax.jacrev(jax.jacrev(scalar_fn, argnums=1), argnums=1),
"jacrev(jacfwd)": jax.jacrev(jax.jacfwd(scalar_fn, argnums=1), argnums=1),
"jacfwd(jacfwd)": jax.jacfwd(jax.jacfwd(scalar_fn, argnums=1), argnums=1),
}
H_ref = jax.hessian(scalar_fn, argnums=1)(W_sq, x0)
for name, fn in combos.items():
fn_jit = jax.jit(fn)
assert jnp.allclose(fn_jit(W_sq, x0), H_ref, atol=1e-4), name
print(f"{name:34s}: {bench(fn_jit, W_sq, x0, n=10):7.2f} ms")
print("Assertion passed: all four combinations give the same Hessian")
The timings show that the two combinations with reverse mode on the inside (jacfwd(jacrev), jacrev(jacrev)) are clearly faster than the ones with forward mode inside, because the inner step computes the gradient of a scalar function ($n\to1$). Between those two the difference is small (which one wins depends on the machine). jax.hessian picks jacfwd(jacrev) because forward mode on the outside doesn't need to store the intermediates that reverse mode needs, so it saves memory.
3.4 Differentiating with respect to arguments¶
By default jax.grad differentiates with respect to the first argument. In machine learning, though, a loss function usually takes both the model parameters and the data, and we only want gradients with respect to the parameters. The argnums argument lets you choose which positional argument (or arguments) to differentiate.
Choosing a single argument¶
Take $f(x,y)=x^2 y$: $\dfrac{\partial f}{\partial x}=2xy$, $\dfrac{\partial f}{\partial y}=x^2$. Use $x=3,\ y=4$:
import jax
import jax.numpy as jnp
def power_product(x, y):
"""Compute x^2 * y"""
return (x**2) * y
# Some input values
x_val = 3.0
y_val = 4.0
print(f"Function output: {power_product(x_val, y_val)}")
# Differentiate with respect to the first argument (x) - the default
grad_f_wrt_x = jax.grad(power_product)
gradient_x = grad_f_wrt_x(x_val, y_val)
print(f"Gradient w.r.t. x (default): {gradient_x}") # Expected: 2 * 3.0 * 4.0 = 24.0
argnums counts from 0, so argnums=1 means differentiate with respect to the second argument $y$:
# Use argnums=1 to get the gradient with respect to the second argument (y)
grad_f_wrt_y = jax.grad(power_product, argnums=1)
gradient_y = grad_f_wrt_y(x_val, y_val)
print(f"Gradient w.r.t. y (argnums=1): {gradient_y}") # Expected: 3.0**2 = 9.0
Choosing several arguments¶
Pass a tuple of integers to argnums, and the gradient function returns a tuple in the same order as argnums. Arguments that aren't selected are treated as constants during differentiation.
Take the affine function $g(w,b,x)=wx+b$: $\dfrac{\partial g}{\partial w}=x$, $\dfrac{\partial g}{\partial b}=1$.
def affine(w, b, x):
"""Compute w*x + b"""
return w * x + b
# Input values
w_val = 2.0
b_val = 1.0
x_data = 5.0
print(f"Affine function output: {affine(w_val, b_val, x_data)}")
# Gradients with respect to the first (w) and second (b) arguments
grad_g_wrt_wb = jax.grad(affine, argnums=(0, 1))
# Note: the input x_data is treated as a constant during differentiation
gradients_wb = grad_g_wrt_wb(w_val, b_val, x_data)
print(f"Gradients w.r.t. (w, b) (with argnums=(0, 1)): {gradients_wb}")
# Expected: (gradient w.r.t. w, gradient w.r.t. b) = (x, 1) = (5.0, 1.0)
Connection to machine learning¶
During training we want the gradient of the loss with respect to the parameters, $\nabla_{\text{params}}\,\text{loss}(\text{params},\ \text{data\_batch})$; the data batch is just a fixed input. So the usual convention is to put the parameters first and use the default argnums=0.
Real model parameters are usually nested dicts / lists / tuples, which JAX calls a pytree. jax.grad supports pytrees directly, and the gradient has exactly the same structure as the parameters. So to update the parameters, use jax.tree.map to apply p - lr * g to each leaf (array).
⚠️ Note:
updated_params = current_params - learning_rate * gradientsonly works when the parameters are a single array. When they are a dict it raisesTypeError: unsupported operand type(s); usejax.tree.map(lambda p, g: p - learning_rate * g, params, grads)instead.
Here is that example filled out into runnable code:
# The parameters are a dict (a pytree)
params = {
"w": jnp.array([1.0, -2.0]),
"b": jnp.array(0.5),
}
batch = {
"inputs": jnp.array([[1.0, 2.0], [3.0, 4.0], [5.0, 6.0]]),
"targets": jnp.array([1.0, 0.0, -1.0]),
}
def model_apply(params, x):
return x @ params["w"] + params["b"]
def loss_function(params, data_batch):
predictions = model_apply(params, data_batch["inputs"])
error = predictions - data_batch["targets"]
return jnp.mean(error**2) # Mean squared error
grad_loss_wrt_params = jax.grad(loss_function, argnums=0)
gradients = grad_loss_wrt_params(params, batch)
print("gradients =", gradients)
assert jax.tree.structure(gradients) == jax.tree.structure(params)
print("Assertion passed: the gradient has the same pytree structure as the parameters")
# ❌ Wrong: you can't do arithmetic directly on a dict of parameters
learning_rate = 0.01
try:
updated_params = params - learning_rate * gradients
dict_arith_raised = False
except TypeError as e:
dict_arith_raised = True
print("Raises TypeError:", e)
assert dict_arith_raised
# ✅ Recommended: update each leaf with jax.tree.map
updated_params = jax.tree.map(lambda p, g: p - learning_rate * g, params, gradients)
loss_before = loss_function(params, batch)
loss_after = loss_function(updated_params, batch)
print("loss before update:", loss_before)
print("loss after update:", loss_after)
assert loss_after < loss_before
assert jnp.allclose(updated_params["w"], params["w"] - learning_rate * gradients["w"])
print("Assertion passed: tree.map updated every leaf correctly and the loss went down")
🔍 Extra: has_aux=True for functions that return more than the loss¶
🟢 Must-read
grad requires the function to return a scalar. But a loss function often wants to hand back auxiliary information too, like predictions or accuracy, and returning a tuple directly raises an error. The right way is to return (loss, aux) and add has_aux=True. The gradient function then returns (grads, aux), and aux is not differentiated.
def loss_with_metrics(params, data_batch):
predictions = model_apply(params, data_batch["inputs"])
loss = jnp.mean((predictions - data_batch["targets"]) ** 2)
aux = {"predictions": predictions, "max_abs_err": jnp.max(jnp.abs(predictions - data_batch["targets"]))}
return loss, aux
# Without has_aux: returning a tuple raises an error
try:
jax.grad(loss_with_metrics)(params, batch)
except TypeError as e:
print("Error without has_aux:", str(e).splitlines()[0])
# With has_aux=True
grads, aux = jax.grad(loss_with_metrics, has_aux=True)(params, batch)
print("\ngrads:", grads)
print("aux :", aux)
3.5 Higher-order derivatives (grad of grad)¶
jax.grad takes a function $f$ with scalar output and returns a new Python function that computes the gradient. JAX transformations take Python functions in and give Python functions back, so the result of a transformation can be transformed again. For a scalar function of one variable, grad gives you another scalar function, so you can apply grad once more and get the second derivative.
Take $f(x)=x^3$: $f'(x)=3x^2$, $f''(x)=6x$. At $x=2$: $f=8,\ f'=12,\ f''=12$.
import jax
import jax.numpy as jnp
def f(x):
return x**3
# First derivative
grad_f = jax.grad(f)
# Second derivative: differentiate the first-derivative function again
grad_grad_f = jax.grad(grad_f)
x = 2.0
print(f"f({x}) = {f(x)}") # 8.0
print(f"f'({x}) = {grad_f(x)}") # 3 * 2^2 = 12.0
print(f"f''({x}) = {grad_grad_f(x)}") # 6 * 2 = 12.0
You can also nest the calls directly. The same trick extends to third and higher orders, at the cost of more computation and possibly worse numerical stability:
# Nested form
second_derivative = jax.grad(jax.grad(f))
print("f''(2.0) =", second_derivative(2.0)) # 12.0
# Third derivative f⁽³⁾(x) = 6
third_derivative = jax.grad(jax.grad(jax.grad(f)))
print("f⁽³⁾(2.0) =", third_derivative(2.0)) # 6.0
Functions of several variables and the Hessian¶
For a function of several variables, all the second partial derivatives together form the Hessian matrix:
$$ H_{ij}=\frac{\partial^2 f}{\partial x_i\,\partial x_j} $$
Example: $f(x,y)=x^2y+y^3$.
$$ \frac{\partial f}{\partial x}=2xy,\quad \frac{\partial^2 f}{\partial x^2}=2y,\quad \frac{\partial^2 f}{\partial y\,\partial x}=2x $$
Differentiating with respect to $x$ twice (argnums=0) gives $\partial^2 f/\partial x^2$. Differentiating first with respect to $x$ and then switching to argnums=1 for $y$ gives the mixed partial. At $(2,3)$ they are $6$ and $4$:
def f_xy(x, y):
return x**2 * y + y**3
x, y = 2.0, 3.0
# ∂f/∂x
df_dx = jax.grad(f_xy, argnums=0)
# ∂²f/∂x²: differentiate with respect to x again
d2f_dx2 = jax.grad(df_dx, argnums=0)
print("∂²f/∂x² at (2,3) =", d2f_dx2(x, y)) # 2y = 6.0
# ∂²f/∂y∂x: differentiate ∂f/∂x with respect to y
d2f_dydx = jax.grad(df_dx, argnums=1)
print("∂²f/∂y∂x at (2,3) =", d2f_dydx(x, y)) # 2x = 4.0
For the full Hessian you can use jax.hessian directly, which is usually more efficient.
🔍 Extra: how to use jax.hessian and how it works¶
⚪ Optional: read this if you need second derivatives, Hessians or Hessian-vector products.
jax.hessian(f)is implemented asjax.jacfwd(jax.jacrev(f)), that is, forward-over-reverse. The innerjacrevcomputes the gradient (many inputs, one output, a good fit for reverse mode), and the outerjacfwdtakes the Jacobian of the gradient ($n$ inputs, $n$ outputs, where forward mode uses less memory).jax.hessiandifferentiates with respect to the first argument by default, so for a function of several variables you usually pack them into one vector. You can also useargnums=(0, 1), which returns a nested tuple((H_xx, H_xy), (H_yx, H_yy)).- A full Hessian has $n^2$ entries, which simply won't fit in memory when $n$ is large. Many algorithms (conjugate gradient, Newton-CG) only need Hessian-vector products $Hv$, which you can compute with
jax.jvp(jax.grad(f), (x,), (v,))without ever building the whole matrix (see Exercise C).
# Pack (x, y) into one vector
def f_vec(v):
x, y = v
return x**2 * y + y**3
v = jnp.array([2.0, 3.0])
H = jax.hessian(f_vec)(v)
print("Hessian =\n", H)
# Analytic result: [[2y, 2x], [2x, 6y]] = [[6, 4], [4, 18]]
assert jnp.allclose(H, jnp.array([[6.0, 4.0], [4.0, 18.0]]))
assert jnp.allclose(H, jax.jacfwd(jax.jacrev(f_vec))(v))
print("Assertion passed: matches the analytic result and jacfwd(jacrev(f))")
# Multi-argument form: argnums=(0, 1) returns a nested tuple
H_tuple = jax.hessian(f_xy, argnums=(0, 1))(2.0, 3.0)
print("Result with argnums=(0,1):", H_tuple)
3.6 Value and gradient (jax.value_and_grad)¶
When training a model you usually want both the gradient (to update the parameters) and the loss value (to monitor progress). If you call f(x) and jax.grad(f)(x) separately, the forward computation runs twice. But reverse mode already does a forward pass first, so the function value is computed in that step anyway.
jax.value_and_grad(f) returns a new function that, when called, returns a tuple (value, gradient) and runs the forward computation only once.
Example: $p(x)=x^3+2x^2-3x+1$, $p'(x)=3x^2+4x-3$. At $x=2$: $p(2)=8+8-6+1=11$, $p'(2)=12+8-3=17$.
import jax
import jax.numpy as jnp
def polynomial(x):
return x**3 + 2 * x**2 - 3 * x + 1
# Computed separately: the forward computation runs twice
value = polynomial(2.0)
gradient = jax.grad(polynomial)(2.0)
print(f"Separately: value = {value}, gradient = {gradient}")
# Get both at once with value_and_grad
value_and_grad_fn = jax.value_and_grad(polynomial)
value, gradient = value_and_grad_fn(2.0)
print(f"value_and_grad: value = {value}, gradient = {gradient}") # 11.0, 17.0
value_and_grad supports argnums too:
- When
argnumsis an integer, the returned gradient corresponds to that one argument; - When
argnumsis a tuple of integers, the returned gradient is also a tuple, with one element per chosen argument.
Example: $m(a,b)=a^2\sin b$, $\dfrac{\partial m}{\partial a}=2a\sin b$, $\dfrac{\partial m}{\partial b}=a^2\cos b$. With $a=3,\ b=\pi/2$: the value is $9$, the gradient with respect to $a$ is $6$, and the gradient with respect to $b$ is mathematically $0$.
⚠️ Note: the gradient with respect to $b$ does not print as
0. It prints about-3.93e-07(float32). Same reason as in 3.2: $\pi/2$ is rounded in float32, so check it withjnp.allclose.
def multi_arg_func(a, b):
return a**2 * jnp.sin(b)
a, b = 3.0, jnp.pi / 2
# Differentiate with respect to a only
value, grad_a = jax.value_and_grad(multi_arg_func, argnums=0)(a, b)
print(f"argnums=0 : value = {value}, ∂/∂a = {grad_a}") # 9.0, 6.0
# Differentiate with respect to both a and b
value, (grad_a, grad_b) = jax.value_and_grad(multi_arg_func, argnums=(0, 1))(a, b)
print(f"argnums=(0,1) : value = {value}, ∂/∂a = {grad_a}, ∂/∂b = {grad_b}")
assert jnp.allclose(value, 9.0) and jnp.allclose(grad_a, 6.0)
assert grad_b != 0.0 and jnp.allclose(grad_b, 0.0, atol=1e-5) # Not exactly 0, but 0 within precision
print("Assertion passed: value=9, ∂/∂a=6, ∂/∂b≈0")
Optimization algorithms like gradient descent need "loss + gradient" at every step, so value_and_grad is the standard way to write a training loop. It also composes with jax.jit (see the linear regression example in 3.9).
🔍 Extra: the return structure when combined with has_aux¶
🟢 Must-read
In real code the most common combination is jax.value_and_grad(loss_fn, has_aux=True), which returns ((loss, aux), grads). The value part is the (loss, aux) tuple exactly as the function returned it. The parentheses are easy to get wrong when unpacking:
def loss_with_acc(w, x, y):
logits = x * w
loss = jnp.mean((logits - y) ** 2)
acc = jnp.mean(jnp.sign(logits) == jnp.sign(y)) # Auxiliary info: sign-prediction accuracy
return loss, acc
x = jnp.array([1.0, -2.0, 3.0])
y = jnp.array([2.0, -4.0, -1.0])
(loss, acc), g = jax.value_and_grad(loss_with_acc, has_aux=True)(1.0, x, y)
print(f"loss = {loss:.4f}, acc = {acc:.3f}, grad = {g:.4f}")
# Analytic gradient: mean(2 (w x - y) x)
assert jnp.allclose(g, jnp.mean(2 * (1.0 * x - y) * x))
3.7 Differentiation and control flow¶
What grad differentiates is the path the function actually takes for the current input. To understand this, the key is to tell apart two kinds of tracing:
jax.grad on its own | jax.jit (or jit(grad(f))) | |
|---|---|---|
| Tracer | Carries a concrete value (prints as GradTracer(primal=...)) | An abstract value with only shape and dtype |
| When tracing happens | Retraced on every call | Traced and compiled once per input shape/dtype |
Python if x > 0 | Works; picks the branch from the actual value of x | Raises TracerBoolConversionError |
Python for/while (count depends on values) | Works | Raises an error |
In real training, grad almost always sits inside jit, so value-dependent control flow has to use the structured control-flow primitives jax.lax.cond, jnp.where, jax.lax.fori_loop and jax.lax.scan.
⚠️ Note: it's easy to believe that "JAX traces only once per input shape and type", so
gradcan only differentiate the one branch taken during tracing, and that a Pythonifwill raise "the truth value of an abstract tracer is ambiguous" atx=-2.0. In fact, both "trace only once" and that error arejitbehaviors. Withgradalone, a Pythonifworks fine (x=-2.0returns-1.0); onlyjit(grad(f))(-2.0)raisesTracerBoolConversionError.
Conditionals¶
The course's example: return $x^2$ when $x>0$, otherwise return $-x$.
import jax
import jax.numpy as jnp
# ❌ Common misconception: expecting the call at x=-2.0 below to fail with "ambiguous tracer truth value"
def conditional_func(x):
if x > 0:
return x * x
else:
return -x
grad_conditional = jax.grad(conditional_func)
try:
g_pos, g_neg = grad_conditional(2.0), grad_conditional(-2.0)
print("Measured: no error. grad at x=2.0 :", g_pos, "| x=-2.0 :", g_neg)
except Exception as e:
g_pos = g_neg = None
print("Error:", type(e).__name__, e)
# Assert: with grad alone, Python if works and both branches give correct gradients
assert g_pos == 4.0 and g_neg == -1.0
# Why: with grad alone, the tracer carries a concrete value
seen = []
def peek(x):
print(" x as seen inside grad:", x)
seen.append(repr(x))
return x * x
jax.grad(peek)(2.0)
assert "primal=2.0" in seen[0] # grad alone: the tracer carries the concrete value 2.0
# What really raises is jit: jit's tracers are abstract values
try:
jax.jit(grad_conditional)(-2.0)
jit_if_raised = False
except jax.errors.TracerBoolConversionError as e:
jit_if_raised = True
print("\njit(grad) raises: TracerBoolConversionError")
print(" ", str(e).splitlines()[0])
assert jit_if_raised, "A Python if on a traced value under jit should raise"
print("Assertion passed: grad alone doesn't raise; wrapping it in jit does")
Rewriting with jax.lax.cond (works under jit too)¶
jax.lax.cond(pred, true_fun, false_fun, *operands) uses pred to decide whether to run true_fun(*operands) or false_fun(*operands). Both branches are traced and the choice is made at run time, so it works with both grad and jit. The two branches must return results with the same shape and dtype. Operands can be passed as several positional arguments (*operands).
For an elementwise choice between two values you can also use jnp.where(pred, a, b), but watch out for its NaN-gradient trap (see the special topic in 3.8).
# ✅ Recommended: lax.cond, correct under both grad and jit(grad)
def conditional_func_lax(x):
return jax.lax.cond(
x > 0, # condition
lambda x: x * x, # runs when the condition is true
lambda x: -x, # runs when the condition is false
x, # operand
)
grad_cond_lax = jax.grad(conditional_func_lax)
grad_cond_lax_jit = jax.jit(grad_cond_lax)
for x in [2.0, -2.0]:
print(f"x={x:5}: grad = {grad_cond_lax(x)}, jit(grad) = {grad_cond_lax_jit(x)}")
assert grad_cond_lax(2.0) == 4.0 and grad_cond_lax(-2.0) == -1.0
assert grad_cond_lax_jit(2.0) == 4.0 and grad_cond_lax_jit(-2.0) == -1.0
print("Assertion passed")
Loops¶
Static loops: if the number of iterations of a for loop is known at trace time (say, a Python constant), the loop is unrolled into a sequence of ordinary operations, and differentiation works fine. For example, looping 3 times and multiplying by 2 each time gives $y=8x$, whose gradient is $8$. The downside is that with many iterations the unrolled graph gets huge and compilation slows down.
def fixed_loop(x, n=3):
y = x
for _ in range(n): # n is a Python int known at trace time, so the loop is unrolled
y = y * 2
return y
print("fixed_loop gradient:", jax.grad(fixed_loop)(1.0)) # 8.0
assert jax.jit(jax.grad(fixed_loop))(1.0) == 8.0
Dynamic loops: if the iteration count or the while condition depends on a traced value, a plain Python loop won't run under jit, and you need the structured control-flow primitives instead. But these primitives differ in how well they support reverse-mode differentiation (grad/vjp):
| Primitive | Forward mode (jvp/jacfwd) | Reverse mode (grad/vjp) |
|---|---|---|
lax.scan(f, init, xs, length) | ✅ | ✅ |
lax.fori_loop(lo, hi, body, init) with concrete integer lo/hi | ✅ | ✅ (converted to scan internally) |
lax.fori_loop with traced lo/hi (e.g. a normal argument under jit) | ✅ | ❌ (falls back to while_loop) |
lax.while_loop(cond, body, init) | ✅ | ❌ |
Reverse mode has to store the intermediate values of every step, and the number of steps of a while_loop isn't known at compile time, so there is no way to allocate that space ahead of time. That's why it isn't supported. For loops you need to differentiate in reverse mode, use lax.scan (fixed count), or declare the count as a static argument (jax.jit(..., static_argnums=...)).
⚠️ Note: neither
jax.lax.while_loopnor afori_loopwith traced bounds can be used for loops that need reverse-mode differentiation. Runninggradthrough them raisesValueError: Reverse-mode differentiation does not work for lax.while_loop ...; they only support forward mode. Thefori_loopexample below works only becausen=3is a concrete integer whengradis used on its own.
body_fun(i, val) returns the new val:
def dynamic_loop_lax(x, n):
# body_fun(i, val) -> new val
return jax.lax.fori_loop(0, n, lambda i, val: val * 2, x)
# grad alone: n=3 is a concrete integer, so fori_loop becomes a scan and reverse mode works
print("fori_loop gradient (n=3):", jax.grad(dynamic_loop_lax)(1.0, 3)) # 8.0
assert jax.grad(dynamic_loop_lax)(1.0, 3) == 8.0
# ❌ Common misconception: thinking while_loop / a dynamic fori_loop can implement a reverse-differentiable dynamic loop
def double_until(x):
# State (value, counter), loops 3 times
return jax.lax.while_loop(lambda c: c[1] < 3, lambda c: (c[0] * 2, c[1] + 1), (x, 0))[0]
def raises_reverse_mode_error(fn, *args):
try:
fn(*args)
return False
except ValueError as e:
print(" Error:", str(e).splitlines()[0])
return True
print("grad through while_loop:")
assert raises_reverse_mode_error(jax.grad(double_until), 1.0)
print("fori_loop under jit with traced n:")
assert raises_reverse_mode_error(jax.jit(jax.grad(dynamic_loop_lax)), 1.0, 3)
# while_loop supports forward mode
primal, tangent = jax.jvp(double_until, (1.0,), (1.0,))
print("while_loop + jvp:", primal, tangent)
assert tangent == 8.0
# ✅ Recommended option 1: declare the loop count as a static argument
grad_static = jax.jit(jax.grad(dynamic_loop_lax), static_argnums=1)
print("static_argnums:", grad_static(1.0, 3))
assert grad_static(1.0, 3) == 8.0
# ✅ Recommended option 2: write a fixed-count loop with lax.scan
def double_scan(x, n=3):
y, _ = jax.lax.scan(lambda c, _: (c * 2, None), x, None, length=n)
return y
print("scan + jit(grad):", jax.jit(jax.grad(double_scan))(1.0))
assert jax.jit(jax.grad(double_scan))(1.0) == 8.0
print("Assertion passed")
Two final reminders. First, if you don't use these primitives inside jit, you'll get an error at trace time. Second, even without jit, grad only differentiates the path taken for the current input, so gradients near a branch boundary can be misleading.
3.8 Limitations and caveats¶
1. Non-differentiable operations¶
Automatic differentiation relies on the chain rule, which needs every elementary operation to be differentiable. The following operations are either not differentiable in the mathematical sense, or have a derivative that is zero almost everywhere:
- Discontinuous functions:
jnp.sign,jnp.round,jnp.floor,jnp.ceiland so on. JAX defines their derivative as 0; - Casting to integer:
x.astype(jnp.int32)is a step function, so the gradient gets cut to 0 there (if the function's output itself is an integer,gradraises aTypeErrorright away); - Comparisons: operations like
x > 0produce booleans, which are constant near the input, so the gradient is zero.
At kinks (points where the derivative doesn't exist), JAX assigns each primitive a fixed value that doesn't vary by platform:
| Expression | Gradient at 0 (measured) | Explanation |
|---|---|---|
jnp.abs | 1.0 | Takes the right derivative |
jax.nn.relu | 0.0 | Takes the left derivative |
jnp.sign | 0.0 | Derivative defined as 0 everywhere |
jnp.sqrt | inf | $\frac{1}{2\sqrt x}$ blows up at 0 |
jnp.linalg.norm (zero vector) | nan | Internally $\frac{x}{\lVert x\rVert}=\frac{0}{0}$ |
Real NaNs mostly come from points like the last two rows, where "the derivative is infinite or 0/0" (inf times 0 also becomes NaN). The special topic below covers this in detail. If you want useful gradients at discontinuities, you have a few options: replace the function with a smooth one (for example, approximate a step with jax.nn.sigmoid(k*x)); use a straight-through estimator (see stop_gradient below); or use an optimization method other than gradient descent.
⚠️ Note:
jax.grad(jnp.abs)(0.0)is1.0, not 0.0.⚠️ Note:
jax.grad(jnp.sign)(0.0)is a fixed0.0(the derivative ofsignis defined as 0 everywhere). It is never NaN and doesn't vary by platform.
import jax
import jax.numpy as jnp
import numpy as np
# Course example: cast to integer, then square
def cast_and_square(x):
x_int = x.astype(jnp.int32) # Step function: information is truncated
return x_int.astype(jnp.float32) ** 2
g_cast = jax.grad(cast_and_square)(2.7)
print("Gradient of cast_and_square(2.7):", g_cast)
assert g_cast == 0.0
# Other discontinuous functions and comparisons: gradient is 0
for name, fn in [("sign", jnp.sign), ("round", jnp.round), ("floor", jnp.floor), ("ceil", jnp.ceil),
("(x>1).astype(float)", lambda x: (x > 1).astype(jnp.float32))]:
g = jax.grad(fn)(2.7)
print(f"grad({name})(2.7) = {g}")
assert g == 0.0
# When the output itself is an integer, grad raises right away
try:
jax.grad(lambda x: x.astype(jnp.int32))(2.7)
except TypeError as e:
print("\nInteger output raises:", str(e).splitlines()[0])
# ❌ Common misconception: grad(abs)(0.0) is usually 0.0, and grad(sign)(0.0) is often NaN or platform-dependent
course_claim = {"grad(abs)(0.0)": 0.0, "grad(sign)(0.0)": "NaN or platform-dependent"}
measured = {"grad(abs)(0.0)": jax.grad(jnp.abs)(0.0), "grad(sign)(0.0)": jax.grad(jnp.sign)(0.0)}
for k in course_claim:
print(f"{k:16s} common belief: {course_claim[k]!s:18s} actual: {measured[k]}")
# ✅ Correct: the values at kinks are fixed
assert jax.grad(jnp.abs)(0.0) == 1.0
assert jax.grad(jnp.sign)(0.0) == 0.0
assert jax.grad(jax.nn.relu)(0.0) == 0.0
assert jnp.isinf(jax.grad(jnp.sqrt)(0.0))
assert jnp.all(jnp.isnan(jax.grad(jnp.linalg.norm)(jnp.zeros(3))))
print("Assertion passed: abs→1.0, sign→0.0, relu→0.0, sqrt→inf, norm(0)→nan")
# Smooth approximation: approximate a step with sigmoid(k x); the gradient is no longer zero
k = 5.0
g_smooth = jax.grad(lambda x: jax.nn.sigmoid(k * x))(0.1)
print("grad(sigmoid(k x))(0.1) =", g_smooth)
assert g_smooth > 0
🔍 Extra (special topic): NaN gradients and the "double where" trick¶
🟢 Must-read
The symptom¶
To avoid dangerous points like sqrt(0) or log(0), many people write jnp.where(x > 0, jnp.log(x), 0.0) and expect 0.0 to be picked when $x\le0$. The forward result is indeed correct, but the gradient is still NaN.
Why: where can't block $0\times\infty$ in the backward pass¶
Let $y=\text{where}(c,\ a(x),\ b(x))$. jnp.where first computes both branches, then picks elementwise. In the backward pass, the upstream gradient $\bar y$ is split between the two branches according to the condition:
$$ \bar a = \text{where}(c,\ \bar y,\ 0),\qquad \bar b = \text{where}(c,\ 0,\ \bar y) $$
Then each branch keeps propagating back by the chain rule: $\bar x = \bar a\cdot a'(x) + \bar b\cdot b'(x)$.
When $c$ is false, $\bar a=0$. If $a'(x)$ is finite there, $0\times a'(x)=0$ and all is well. But if the dangerous branch has derivative $\pm\infty$ at that point (for example $\log'(0)=\frac10$) or NaN, you get
$$ 0\times\infty=\text{NaN}\quad(\text{per IEEE 754}) $$
NaN plus anything is still NaN, so $\bar x$ becomes NaN too. In other words, where only picks the forward value; it can't block the "$0\times\infty$" that the unselected branch brings in during the backward pass.
The "double where" trick¶
The root cause is that the dangerous branch was evaluated on the dangerous input. The fix: use a first where to replace the dangerous input with a safe value (any value that keeps the dangerous branch's derivative finite), then use a second where to pick the output:
x_safe = jnp.where(cond, x, SAFE) # 1st where: the dangerous branch never sees the dangerous input
y = jnp.where(cond, danger_fn(x_safe), fallback) # 2nd where: pick the output
Now the unselected branch's derivative $a'(\text{SAFE})$ is finite, and $0\times\text{finite}=0$. And when $c$ is false, the first where routes the gradient to the constant SAFE, so nothing flows back to $x$.
Common cases¶
| Case | Dangerous point | ❌ Gives a NaN gradient | ✅ Double-where version |
|---|---|---|---|
sqrt | $x=0$ | where(x>0, sqrt(x), 0.) | xs=where(x>0, x, 1.); where(x>0, sqrt(xs), 0.) |
log | $x=0$ | where(x>0, log(x), 0.) | xs=where(x>0, x, 1.); where(x>0, log(xs), 0.) |
| Vector norm | $\mathbf v=\mathbf 0$ | where(sum(v**2)==0, 0., norm(v)) | s=sum(v**2); ss=where(s==0, 1., s); where(s==0, 0., sqrt(ss)) |
| Division | Denominator $y=0$ | where(y!=0, a/y, 0.) | ys=where(y!=0, y, 1.); where(y!=0, a/ys, 0.) |
arcsin | $\lvert x\rvert=1$ | where(abs(x)<1, arcsin(x), sign(x)*π/2) | xs=where(abs(x)<1, x, 0.); where(abs(x)<1, arcsin(xs), sign(x)*π/2) |
First, take the smallest possible example apart by hand and watch $0\times\infty$ happen:
# Unpack the backward pass of where(x>0, log(x), 0.) at x=0 by hand
x = jnp.float32(0.0)
y_bar = 1.0 # Upstream gradient
a_bar = jnp.where(x > 0, y_bar, 0.0) # Gradient routed to the log branch: 0
dlog_dx = 1.0 / x # log'(0) = inf
print("ā =", a_bar, "| log'(0) =", dlog_dx, "| ā × log'(0) =", a_bar * dlog_dx)
assert jnp.isnan(a_bar * dlog_dx) # 0 × inf = nan
# ❌ Wrong (single where): the forward value is right, but the gradient is NaN
naive = {
"sqrt": (lambda x: jnp.where(x > 0, jnp.sqrt(x), 0.0), 0.0),
"log": (lambda x: jnp.where(x > 0, jnp.log(x), 0.0), 0.0),
"norm": (lambda v: jnp.where(jnp.sum(v**2) == 0, 0.0, jnp.linalg.norm(v)), jnp.zeros(3)),
"div": (lambda y: jnp.where(y != 0, 2.0 / y, 0.0), 0.0),
"arcsin": (lambda x: jnp.where(jnp.abs(x) < 1, jnp.arcsin(x), jnp.sign(x) * jnp.pi / 2), 1.0),
}
for name, (fn, x_bad) in naive.items():
val, g = jax.value_and_grad(fn)(x_bad)
print(f"{name:6s} at dangerous point: value = {val:.4f}, gradient = {g}")
assert jnp.all(jnp.isfinite(val)) and jnp.any(jnp.isnan(g)) # finite value, NaN gradient
# ✅ Recommended (double where): the dangerous branch never sees the dangerous input
def safe_sqrt(x):
xs = jnp.where(x > 0, x, 1.0)
return jnp.where(x > 0, jnp.sqrt(xs), 0.0)
def safe_log(x):
xs = jnp.where(x > 0, x, 1.0)
return jnp.where(x > 0, jnp.log(xs), 0.0)
def safe_norm(v):
s = jnp.sum(v**2)
ss = jnp.where(s == 0, 1.0, s)
return jnp.where(s == 0, 0.0, jnp.sqrt(ss))
def safe_div(y, a=2.0):
ys = jnp.where(y != 0, y, 1.0)
return jnp.where(y != 0, a / ys, 0.0)
def safe_arcsin(x):
inside = jnp.abs(x) < 1
xs = jnp.where(inside, x, 0.0)
return jnp.where(inside, jnp.arcsin(xs), jnp.sign(x) * jnp.pi / 2)
safe = {
# name: (function, dangerous point, normal point, analytic gradient at the normal point)
"sqrt": (safe_sqrt, 0.0, 4.0, 0.25),
"log": (safe_log, 0.0, 2.0, 0.5),
"norm": (safe_norm, jnp.zeros(3), jnp.array([3., 4., 0.]), jnp.array([0.6, 0.8, 0.0])),
"div": (safe_div, 0.0, 4.0, -2.0 / 16),
"arcsin": (safe_arcsin, 1.0, 0.5, 1 / jnp.sqrt(1 - 0.25)),
}
for name, (fn, x_bad, x_ok, g_expected) in safe.items():
g_bad = jax.grad(fn)(x_bad)
g_ok = jax.grad(fn)(x_ok)
print(f"{name:6s} gradient at dangerous point = {g_bad} | at normal point = {g_ok}")
assert jnp.all(jnp.isfinite(g_bad)), name # gradient at the dangerous point is finite
assert jnp.allclose(g_ok, g_expected, atol=1e-6), name # gradient at normal points is unaffected
assert jnp.allclose(fn(x_ok), naive[name][0](x_ok)) # forward value matches the naive version
print("Assertion passed: all 5 cases have finite gradients at the dangerous point and correct gradients elsewhere")
Practical tips
- The safe value has to keep the dangerous branch's derivative finite; a finite value alone isn't enough. Use
1.0forsqrt/log/division and0.0forarcsin. - If the masked-out branch has a finite derivative at the dangerous point, a single
whereis enough. For example,where(x>0, x**2, 0.)has gradient 0 at 0, with no NaN. - When NaNs show up during training, you can turn on
jax.config.update("jax_debug_nans", True)so JAX raises at the first primitive that produces a NaN, which pinpoints the problem quickly. It does miss some cases in this environment, though (for example after usingvmap); see Advanced 08 §3.1 for usage and known limitations. - For a fixed mathematical function, you can also supply a stable derivative directly with
jax.custom_jvp(see below).
2. Numerical stability¶
- Vanishing / exploding gradients: in deep networks the chain rule multiplies many terms together, so gradients can shrink toward 0 or blow up.
- For example, $\frac{d}{dx}\log x=\frac1x$ is $10^{20}$ at $x=10^{-20}$, which is huge.
Ways to mitigate this:
- Gradient clipping;
- Use numerically stable implementations, e.g.
jax.scipy.special.logsumexp(x)instead ofjnp.log(jnp.sum(jnp.exp(x))); - Turn on 64-bit precision when needed:
jax.config.update("jax_enable_x64", True)(set at the start of the program, before creating any arrays).
from jax.scipy.special import logsumexp
g_log = jax.grad(jnp.log)(1e-20)
print("grad(log)(1e-20) =", g_log)
assert jnp.allclose(g_log, 1e20, rtol=1e-5)
# A naive log-sum-exp overflows, and its gradient turns into nan as well
def naive_lse(x):
return jnp.log(jnp.sum(jnp.exp(x)))
x_big = jnp.array([1000.0, 1000.0])
print("\nnaive value:", naive_lse(x_big), "| gradient:", jax.grad(naive_lse)(x_big))
print("logsumexp value:", logsumexp(x_big), "| gradient:", jax.grad(logsumexp)(x_big))
assert jnp.all(jnp.isnan(jax.grad(naive_lse)(x_big)))
assert jnp.allclose(jax.grad(logsumexp)(x_big), 0.5)
🔍 Extra: custom derivatives with jax.custom_jvp¶
⚪ Optional: read this if automatic differentiation of some function is numerically unstable and you want to write the derivative rule yourself.
For some functions, the derivative you get by composing elementary operations is numerically unstable, even though the derivative itself has a simple, stable closed form. In that case you can use jax.custom_jvp to tell JAX directly how to compute the derivative. The classic example from the official docs is $\log(1+e^x)$ (softplus). Its derivative is $\sigma(x)=\frac{e^x}{1+e^x}$, but plain autodiff computes $\frac{\infty}{\infty}=\text{NaN}$ when $x$ is large.
You only define the JVP rule (forward mode), and JAX derives reverse mode automatically, so grad, jacrev and hessian all work. (There is also jax.custom_vjp, which lets you define the backward rule directly, for things like gradient clipping or saving memory.)
def log1pexp_naive(x):
return jnp.log1p(jnp.exp(x))
print("naive grad(100.0) =", jax.grad(log1pexp_naive)(100.0)) # nan
assert jnp.isnan(jax.grad(log1pexp_naive)(100.0))
@jax.custom_jvp
def log1pexp(x):
return jnp.log1p(jnp.exp(x))
@log1pexp.defjvp
def log1pexp_jvp(primals, tangents):
(x,), (x_dot,) = primals, tangents
y = log1pexp(x)
# Stable form of the derivative: sigmoid(x)
return y, x_dot * jax.nn.sigmoid(x)
print("custom_jvp grad(100.0) =", jax.grad(log1pexp)(100.0)) # 1.0
print("custom_jvp grad(0.0) =", jax.grad(log1pexp)(0.0)) # 0.5
print("second derivative works too (0.0) =", jax.grad(jax.grad(log1pexp))(0.0)) # σ(0)(1-σ(0)) = 0.25
assert jax.grad(log1pexp)(100.0) == 1.0
assert jnp.allclose(jax.grad(jax.grad(log1pexp))(0.0), 0.25)
🔍 Extra: jax.lax.stop_gradient to keep part of a computation out of differentiation¶
⚪ Optional: read this if you need to cut off part of the gradient (target networks, straight-through estimators, etc.).
stop_gradient(x) returns x unchanged in the forward computation, but treats it as a constant (gradient 0) when differentiating. Typical uses: target networks in reinforcement learning, the stop-grad branch in contrastive learning, straight-through estimators, and so on.
from jax.lax import stop_gradient
def f_normal(x):
return x * x # derivative 2x
def f_stopped(x):
return x * stop_gradient(x) # The second x is treated as a constant, derivative = x
print("grad(x*x)(3.0) =", jax.grad(f_normal)(3.0)) # 6.0
print("grad(x*stop_gradient(x))(3.0) =", jax.grad(f_stopped)(3.0)) # 3.0
assert jax.grad(f_normal)(3.0) == 6.0 and jax.grad(f_stopped)(3.0) == 3.0
# A handy trick: the straight-through estimator
# Use round (not differentiable) in the forward pass; pretend it's the identity (gradient 1) in the backward pass
def ste_round(x):
return x + stop_gradient(jnp.round(x) - x)
print("\nste_round(2.7) =", ste_round(2.7), "| gradient =", jax.grad(ste_round)(2.7))
assert ste_round(2.7) == 3.0 and jax.grad(ste_round)(2.7) == 1.0
3. Interaction with Python control flow¶
grad only differentiates the path the current input actually takes. If the function switches branches for different inputs, you only get the gradient of the current branch, which can be misleading near a branch boundary. With grad alone a Python if works fine, but under jit you need lax.cond / jnp.where instead (see 3.7).
4. External code and side effects¶
Inside a function you differentiate, use only JAX functions such as jnp, jax.lax, jax.scipy and jax.nn. External libraries behave in one of three ways under grad:
- NumPy computations that don't depend on the input (like a constant array generated with
np.random): they really are constants, and no gradient flows through them. - Passing a tracer to a NumPy function (like
np.sin(x)): raisesTracerArrayConversionErrorright away. Libraries like SciPy (notjax.scipy), Pandas and scikit-learn convert inputs to NumPy arrays internally, so they usually raise too. - A few NumPy functions (like
np.sum) end up callingx.sum()through duck typing, so they happen to be differentiable. That relies on implementation details and isn't reliable; switch tojnpeverywhere anyway.
Side effects such as printing, reading or writing files, and modifying globals break JAX's functional assumptions. Inside grad, print prints the tracer; inside jit, it runs only once, at trace time. To debug values, use jax.debug.print.
⚠️ Note: a NumPy call inside a differentiated function (like
np.sum) is not silently treated as a constant with a zero gradient.np.sin(tracer)raisesTracerArrayConversionErrorright away, whilenp.sumhappens to differentiate correctly. Only NumPy constants that don't depend on the input are truly constants.
# ❌ Common misconception: calling NumPy inside a differentiated function is treated as a constant with zero gradient
x = jnp.array([1.0, 2.0, 3.0])
try:
g_np = jax.grad(lambda x: np.sin(x).sum())(x)
np_raised = False
print("Gradient of the np.sin version:", g_np)
except jax.errors.TracerArrayConversionError as e:
np_raised = True
print("np.sin applied to a tracer -> TracerArrayConversionError")
assert np_raised, "A NumPy function applied to a tracer should raise"
# np.sum happens to call x.sum(), so the gradient is not 0
g_npsum = jax.grad(lambda x: np.sum(x**2))(x)
print("Gradient of the np.sum version:", g_npsum)
assert jnp.allclose(g_npsum, 2 * x) # Not 0, but the correct 2x
# ✅ Recommended: use jnp everywhere in differentiated functions; use NumPy only for constants that don't depend on the input
noise = np.random.default_rng(0).random(3) # NumPy constant
g_jnp = jax.grad(lambda x: jnp.sum(jnp.sin(x) * noise))(x)
print("Gradient of the jnp version:", g_jnp)
assert jnp.allclose(g_jnp, jnp.cos(x) * noise)
# Debugging values: jax.debug.print runs every time, even under jit
@jax.jit
def debug_loss(x):
jax.debug.print("debug_loss received x = {}", x)
return jnp.sum(x**2)
_ = jax.grad(debug_loss)(x)
5. Non-scalar outputs¶
jax.grad only works for $f:\mathbb R^n\to\mathbb R$ (scalar output). If the function returns an array, it raises a TypeError. For vector-valued functions, use jax.jacfwd or jax.jacrev (for the full Jacobian), or jax.jvp and jax.vjp (for products of the Jacobian with a vector); see 3.3.
def vector_output(x):
return x**2 # The output is a vector, not a scalar
x = jnp.array([1.0, 2.0, 3.0])
try:
jax.grad(vector_output)(x)
vec_raised = False
except TypeError as e:
vec_raised = True
print("TypeError:", str(e).splitlines()[0])
assert vec_raised
# Alternatives
J = jax.jacrev(vector_output)(x)
print("\njacrev gives the full Jacobian (diagonal matrix 2x):\n", J)
assert jnp.allclose(J, jnp.diag(2 * x))
# If you only need "the derivative at each element" of an elementwise function, sum the output and then take grad
print("grad(sum(f)) =", jax.grad(lambda x: jnp.sum(vector_output(x)))(x))
🔍 Extra: checking gradients with finite differences¶
⚪ Optional: read this if you wrote a custom derivative or a complex model and need to verify the gradients.
When you've written a custom_jvp, hand-written a backward rule, or built a fairly complex model, it's a good idea to check the gradients against numerical differentiation.
- Official tool:
jax.test_util.check_grads(f, args, order, modes)uses central differences to check both forward (fwd) and reverse (rev) mode;order=2also checks second derivatives. Note that you have toimport jax.test_utilexplicitly. With onlyimport jax, accessingjax.test_utilraisesAttributeError. - By hand: central differences $\dfrac{f(x+h e_i)-f(x-h e_i)}{2h}$. The error is fairly large in float32, so use $h$ around $10^{-3}$ and loosen the tolerance accordingly.
import jax.test_util # Must be imported explicitly
from jax.test_util import check_grads
def g(x):
return jnp.sum(jnp.sin(x) * x**2)
x = jnp.array([0.5, 1.0, 2.0])
# Official tool: silent on success, raises AssertionError on failure
check_grads(g, (x,), order=2, modes=["fwd", "rev"])
print("check_grads: first- and second-order gradient checks of g passed")
# Deliberately wrong derivative (an extra factor of 2); check_grads catches it
@jax.custom_jvp
def wrong_sin(x):
return jnp.sin(x)
@wrong_sin.defjvp
def wrong_sin_jvp(primals, tangents):
(x,), (t,) = primals, tangents
return jnp.sin(x), 2 * jnp.cos(x) * t # Wrong: should be cos(x) * t
try:
check_grads(wrong_sin, (x,), order=1, modes=["rev"])
caught = False
except AssertionError as e:
caught = True
print("check_grads found a wrong gradient:", str(e).strip().splitlines()[0])
assert caught
# Hand-written central differences
def numerical_grad(f, x, h=1e-3):
grads = []
for i in range(x.size):
e = jnp.zeros_like(x).at[i].set(h)
grads.append((f(x + e) - f(x - e)) / (2 * h))
return jnp.array(grads)
print("\nhand-written central diff:", numerical_grad(g, x))
print("jax.grad :", jax.grad(g)(x))
assert jnp.allclose(numerical_grad(g, x), jax.grad(g)(x), rtol=1e-3)
3.9 Hands-on: computing gradients¶
This section uses a few exercises to practice jax.grad: starting from simple functions, then differentiating with respect to chosen arguments, higher-order derivatives, value_and_grad, and gradients of jax.numpy functions.
Setup: import jax, jax.numpy and numpy, turn on 64-bit precision if needed, then print the JAX version and the default backend.
Setup: how to turn on 64-bit precision¶
⚠️ Note: older material often does
from jax.config import configfollowed byconfig.update("jax_enable_x64", True). JAX 0.12 has removed thejax.configsubmodule, so that raisesModuleNotFoundError: No module named 'jax.config'. Usejax.config.update("jax_enable_x64", True)instead, and put it at the start of the program, before creating any arrays (or use the environment variableJAX_ENABLE_X64=1).
To keep JAX's default float32 behavior for the rest of this chapter, the cells below only show the syntax and don't actually turn on 64-bit.
# ❌ Old style (outdated): the jax.config submodule was removed in JAX 0.12
try:
from jax.config import config
config.update("jax_enable_x64", True)
old_import_ok = True
except ModuleNotFoundError as e:
old_import_ok = False
print("Measured: raises ModuleNotFoundError:", e)
assert not old_import_ok
# ✅ Recommended
import jax
import jax.numpy as jnp
import numpy as np
# jax.config.update("jax_enable_x64", True) # Uncomment when you need 64-bit, and put it at the very start of the program
print("Current jax_enable_x64 =", jax.config.jax_enable_x64)
assert hasattr(jax.config, "update")
print(f"JAX version: {jax.__version__}")
print(f"Default backend: {jax.default_backend()}")
Exercise 1: gradient of a simple polynomial¶
$f(x)=3x^2+2x+5$, with analytic derivative $f'(x)=6x+2$. At $x=4$: $f'(4)=6\times4+2=26$.
def poly_func(x):
return 3 * x**2 + 2 * x + 5
# Get the gradient function
grad_poly = jax.grad(poly_func)
x_val = 4.0
gradient_val = grad_poly(x_val)
analytical = 6 * x_val + 2
print(f"f({x_val}) = {poly_func(x_val)}")
print(f"JAX gradient: {gradient_val}")
print(f"Analytic gradient: {analytical}")
assert gradient_val == analytical == 26.0
Exercise 2: differentiating with respect to specific arguments¶
$g(x,y)=x^3y+2x^2$, with partial derivatives:
$$ \frac{\partial g}{\partial x}=3x^2y+4x,\qquad \frac{\partial g}{\partial y}=x^3 $$
With $x=2,\ y=3$: $\partial g/\partial x=3\times4\times3+8=44$, $\partial g/\partial y=8$.
def multi_var_func(x, y):
return x**3 * y + 2 * x**2
x_val, y_val = 2.0, 3.0
# By default, differentiate with respect to the first argument x (argnums=0)
grad_x = jax.grad(multi_var_func)(x_val, y_val)
print(f"∂g/∂x: {grad_x} (analytic: {3 * x_val**2 * y_val + 4 * x_val})")
# argnums=1 differentiates with respect to y
grad_y = jax.grad(multi_var_func, argnums=1)(x_val, y_val)
print(f"∂g/∂y: {grad_y} (analytic: {x_val**3})")
# argnums=(0, 1) returns a tuple of two gradients
grad_xy = jax.grad(multi_var_func, argnums=(0, 1))(x_val, y_val)
print(f"(∂g/∂x, ∂g/∂y): {grad_xy}")
assert grad_xy == (44.0, 8.0)
Exercise 3: higher-order derivatives¶
For $f(x)=3x^2+2x+5$ from Exercise 1, the second derivative is $f''(x)=6$, independent of $x$. Just apply jax.grad twice.
second_grad_poly = jax.grad(jax.grad(poly_func))
for x_val in [4.0, -1.0, 100.0]:
print(f"f''({x_val}) = {second_grad_poly(x_val)}") # always 6.0
assert second_grad_poly(x_val) == 6.0
Exercise 4: jax.value_and_grad¶
$h(w)=(w-5)^2$, $h'(w)=2(w-5)$. At $w=2$: $h(2)=9$, $h'(2)=-6$.
def simple_loss(w):
return (w - 5.0) ** 2
value, grad = jax.value_and_grad(simple_loss)(2.0)
print(f"Value : {value}") # 9.0
print(f"Gradient: {grad}") # -6.0
assert (value, grad) == (9.0, -6.0)
Exercise 5: gradients of jax.numpy functions¶
$k(\mathbf v)=\sum_i\sin(v_i)$. The $j$-th component of its gradient is $\dfrac{\partial k}{\partial v_j}=\cos(v_j)$. With $\mathbf v=[0,\ \pi/2,\ \pi]$, the gradient is mathematically $[1,\ 0,\ -1]$.
⚠️ Note: the printed result is not exactly
[1. 0. -1.]; the middle entry is about-4.37e-08(float32). Same reason as in 3.2: $\pi/2$ is rounded in float32, so check it withjnp.allclose.
def sum_sin(v):
return jnp.sum(jnp.sin(v))
v = jnp.array([0.0, jnp.pi / 2, jnp.pi])
grad_v = jax.grad(sum_sin)(v)
print("JAX gradient :", grad_v)
print("jnp.cos(v) :", jnp.cos(v))
print("Matches cos(v):", jnp.allclose(grad_v, jnp.cos(v)))
assert jnp.allclose(grad_v, jnp.array([1.0, 0.0, -1.0]), atol=1e-6)
To sum up: these exercises covered a simple polynomial, differentiating with respect to several arguments, higher-order derivatives, getting the value and gradient together, and gradients of jax.numpy functions.
🔍 Extra: full gradient descent for linear regression¶
⚪ Optional: read this if you want to see this chapter's APIs strung together into a complete training loop.
Putting the chapter together: generate data with jax.random.key, keep the parameters in a dict, write the training step with jax.jit(jax.value_and_grad(...)), and update the parameters with jax.tree.map. At the end, compare with the closed-form least-squares solution.
# 1) Generate data: y = 3x + 2 + noise
key = jax.random.key(42)
key_x, key_noise = jax.random.split(key)
n_samples = 200
X = jax.random.uniform(key_x, (n_samples,), minval=-1.0, maxval=1.0)
y = 3.0 * X + 2.0 + 0.1 * jax.random.normal(key_noise, (n_samples,))
# 2) Model and loss
def predict(params, x):
return params["w"] * x + params["b"]
def mse_loss(params, x, y):
return jnp.mean((predict(params, x) - y) ** 2)
# 3) Training step: value_and_grad + jit
@jax.jit
def train_step(params, x, y, lr):
loss, grads = jax.value_and_grad(mse_loss)(params, x, y)
new_params = jax.tree.map(lambda p, g: p - lr * g, params, grads)
return new_params, loss
# 4) Training loop
params = {"w": jnp.array(0.0), "b": jnp.array(0.0)}
lr = 0.1
for step in range(201):
params, loss = train_step(params, X, y, lr)
if step % 40 == 0:
print(f"step {step:3d} | loss = {loss:.5f} | w = {params['w']:.4f} | b = {params['b']:.4f}")
# 5) Compare with the closed-form (least-squares) solution
A = jnp.stack([X, jnp.ones_like(X)], axis=1)
w_ls, b_ls = jnp.linalg.lstsq(A, y)[0]
print(f"\nGradient descent: w = {params['w']:.4f}, b = {params['b']:.4f}")
print(f"Least squares : w = {w_ls:.4f}, b = {b_ls:.4f}")
assert jnp.allclose(params["w"], w_ls, atol=1e-3) and jnp.allclose(params["b"], b_ls, atol=1e-3)
Extra exercises¶
The following exercises are closer to real-world use. Try writing them yourself first, then look at the reference solutions.
Exercise A: gradient of logistic regression, checked against the closed form¶
Binary logistic regression: $p_i=\sigma(\mathbf x_i^\top\mathbf w+b)$, with mean cross-entropy loss
$$ L=-\frac1N\sum_i\big[y_i\log p_i+(1-y_i)\log(1-p_i)\big] $$
Its analytic gradient is $\nabla_{\mathbf w}L=\frac1N X^\top(\mathbf p-\mathbf y)$, $\nabla_b L=\frac1N\sum_i(p_i-y_i)$.
Requirements:
- Store the parameters in a dict
{"w": ..., "b": ...}; - Implement the loss in a numerically stable way (hint: $\log\sigma(z)=-\mathrm{softplus}(-z)$; you can use
jax.nn.log_sigmoid); - Compute the gradient with
jax.gradand compare it with the analytic gradient; - Use
has_aux=Trueto return the accuracy as well.
Self-check interface: define logistic_loss(params, X, y) -> (loss, acc), where params = {"w": ..., "b": ...}.
# ✍️ Your answer: Exercise A
# Write your code here, then run the 🧪 Self-check below. Reference solutions are in the appendix at the end of this chapter.
# 🧪 Self-check: Exercise A
try:
logistic_loss
except NameError as e:
print(f"⏳ Exercise A: not answered yet: {e}")
else:
_k1, _k2, _k3 = jax.random.split(jax.random.key(99), 3)
_X = jax.random.normal(_k1, (64, 4))
_y = (jax.random.uniform(_k2, (64,)) > 0.5).astype(jnp.float32)
_p = {"w": jax.random.normal(_k3, (4,)), "b": jnp.array(0.3)}
_out = logistic_loss(_p, _X, _y)
assert isinstance(_out, tuple) and len(_out) == 2, "logistic_loss should return two values, (loss, acc)"
_loss, _acc = _out
_Xn, _yn = np.asarray(_X, np.float64), np.asarray(_y, np.float64)
def _ref(w, b): # float64 reference: -log σ(z) = log(1 + e^{-z})
z = _Xn @ np.asarray(w, np.float64) + float(b)
return np.mean(_yn * np.logaddexp(0, -z) + (1 - _yn) * np.logaddexp(0, z)), z
_lref, _z = _ref(_p["w"], _p["b"])
assert np.isclose(float(_loss), _lref, rtol=1e-4), f"Loss {float(_loss):.6f} is wrong; expected {_lref:.6f}"
assert np.isclose(float(_acc), np.mean((_z > 0) == (_yn > 0.5)), atol=1e-6), "Accuracy is wrong"
_loss_only = lambda p: logistic_loss(p, _X, _y)[0]
_g = jax.grad(_loss_only)(_p)
_pr = 1 / (1 + np.exp(-_z))
assert np.allclose(_g["w"], _Xn.T @ (_pr - _yn) / 64, atol=1e-5), "Gradient w.r.t. w doesn't match the closed form"
assert np.isclose(float(_g["b"]), np.mean(_pr - _yn), atol=1e-5), "Gradient w.r.t. b doesn't match the closed form"
_big = {"w": _p["w"] * 1000.0, "b": _p["b"]} # logits in the thousands: a naive implementation gives inf/NaN
_lb = float(_loss_only(_big))
_gb = jax.grad(_loss_only)(_big)
assert np.isfinite(_lb) and all(np.all(np.isfinite(np.asarray(v))) for v in _gb.values()), \
"The loss or gradient has inf/NaN for large logits: the loss needs a numerically stable implementation"
assert np.isclose(_lb, _ref(_big["w"], _big["b"])[0], rtol=1e-3), "The loss value is wrong for large logits"
print("✅ Exercise A: self-check passed")
Exercise B: write a safe_sqrt whose gradient is safe at 0¶
Implement safe_sqrt(x): equal to $\sqrt x$ for $x>0$ and $0$ for $x\le 0$, such that:
jax.grad(safe_sqrt)(0.0)andjax.grad(safe_sqrt)(-1.0)are finite (take them as 0);jax.grad(safe_sqrt)(4.0) == 0.25;- It handles vector inputs (take the gradient together with
jnp.sum).
Implement it in two ways, with the "double where" trick and with jax.custom_jvp, and check both with check_grads at points where $x>0$.
Self-check interface: define safe_sqrt_where(x) (double where) and safe_sqrt_custom(x) (jax.custom_jvp).
# ✍️ Your answer: Exercise B
# Write your code here, then run the 🧪 Self-check below. Reference solutions are in the appendix at the end of this chapter.
# 🧪 Self-check: Exercise B
try:
safe_sqrt_where, safe_sqrt_custom
except NameError as e:
print(f"⏳ Exercise B: not answered yet: {e}")
else:
assert isinstance(safe_sqrt_custom, jax.custom_jvp), "safe_sqrt_custom should be defined with @jax.custom_jvp"
_xs = jnp.array([-3.0, -1.0, 0.0, 1e-6, 0.25, 4.0, 9.0])
_xn = np.asarray(_xs, np.float64)
_vref = np.sqrt(np.maximum(_xn, 0.0))
_gref = np.where(_xn > 0, 0.5 / np.sqrt(np.maximum(_xn, 1e-30)), 0.0)
for _name, _fn in [("safe_sqrt_where", safe_sqrt_where), ("safe_sqrt_custom", safe_sqrt_custom)]:
_v = np.asarray(_fn(_xs))
assert np.allclose(_v, _vref, rtol=1e-5, atol=1e-7), f"{_name} returns wrong values: expected {_vref}, got {_v}"
_gsum = jax.grad(lambda x: jnp.sum(_fn(x)))
for _tag, _gfn in [("", _gsum), (" (under jit)", jax.jit(_gsum))]:
_g = np.asarray(_gfn(_xs))
assert np.all(np.isfinite(_g)), f"{_name}{_tag} has inf/NaN in its gradient: {_g}"
assert np.allclose(_g, _gref, rtol=1e-4), f"{_name}{_tag} has a wrong gradient: expected {_gref}, got {_g}"
assert float(jax.grad(_fn)(4.0)) == 0.25, f"{_name}: grad at 4.0 should be 0.25"
_, _t = jax.jvp(_fn, (_xs,), (jnp.ones_like(_xs),))
assert np.all(np.isfinite(np.asarray(_t))), f"{_name}: forward mode (jvp) gives inf/NaN at non-positive points"
print("✅ Exercise B: self-check passed")
Exercise C: Hessian-vector products without building the Hessian¶
For $f(\mathbf x)=\sum_i x_i^4 + \big(\sum_i x_i\big)^2$ and a given vector $\mathbf v$, compute $H\mathbf v$.
Requirements:
- Implement
hvp(f, x, v)withjax.jvp(jax.grad(f), (x,), (v,))(forward-over-reverse; it never builds the $n\times n$ Hessian, and its memory cost is about that of one gradient computation); - Compare against
jax.hessian(f)(x) @ vfor $n=5$; - Think about it: why not just take
gradofjax.grad(f)(x) @ v? (Hint: that also works. It's called reverse-over-reverse, and it just uses more memory.)
Self-check interface: define hvp(f, x, v), which returns $H\mathbf v$ for any scalar function f.
# ✍️ Your answer: Exercise C
# Write your code here, then run the 🧪 Self-check below. Reference solutions are in the appendix at the end of this chapter.
# 🧪 Self-check: Exercise C
try:
hvp
except NameError as e:
print(f"⏳ Exercise C: not answered yet: {e}")
else:
_n = 13
_a = jnp.linspace(-1.0, 1.0, _n)
def _g(x): # Use a fresh function to check that hvp works for any f
return jnp.sum(jnp.sin(x) * x**2) + jnp.sum(_a * x) ** 3
_kx, _kv = jax.random.split(jax.random.key(3))
_x, _v = jax.random.normal(_kx, (_n,)), jax.random.normal(_kv, (_n,))
_xn, _vn, _an = (np.asarray(t, np.float64) for t in (_x, _v, _a))
_diag = (2 - _xn**2) * np.sin(_xn) + 4 * _xn * np.cos(_xn) # closed form of (x² sin x)''
_Hv_ref = _diag * _vn + 6 * (_an @ _xn) * _an * (_an @ _vn)
_Hv = np.asarray(hvp(_g, _x, _v))
assert _Hv.shape == (_n,), f"hvp should return a vector of length {_n}; got shape {_Hv.shape}"
assert np.allclose(_Hv, _Hv_ref, rtol=1e-4, atol=1e-4), f"hvp result is wrong:\n expected {_Hv_ref}\n got {_Hv}"
_jx = str(jax.make_jaxpr(lambda x, v: hvp(_g, x, v))(_x, _v))
assert f"[{_n},{_n}]" not in _jx, f"hvp builds a {_n}×{_n} matrix along the way: don't construct the full Hessian"
print("✅ Exercise C: self-check passed")
Exercise D: Newton's method with grad of grad¶
Newton's method for minimizing a function of one variable: $x_{k+1}=x_k-\dfrac{f'(x_k)}{f''(x_k)}$.
For $f(x)=x^4-3x^2+x$, start from $x_0=2$ and iterate 8 steps, printing $x$ and $f'(x)$ at each step. Compare the convergence speed with gradient descent (learning rate 0.01, also 8 steps). The whole iteration function must be jax.jit-able (hint: use lax.fori_loop with Python constants as bounds).
Self-check interface: define the first- and second-derivative functions df and d2f, a single Newton update newton_step(x), and gradient descent gd(x0) (8 steps, learning rate 0.01, jax.jit-able).
# ✍️ Your answer: Exercise D
# Write your code here, then run the 🧪 Self-check below. Reference solutions are in the appendix at the end of this chapter.
# 🧪 Self-check: Exercise D
try:
df, d2f, newton_step, gd
except NameError as e:
print(f"⏳ Exercise D: not answered yet: {e}")
else:
_fp = lambda x: 4 * x**3 - 6 * x + 1 # derivative of f(x) = x⁴ - 3x² + x (closed form)
_fpp = lambda x: 12 * x**2 - 6
for _x0 in [-1.7, 0.4, 1.3, 2.5]:
assert np.isclose(float(df(_x0)), _fp(_x0), rtol=1e-5, atol=1e-5), f"df({_x0}) = {float(df(_x0))}, expected {_fp(_x0):.6f}"
assert np.isclose(float(d2f(_x0)), _fpp(_x0), rtol=1e-5, atol=1e-5), f"d2f({_x0}) = {float(d2f(_x0))}, expected {_fpp(_x0):.6f}"
_want = _x0 - _fp(_x0) / _fpp(_x0)
assert np.isclose(float(newton_step(_x0)), _want, rtol=1e-5, atol=1e-5), f"newton_step({_x0}) should be {_want:.6f}"
_xk = 2.0
for _ in range(8):
_xk = newton_step(_xk)
assert abs(_fp(float(_xk))) < 1e-4, f"After 8 steps from 2, f'(x) should be close to 0; got {_fp(float(_xk)):.2e}"
for _x0 in [2.0, -0.5]:
_xr = _x0
for _ in range(8):
_xr = _xr - 0.01 * _fp(_xr)
assert np.isclose(float(gd(_x0)), _xr, rtol=1e-5), f"gd({_x0}) = {float(gd(_x0)):.6f}; 8 steps of gradient descent with learning rate 0.01 should give {_xr:.6f}"
jax.jit(gd)(jnp.float32(2.0)) # The whole iteration function can be jitted
print("✅ Exercise D: self-check passed")
Chapter 3 Quiz¶
These 8 questions cover this chapter and help you check how well you've got it. A few of them target common pitfalls. Click "Answer and explanation" to expand.
1. Which statement about jax.grad(f) is correct?
- A. It computes the gradient of
fimmediately and returns an array - B. It returns a new function that, by default, computes the gradient of
f's output with respect to the first positional argument - C. It returns a new function that computes the gradient of
fwith respect to all arguments - D. It only works for functions of one variable
Answer and explanation
B. grad is a function transformation that returns a new function, with argnums=0 by default. To differentiate with respect to several arguments you have to pass argnums=(0, 1, ...) explicitly. It also handles multivariate inputs in the form of arrays and pytrees.
2. For def f(w, b, x): return w * x + b, what is jax.grad(f, argnums=(0, 1))(2.0, 1.0, 5.0)?
- A.
5.0 - B.
(5.0, 1.0) - C.
(5.0, 1.0, 2.0) - D.
[5.0, 1.0](an array)
Answer and explanation
B. When argnums is a tuple you get a tuple back, in the same order as argnums. $\partial/\partial w=x=5$, $\partial/\partial b=1$, and x is treated as a constant.
3. Why does jax.grad use reverse-mode automatic differentiation?
- A. Reverse mode is more accurate than forward mode
- B. Reverse mode doesn't need to store intermediate values, so it uses less memory
- C. For functions with "many inputs and one scalar output", one reverse pass gives the gradient with respect to all inputs
- D. Forward mode can't handle transcendental functions like
jnp.sin
Answer and explanation
C. Both modes are equally accurate (A is wrong). Reverse mode is exactly the one that does need to store forward intermediates, so it uses more memory (B is wrong). Forward mode supports every differentiable primitive too (D is wrong). For $f:\mathbb R^n\to\mathbb R^m$, forward mode needs $n$ passes and reverse mode needs $m$.
4. To compute the full Jacobian of $f:\mathbb R^{3}\to\mathbb R^{10000}$, which is more efficient?
- A.
jax.jacfwd - B.
jax.jacrev - C. They're the same
- D.
jax.grad
Answer and explanation
A. The input dimension (3) is much smaller than the output dimension (10000). Forward mode needs only 3 JVPs, while reverse mode needs 10000 VJPs. grad only works for scalar outputs.
5. Given def f(x): return x*x if x > 0 else -x, what do the following two lines give in JAX 0.12?
jax.grad(f)(-2.0)
jax.jit(jax.grad(f))(-2.0)
- A. Both return
-1.0 - B. Both raise
- C. The first returns
-1.0, the second raisesTracerBoolConversionError - D. The first raises, the second returns
-1.0
Answer and explanation
C. With grad alone the tracer carries a concrete value, so the Python if can be evaluated, and every call retraces. jit uses abstract tracers, which can't be converted to booleans. It's easy to think the first line already raises, but it doesn't (see 3.7). To make both lines work, switch to jax.lax.cond or jnp.where.
6. What is jax.grad(lambda x: jnp.where(x > 0, jnp.log(x), 0.0))(0.0)?
- A.
0.0 - B.
nan - C.
inf - D. It raises
Answer and explanation
B. where evaluates both branches. The unselected branch gets an upstream gradient of 0, but $0\times\frac{1}{0}=0\times\infty=\text{NaN}$. The fix is the "double where": replace the input with a safe value first, then take the log.
7. What is the correct way to unpack the return value of jax.value_and_grad(loss_fn, has_aux=True)(params, batch)?
- A.
loss, aux, grads = ... - B.
(loss, grads), aux = ... - C.
(loss, aux), grads = ... - D.
loss, (aux, grads) = ...
Answer and explanation
C. The value part is the (loss, aux) that loss_fn returns, unchanged, and the gradient part is taken with respect to loss only.
8. Which of these loops can be differentiated in reverse mode with jax.grad under jax.jit?
- A.
lax.while_loop(cond, body, x) - B.
lax.fori_loop(0, n, body, x), wherenis a normal (non-static) argument of thejitfunction - C.
lax.scan(body, x, None, length=10) - D. All of the above
Answer and explanation
C. while_loop only supports forward mode. A fori_loop with traced bounds falls back to while_loop, so it can't be differentiated in reverse mode either. scan has a fixed count and supports reverse mode. If you really want fori_loop, make n a static_argnums argument.
Chapter summary¶
- The gradient is the vector of partial derivatives of a scalar function with respect to each input, and it points in the direction of fastest increase. Gradient descent updates parameters along the negative gradient: $\theta\leftarrow\theta-\eta\nabla f(\theta)$.
- Automatic differentiation applies the chain rule at the level of elementary operations, and its results are exact to machine precision. Numerical differentiation is only good for checking gradients.
jax.gradis a function transformation: it takes a function with scalar output and returns its gradient function. It differentiates with respect to the first argument by default; useargnumsto choose others.graduses reverse mode (backpropagation): it first runs the forward computation and stores intermediates, then propagates sensitivities back from the output. It suits "many inputs, scalar output".- Forward mode =
jax.jvp($Jv$), reverse mode =jax.vjp($u^\top J$). For a full Jacobian: few inputs, usejacfwd; few outputs, usejacrev.jax.hessian = jacfwd(jacrev(f)). - Transformations compose:
grad(grad(f))for higher-order derivatives,jax.jvp(jax.grad(f), ...)for Hessian-vector products,jit(value_and_grad(f))for training steps. - Parameters can be a pytree (dict/list/tuple). The gradient has the same structure as the parameters; update with
jax.tree.map. - Use
has_aux=Truewhen the function needs to return extra information;value_and_grad+has_auxreturns((loss, aux), grads). - Control flow: with
gradalone a Pythonifworks; underjityou needlax.cond/jnp.where.while_loopandfori_loopwith dynamic bounds don't support reverse mode; usescanor a static count. - Common pitfalls: integer inputs raise; discontinuous functions have zero gradient;
abshas gradient 1 at 0;sqrt/normproduce inf/NaN at 0;jnp.wherecan't block NaN gradients, so use double where; don't usenpin code you differentiate; the output must be a scalar. - Tools:
stop_gradientcuts off gradients;custom_jvp/custom_vjpdefine custom derivatives;jax.test_util.check_gradschecks gradients. - 64-bit precision:
jax.config.update("jax_enable_x64", True)(from jax.config import configno longer works).
Cheat sheet¶
| API | What it does |
|---|---|
jax.grad(f, argnums=0, has_aux=False, allow_int=False) | Returns a function that computes the gradient of a scalar function |
jax.value_and_grad(f, argnums=0, has_aux=False) | Returns (value, gradient) from a single forward computation |
argnums=1 / argnums=(0, 1) | Differentiate with respect to the 2nd argument / several arguments (returns a tuple) |
has_aux=True | The function returns (scalar, aux); aux is not differentiated |
jax.jvp(f, primals, tangents) | Forward mode: returns (f(x), J·v) |
jax.vjp(f, *primals) | Reverse mode: returns (f(x), vjp_fn), with vjp_fn(u) = uᵀJ |
jax.jacfwd(f) | Full Jacobian in forward mode; good when the input dimension is small |
jax.jacrev(f) / jax.jacobian(f) | Full Jacobian in reverse mode; good when the output dimension is small (jacobian is an alias of jacrev) |
jax.hessian(f) | Hessian matrix, equivalent to jacfwd(jacrev(f)) |
jax.lax.stop_gradient(x) | Returns its input unchanged in the forward pass; treated as a constant in the backward pass |
jax.custom_jvp / jax.custom_vjp | Custom forward / reverse differentiation rules |
jax.lax.cond(pred, t_fn, f_fn, *ops) | Conditional branch that works with jit and differentiation |
jax.lax.fori_loop(lo, hi, body, init) | Counted loop; supports reverse mode when the bounds are constants |
jax.lax.while_loop(cond, body, init) | While loop; forward mode only |
jax.lax.scan(f, init, xs, length) | Fixed-count loop; supports reverse mode |
jnp.where(c, a, b) | Elementwise select; use "double where" around non-differentiable points |
jax.tree.map(fn, params, grads) | Update pytree parameters leaf by leaf |
jax.test_util.check_grads(f, args, order, modes) | Check gradients with finite differences (needs an explicit import jax.test_util) |
jax.make_jaxpr(f)(x) | Show the jaxpr produced by tracing (works for gradient functions too) |
jax.config.update("jax_enable_x64", True) | Turn on 64-bit precision |
Version notes¶
Below is a summary of the usage points and conclusions in this chapter that need attention under JAX 0.12. Each one is checked with assert at the corresponding place in the text.
| No. | Section | Key point | Where |
|---|---|---|---|
| 1 | 3.2 | jax.jacobian is just an alias of jax.jacrev and is not built on jax.grad; jacrev and grad are both based on jax.vjp, and jacfwd on jax.jvp | 3.2 Introducing jax.grad |
| 2 | 3.2 | Using an integer as a differentiated input raises TypeError right away; keep integer arguments out of argnums, or use allow_int=True (the gradient is float0) | 3.2 Introducing jax.grad |
| 3 | 3.2 | grad(sin)(π/2) is about -4.37e-08 in float32, not exactly 0.0; compare with jnp.allclose | 3.2 Introducing jax.grad |
| 4 | 3.4 | When the parameters are a dict, params - learning_rate * gradients raises TypeError; use jax.tree.map(lambda p, g: p - lr * g, params, grads) | 3.4 Differentiating with respect to arguments |
| 5 | 3.6 | The gradient of a²·sin(b) with respect to b at b=π/2 is about -3.93e-07 in float32, not exactly 0 | 3.6 Value and gradient (jax.value_and_grad) |
| 6 | 3.7 | With grad alone, every call retraces and the tracer carries concrete values, so a function with a Python if returns -1.0 normally at x=-2.0; only jit(grad(f)) raises TracerBoolConversionError | 3.7 Differentiation and control flow |
| 7 | 3.7 | lax.while_loop and a lax.fori_loop with a dynamic count only support forward mode, and grad through them raises ValueError; for reverse mode use lax.scan or static_argnums | 3.7 Differentiation and control flow |
| 8 | 3.8 | jax.grad(jnp.abs)(0.0) is 1.0 (takes the right derivative) | 3.8 Limitations and caveats |
| 9 | 3.8 | jax.grad(jnp.sign)(0.0) is a fixed 0.0, not NaN, and doesn't vary by platform | 3.8 Limitations and caveats |
| 10 | 3.8 | NumPy calls in a differentiated function are not treated as constants: np.sin(tracer) raises TracerArrayConversionError, and np.sum happens to differentiate correctly; only NumPy constants independent of the input are constants | 3.8 Limitations and caveats |
| 11 | 3.8 | jnp.where can't block 0×inf in the backward pass and gives NaN gradients; use "double where" for sqrt/log/norm/division/arcsin | 3.8 Limitations and caveats |
| 12 | 3.9 | from jax.config import config raises ModuleNotFoundError; turn on 64-bit precision with jax.config.update("jax_enable_x64", True) | 3.9 Hands-on: computing gradients |
| 13 | 3.9 | In Exercise 5, the gradient of sum(sin(v)) at v=[0, π/2, π] has a middle entry of about -4.37e-08 in float32, not exactly [1. 0. -1.] | 3.9 Hands-on: computing gradients |
| 14 | 3.3 | Forward mode = jax.jvp (gives Jacobian columns), reverse mode = jax.vjp (gives rows); choose jacfwd/jacrev by input/output dimension | 3.3 How autodiff works: reverse mode |
| 15 | 3.4 | grad supports pytree parameters such as dicts; use has_aux=True to return extra information | 3.4 Differentiating with respect to arguments |
| 16 | 3.5 | jax.hessian = jacfwd(jacrev(f)); at large scale, compute Hessian-vector products with jvp(grad(f)) | 3.5 Higher-order derivatives (grad of grad) |
| 17 | 3.8 | Custom derivatives and gradient checking: jax.custom_jvp/custom_vjp, jax.lax.stop_gradient, jax.test_util.check_grads (needs an explicit import) | 3.8 Limitations and caveats |
| 18 | 3.3 | With grad alone, tracers carry concrete values (GradTracer(primal=...)); abstract tracers with only shape and dtype are a jit behavior | 3.3 How autodiff works: reverse mode |
Appendix: reference solutions¶
First write your own answer in the "✍️ Your answer" cell in the main text and check it with the "🧪 Self-check". Come here to compare once the self-check passes, or if you're really stuck. Running the solutions below in order will overwrite any functions with the same names you defined in your answer cells.
Exercise A¶
The problem is in the main text under "Exercise A: gradient of logistic regression, checked against the closed form".
# Reference solution: Exercise A
key = jax.random.key(0)
k1, k2, k3 = jax.random.split(key, 3)
N, D = 100, 3
X = jax.random.normal(k1, (N, D))
true_w = jnp.array([1.5, -2.0, 0.5])
y = (X @ true_w + 0.3 * jax.random.normal(k2, (N,)) > 0).astype(jnp.float32)
params = {"w": 0.1 * jax.random.normal(k3, (D,)), "b": jnp.array(0.0)}
def logistic_loss(params, X, y):
z = X @ params["w"] + params["b"]
# Stable form: log σ(z) and log(1-σ(z)) = log σ(-z)
loss = -jnp.mean(y * jax.nn.log_sigmoid(z) + (1 - y) * jax.nn.log_sigmoid(-z))
acc = jnp.mean((z > 0) == (y > 0.5))
return loss, acc
grads, acc = jax.grad(logistic_loss, has_aux=True)(params, X, y)
# Analytic gradient
p = jax.nn.sigmoid(X @ params["w"] + params["b"])
grad_w_analytic = X.T @ (p - y) / N
grad_b_analytic = jnp.mean(p - y)
assert jnp.allclose(grads["w"], grad_w_analytic, atol=1e-6)
assert jnp.allclose(grads["b"], grad_b_analytic, atol=1e-6)
print("Assertion passed: jax.grad matches the analytic gradient")
print("Initial accuracy:", acc)
Exercise B¶
The problem is in the main text under "Exercise B: write a safe_sqrt whose gradient is safe at 0".
# Reference solution: Exercise B
from jax.test_util import check_grads
# Method 1: double where
def safe_sqrt_where(x):
pos = x > 0
x_safe = jnp.where(pos, x, 1.0)
return jnp.where(pos, jnp.sqrt(x_safe), 0.0)
# Method 2: custom_jvp
@jax.custom_jvp
def safe_sqrt_custom(x):
return jnp.sqrt(jnp.maximum(x, 0.0))
@safe_sqrt_custom.defjvp
def _safe_sqrt_jvp(primals, tangents):
(x,), (t,) = primals, tangents
y = safe_sqrt_custom(x)
pos = x > 0
# Derivative 1/(2 sqrt(x)), defined as 0 for non-positive x; we still have to avoid 1/0
dy = jnp.where(pos, 0.5 / jnp.where(pos, y, 1.0), 0.0)
return y, dy * t
xs = jnp.array([-1.0, 0.0, 4.0])
for name, fn in [("double where", safe_sqrt_where), ("custom_jvp", safe_sqrt_custom)]:
g = jax.grad(lambda x: jnp.sum(fn(x)))(xs)
print(f"{name:10s} value = {fn(xs)} | gradient = {g}")
# Run the finite-difference check only where the function is differentiable (x>0)
x_pos = jnp.array([0.5, 1.0, 4.0])
check_grads(safe_sqrt_where, (x_pos,), order=1, modes=["rev"])
check_grads(safe_sqrt_custom, (x_pos,), order=1, modes=["fwd", "rev"])
print("check_grads passed")
for fn in (safe_sqrt_where, safe_sqrt_custom):
assert jnp.all(jnp.isfinite(jax.grad(lambda x: jnp.sum(fn(x)))(xs)))
assert jax.grad(fn)(4.0) == 0.25
Exercise C¶
The problem is in the main text under "Exercise C: Hessian-vector products without building the Hessian".
# Reference solution: Exercise C
def f_quartic(x):
return jnp.sum(x**4) + jnp.sum(x) ** 2
def hvp(f, x, v):
# Directional derivative of grad(f) in forward mode: d/dε ∇f(x + ε v) = H v
return jax.jvp(jax.grad(f), (x,), (v,))[1]
def hvp_rev_rev(f, x, v):
# Another way: ∇_x (∇f(x) · v) = H v
return jax.grad(lambda x: jnp.vdot(jax.grad(f)(x), v))(x)
key = jax.random.key(1)
kx, kv = jax.random.split(key)
x = jax.random.normal(kx, (5,))
v = jax.random.normal(kv, (5,))
hv1 = hvp(f_quartic, x, v)
hv2 = hvp_rev_rev(f_quartic, x, v)
hv_full = jax.hessian(f_quartic)(x) @ v
print("jvp(grad) :", hv1)
print("grad(grad·v):", hv2)
print("hessian @ v:", hv_full)
assert jnp.allclose(hv1, hv_full, atol=1e-4) and jnp.allclose(hv2, hv_full, atol=1e-4)
print("Assertion passed: all three methods agree")
Exercise D¶
The problem is in the main text under "Exercise D: Newton's method with grad of grad".
# Reference solution: Exercise D
def f_newton(x):
return x**4 - 3 * x**2 + x
df = jax.grad(f_newton)
d2f = jax.grad(df)
@jax.jit
def newton_step(x):
return x - df(x) / d2f(x)
x = 2.0
print("Newton's method:")
for k in range(8):
x = newton_step(x)
print(f" step {k + 1}: x = {x:.8f}, f'(x) = {df(x):.2e}")
# Compare with gradient descent: the whole loop goes into jit; fori_loop has constant bounds, so it supports reverse mode and jit
@jax.jit
def gd(x0):
return jax.lax.fori_loop(0, 8, lambda i, x: x - 0.01 * df(x), x0)
x_gd = gd(2.0)
print(f"\nGradient descent, 8 steps: x = {x_gd:.6f}, f'(x) = {df(x_gd):.2e}")
assert abs(df(x)) < 1e-5 and abs(df(x_gd)) > abs(df(x)) # Newton's method converges much faster than gradient descent