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

  1. Compute gradients of scalar functions with jax.grad;
  2. Understand the reverse-mode automatic differentiation behind grad;
  3. Control which arguments to differentiate with argnums;
  4. Compose grad to get higher-order derivatives;
  5. Get the function value and the gradient together with jax.value_and_grad;
  6. Understand how control flow interacts with differentiation, and the limits of grad.

Sections

SectionOriginal course page
3.1 Understanding gradientsunderstanding-gradients
3.2 Introducing jax.gradintroducing-jax-grad
3.3 How autodiff works: reverse modehow-autodiff-works
3.4 Differentiating with respect to argumentsgrad-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 flowgrad-control-flow
3.8 Limitations and caveatsgrad-limitations
3.9 Hands-on: computing gradientshands-on-computing-gradients
Chapter 3 Quizquiz
Version notesEnd 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 with assert.
  • 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())
JAX version: 0.12.0.1
Default backend: cpu

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:

  1. Start from some initial parameters;
  2. Compute the gradient of the loss at the current parameters;
  3. Take a small step along the negative gradient;
  4. 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:

MethodHow it worksDrawbacks
Numerical differentiationApproximate 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 differentiationManipulate expressions symbolically, like SymPyExpressions can blow up, and general programs with loops and branches are hard to handle
Automatic differentiationApply the chain rule systematically at the level of elementary operationsNone 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])))
d/dx x^2 at x=2: 4.0
∇f(1, 0) = [2. 1.]

🔍 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))
       h | fwd diff error | ctr diff error
   1e-01 |       4.29e-02 |       9.00e-04
   1e-02 |       4.21e-03 |       1.00e-05
   1e-03 |       3.44e-04 |       1.38e-05
   1e-04 |       3.12e-04 |       1.38e-05
   1e-05 |       2.10e-03 |       8.80e-04
   1e-06 |       3.86e-03 |       3.86e-03

jax.grad error: 2.9256811640010483e-08

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)}")
f(2.0) = 8.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)}")
Type of f: <class 'function'>
Type of grad_f: <class 'function'>
# 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
Gradient of f at x=2.0: 12.0

Key points

  • Input: a Python function that returns a single scalar;
  • jax.grad returns 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}")
g(2.0, 3.0) = 12.0
Gradient of g w.r.t x at (2.0, 3.0): 12.0

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, use jax.jacrev / jax.jacfwd (jax.jacobian is an alias of jax.jacrev), or the lower-level jax.vjp / jax.jvp. Like grad, they are all built on jax.vjp (reverse) or jax.jvp (forward); see 3.3.
  • The inputs you differentiate with respect to must be floating-point (or complex). Passing an integer raises a TypeError right 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 of argnums. If you must list it in argnums, add allow_int=True; its gradient is then a zero of type float0.

⚠️ Note: it's easy to assume that multi-output tools like jax.jacobian are built on top of jax.grad. They aren't. The source of jax.jacobian simply calls jacrev (the docs say Alias of jax.jacrev), and both jacrev and grad are implemented on top of jax.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")
Integer input raises: grad requires real- or complex-valued inputs (input dtype that is a sub-dtype of np.inexact), but got int32. If you want to use Boolean- or integer-valued inputs, use vjp or set allow_int to True.
allow_int=True -> 3.0 | gradient of n has type float0: True
Docstring of jax.jacobian: Alias of :func:`jax.jacrev`.
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 print 0.0. It prints about -4.37e-08 (float32 by default). The reason is that $\pi/2$ gets rounded when converted to float32, and cos at the rounded point is not exactly 0. Compare floating-point results with jnp.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))
h(pi/2) = 1.0
Gradient of h at x=pi/2: -4.371138828673793e-08
Exactly equal to 0.0? False
# ✅ 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)")
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:

  1. Initialize: the sensitivity of the output to itself is $\bar y=\dfrac{dy}{dy}=1$.
  2. Through the sin node: $\bar a=\bar y\cdot\dfrac{dy}{da}=\bar y\cos(a)=\cos(4.0)\approx-0.654$.
  3. 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

  1. Tracing: JAX runs the function once with "tracer" objects in place of the inputs and records the sequence of operations (the computation graph). With grad alone, the tracers carry concrete values, so the forward values are computed on the spot while the operations needed for differentiation are recorded. Only inside jit are the tracers abstract values that carry just shape and dtype, and what gets recorded is the intermediate representation jaxpr (3.7 explains the difference).
  2. Forward pass: run these operations and compute the intermediate values the gradient needs (with value_and_grad you also get the function value along the way).
  3. 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 grad alone, tracers are not abstract. A tracer prints as GradTracer(primal=2.0, ...) and carries a concrete value. Abstract tracers that carry only shape and dtype are a jit behavior (a cell in 3.7 checks this with assert).

