Skip to content

Chapter 4: Automatic Differentiation - jax.grad

Overview

Automatic differentiation is JAX's superpower. This chapter covers computing gradients with jax.grad.


What is Automatic Differentiation?

Automatic differentiation computes derivatives without: - Manual calculus - Finite differences approximation

Instead, JAX tracks operations and applies the chain rule automatically.

Why Not Manual Calculus?

# Manual: Error-prone, time-consuming
def f(x):
    return x**3 + 2*x**2 + 5*x + 1

# df/dx = 3x² + 4x + 5
def df_manual(x):
    return 3*x**2 + 4*x + 5

print(df_manual(2.0))  # 23.0

# But manually compute for complex functions? Impossible!
def complex_f(x):
    y = x**2
    z = jnp.sin(y) + jnp.exp(-y)
    return jnp.log(z + 2)

# df/dx by hand? Tedious and error-prone!

Why Not Finite Differences?

import jax.numpy as jnp

def f(x):
    return x**2

# Approximate derivative
eps = 1e-5
df_approx = (f(2.0 + eps) - f(2.0)) / eps
print(df_approx)  # ~4.0 (should be exactly 4.0)

# Problems:
# 1. Numerical error (ε too small → rounding error)
# 2. Expensive (need f(x+ε) for each variable)
# 3. For 1M parameters: 1M function calls!

Automatic Differentiation is Better

import jax
import jax.numpy as jnp

def f(x):
    return x**2

# Exact derivative, automatic!
grad_f = jax.grad(f)
print(grad_f(2.0))  # 4.0 (exact)

# Works for any composition:
def complex_f(x):
    y = x**2
    z = jnp.sin(y) + jnp.exp(-y)
    return jnp.log(z + 2)

grad_complex = jax.grad(complex_f)
print(grad_complex(1.0))  # Exact derivative!

Forward-Mode vs Reverse-Mode

JAX supports both, but focuses on reverse-mode (backpropagation).

Reverse-Mode (Backpropagation)

import jax
import jax.numpy as jnp

def f(x):
    y = x**2          # y = x²
    z = jnp.sin(y)    # z = sin(y)
    return z          # return z

# Forward pass (normal computation):
# x=2 → y=4 → z=sin(4)

# Backward pass (gradient computation):
# dz/dz = 1
# dy/dx = 2x = 4
# dz/dx = dz/dy * dy/dx = cos(4) * 4

grad_f = jax.grad(f)
print(grad_f(2.0))  # cos(4) * 4 ≈ -2.614

Why reverse-mode? Efficient for many inputs, one output (like neural networks with single loss).

Forward-Mode

import jax

def f(x):
    return x**3 + 2*x**2 + 1

# Compute Jacobian-vector product (JVP)
# = "forward-mode differentiation"

y, vjp_fn = jax.linearize(f, 2.0)
# y = f(2.0) = 17
# vjp_fn = function to compute vjp

(Reverse-mode is primary; forward-mode used for specific cases)


Basic Usage: jax.grad

Simple Scalar Function

import jax
import jax.numpy as jnp

def f(x):
    return x**3 - 2*x + 5

# Create gradient function
grad_f = jax.grad(f)

# Compute gradient at x=2
gradient = grad_f(2.0)
# f'(x) = 3x² - 2
# f'(2) = 3*4 - 2 = 10
print(gradient)  # 10.0

Visualize: Automatic Chain Rule

import jax
import jax.numpy as jnp

def f(x):
    a = jnp.sin(x)      # a = sin(x), da/dx = cos(x)
    b = a**2            # b = a², db/da = 2a
    c = jnp.exp(b)      # c = e^b, dc/db = e^b
    return c            # dc/dx = dc/db * db/da * da/dx

# JAX automatically chains:
# df/dx = cos(x) * 2*sin(x) * exp(sin²(x))

grad_f = jax.grad(f)

# At x = π/4:
x = jnp.pi / 4
print(grad_f(x))  # Automatic chain rule applied!

Vector Functions: Jacobian and Gradient

Vector Input, Scalar Output

import jax
import jax.numpy as jnp

def loss(w):
    """w is vector [w1, w2, w3]"""
    x = jnp.array([1., 2., 3.])
    return jnp.sum((w * x)**2)  # Scalar loss

w = jnp.array([1., 2., 3.])

# Gradient of scalar → gradient vector
grad_loss = jax.grad(loss)
gradient = grad_loss(w)
print(gradient.shape)  # (3,) - same as w
print(gradient)        # [2., 8., 18.]

