Custom Derivatives in JAX#
This notebook covers how to define custom gradient rules in JAX, which is essential for:
Implementing non-standard operations (e.g., numerical solvers)
Improving numerical stability of gradients
Skipping expensive forward computations during backprop
Stopping gradient flow through certain computations
Topics covered:
jax.lax.stop_gradient- Skipping functions in derivativesjax.custom_vjp- Custom reverse-mode gradientsjax.custom_jvp- Custom forward-mode gradientsnondiff_argnums- Non-differentiable argumentsPractical examples: implicit differentiation, straight-through estimators
import jax
import jax.numpy as jnp
from jax import grad, jit, vmap, jacfwd, jacrev
from jax import lax
from jax import custom_vjp, custom_jvp
from functools import partial
import matplotlib.pyplot as plt
jax.config.update("jax_enable_x64", True)
1. Stopping Gradient Flow with stop_gradient#
Sometimes you want to skip certain computations during backpropagation. The jax.lax.stop_gradient function treats its input as a constant during differentiation.
Use cases:#
Target values in reinforcement learning
Detaching parts of a computation graph
Implementing stop-gradient tricks in optimization
# Basic example: stop_gradient
def with_gradient(x):
"""Normal function - gradients flow through."""
return x ** 2 + 3 * x
def without_gradient(x):
"""The squared term is treated as constant."""
return lax.stop_gradient(x ** 2) + 3 * x
x = 2.0
print(f"f(x) = x² + 3x at x = {x}")
print(f"Normal gradient: d/dx[x² + 3x] = 2x + 3 = {grad(with_gradient)(x)}")
print(f"With stop_gradient on x²: d/dx[const + 3x] = 3 = {grad(without_gradient)(x)}")
WARNING:2026-01-10 20:26:23,806:jax._src.xla_bridge:852: An NVIDIA GPU may be present on this machine, but a CUDA-enabled jaxlib is not installed. Falling back to cpu.
f(x) = x² + 3x at x = 2.0
Normal gradient: d/dx[x² + 3x] = 2x + 3 = 7.0
With stop_gradient on x²: d/dx[const + 3x] = 3 = 3.0
# Practical example: Target networks in RL
# In Q-learning, we compute loss = (Q(s,a) - target)²
# where target = r + γ * max_a' Q_target(s', a')
# The target should NOT receive gradients!
def td_loss_wrong(q_value, target_q_value):
"""Wrong: gradients flow to both terms."""
return (q_value - target_q_value) ** 2
def td_loss_correct(q_value, target_q_value):
"""Correct: target is treated as constant."""
return (q_value - lax.stop_gradient(target_q_value)) ** 2
q = 5.0
target = 3.0
# Gradient w.r.t. both inputs
grad_wrong = grad(td_loss_wrong, argnums=(0, 1))(q, target)
grad_correct = grad(td_loss_correct, argnums=(0, 1))(q, target)
print("TD Loss gradients:")
print(f" Without stop_gradient: dL/dq = {grad_wrong[0]}, dL/dtarget = {grad_wrong[1]}")
print(f" With stop_gradient: dL/dq = {grad_correct[0]}, dL/dtarget = {grad_correct[1]}")
print("\nNote: With stop_gradient, the target gradient is 0!")
TD Loss gradients:
Without stop_gradient: dL/dq = 4.0, dL/dtarget = -4.0
With stop_gradient: dL/dq = 4.0, dL/dtarget = 0.0
Note: With stop_gradient, the target gradient is 0!
# More complex example: Partial gradient through a computation
def complex_function(x):
"""Only differentiate through part of the computation."""
# Expensive computation we don't want gradients through
expensive_part = jnp.sin(x ** 2) * jnp.exp(-x)
# Use the value but block gradients
detached = lax.stop_gradient(expensive_part)
# Simple part we DO want gradients through
simple_part = x * detached
return simple_part
x = 1.0
print(f"f(x) = x * stop_gradient(sin(x²)exp(-x))")
print(f"f({x}) = {complex_function(x):.6f}")
print(f"df/dx = stop_gradient(sin(x²)exp(-x)) = {grad(complex_function)(x):.6f}")
print(f"(The inner expression is treated as a constant: {jnp.sin(x**2) * jnp.exp(-x):.6f})")
f(x) = x * stop_gradient(sin(x²)exp(-x))
f(1.0) = 0.309560
df/dx = stop_gradient(sin(x²)exp(-x)) = 0.309560
(The inner expression is treated as a constant: 0.309560)
2. Custom Reverse-Mode Gradients with custom_vjp#
When you need complete control over the backward pass, use custom_vjp. This is useful for:
Implementing gradients through iterative algorithms
Numerical stability improvements
Implicit differentiation
The custom_vjp pattern:#
Define the forward function
Define
fwd: returns (output, residuals_for_backward)Define
bwd: takes (residuals, output_gradient) → input_gradients
# Example 1: Custom gradient for numerical stability
# log(1 + exp(x)) has numerical issues for large x
# Standard implementation can overflow, but we can define stable gradients
@custom_vjp
def safe_softplus(x):
"""Numerically stable softplus: log(1 + exp(x))"""
return jnp.where(x > 20, x, jnp.log1p(jnp.exp(x)))
def safe_softplus_fwd(x):
"""Forward pass: return output and save x for backward."""
y = safe_softplus(x)
return y, x # (output, residuals)
def safe_softplus_bwd(x, g):
"""Backward pass: gradient is sigmoid(x)."""
# d/dx log(1 + exp(x)) = exp(x)/(1 + exp(x)) = sigmoid(x)
return (g * jax.nn.sigmoid(x),) # Must return tuple!
safe_softplus.defvjp(safe_softplus_fwd, safe_softplus_bwd)
# Test
x_test = jnp.array([0.0, 10.0, 100.0, 1000.0])
print("Safe softplus with custom gradient:")
for x in x_test:
y = safe_softplus(x)
dy = grad(safe_softplus)(x)
print(f" x={x:6.1f}: softplus={y:10.4f}, gradient={dy:.6f}")
Safe softplus with custom gradient:
x= 0.0: softplus= 0.6931, gradient=0.500000
x= 10.0: softplus= 10.0000, gradient=0.999955
x= 100.0: softplus= 100.0000, gradient=1.000000
x=1000.0: softplus= 1000.0000, gradient=1.000000
# Example 2: Implicit differentiation through a fixed-point iteration
# Solve: x = cos(x) (find fixed point)
# Instead of differentiating through all iterations, use implicit diff!
# nondiff_argnums specifies which args (tol, max_iter) are non-differentiable
@partial(custom_vjp, nondiff_argnums=(1, 2))
def fixed_point_cos(x0, tol=1e-10, max_iter=100):
"""
Find x such that x = cos(x), starting from x0.
Returns the fixed point (independent of x0 in exact arithmetic).
"""
def body(carry):
x, _ = carry
x_new = jnp.cos(x)
return (x_new, x)
def cond(carry):
x, x_prev = carry
return jnp.abs(x - x_prev) > tol
x_final, _ = lax.while_loop(cond, body, (x0, x0 + 1.0))
return x_final
def fixed_point_cos_fwd(x0, tol, max_iter):
"""Forward pass - same signature as primal."""
x_star = fixed_point_cos(x0, tol, max_iter)
return x_star, x_star # Save fixed point for backward
def fixed_point_cos_bwd(tol, max_iter, x_star, g):
"""
Backward pass - nondiff args come first, then residuals, then g.
At fixed point: x* = cos(x*)
But x* doesn't depend on x0 (it's the unique fixed point)!
So dx*/dx0 = 0.
"""
# Fixed point of cos doesn't depend on initial guess
return (0.0,) # No gradient w.r.t. x0 (only differentiable arg)
fixed_point_cos.defvjp(fixed_point_cos_fwd, fixed_point_cos_bwd)
# Test
x0_values = [0.0, 0.5, 1.0, 2.0]
print("Fixed point of cos(x):")
for x0 in x0_values:
x_star = fixed_point_cos(x0)
grad_x0 = grad(fixed_point_cos)(x0)
print(f" x0={x0}: x* = {x_star:.10f}, dx*/dx0 = {grad_x0}")
print(f"\nVerification: cos(x*) = {jnp.cos(fixed_point_cos(0.5)):.10f}")
Fixed point of cos(x):
x0=0.0: x* = 0.7390851332, dx*/dx0 = 0.0
x0=0.5: x* = 0.7390851333, dx*/dx0 = 0.0
x0=1.0: x* = 0.7390851332, dx*/dx0 = 0.0
x0=2.0: x* = 0.7390851332, dx*/dx0 = 0.0
Verification: cos(x*) = 0.7390851332
# Example 3: Implicit differentiation through a root-finding problem
# Solve f(x, θ) = 0 for x given θ
# df*/dθ = -(df/dx)^{-1} * df/dθ
@custom_vjp
def solve_cubic(theta):
"""
Solve x³ - θ = 0 for x.
Solution: x = θ^(1/3)
"""
# Newton's method
def newton_step(x):
f = x**3 - theta
df = 3 * x**2
return x - f / df
x = jnp.abs(theta) ** (1/3) * jnp.sign(theta) # Good initial guess
for _ in range(10):
x = newton_step(x)
return x
def solve_cubic_fwd(theta):
x_star = solve_cubic(theta)
return x_star, (x_star, theta)
def solve_cubic_bwd(res, g):
"""
Implicit differentiation:
f(x, θ) = x³ - θ = 0
df/dx = 3x², df/dθ = -1
dx*/dθ = -(df/dx)^{-1} * df/dθ = -1/(3x²) * (-1) = 1/(3x²)
"""
x_star, theta = res
dx_dtheta = 1.0 / (3 * x_star**2)
return (g * dx_dtheta,)
solve_cubic.defvjp(solve_cubic_fwd, solve_cubic_bwd)
# Test
theta = 8.0
x_star = solve_cubic(theta)
grad_theta = grad(solve_cubic)(theta)
print(f"Solving x³ = θ for θ = {theta}")
print(f"Solution: x* = {x_star:.10f} (should be {theta**(1/3):.10f})")
print(f"Gradient dx*/dθ = {grad_theta:.10f}")
print(f"Expected: 1/(3x²) = {1/(3*x_star**2):.10f}")
print(f"Also: (1/3)θ^(-2/3) = {(1/3) * theta**(-2/3):.10f}")
Solving x³ = θ for θ = 8.0
Solution: x* = 2.0000000000 (should be 2.0000000000)
Gradient dx*/dθ = 0.0833333333
Expected: 1/(3x²) = 0.0833333333
Also: (1/3)θ^(-2/3) = 0.0833333333
3. Custom Forward-Mode Gradients with custom_jvp#
While custom_vjp handles reverse-mode (backprop), custom_jvp handles forward-mode differentiation.
When to use custom_jvp:#
When forward-mode is more efficient (few inputs, many outputs)
When you need JVP for higher-order derivatives
When the JVP is simpler to express than VJP
# Example: Custom JVP for a special function
@custom_jvp
def my_sin(x):
"""Just sin(x), but we'll define custom forward-mode derivative."""
return jnp.sin(x)
@my_sin.defjvp
def my_sin_jvp(primals, tangents):
"""
JVP rule: returns (primal_output, tangent_output)
For y = sin(x):
dy = cos(x) * dx
"""
x, = primals
x_dot, = tangents
y = my_sin(x)
y_dot = jnp.cos(x) * x_dot
return y, y_dot
# Test with forward-mode AD
from jax import jvp
x = jnp.pi / 4
y, y_dot = jvp(my_sin, (x,), (1.0,)) # tangent = 1.0 gives the derivative
print(f"my_sin(π/4) = {y:.10f} (should be {jnp.sin(x):.10f})")
print(f"d(my_sin)/dx = {y_dot:.10f} (should be cos(π/4) = {jnp.cos(x):.10f})")
my_sin(π/4) = 0.7071067812 (should be 0.7071067812)
d(my_sin)/dx = 0.7071067812 (should be cos(π/4) = 0.7071067812)
# More interesting example: Straight-through estimator
# Forward: hard threshold (non-differentiable)
# Backward: approximate with sigmoid
@custom_jvp
def straight_through_threshold(x):
"""Hard threshold at 0."""
return jnp.where(x > 0, 1.0, 0.0)
@straight_through_threshold.defjvp
def straight_through_threshold_jvp(primals, tangents):
"""
JVP: Use sigmoid derivative as surrogate gradient.
"""
x, = primals
x_dot, = tangents
# Forward: hard threshold
y = straight_through_threshold(x)
# Backward: sigmoid gradient (temperature can be tuned)
temperature = 1.0
sigmoid_grad = jax.nn.sigmoid(x / temperature) * (1 - jax.nn.sigmoid(x / temperature)) / temperature
y_dot = sigmoid_grad * x_dot
return y, y_dot
# Visualize
x_range = jnp.linspace(-3, 3, 100)
y_values = vmap(straight_through_threshold)(x_range)
grad_values = vmap(grad(straight_through_threshold))(x_range)
plt.figure(figsize=(10, 4))
plt.subplot(1, 2, 1)
plt.plot(x_range, y_values, 'b-', linewidth=2)
plt.xlabel('x')
plt.ylabel('y')
plt.title('Straight-Through Threshold (Forward)')
plt.grid(True)
plt.subplot(1, 2, 2)
plt.plot(x_range, grad_values, 'r-', linewidth=2)
plt.xlabel('x')
plt.ylabel('dy/dx')
plt.title('Surrogate Gradient (Backward)')
plt.grid(True)
plt.tight_layout()
plt.show()
4. Non-Differentiable Arguments with nondiff_argnums#
Sometimes functions have arguments that shouldn’t be differentiated (e.g., integers, booleans, configuration). Use nondiff_argnums to specify these.
# Example: Function with configuration argument
# nondiff_argnums goes in the decorator, not defvjp()
@partial(custom_vjp, nondiff_argnums=(1, 2))
def power_with_mode(x, n, use_abs=False):
"""
Compute x^n, optionally using |x|.
n and use_abs are not differentiable.
"""
if use_abs:
return jnp.abs(x) ** n
return x ** n
def power_with_mode_fwd(x, n, use_abs):
"""Forward pass - same signature as primal."""
y = power_with_mode(x, n, use_abs)
return y, x # Only need to save x for backward
def power_with_mode_bwd(n, use_abs, x, g):
"""Backward pass - nondiff args (n, use_abs) come first, then residuals (x), then g."""
if use_abs:
# d/dx |x|^n = n * |x|^(n-1) * sign(x)
grad_x = g * n * jnp.abs(x) ** (n - 1) * jnp.sign(x)
else:
# d/dx x^n = n * x^(n-1)
grad_x = g * n * x ** (n - 1)
return (grad_x,) # Only gradient for x
power_with_mode.defvjp(power_with_mode_fwd, power_with_mode_bwd)
# Test
x = 2.0
print(f"power_with_mode({x}, n=3, use_abs=False) = {power_with_mode(x, 3, False)}")
print(f"Gradient: {grad(power_with_mode)(x, 3, False)} (expected: 3*x² = {3*x**2})")
x = -2.0
print(f"\npower_with_mode({x}, n=3, use_abs=True) = {power_with_mode(x, 3, True)}")
print(f"Gradient: {grad(power_with_mode)(x, 3, True)} (expected: -3*|x|² = {-3*abs(x)**2})")
power_with_mode(2.0, n=3, use_abs=False) = 8.0
Gradient: 12.0 (expected: 3*x² = 12.0)
power_with_mode(-2.0, n=3, use_abs=True) = 8.0
Gradient: -12.0 (expected: -3*|x|² = -12.0)
5. Practical Example: Differentiable Sorting#
Sorting is not differentiable, but we can define a relaxed version with custom gradients.
# Soft sorting using a differentiable relaxation
@partial(custom_vjp, nondiff_argnums=(1,))
def soft_sort(x, temperature=1.0):
"""
Differentiable approximation to sorting.
Uses soft assignment based on pairwise comparisons.
"""
n = len(x)
# For each position i, compute soft weights for which element should go there
# Element j should be at position i if j is the i-th smallest
# Pairwise comparison: how much is x[i] > x[j]?
diff_matrix = x[:, None] - x[None, :] # (n, n)
comparison = jax.nn.sigmoid(diff_matrix / temperature)
# Sum columns to get "rank" of each element
ranks = comparison.sum(axis=0) # Higher rank = larger element
# Convert ranks to soft permutation matrix
positions = jnp.arange(n)
rank_diff = ranks[:, None] - positions[None, :] # (n, n)
perm_matrix = jax.nn.softmax(-rank_diff**2 / temperature, axis=0)
# Apply permutation
sorted_x = perm_matrix.T @ x
return sorted_x
def soft_sort_fwd(x, temperature):
"""Forward pass - same signature as primal."""
y = soft_sort(x, temperature)
return y, x # Save x for backward
def soft_sort_bwd(temperature, x, g):
"""Backward pass - nondiff arg (temperature) comes first, then residuals (x), then g."""
# Use JAX's autodiff for the backward pass
# This works because soft_sort is composed of differentiable ops
grad_x = jacrev(lambda x_: soft_sort(x_, temperature))(x).T @ g
return (grad_x,)
soft_sort.defvjp(soft_sort_fwd, soft_sort_bwd)
# Test
x = jnp.array([3.0, 1.0, 4.0, 1.0, 5.0])
print(f"Original: {x}")
print(f"Hard sort: {jnp.sort(x)}")
print(f"Soft sort (temp=0.1): {soft_sort(x, temperature=0.1)}")
print(f"Soft sort (temp=1.0): {soft_sort(x, temperature=1.0)}")
Original: [3. 1. 4. 1. 5.]
Hard sort: [1. 1. 3. 4. 5.]
Soft sort (temp=0.1): [5. 4.50011349 3.49988652 2.99778911 1. ]
Soft sort (temp=1.0): [4.82967932 4.4469188 3.62642247 1.97001939 1.0704668 ]
6. Practical Example: Implicit Layer#
Neural networks can include implicit layers where the output is defined as the solution to an optimization problem. We use custom_vjp to differentiate through the solution.
# Implicit layer: output is the solution to Ax = b
# We want gradients w.r.t. A and b
@custom_vjp
def solve_linear_system(A, b):
"""Solve Ax = b for x."""
return jnp.linalg.solve(A, b)
def solve_linear_system_fwd(A, b):
x = solve_linear_system(A, b)
return x, (A, x)
def solve_linear_system_bwd(res, g):
"""
Implicit differentiation:
Ax = b
A dx + dA x = db
dx = A^{-1}(db - dA x)
For VJP with output gradient g:
g^T dx = g^T A^{-1} db - g^T A^{-1} dA x
So:
grad_b = A^{-T} g
grad_A = -A^{-T} g x^T (using the identity for matrix gradients)
"""
A, x = res
# Solve A^T v = g
v = jnp.linalg.solve(A.T, g)
grad_b = v
grad_A = -jnp.outer(v, x)
return (grad_A, grad_b)
solve_linear_system.defvjp(solve_linear_system_fwd, solve_linear_system_bwd)
# Test
A = jnp.array([[2.0, 1.0], [1.0, 3.0]])
b = jnp.array([1.0, 2.0])
x = solve_linear_system(A, b)
print(f"Solution x = {x}")
print(f"Verification Ax = {A @ x} (should be {b})")
# Compute gradient of x[0] w.r.t. A and b
def objective(A, b):
x = solve_linear_system(A, b)
return x[0]
grad_A, grad_b = grad(objective, argnums=(0, 1))(A, b)
print(f"\nGradient of x[0] w.r.t. b: {grad_b}")
print(f"Gradient of x[0] w.r.t. A:\n{grad_A}")
Solution x = [0.2 0.6]
Verification Ax = [1. 2.] (should be [1. 2.])
Gradient of x[0] w.r.t. b: [ 0.6 -0.2]
Gradient of x[0] w.r.t. A:
[[-0.12 -0.36]
[ 0.04 0.12]]
7. Common Patterns Summary#
Pattern 1: Stopping gradients#
# Use stop_gradient to treat part of computation as constant
def loss(x, target):
return (x - lax.stop_gradient(target)) ** 2
Pattern 2: Custom VJP for reverse-mode#
@custom_vjp
def my_function(x):
return ...
def my_function_fwd(x):
y = my_function(x)
return y, residuals # Save what you need for backward
def my_function_bwd(residuals, g):
return (grad_x,) # Tuple of gradients for each input
my_function.defvjp(my_function_fwd, my_function_bwd)
Pattern 3: Custom JVP for forward-mode#
@custom_jvp
def my_function(x):
return ...
@my_function.defjvp
def my_function_jvp(primals, tangents):
x, = primals
x_dot, = tangents
y = my_function(x)
y_dot = ... # Compute tangent output
return y, y_dot
Pattern 4: Non-differentiable arguments#
from functools import partial
# nondiff_argnums goes in the decorator
@partial(custom_vjp, nondiff_argnums=(1,))
def f(x, config):
return ...
def f_fwd(x, config):
# Same signature as primal
return f(x, config), x # residuals
def f_bwd(config, x, g):
# nondiff args come FIRST, then residuals, then g
return (grad_x,) # Only gradient for differentiable args
f.defvjp(f_fwd, f_bwd)
# Final example: Combining everything
# A differentiable argmax using straight-through estimation
@partial(custom_vjp, nondiff_argnums=(1,))
def differentiable_argmax(logits, temperature=1.0):
"""
Forward: hard argmax (one-hot)
Backward: softmax gradient
"""
idx = jnp.argmax(logits)
return jax.nn.one_hot(idx, len(logits))
def differentiable_argmax_fwd(logits, temperature):
"""Forward pass - same signature as primal."""
y = differentiable_argmax(logits, temperature)
return y, logits # Save logits for backward
def differentiable_argmax_bwd(temperature, logits, g):
"""Backward pass - nondiff arg (temperature) comes first, then residuals, then g."""
# Softmax gradient as surrogate
softmax = jax.nn.softmax(logits / temperature)
# Jacobian of softmax: diag(s) - s s^T
jacobian = jnp.diag(softmax) - jnp.outer(softmax, softmax)
grad_logits = jacobian.T @ g / temperature
return (grad_logits,)
differentiable_argmax.defvjp(differentiable_argmax_fwd, differentiable_argmax_bwd)
# Test
logits = jnp.array([1.0, 2.0, 0.5])
one_hot = differentiable_argmax(logits)
print(f"Logits: {logits}")
print(f"One-hot (hard argmax): {one_hot}")
print(f"Softmax (for reference): {jax.nn.softmax(logits)}")
# Gradient of sum(one_hot * logits) w.r.t. logits
def score(logits):
return jnp.sum(differentiable_argmax(logits) * logits)
print(f"\nGradient of selected logit w.r.t. all logits:")
print(f"{grad(score)(logits)}")
Logits: [1. 2. 0.5]
One-hot (hard argmax): [0. 1. 0.]
Softmax (for reference): [0.2312239 0.62853172 0.14024438]
Gradient of selected logit w.r.t. all logits:
[-0.12911763 1.27755362 -0.14843599]
Summary#
This notebook covered:
lax.stop_gradient- Block gradient flow through parts of computationcustom_vjp- Define custom reverse-mode (backprop) gradientscustom_jvp- Define custom forward-mode gradientsnondiff_argnums- Mark arguments that shouldn’t be differentiated
Key use cases:#
Numerical stability: Custom gradients that avoid numerical issues
Implicit differentiation: Gradients through iterative solvers
Straight-through estimators: Differentiable approximations to discrete operations
Efficiency: Skip expensive forward computations during backprop
Detaching: Stop gradients for target networks, etc.
Best practices:#
Always return a tuple from the backward function, even for single inputs
Use
nondiff_argnumsfor configuration/hyperparametersSave only what you need in the forward pass (residuals)
Test your custom gradients against numerical differentiation