# 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)
y      = -0.7568025
ā = cos(4.0)      ≈ -0.6536436
x̄ = ā * 2x        ≈ -2.6145744
jax.grad(f)(2.0)  = -2.6145744

🔍 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))
jaxpr of the original function:
{ lambda ; a:f32[]. let b:f32[] = integer_pow[y=2] a; c:f32[] = sin b in (c,) }
jaxpr of the gradient function:
{ lambda ; a:f32[]. let
    b:f32[] = integer_pow[y=2] a
    c:f32[] = mul 2.0:f32[] a
    _:f32[] = sin b
    d:f32[] = cos b
    e:f32[] = mul 1.0:f32[] d
    f:f32[] = mul e c
  in (f,) }

(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}$:

APIModeComputesOne 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 essentially vjp_fn(1.0) (when $m=1$, $J$ has a single row, so one VJP gives the whole gradient).
  • jax.jvp returns the function value and the directional derivative together. Forward mode doesn't need to store intermediate values, so it uses little memory.
  • jax.vjp returns "the function value plus a vjp_fn you can call many times". The intermediate values stored during the forward pass live inside vjp_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)
F(x) = [2.      1.14112] | column 1 of J = [2. 1.]
row 1 of J = [2. 1. 0.]
full Jacobian J =
 [[ 2.         1.         0.       ]
 [ 1.         0.        -0.9899925]]
vjp_fn(1.0) = [2. 4. 6.] | grad = [2. 4. 6.]

🔍 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$:

  • jacfwd uses forward mode and builds it column by column, with $n$ JVPs (JAX batches them in one go with vmap);
  • jacrev uses 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:

SituationChoiceWhy
Input dimension $n$ > output dimension $m$ ("wide" Jacobian)jax.jacrevOnly $m$ VJPs
Input dimension $n$ < output dimension $m$ ("tall" Jacobian)jax.jacfwdOnly $n$ JVPs
Scalar output ($m=1$, e.g. a loss)jax.gradSpecial case of jacrev, one VJP
$n\approx m$Either works; lean toward jacfwdForward 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 matrixjax.jvp / jax.vjpNever 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)
R^2000 -> R^4 (n > m): jacfwd   3.047 ms | jacrev   0.033 ms | faster: jacrev
R^4 -> R^2000 (n < m): jacfwd   0.039 ms | jacrev   1.800 ms | faster: jacfwd

Rule 'n > m -> use jacrev' confirmed: True
Rule 'n < m -> use jacfwd' confirmed: True
# 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")
jacfwd(jacrev)  [= jax.hessian]   :    0.60 ms
jacrev(jacrev)                    :    0.47 ms
jacrev(jacfwd)                    :    5.42 ms
jacfwd(jacfwd)                    :    2.35 ms
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)}")
Function output: 36.0
# 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
Gradient w.r.t. x (default): 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
Gradient w.r.t. y (argnums=1): 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)
Affine function output: 11.0
Gradients w.r.t. (w, b) (with argnums=(0, 1)): (Array(5., dtype=float32, weak_type=True), Array(1., dtype=float32, weak_type=True))

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 * gradients only works when the parameters are a single array. When they are a dict it raises TypeError: unsupported operand type(s); use jax.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")
gradients = {'b': Array(-9., dtype=float32, weak_type=True), 'w': Array([-29.666668, -38.666668], dtype=float32)}
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
Raises TypeError: unsupported operand type(s) for *: 'float' and 'dict'
# ✅ 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")
loss before update: 20.916668
loss after update: 3.983675
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)
Error without has_aux: Gradient only defined for scalar-output functions. Output was (Array(20.916668, dtype=float32), {'max_abs_err': Array(5.5, dtype=float32), 'predictions': Array([-2.5, -4.5, -6.5], dtype=float32)}).