Jacobian (Vector Input, Vector Output)

import jax
import jax.numpy as jnp

def f(x):
    """x is vector, returns vector"""
    return jnp.array([x[0]**2, x[1]**3, x[0]*x[1]])

x = jnp.array([2., 3.])

# Jacobian matrix: ∂f_i/∂x_j
jacobian = jax.jacobian(f)(x)
print(jacobian.shape)  # (3, 2) - 3 outputs, 2 inputs
print(jacobian)        # [[4., 0.],
                       #  [0., 27.],
                       #  [3., 2.]]

Gradients of Specific Arguments

Single Argument

import jax
import jax.numpy as jnp

def f(x):
    return x**2 + 2*x + 1

# Gradient w.r.t. x
grad_f = jax.grad(f)
print(grad_f(3.0))  # 8.0

Multiple Arguments: argnums

import jax
import jax.numpy as jnp

def f(x, y):
    return x*y + x**2 + y**2

# Gradient w.r.t. first argument (x)
grad_f_x = jax.grad(f, argnums=0)
print(grad_f_x(2.0, 3.0))  # y + 2x = 3 + 4 = 7

# Gradient w.r.t. second argument (y)
grad_f_y = jax.grad(f, argnums=1)
print(grad_f_y(2.0, 3.0))  # x + 2y = 2 + 6 = 8

# Gradient w.r.t. both arguments
grad_f_both = jax.grad(f, argnums=(0, 1))
dx, dy = grad_f_both(2.0, 3.0)
print(dx, dy)  # (7.0, 8.0)

Gradient of Arguments (None by Default)

import jax
import jax.numpy as jnp

def f(x, y, z):
    return x*y + jnp.sin(z)

# Only gradient w.r.t. x and z (not y)
grad_f = jax.grad(f, argnums=(0, 2))

# Returns gradient w.r.t. specified args
grad_x, grad_z = grad_f(1.0, 2.0, 0.0)
print(grad_x, grad_z)  # (2.0, 1.0)

Higher-Order Derivatives

Second Derivative (Hessian)

import jax
import jax.numpy as jnp

def f(x):
    return x**4 + 3*x**2 + 2*x

# First derivative
df = jax.grad(f)

# Second derivative
d2f = jax.grad(df)

x = 2.0
print(df(x))   # 64 + 12 + 2 = 78
print(d2f(x))  # 12x² + 6 = 48 + 6 = 54

Nth Derivative

import jax
import jax.numpy as jnp

def f(x):
    return x**5

# Compute nth derivative
def nth_derivative(f, n, x):
    for _ in range(n):
        f = jax.grad(f)
    return f(x)

# 5th derivative of x^5 = 120
print(nth_derivative(f, 5, 2.0))  # 120.0

Hessian for Multiple Variables

import jax
import jax.numpy as jnp

def f(x):
    """x is vector"""
    return x[0]**2 + x[1]**2 + x[0]*x[1]

# Hessian = matrix of 2nd derivatives
hessian_f = jax.hessian(f)
x = jnp.array([1.0, 2.0])
H = hessian_f(x)
print(H)  # [[2., 1.],
          #  [1., 2.]]

Jacobian and Hessian

Jacobian-Vector Product (JVP)

import jax
import jax.numpy as jnp

def f(x):
    return jnp.array([x[0]**2, x[1]**3])

x = jnp.array([2., 3.])
v = jnp.array([1., 0.])  # Direction vector

# Jacobian-vector product
y, jvp = jax.linearize(f, x)
jvp_result = jvp(v)
print(jvp_result)  # [4., 0.] - directional derivative

Vector-Jacobian Product (VJP)

import jax
import jax.numpy as jnp

def f(x):
    return jnp.array([x[0]**2 + x[1], x[0] * x[1]**2])

x = jnp.array([2., 3.])
v = jnp.array([1., 1.])  # Cotangent vector

# Vector-Jacobian product (used internally by grad)
y, vjp_fn = jax.vjp(f, x)
vjp_result = vjp_fn(v)
print(vjp_result)  # Gradient direction

Practical Example: Linear Regression

import jax
import jax.numpy as jnp
from jax import grad

# Data
X = jnp.array([[1., 2.], [3., 4.], [5., 6.]])
y = jnp.array([1., 2., 3.])

def model(params, x):
    """Linear model: y = w·x + b"""
    w, b = params
    return jnp.dot(x, w) + b

