1.2 First climate gradient#
The simplest climate model is an algebraic statement of energy balance: sunlight absorbed equals infrared radiation emitted. For a planet with albedo (reflectivity) \(\alpha\) receiving solar irradiance \(S_0\) and emitting like a grey body with emissivity \(\varepsilon\),
Using jax.grad take the derivative of this model with respect to albedo.
Outline
Compute the derivative of a model output with
jax.gradand interpret it physicallyVerify a gradient against finite differences
Use
jax.jvpandjax.vjp, and connect them to the tangent linear and adjoint modelsAssess the cost: reverse mode prices all input sensitivities at a few model runs
import jax
import jax.numpy as jnp
import matplotlib.pyplot as plt
jax.config.update("jax_enable_x64", True) # climate work needs double precision
# Running on Colab? First run: %pip install -q optax equinox diffrax
The model, and its gradient#
S0 = 1361.0 # solar constant, W m^-2
sigma = 5.67e-8 # Stefan-Boltzmann constant, W m^-2 K^-4
def equilibrium_temperature(albedo, emissivity=0.61):
"""Global-mean surface temperature (K) in radiative equilibrium."""
return (S0 * (1.0 - albedo) / (4.0 * emissivity * sigma)) ** 0.25
T = equilibrium_temperature(0.30)
dT_dalbedo = jax.grad(equilibrium_temperature)(0.30)
print(f"T_eq = {T:.1f} K")
print(f"dT/d(albedo) = {dT_dalbedo:.1f} K per unit albedo")
T_eq = 288.1 K
dT/d(albedo) = -102.9 K per unit albedo
With Earth-like numbers (\(\alpha = 0.30\), \(\varepsilon = 0.61\)) the model gives a realistic 288 K. The gradient says that increasing planetary albedo by 0.01 — roughly the difference made by a large volcanic eruption — cools the planet by about 1 K. That is a sensitivity, obtained without running any perturbation experiment.
jax.grad differentiates by the chain rule, applied mechanically to each elementary
operation in the function — the same mathematics you would use by hand, executed by the
computer on the code as written. It is not a numerical approximation. For this function
the derivative also exists in closed form,
\(\partial T_\mathrm{eq}/\partial\alpha = -T_\mathrm{eq}\,/\,[4(1-\alpha)]\),
so we can check that autodiff is exact:
analytic = -T / (4.0 * (1.0 - 0.30))
print(f"autodiff: {dT_dalbedo:.10f}")
print(f"analytic: {analytic:.10f}")
autodiff: -102.8816186994
analytic: -102.8816186994
Always check against finite differences#
Real models have no closed-form derivative, so the standard independent check is a finite difference: perturb the input by a small step \(h\) and difference the outputs, \([f(x+h) - f(x-h)]\,/\,2h\). This estimate carries its own errors — too large an \(h\) misses curvature, too small an \(h\) amplifies floating-point roundoff — which is exactly why autodiff is preferable for the science, and why the finite difference is best used as a check rather than as the method:
def finite_difference(f, x, h):
return (f(x + h) - f(x - h)) / (2.0 * h)
print(" h FD estimate |FD - autodiff|")
for h in [1e-2, 1e-4, 1e-6, 1e-8, 1e-10]:
fd = finite_difference(equilibrium_temperature, 0.30, h)
print(f"{h:8.0e} {fd:14.8f} {abs(fd - dT_dalbedo):.2e}")
h FD estimate |FD - autodiff|
1e-02 -102.88621211 4.59e-03
1e-04 -102.88161916 4.59e-07
1e-06 -102.88161869 1.19e-08
1e-08 -102.88162002 1.32e-06
1e-10 -102.88147223 1.46e-04
The error shrinks as \(h\) decreases, then grows again as roundoff takes over — with the best step giving 8–10 correct digits. Autodiff has no step size and no such trade-off. We will run a check like this on every gradient in this book; Chapter 3.5 turns it into a routine.
Two modes: jvp and vjp#
grad is a convenience wrapper around two more fundamental operations.
Forward mode (jax.jvp, “Jacobian-vector product”) pushes an input perturbation
through the model and returns the resulting output perturbation — the tangent linear model.
Reverse mode (jax.vjp, “vector-Jacobian product”) propagates a sensitivity backwards from output to inputs —
this is the adjoint model. Chapter 0.1 describes the correspondence; here is what
the calls look like:
# forward mode: effect on T of an albedo perturbation of +0.01
_, dT = jax.jvp(equilibrium_temperature, (0.30,), (0.01,))
print(f"tangent linear: dT = {dT:.3f} K for d(albedo) = +0.01")
# reverse mode: sensitivity of T to the input, from one backward pass
T_value, backward = jax.vjp(equilibrium_temperature, 0.30)
(sens,) = backward(1.0) # propagate dT = 1 back to the input
print(f"adjoint: dT/d(albedo) = {sens:.1f} K")
tangent linear: dT = -1.029 K for d(albedo) = +0.01
adjoint: dT/d(albedo) = -102.9 K
The cost#
The two modes differ in what they charge for. Forward mode costs one model evaluation per input direction; reverse mode costs a few model evaluations total, regardless of how many inputs there are, because one backward pass yields the sensitivity to everything at once.
The demonstration below uses a function of 1,000 inputs. Computing all 1,000 sensitivities takes forward mode 1,000 differentiation passes and reverse mode one. (The measured gap is smaller than 1,000× because forward mode evaluates its passes in one batched sweep, but the gap widens without bound as inputs are added, while reverse mode’s cost stays flat.)
import time
key = jax.random.key(0)
M = jax.random.normal(key, (1000, 1000)) / 1000.0
def scalar_diagnostic(v): # many inputs -> one output
return jnp.sum(jnp.tanh(M @ v) ** 2)
v = jnp.ones(1000)
rev = jax.jit(jax.grad(scalar_diagnostic)) # reverse mode: one backward pass
fwd = jax.jit(jax.jacfwd(scalar_diagnostic)) # forward mode: one pass per input
rev(v).block_until_ready(); fwd(v).block_until_ready() # compile both
t = time.perf_counter(); rev(v).block_until_ready()
t_rev = time.perf_counter() - t
t = time.perf_counter(); fwd(v).block_until_ready()
t_fwd = time.perf_counter() - t
print(f"reverse mode: {t_rev*1000:7.1f} ms")
print(f"forward mode: {t_fwd*1000:7.1f} ms ({t_fwd/t_rev:.0f}x slower for 1000 inputs)")
print(f"same result: {bool(jnp.allclose(rev(v), fwd(v)))}")
reverse mode: 0.6 ms
forward mode: 8.8 ms (16x slower for 1000 inputs)
same result: True
The rule of thumb:
You want |
Use |
Cost |
|---|---|---|
Effect of one perturbation on many outputs |
forward ( |
~1 model run |
Sensitivity of one diagnostic to many inputs |
reverse ( |
~a few model runs |
Full Jacobian, few inputs |
|
~1 run per input |
Full Jacobian, few outputs |
|
~1 run per output |
Reverse mode’s constant price is what makes adjoint sensitivity maps (Chapter 2.1), variational data assimilation (2.3), and neural-network training (2.4) affordable. Its hidden cost — memory — is the subject of Chapter 3.2.
Exercises.
Compute \(\partial T_\mathrm{eq}/\partial \varepsilon\) and check it against a finite difference. Is the sign what you expect physically?
The second derivative is one line:
jax.grad(jax.grad(equilibrium_temperature)). Evaluate it and interpret the sign.Use
jax.jacfwdto get \(\partial T/\partial(\alpha, \varepsilon)\) jointly, by rewriting the function to take a single array of both parameters.