grads: {'b': Array(-9., dtype=float32, weak_type=True), 'w': Array([-29.666668, -38.666668], dtype=float32)}
aux  : {'max_abs_err': Array(5.5, dtype=float32), 'predictions': Array([-2.5, -4.5, -6.5], dtype=float32)}

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
f(2.0)   = 8.0
f'(2.0)  = 12.0
f''(2.0) = 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
f''(2.0)  = 12.0
f⁽³⁾(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
∂²f/∂x²  at (2,3) = 6.0
∂²f/∂y∂x at (2,3) = 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 as jax.jacfwd(jax.jacrev(f)), that is, forward-over-reverse. The inner jacrev computes the gradient (many inputs, one output, a good fit for reverse mode), and the outer jacfwd takes the Jacobian of the gradient ($n$ inputs, $n$ outputs, where forward mode uses less memory).
  • jax.hessian differentiates 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 use argnums=(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)
Hessian =
 [[ 6.  4.]
 [ 4. 18.]]
Assertion passed: matches the analytic result and jacfwd(jacrev(f))
Result with argnums=(0,1): ((Array(6., dtype=float32, weak_type=True), Array(4., dtype=float32, weak_type=True)), (Array(4., dtype=float32, weak_type=True), Array(18., dtype=float32, weak_type=True)))

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
Separately: value = 11.0, gradient = 17.0
value_and_grad: value = 11.0, gradient = 17.0

value_and_grad supports argnums too:

  • When argnums is an integer, the returned gradient corresponds to that one argument;
  • When argnums is 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 with jnp.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")
argnums=0     : value = 9.0, ∂/∂a = 6.0
argnums=(0,1) : value = 9.0, ∂/∂a = 6.0, ∂/∂b = -3.934025016860687e-07
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))
loss = 7.0000, acc = 0.667, grad = 4.6667

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 ownjax.jit (or jit(grad(f)))
TracerCarries a concrete value (prints as GradTracer(primal=...))An abstract value with only shape and dtype
When tracing happensRetraced on every callTraced and compiled once per input shape/dtype
Python if x > 0Works; picks the branch from the actual value of xRaises TracerBoolConversionError
Python for/while (count depends on values)WorksRaises 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 grad can only differentiate the one branch taken during tracing, and that a Python if will raise "the truth value of an abstract tracer is ambiguous" at x=-2.0. In fact, both "trace only once" and that error are jit behaviors. With grad alone, a Python if works fine (x=-2.0 returns -1.0); only jit(grad(f))(-2.0) raises TracerBoolConversionError.

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
Measured: no error. grad at x=2.0 : 4.0 | x=-2.0 : -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")
  x as seen inside grad: GradTracer(primal=2.0, typeof(tangent)=f32[])

jit(grad) raises: TracerBoolConversionError
  Attempted boolean conversion of traced array with shape bool[].
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")
x=  2.0: grad = 4.0, jit(grad) = 4.0
x= -2.0: grad = -1.0, jit(grad) = -1.0
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
fixed_loop gradient: 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):