def loss(params, X, y):
    """Mean squared error"""
    preds = jnp.array([model(params, x) for x in X])
    return jnp.mean((preds - y)**2)

# Initial parameters
params = (jnp.array([1., 1.]), 0.)

# Create gradient function
grad_loss = grad(loss)

# Training loop
learning_rate = 0.01
for step in range(100):
    grads = grad_loss(params, X, y)
    # Update: params -= lr * grads (but params are immutable!)
    w_new = params[0] - learning_rate * grads[0]
    b_new = params[1] - learning_rate * grads[1]
    params = (w_new, b_new)

    if step % 20 == 0:
        print(f"Step {step}, Loss: {loss(params, X, y):.4f}")

print(f"Learned w: {params[0]}, b: {params[1]}")

Practical Example: Neural Network

import jax
import jax.numpy as jnp
from jax import grad, random

def relu(x):
    return jnp.maximum(0, x)

def forward(params, x):
    """Simple 2-layer network"""
    w1, b1, w2, b2 = params
    h = relu(jnp.dot(x, w1) + b1)
    return jnp.dot(h, w2) + b2

def loss(params, x, y):
    pred = forward(params, x)
    return jnp.mean((pred - y)**2)

# Initialize
key = random.PRNGKey(0)
params = (
    random.normal(key, (2, 3)) * 0.1,      # w1
    jnp.zeros(3),                           # b1
    random.normal(key, (3, 1)) * 0.1,      # w2
    jnp.zeros(1),                           # b2
)

# Training
grad_loss = grad(loss)
X = random.normal(key, (10, 2))
y = random.normal(key, (10, 1))

for step in range(100):
    grads = grad_loss(params, X, y)

    # Update params
    new_params = tuple(
        p - 0.01 * g for p, g in zip(params, grads)
    )
    params = new_params

print(f"Final loss: {loss(params, X, y):.4f}")

Gotchas and Debugging

Gotcha 1: Gradients w.r.t. Constants

import jax
import jax.numpy as jnp

def f(x):
    const = 5.0  # Constant (not differentiated)
    return x**2 + const

grad_f = jax.grad(f)
print(grad_f(3.0))  # 6.0 (const doesn't affect gradient)

Gotcha 2: Python Control Flow

import jax
import jax.numpy as jnp

def f(x):
    if x > 0:           # ❌ Python control flow
        return x**2
    else:
        return -x**2

grad_f = jax.grad(f)
# Might not work or produce unexpected results!
# Use jax.lax.cond instead

# ✅ Correct: use JAX control flow
def f_correct(x):
    return jax.lax.cond(
        x > 0,
        lambda: x**2,
        lambda: -x**2
    )

Gotcha 3: Gradient of Scalar Expected

import jax
import jax.numpy as jnp

def f(x):
    return jnp.array([x**2, x**3])  # Returns vector!

# ❌ This fails
# grad_f = jax.grad(f)  # Error: can't differentiate vector output

# ✅ Use jacobian for vector output
jac_f = jax.jacobian(f)
print(jac_f(2.0))  # Matrix of derivatives

Gotcha 4: Tracing Issues

import jax
import jax.numpy as jnp

counter = 0

def f(x):
    global counter
    counter += 1  # ❌ Impure!
    return x**2

# During tracing, counter is incremented
# But gradient computation sees different behavior

Performance Tips

Tip 1: Jit Compile Gradient Computation

import jax
import jax.numpy as jnp

def loss(params, x, y):
    return jnp.mean((params @ x - y)**2)

# Slow
grad_loss = jax.grad(loss)

# Fast
grad_loss_jit = jax.jit(jax.grad(loss))

Tip 2: Reuse Gradient Functions

# Compile once
grad_loss_jit = jax.jit(jax.grad(loss))

# Reuse many times
for step in range(1000):
    grads = grad_loss_jit(params, X[step], y[step])  # Fast!

Summary

AUTOMATIC DIFFERENTIATION QUICK REFERENCE:

Basic gradient:
    grad_f = jax.grad(f)
    g = grad_f(x)

Multiple args:
    grad_f = jax.grad(f, argnums=(0, 2))
    g0, g2 = grad_f(x0, x1, x2)

Jacobian:
    J = jax.jacobian(f)(x)

Hessian:
    H = jax.hessian(f)(x)

Higher derivatives:
    d2f = jax.grad(jax.grad(f))
    d2f(x)

Next Steps

Checkpoint: Implement gradient descent for y = (x-3)² (minimum at x=3)


Last Updated: 2026-08-09