PrimitiveForward 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_loop nor a fori_loop with traced bounds can be used for loops that need reverse-mode differentiation. Running grad through them raises ValueError: Reverse-mode differentiation does not work for lax.while_loop ...; they only support forward mode. The fori_loop example below works only because n=3 is a concrete integer when grad is 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
fori_loop gradient (n=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
grad through while_loop:
  Error: Reverse-mode differentiation does not work for lax.while_loop or lax.fori_loop with dynamic start/stop values. Try using lax.scan, or using fori_loop with static start/stop.
fori_loop under jit with traced n:
  Error: Reverse-mode differentiation does not work for lax.while_loop or lax.fori_loop with dynamic start/stop values. Try using lax.scan, or using fori_loop with static start/stop.
while_loop + jvp: 8.0 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")
static_argnums: 8.0
scan + jit(grad): 8.0
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.ceil and 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, grad raises a TypeError right away);
  • Comparisons: operations like x > 0 produce 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:

ExpressionGradient at 0 (measured)Explanation
jnp.abs1.0Takes the right derivative
jax.nn.relu0.0Takes the left derivative
jnp.sign0.0Derivative defined as 0 everywhere
jnp.sqrtinf$\frac{1}{2\sqrt x}$ blows up at 0
jnp.linalg.norm (zero vector)nanInternally $\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) is 1.0, not 0.0.

⚠️ Note: jax.grad(jnp.sign)(0.0) is a fixed 0.0 (the derivative of sign is 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])
Gradient of cast_and_square(2.7): 0.0
grad(sign)(2.7) = 0.0
grad(round)(2.7) = 0.0
grad(floor)(2.7) = 0.0
grad(ceil)(2.7) = 0.0
grad((x>1).astype(float))(2.7) = 0.0

Integer output raises: grad requires real-valued outputs (output dtype that is a sub-dtype of np.floating), but got int32. For differentiation of functions with integer outputs, use jax.vjp directly.
# ❌ 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]}")
grad(abs)(0.0)   common belief: 0.0                actual: 1.0
grad(sign)(0.0)  common belief: NaN or platform-dependent actual: 0.0
# ✅ 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
Assertion passed: abs→1.0, sign→0.0, relu→0.0, sqrt→inf, norm(0)→nan
grad(sigmoid(k x))(0.1) = 1.1750185

🔍 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
CaseDangerous 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))
DivisionDenominator $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
ā = 0.0 | log'(0) = inf | ā × log'(0) = 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
sqrt   at dangerous point: value = 0.0000, gradient = nan
log    at dangerous point: value = 0.0000, gradient = nan
norm   at dangerous point: value = 0.0000, gradient = [nan nan nan]
div    at dangerous point: value = 0.0000, gradient = nan
arcsin at dangerous point: value = 1.5708, gradient = nan
# ✅ 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")
sqrt   gradient at dangerous point = 0.0 | at normal point = 0.25
log    gradient at dangerous point = 0.0 | at normal point = 0.5
norm   gradient at dangerous point = [0. 0. 0.] | at normal point = [0.6 0.8 0. ]
div    gradient at dangerous point = 0.0 | at normal point = -0.125
arcsin gradient at dangerous point = 0.0 | at normal point = 1.154700517654419
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.0 for sqrt/log/division and 0.0 for arcsin.
  • If the masked-out branch has a finite derivative at the dangerous point, a single where is 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 using vmap); 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 of jnp.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)
grad(log)(1e-20) = 1e+20
naive     value: inf | gradient: [nan nan]
logsumexp value: 1000.6932 | gradient: [0.5 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)
naive grad(100.0) = nan
custom_jvp grad(100.0) = 1.0
custom_jvp grad(0.0)   = 0.5
second derivative works too (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
grad(x*x)(3.0)                 = 6.0
grad(x*stop_gradient(x))(3.0)  = 3.0
ste_round(2.7) = 3.0 | gradient = 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:

  1. 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.
  2. Passing a tracer to a NumPy function (like np.sin(x)): raises TracerArrayConversionError right away. Libraries like SciPy (not jax.scipy), Pandas and scikit-learn convert inputs to NumPy arrays internally, so they usually raise too.
  3. A few NumPy functions (like np.sum) end up calling x.sum() through duck typing, so they happen to be differentiable. That relies on implementation details and isn't reliable; switch to jnp everywhere 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) raises TracerArrayConversionError right away, while np.sum happens 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
np.sin applied to a tracer -> TracerArrayConversionError
Gradient of the np.sum version: [2. 4. 6.]
# ✅ 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)
Gradient of the jnp version: [ 0.34415185 -0.11227089 -0.04056348]
debug_loss received x = [1. 2. 3.]

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))
TypeError: Gradient only defined for scalar-output functions. Output had shape: (3,). To get the gradient, reduce the output to a scalar with output.sum(), or use jax.jacobian to differentiate a function with a non-scalar output.
jacrev gives the full Jacobian (diagonal matrix 2x):
 [[2. 0. 0.]
 [0. 4. 0.]
 [0. 0. 6.]]
grad(sum(f)) = [2. 4. 6.]

🔍 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=2 also checks second derivatives. Note that you have to import jax.test_util explicitly. With only import jax, accessing jax.test_util raises AttributeError.
  • 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)
check_grads: first- and second-order gradient checks of g passed
check_grads found a wrong gradient: Not equal to tolerance rtol=0.002, atol=0.002

hand-written central diff: [0.6988048 2.2232533 1.9726752]
jax.grad                 : [0.6988212 2.2232442 1.9726022]

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 config followed by config.update("jax_enable_x64", True). JAX 0.12 has removed the jax.config submodule, so that raises ModuleNotFoundError: No module named 'jax.config'. Use jax.config.update("jax_enable_x64", True) instead, and put it at the start of the program, before creating any arrays (or use the environment variable JAX_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
Measured: raises ModuleNotFoundError: No module named 'jax.config'
# ✅ 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()}")
Current jax_enable_x64 = False
JAX version: 0.12.0.1
Default backend: cpu

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
f(4.0) = 61.0
JAX gradient: 26.0
Analytic gradient: 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)
∂g/∂x: 44.0  (analytic: 44.0)
∂g/∂y: 8.0  (analytic: 8.0)
(∂g/∂x, ∂g/∂y): (Array(44., dtype=float32, weak_type=True), Array(8., dtype=float32, weak_type=True))

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
f''(4.0) = 6.0
f''(-1.0) = 6.0
f''(100.0) = 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)
Value   : 9.0
Gradient: -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 with jnp.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)
JAX gradient : [ 1.000000e+00 -4.371139e-08 -1.000000e+00]
jnp.cos(v)   : [ 1.000000e+00 -4.371139e-08 -1.000000e+00]
Matches cos(v): True

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)
step   0 | loss = 7.08759 | w = 0.1965 | b = 0.4160
step  40 | loss = 0.02566 | w = 2.7787 | b = 1.9971
step  80 | loss = 0.00927 | w = 2.9791 | b = 1.9884
step 120 | loss = 0.00916 | w = 2.9951 | b = 1.9877
step 160 | loss = 0.00916 | w = 2.9964 | b = 1.9876
step 200 | loss = 0.00916 | w = 2.9965 | b = 1.9876
Gradient descent: w = 2.9965, b = 1.9876
Least squares   : w = 2.9965, b = 1.9876

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:

  1. Store the parameters in a dict {"w": ..., "b": ...};
  2. Implement the loss in a numerically stable way (hint: $\log\sigma(z)=-\mathrm{softplus}(-z)$; you can use jax.nn.log_sigmoid);
  3. Compute the gradient with jax.grad and compare it with the analytic gradient;
  4. Use has_aux=True to 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 A: not answered yet: name 'logistic_loss' is not defined

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) and jax.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 B: not answered yet: name 'safe_sqrt_where' is not defined

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:

  1. Implement hvp(f, x, v) with jax.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);
  2. Compare against jax.hessian(f)(x) @ v for $n=5$;
  3. Think about it: why not just take grad of jax.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 C: not answered yet: name 'hvp' is not defined

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")
⏳ Exercise D: not answered yet: name 'df' is not defined

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 f immediately 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 f with 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 raises TracerBoolConversionError
  • 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), where n is a normal (non-static) argument of the jit function
  • 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.grad is 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; use argnums to choose others.
  • grad uses 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, use jacfwd; few outputs, use jacrev. 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=True when the function needs to return extra information; value_and_grad + has_aux returns ((loss, aux), grads).
  • Control flow: with grad alone a Python if works; under jit you need lax.cond/jnp.where. while_loop and fori_loop with dynamic bounds don't support reverse mode; use scan or a static count.
  • Common pitfalls: integer inputs raise; discontinuous functions have zero gradient; abs has gradient 1 at 0; sqrt/norm produce inf/NaN at 0; jnp.where can't block NaN gradients, so use double where; don't use np in code you differentiate; the output must be a scalar.
  • Tools: stop_gradient cuts off gradients; custom_jvp/custom_vjp define custom derivatives; jax.test_util.check_grads checks gradients.
  • 64-bit precision: jax.config.update("jax_enable_x64", True) (from jax.config import config no longer works).

Cheat sheet

APIWhat 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=TrueThe 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_vjpCustom 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.SectionKey pointWhere
13.2jax.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.jvp3.2 Introducing jax.grad
23.2Using 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
33.2grad(sin)(π/2) is about -4.37e-08 in float32, not exactly 0.0; compare with jnp.allclose3.2 Introducing jax.grad
43.4When 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
53.6The gradient of a²·sin(b) with respect to b at b=π/2 is about -3.93e-07 in float32, not exactly 03.6 Value and gradient (jax.value_and_grad)
63.7With 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 TracerBoolConversionError3.7 Differentiation and control flow
73.7lax.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_argnums3.7 Differentiation and control flow
83.8jax.grad(jnp.abs)(0.0) is 1.0 (takes the right derivative)3.8 Limitations and caveats
93.8jax.grad(jnp.sign)(0.0) is a fixed 0.0, not NaN, and doesn't vary by platform3.8 Limitations and caveats
103.8NumPy 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 constants3.8 Limitations and caveats
113.8jnp.where can't block 0×inf in the backward pass and gives NaN gradients; use "double where" for sqrt/log/norm/division/arcsin3.8 Limitations and caveats
123.9from 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
133.9In 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
143.3Forward mode = jax.jvp (gives Jacobian columns), reverse mode = jax.vjp (gives rows); choose jacfwd/jacrev by input/output dimension3.3 How autodiff works: reverse mode
153.4grad supports pytree parameters such as dicts; use has_aux=True to return extra information3.4 Differentiating with respect to arguments
163.5jax.hessian = jacfwd(jacrev(f)); at large scale, compute Hessian-vector products with jvp(grad(f))3.5 Higher-order derivatives (grad of grad)
173.8Custom 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
183.3With grad alone, tracers carry concrete values (GradTracer(primal=...)); abstract tracers with only shape and dtype are a jit behavior3.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)
Assertion passed: jax.grad matches the analytic gradient
Initial accuracy: 0.44

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
double where value = [0. 0. 2.] | gradient = [0.   0.   0.25]
custom_jvp value = [0. 0. 2.] | gradient = [0.   0.   0.25]
check_grads passed

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")
jvp(grad) : [ -2.0931249  -0.4941277  -8.804403  -11.358394   52.02126  ]
grad(grad·v): [ -2.0931249  -0.4941277  -8.804403  -11.358394   52.02126  ]
hessian @ v: [ -2.0931249  -0.4941275  -8.804403  -11.358395   52.02126  ]
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
Newton's method:
  step 1: x = 1.50000000, f'(x) = 5.50e+00
  step 2: x = 1.23809528, f'(x) = 1.16e+00
  step 3: x = 1.14427722, f'(x) = 1.27e-01
  step 4: x = 1.13115311, f'(x) = 2.36e-03
  step 5: x = 1.13090122, f'(x) = 9.54e-07
  step 6: x = 1.13090110, f'(x) = 0.00e+00
  step 7: x = 1.13090110, f'(x) = 0.00e+00
  step 8: x = 1.13090110, f'(x) = 0.00e+00

Gradient descent, 8 steps: x = 1.333329, f'(x) = 2.48e+00