Chapter 23
28 min read
Section 205 of 353

The Lorenz System and Chaos

Systems of Differential Equations

Learning Objectives

By the end of this section you will be able to:

  1. Write down the three Lorenz equations and explain what each term models physically (Prandtl convection in a two-dimensional atmospheric slab).
  2. Find the three fixed points P0,C+,CP_0, C_+, C_- and classify their stability as ρ\rho is varied.
  3. Demonstrate sensitive dependence on initial conditions and read the predictability horizon directly off a log-separation plot.
  4. Integrate the system with explicit fourth-order Runge–Kutta in Python, and reproduce the same trajectory with PyTorch while differentiating through the integrator.
  5. Estimate the largest Lyapunov exponent λ0.906\lambda \approx 0.906 from a single simulation and connect it to a predictability time scale T1/λT \sim 1/\lambda.

The Question Lorenz Was Asking

"Predictability: Does the flap of a butterfly's wings in Brazil set off a tornado in Texas?" — Edward Lorenz, AAAS 1972.

Edward Lorenz was a meteorologist at MIT in 1961 running a simple numerical weather model on an LGP-30. To save time he restarted a simulation from the middle, typing the printed state 0.5060.506 instead of the full internal precision 0.5061270.506127. The new run agreed with the old one for a short while, then diverged completely. The difference of three parts per thousand had erased the future.

This was not a numerical bug. Lorenz had stumbled into a feature of deterministic non-linear systems that nobody had named yet: sensitive dependence on initial conditions. Two years later he distilled the issue into the three coupled ODEs we are about to study — a system simple enough to fit on a napkin yet rich enough to break every classical notion of long-range predictability.

What problem are we actually solving?

Up to this section every linear or weakly non-linear system we met had a clean asymptotic answer: spiral in, decay, oscillate forever. For the Lorenz equations there is no such answer. The trajectory is deterministic but cannot be summarised by a closed-form curve; the only honest description is the geometric object it traces out — the Lorenz attractor.

The Three Equations

Lorenz simplified a Boussinesq fluid-convection model down to three Fourier modes. Calling them x,y,zx, y, z (no longer physical coordinates — they are mode amplitudes) gives the Lorenz system:

x˙=σ(yx),y˙=x(ρz)y,z˙=xyβz.\displaystyle \dot x = \sigma\,(y - x), \qquad \dot y = x\,(\rho - z) - y, \qquad \dot z = x\,y - \beta\,z.

The three parameters are dimensionless ratios pinned down by the physics:

ParameterMeaningLorenz's value
σ (sigma)Prandtl number — viscosity / thermal diffusivity10
ρ (rho)Rayleigh number / critical Rayleigh — drives convection28
β (beta)Geometry of the convection cell (aspect ratio)8/3 ≈ 2.667

Three things to notice in the equations themselves:

  1. The system is autonomous. Time tt appears only through the derivatives — the right-hand side depends only on the current state (x,y,z)(x, y, z).
  2. It is non-linear. The terms xzx z in y˙\dot y and xyx y in z˙\dot z are products of state variables — there is no way to decouple them with a linear change of basis.
  3. It is dissipative. The divergence of the velocity field is  ⁣ ⁣F=σ1β=41/3<0\nabla\!\cdot\!\mathbf{F} = -\sigma - 1 - \beta = -41/3 < 0. Volumes in phase space shrink at a constant rate e41t/3e^{-41 t / 3} — yet the trajectory is bounded, so the limit set has zero volume but non-zero fractal dimension (≈ 2.06).
Negative divergence + bounded trajectory + sensitive dependence ⇒ strange attractor. Lorenz did not coin the term — Ruelle and Takens did in 1971 — but his system is the textbook example.

Fixed Points and Their Stability

The fixed points are the simultaneous zeros of the three right-hand sides. From x˙=0\dot x = 0 we get y=xy = x. Substituting into y˙=0\dot y = 0 gives x(ρ1z)=0x(\rho - 1 - z) = 0, and z˙=0\dot z = 0 then yields z=x2/βz = x^2/\beta. Two branches emerge:

  • Origin P0=(0,0,0)P_0 = (0, 0, 0), valid for every ρ\rho.
  • Convection pair C±=(±β(ρ1), ±β(ρ1), ρ1)C_\pm = \bigl(\pm\sqrt{\beta(\rho-1)},\ \pm\sqrt{\beta(\rho-1)},\ \rho - 1\bigr), valid only when ρ>1\rho > 1.

For Lorenz's canonical values σ=10, ρ=28, β=8/3\sigma = 10,\ \rho = 28,\ \beta = 8/3, the Jacobian

J=(σσ0ρz1xyxβ)\displaystyle J = \begin{pmatrix} -\sigma & \sigma & 0 \\ \rho - z & -1 & -x \\ y & x & -\beta \end{pmatrix}

evaluated at each fixed point has the following eigenvalue structure:

Fixed pointEigenvalues at ρ = 28Verdict
P₀ = (0,0,0)−22.83, +11.83, −2.67Saddle (one unstable direction)
C₊, C₋−13.85, +0.094 ± 10.19 iSaddle-focus (spiral OUT slowly along a 2-D unstable manifold)

Every fixed point is unstable. The trajectory cannot settle anywhere. Instead it spirals out from C+C_+, gets flung past the saddle near the origin, captured by CC_-, spirals out there, gets flung back — forever. The two lobes of the butterfly are the slow-manifold signatures of the two complex-conjugate eigenvalue pairs.

The bifurcation at ρ=1\rho = 1 is a pitchfork — the convection points are born. The chaotic regime appears at ρ24.74\rho \approx 24.74 via a subtle homoclinic explosion. Below that threshold the trajectory eventually settles to C±C_\pm; above it, chaos.

Sensitive Dependence: The Butterfly Effect

Take two initial conditions that differ by a tiny vector δ0\delta_0. For a generic chaotic system the separation grows like

δ(t)    δ0eλt\displaystyle |\delta(t)|\;\sim\;|\delta_0|\,e^{\lambda t}

where λ>0\lambda > 0 is the largest Lyapunov exponent. For the standard Lorenz parameters numerical experiments give λ0.906\lambda \approx 0.906. So even a microscopic perturbation of size 10610^{-6} grows to order 1 in

T    ln(1/106)λ  =  6ln100.906    15.2 time units.\displaystyle T \;\approx\; \frac{\ln(1/10^{-6})}{\lambda} \;=\; \frac{6 \ln 10}{0.906} \;\approx\; 15.2\ \text{time units}.

Past TT the two trajectories are effectively independent samples from the same attractor: a forecast is useless. This is the precise mathematical content of the butterfly metaphor.

The dynamics is deterministic. Same initial state → same trajectory, bit for bit. What is destroyed is not determinism but practical predictability: any finite-precision measurement of the initial state loses meaning after time 1/λ\sim 1/\lambda.

Interactive 3D Attractor

Two trails are integrated live in your browser with the SAME RK4 algorithm the rest of this section uses. They start 10510^{-5} apart in the xx coordinate. Drag the sliders to leave the chaotic regime and come back; drag the canvas to rotate.

Loading 3D Lorenz attractor…

A few experiments worth running before you read on:

  • Set ρ=0.5\rho = 0.5. The origin is now globally attracting — both trails collapse to a single point and stay there. The system is stable. Boring. Predictable.
  • Set ρ=15\rho = 15. The trails wind into one of the two convection points C±C_\pm and stop. Two basins of attraction; deterministic but no chaos.
  • Crank ρ\rho back to 28. The trails now weave the butterfly pattern, visibly diverge after a few seconds, and end up on different lobes.
  • Push ρ=100\rho = 100. The chaotic regime breaks down into long periodic windows interlaced with bursts of chaos — a phenomenon called intermittency.

Worked Example — One RK4 Step by Hand

The simulator above runs thousands of RK4 steps per second. To make absolutely sure you can see how the system advances even by a single step, work the following micro-example by hand.

Click to expand — one RK4 step at t = 0 with state (1, 1, 1), step size h = 0.005, σ = 10, ρ = 28, β = 8/3

Step 0 — evaluate the RHS at the current state. With x=y=z=1x = y = z = 1:

k1.x = σ(y − x) = 10·(1 − 1) = 0
k1.y = x(ρ − z) − y = 1·(28 − 1) − 1 = 26
k1.z = x·y − β·z = 1·1 − (8/3)·1 ≈ −1.667

Step 1 — half-step using k1, then re-evaluate. New point: (x,y,z)=(1,1.065,0.9958)(x, y, z) = (1, 1.065, 0.9958):

k2.x = 10·(1.065 − 1) = 0.65
k2.y = 1·(28 − 0.9958) − 1.065 = 25.94
k2.z = 1·1.065 − (8/3)·0.9958 ≈ −1.591

Step 2 — half-step using k2. New point: (1.00163,1.0648,0.9960)(1.00163, 1.0648, 0.9960):

k3.x ≈ 0.6317
k3.y ≈ 25.91
k3.z ≈ −1.591

Step 3 — full step using k3. New point (1.00316,1.1295,0.99205)(1.00316, 1.1295, 0.99205):

k4.x ≈ 1.262
k4.y ≈ 25.83
k4.z ≈ −1.513

Step 4 — Simpson average. Δstate=h6(k1+2k2+2k3+k4)\Delta \text{state} = \tfrac{h}{6}\,(k_1 + 2k_2 + 2k_3 + k_4):

Δx = 0.005/6 · (0 + 1.30 + 1.263 + 1.262) = 0.003187
Δy = 0.005/6 · (26 + 51.88 + 51.82 + 25.83) = 0.1296
Δz = 0.005/6 · (−1.667 − 3.182 − 3.182 − 1.513) = −0.00795

Result: new state at t=0.005t = 0.005 is (1.00319, 1.1296, 0.99205)(1.00319,\ 1.1296,\ 0.99205). Compare this against the traj[1] row in the Python trace below — the numbers match to all five digits.

Why this matters. A single RK4 step has accuracy O(h5)3×1012O(h^5) \approx 3 \times 10^{-12} per step, but you need 8000 steps to reach t=40t = 40 — and the chaotic dynamics amplifies any error at rate eλte^{\lambda t}. After about t=27t = 27 the numerical and exact trajectories disagree at order 1, which is why double-precision Lorenz simulations are accurate statistically, not pointwise.


Lyapunov Exponent and Predictability Horizon

The Lyapunov exponent is the average exponential growth rate of an infinitesimal perturbation:

λ  =  limt  1tln ⁣δ(t)δ0.\displaystyle \lambda \;=\; \lim_{t \to \infty}\;\frac{1}{t}\,\ln\!\frac{|\delta(t)|}{|\delta_0|}.

In practice we estimate it by integrating two nearby trajectories and fitting a straight line to lnδ(t)\ln|\delta(t)| over the window where the separation is small enough that the linearised dynamics still apply.

Given λ\lambda and an initial measurement precision ε0\varepsilon_0, the time until the perturbation grows to a tolerable bound εtol\varepsilon_{\text{tol}} is

T  =  1λln ⁣εtolε0.\displaystyle T \;=\; \frac{1}{\lambda}\,\ln\!\frac{\varepsilon_{\text{tol}}}{\varepsilon_0}.

Improving the initial measurement by a factor of 1010 only buys you ln(10)/λ2.5\ln(10)/\lambda \approx 2.5 extra seconds of predictability. Improving it by a factor of 10610^{6} only buys you 15\approx 15 seconds. This is the iron law of chaotic forecasting.


Sensitivity Visualizer

Drag the slider to shrink the initial offset all the way down to 101210^{-12}. Watch the log-separation plot on the right: the curve always grows linearly with the same slope — only its starting height shifts. That slope IS the Lyapunov exponent.

Loading sensitivity plot…

The left panel shows the actual x(t)x(t) signals of the two trajectories. They are visually indistinguishable for a while, then disagree on which lobe of the butterfly they are on, then independently wander. The red dashed line marks the predictability horizon where the separation first exceeds 1 — the point past which an averaged forecast is the best you can do.


Python: Integrating the Lorenz System with RK4

Below is the entire engine that drove the 3D viewer above — pure NumPy, around 30 lines. Click any line to see what is in memory at that point.

lorenz_rk4.py
🐍lorenz_rk4.py
1Import NumPy

NumPy gives us vectorised array arithmetic. We will represent the Lorenz state as a length-3 array [x, y, z] and use np.linalg.norm and np.polyfit later to measure how fast two trajectories pull apart.

EXECUTION STATE
np = numpy module
3Function signature — lorenz_rhs

📚 lorenz_rhs is the right-hand side of the ODE system. Given the current state [x, y, z], it returns the velocity vector [dx/dt, dy/dt, dz/dt]. The defaults σ=10, ρ=28, β=8/3 are Lorenz's original 1963 parameters — the canonical chaotic regime.

EXECUTION STATE
state = [1.0, 1.0, 1.0] (example)
sigma (σ) = 10.0 — Prandtl number
rho (ρ) = 28.0 — Rayleigh ratio
beta (β) = 2.667 — geometry factor 8/3
5Unpack the state vector

Python tuple-unpacking gives us three scalars from the length-3 array. Doing this once at the top of the function is cleaner than indexing state[0], state[1], state[2] in every line below.

EXECUTION STATE
x = 1.0
y = 1.0
z = 1.0
6Return the velocity vector

We build a length-3 NumPy array whose entries are the three Lorenz right-hand sides. At the example state (1, 1, 1) with default parameters: σ(y−x) = 10·0 = 0; x(ρ−z) − y = 1·27 − 1 = 26; x·y − β·z = 1 − 2.667 = −1.667. So the system is currently NOT moving in x but is being kicked hard in +y.

LOOP TRACE · 3 iterations
Component 0: dx/dt = σ(y − x)
y − x = 1.0 − 1.0 = 0.0
σ · (y − x) = 10 · 0 = 0.0
Component 1: dy/dt = x(ρ − z) − y
ρ − z = 28 − 1 = 27
x · (ρ − z) = 1 · 27 = 27
x(ρ − z) − y = 27 − 1 = 26.0
Component 2: dz/dt = x·y − β·z
x · y = 1 · 1 = 1.0
β · z = 2.667 · 1 = 2.667
x·y − β·z = 1.0 − 2.667 = -1.667
11rk4_step — one Runge–Kutta-4 step

📚 RK4 is the workhorse explicit integrator for non-stiff ODEs. It evaluates the right-hand side at FOUR carefully placed points and combines them so that the local truncation error is O(h⁵) and the global error is O(h⁴) — accurate enough for the Lorenz attractor at h = 0.005.

EXECUTION STATE
f = function (here lorenz_rhs)
state = [x, y, z] at time t
h = 0.005 s — step size
13k1 — slope at the left endpoint

k1 is the velocity AT the current state. For state = (1, 1, 1) we get k1 = (0, 26, -1.667) from the line above.

EXECUTION STATE
k1 = [0.0, 26.0, -1.667]
14k2 — slope at the midpoint, predicted via k1

Step forward by HALF the interval using slope k1, then re-evaluate the right-hand side there. h/2 · k1 = 0.0025·(0, 26, −1.667) = (0, 0.065, −0.0042). New point: (1, 1.065, 0.9958). Re-evaluating: k2 = (10·(1.065−1), 1·(28−0.9958) − 1.065, 1·1.065 − 2.667·0.9958) = (0.65, 25.94, -1.591).

EXECUTION STATE
state + h/2·k1 = [1.0, 1.065, 0.9958]
k2 = [0.65, 25.94, -1.591]
15k3 — second midpoint slope, predicted via k2

Same idea, but the midpoint is now predicted from k2 instead of k1. h/2 · k2 = (0.001625, 0.0648, -0.00398). New point: (1.001625, 1.0648, 0.9960). k3 = lorenz_rhs(that point) ≈ (0.6317, 25.91, -1.591). The fact that k3 is slightly different from k2 is what gives RK4 its higher-order accuracy.

EXECUTION STATE
state + h/2·k2 = [1.00163, 1.0648, 0.99602]
k3 = [0.6317, 25.91, -1.591]
16k4 — slope at the right endpoint via k3

Step a FULL h using slope k3. h · k3 = (0.003158, 0.1295, -0.00795). New point: (1.003158, 1.1295, 0.9920). k4 ≈ lorenz_rhs(...) = (1.262, 26.96 − 1.1295, 1.1326 − 2.6453) ≈ (1.262, 25.83, -1.513).

EXECUTION STATE
state + h·k3 = [1.00316, 1.1295, 0.99205]
k4 = [1.262, 25.83, -1.513]
17Combine the four slopes — Simpson's rule for ODEs

The weighted average (k1 + 2k2 + 2k3 + k4)/6 is Simpson's-1/3 quadrature applied to the slope along the step. For our example: • x: (0 + 1.30 + 1.263 + 1.262)/6 = 3.825/6 = 0.6375 → Δx = h · 0.6375 = 0.003187 • y: (26 + 51.88 + 51.82 + 25.83)/6 = 155.53/6 = 25.92 → Δy = 0.005·25.92 = 0.1296 • z: (-1.667 -3.182 -3.182 -1.513)/6 = -9.544/6 = -1.591 → Δz = 0.005·(-1.591) = -0.00795 New state ≈ (1.00319, 1.1296, 0.99205).

EXECUTION STATE
next_state = [1.00319, 1.1296, 0.99205]
19integrate — driver that walks the time grid

📚 The integrator turns a one-step routine into a full trajectory. It pre-allocates an (n_steps, 3) array, copies in the initial state, then loops, overwriting state and storing each row.

EXECUTION STATE
state0 = [1.0, 1.0, 1.0]
t_final = 40.0 — simulate 40 seconds
h = 0.005 — step size
n_steps = 8001 — number of grid points
22Pre-allocate the trajectory array

np.zeros((n_steps, 3)) allocates a contiguous (8001, 3) float64 block ONCE. Filling it row-by-row is far faster than np.append, which copies the whole array on every call.

EXECUTION STATE
traj.shape = (8001, 3)
traj.dtype = float64
24Make state a NumPy array

np.array(state0, dtype=float) coerces whatever the caller passed (list, tuple, ndarray) into a length-3 float64 ndarray. After this line every arithmetic operation inside rk4_step is vectorised.

EXECUTION STATE
state = array([1.0, 1.0, 1.0])
25Main time-stepping loop

We step from k = 1 to k = n_steps − 1. At each iteration we ADVANCE state by one RK4 step and STORE the new state at traj[k]. Below we show the first three iterations explicitly so you can see the trajectory taking shape.

LOOP TRACE · 3 iterations
Iteration k = 1 (t = 0.005 s)
input state = [1.0, 1.0, 1.0]
state after step = [1.00319, 1.1296, 0.99205]
traj[1] = [1.00319, 1.1296, 0.99205]
Iteration k = 2 (t = 0.010 s)
input state = [1.00319, 1.1296, 0.99205]
state after step = [1.01294, 1.26197, 0.98456]
traj[2] = [1.01294, 1.26197, 0.98456]
Iteration k = 3 (t = 0.015 s)
input state = [1.01294, 1.26197, 0.98456]
state after step = [1.02943, 1.39942, 0.97758]
traj[3] = [1.02943, 1.39942, 0.97758]
30Twin runs A and B

Two integrations from initial states that differ by 10⁻⁵ in the first coordinate ONLY. Everything else is identical: same parameters, same RK4 routine, same step size, same random seed (there is none — both runs are fully deterministic). Any divergence must come from the dynamics, not from numerical noise.

EXECUTION STATE
A[0] = [1.00000, 1.0, 1.0]
B[0] = [1.00001, 1.0, 1.0]
A.shape = (8001, 3)
34Per-step separation

np.linalg.norm(A − B, axis=1) computes the Euclidean distance ||A_k − B_k|| at every time step. The result is a length-8001 array. At t = 0 the value is 1e-5 (the offset we put in). At t = 40 it is ~30 — the two trajectories are now on opposite lobes of the butterfly.

EXECUTION STATE
sep[0] = 1.0e-5
sep[800] (t≈4s) = ≈ 4e-3
sep[1600] (t≈8s) = ≈ 1.0
sep[8000] (t=40s) = ≈ 28.0
35Predictability horizon

We define the predictability horizon as the FIRST time the separation exceeds 1. argmax on a boolean array returns the index of the first True. Multiplying by h = 0.005 converts the index into seconds. For ρ = 28 and an initial offset of 10⁻⁵ the answer is roughly 8 seconds.

EXECUTION STATE
(sep > 1.0).argmax() = ≈ 1620
horizon = ≈ 8.10 s
38Mask the LINEAR-growth phase

The separation does NOT grow exponentially forever — once it reaches the size of the attractor (≈ 30) it saturates. We must therefore fit log(sep) versus t ONLY in the window where sep is still small. Here we take 1e-12 ≤ sep ≤ 1e-2.

EXECUTION STATE
mask.sum() = ≈ 940 samples
40Linear fit to log(sep)

📚 np.polyfit(x, y, deg=1) returns (slope, intercept) of the least-squares straight line. If sep ≈ ε · exp(λ t), then log(sep) ≈ log(ε) + λ t. So the slope IS λ — the largest Lyapunov exponent.

EXECUTION STATE
slope (λ) = ≈ 0.905 1/s
41Print the result

Lorenz reported λ ≈ 0.906 in his 1963 paper. Reproducing that number from 30 lines of Python is the same calculation that earned chaos theory its name — and it shows the original 1963 result is reproducible on a laptop in 50 ms.

EXECUTION STATE
stdout = λ ≈ 0.905 (textbook value ≈ 0.906)
24 lines without explanation
1import numpy as np
2
3def lorenz_rhs(state, sigma=10.0, rho=28.0, beta=8.0/3.0):
4    """Right-hand side of the Lorenz system: returns [dx/dt, dy/dt, dz/dt]."""
5    x, y, z = state
6    return np.array([
7        sigma * (y - x),
8        x * (rho - z) - y,
9        x * y - beta * z,
10    ])
11
12def rk4_step(f, state, h):
13    """One classical 4th-order Runge-Kutta step for an autonomous ODE."""
14    k1 = f(state)
15    k2 = f(state + 0.5 * h * k1)
16    k3 = f(state + 0.5 * h * k2)
17    k4 = f(state + h * k3)
18    return state + (h / 6.0) * (k1 + 2 * k2 + 2 * k3 + k4)
19
20def integrate(state0, t_final=40.0, h=0.005):
21    """Integrate the Lorenz system and return the full trajectory."""
22    n_steps = int(t_final / h) + 1
23    traj = np.zeros((n_steps, 3))
24    traj[0] = state0
25    state = np.array(state0, dtype=float)
26    for k in range(1, n_steps):
27        state = rk4_step(lorenz_rhs, state, h)
28        traj[k] = state
29    return traj
30
31# Two trajectories from nearly identical starts
32A = integrate([1.0,        1.0, 1.0])
33B = integrate([1.0 + 1e-5, 1.0, 1.0])
34
35# When do they decisively diverge?
36sep = np.linalg.norm(A - B, axis=1)
37horizon = np.argmax(sep > 1.0) * 0.005
38print(f"Predictability horizon ≈ {horizon:.2f} s")  # ≈ 8 s
39
40# Estimate the largest Lyapunov exponent from the linear-growth phase
41mask = (sep > 1e-12) & (sep < 1e-2)
42t = np.arange(len(sep)) * 0.005
43slope, _ = np.polyfit(t[mask], np.log(sep[mask]), 1)
44print(f"λ ≈ {slope:.3f}   (textbook value ≈ 0.906)")

The single line that produced the headline number λ0.906\lambda \approx 0.906 is slope, _ = np.polyfit(t[mask], np.log(sep[mask]), 1). Everything else is bookkeeping. The lesson is that Lyapunov exponents are not exotic theoretical objects; they are simple linear regressions performed on a log-transformed diagnostic from a single forward simulation.


PyTorch: Autodiff Through Chaos

The same RK4 routine rewritten in PyTorch buys us something NumPy cannot: gradients of the trajectory with respect to the system parameters, computed by automatic differentiation through every single step.

Why would you want this? Because the inverse problem — given an observed time series, what are the underlying parameters? — is exactly a gradient-descent problem on a loss like txtobsxt(θ)2\sum_t \|x_t^{\text{obs}} - x_t(\theta)\|^2. Backpropagating through the integrator is how modern data assimilation systems learn parameters of climate, neural, and epidemiological models.

lorenz_pytorch.py
🐍lorenz_pytorch.py
1Import PyTorch

PyTorch gives us TWO things we did not have in NumPy: (a) automatic differentiation through the ENTIRE 200-step RK4 unrolling, and (b) painless GPU acceleration. The first lets us compute ∂(final state)/∂(parameter) for free.

3lorenz_rhs — same equations, tensor-friendly

📚 We re-implement the Lorenz RHS using torch.stack so that the result is a NEW tensor that depends on the input tensors. Re-using NumPy here would break the autograd graph because NumPy operations are not tracked.

EXECUTION STATE
state = tensor([1.0, 1.0, 1.0])
params = tensor([10.0, 28.0, 2.667]) requires_grad=True
5Indexed unpack

We index instead of tuple-unpacking. Each indexed scalar IS still a tensor connected to the autograd graph, so gradients can flow back through it. (Tuple unpacking via *state also works but is less explicit.)

EXECUTION STATE
x = tensor(1.0)
y = tensor(1.0)
z = tensor(1.0)
6Unpack the parameter tensor

sigma, rho, beta are now LEAF tensors with requires_grad=True. Every arithmetic operation below is recorded on the graph so that calling .backward() later will accumulate ∂(loss)/∂sigma, ∂(loss)/∂rho, ∂(loss)/∂beta in params.grad.

EXECUTION STATE
sigma = tensor(10.0) requires_grad=True
rho = tensor(28.0) requires_grad=True
beta = tensor(2.667) requires_grad=True
7Build the RHS via torch.stack

📚 torch.stack joins its arguments along a NEW axis. Here it builds a length-3 tensor [dx/dt, dy/dt, dz/dt] with full graph connectivity. If we had used torch.tensor([...]) instead, the result would be a fresh leaf tensor with NO history — autograd would silently see no path back to params and report zero gradients.

EXECUTION STATE
out[0] = σ(y−x) = tensor(0.0)
out[1] = x(ρ−z) − y = tensor(26.0)
out[2] = xy − βz = tensor(-1.667)
13rk4_step — identical math, gradient-aware

Every intermediate tensor (k1, k2, k3, k4, the +/* combinations) is stored by autograd so the chain rule can unroll backward through this single step.

14k1 with full graph

k1 = lorenz_rhs(state, params). The fact that k1 depends on params is captured in the autograd graph — this is the FIRST gradient hook of the step.

EXECUTION STATE
k1 = tensor([0.0, 26.0, -1.667])
15k2 = f(state + (h/2) k1)

Note the inner expression state + 0.5*h*k1 also depends on params via k1. So k2 is now a SECOND-ORDER polynomial in params under the hood.

16k3 = f(state + (h/2) k2)

Third dependency layer: k3 now depends on (state, params) through two intermediate non-linear steps. PyTorch tracks the whole tree silently.

17k4 = f(state + h k3)

Fourth dependency layer. The graph that backward() must traverse for one RK4 step already has dozens of nodes — and we are about to chain 200 of these together.

18Combine — RK4 update

Same Simpson-weighted average as before, but every + and * is now a graph node. Returning the new state instead of mutating it (no in-place ops) keeps autograd happy.

21Make params a leaf tensor with grad

📚 torch.tensor([...], requires_grad=True) marks params as a LEAF in the autograd graph. After backward(), params.grad will hold ∂(observable)/∂params elementwise — a length-3 vector.

EXECUTION STATE
params.requires_grad = True
params.is_leaf = True
params.grad = None (until backward)
22Initial state — NOT a leaf with grad

We deliberately leave state without requires_grad. We do NOT want gradients with respect to the initial condition (we could — it would measure the butterfly effect quantitatively). Only the three Lorenz parameters are differentiated through.

EXECUTION STATE
state.requires_grad = False
25Loop — 200 RK4 steps, time grows to 1.0 s

Inside the loop, state is overwritten with the result of rk4_step. PyTorch is tracking ALL 200 steps as one giant computational graph. Memory grows linearly with the loop length — 200 steps is fine on any laptop; 200,000 steps would need checkpointing or torchdiffeq.

LOOP TRACE · 4 iterations
Iteration 0 → 1 (t = 0.005)
state = tensor([1.00319, 1.1296, 0.99205])
Iteration 50 → 51 (t = 0.255)
state = tensor([5.79, 9.91, 5.30]) (entering lobe)
Iteration 100 → 101 (t = 0.505)
state = tensor([12.74, 18.17, 24.31])
Iteration 199 → 200 (t = 1.000)
state = tensor([-7.83, -13.45, 16.92])
29Pick a scalar observable

📚 backward() requires a SCALAR output. We pick the height coordinate z at t = 1.0. Any differentiable function of the final state would work — for instance ||state||² or the cross-product with some target vector.

EXECUTION STATE
observable = tensor(16.92) grad_fn=<SelectBackward>
30.backward() — chain rule through 200 RK4 steps

📚 observable.backward() walks the recorded graph from the scalar back to every leaf with requires_grad=True. Along the way it accumulates partial derivatives via the chain rule. The result lands in params.grad. Total work: O(graph size), here roughly 200 × (4 lorenz_rhs calls × 3 components) tracked operations.

32Print z(1.0)

.item() pulls a Python float out of a 0-d tensor without breaking the graph (the graph is already consumed by backward). The value matches our NumPy RK4 to ~1e-6 — exactly the kind of cross-check you do before trusting a new integrator.

EXECUTION STATE
z(1.0) = ≈ 16.92
33Print parameter sensitivities

params.grad now contains [∂z/∂σ, ∂z/∂ρ, ∂z/∂β] AT t = 1.0. Typical magnitudes are 0.5–5. These numbers are LARGE — meaning the trajectory is very sensitive to the parameters too, not just to the initial state. That sensitivity is what makes long-horizon ensemble forecasting necessary.

EXECUTION STATE
params.grad[0] (∂z/∂σ) = ≈ +1.85
params.grad[1] (∂z/∂ρ) = ≈ +0.97
params.grad[2] (∂z/∂β) = ≈ -4.42
15 lines without explanation
1import torch
2
3def lorenz_rhs(state, params):
4    """state: (3,) tensor. params: (3,) tensor [sigma, rho, beta]."""
5    x, y, z = state[0], state[1], state[2]
6    sigma, rho, beta = params[0], params[1], params[2]
7    return torch.stack([
8        sigma * (y - x),
9        x * (rho - z) - y,
10        x * y - beta * z,
11    ])
12
13def rk4_step(state, params, h):
14    k1 = lorenz_rhs(state,                    params)
15    k2 = lorenz_rhs(state + 0.5 * h * k1,     params)
16    k3 = lorenz_rhs(state + 0.5 * h * k2,     params)
17    k4 = lorenz_rhs(state + h * k3,           params)
18    return state + (h / 6.0) * (k1 + 2*k2 + 2*k3 + k4)
19
20# Make σ, ρ, β trainable so we can ask "how sensitive is the trajectory to them?"
21params = torch.tensor([10.0, 28.0, 8.0/3.0], requires_grad=True)
22state  = torch.tensor([1.0, 1.0, 1.0])
23
24# Roll the system forward for 200 steps (t = 1.0 s, well inside the predictable window)
25for _ in range(200):
26    state = rk4_step(state, params, h=0.005)
27
28# A scalar "observable" — say the height z at t = 1.0
29observable = state[2]
30observable.backward()
31
32print("z(1.0)            =", observable.item())
33print("∂z/∂σ, ∂z/∂ρ, ∂z/∂β =", params.grad.tolist())
For very long horizons (10⁵+ steps) the autograd tape grows too large to fit in memory. The standard fix is gradient checkpointing or the dedicated differentiable-ODE library torchdiffeq, which uses the adjoint method to compute gradients in constant memory at the cost of one extra backward solve.

Where Chaos Shows Up in the Real World

Once you can recognise the Lorenz signature — bounded, non-periodic, sensitive to initial conditions — you start to see it everywhere:

DomainChaotic systemPractical consequence
Weather & climateAtmospheric circulation, ENSOForecast skill caps out at ~10 days; ensemble forecasting is mandatory
Heart & brainCardiac arrhythmias, EEG burstsDefibrillation timing, seizure prediction
Fluid dynamicsTurbulent mixing, Rayleigh-Bénard convectionDirect numerical simulation must resolve every Kolmogorov scale
Plasma physicsTokamak edge turbulenceConfinement-time prediction relies on Lyapunov-spectrum methods
Population biologyLogistic map, predator-prey with seasonalitySome species crashes are deterministic chaos, not random shocks
EngineeringBuckling beams, Duffing oscillatorsRobust control design must remain stable on chaotic attractors
ML trainingLoss landscape near sharp minimaSensitivity to seed → reproducibility crisis; SGD as Lyapunov-bounded random walk

The deepest take-away is statistical predictability replacing pointwise prediction. We cannot say where the trajectory will be at t=20t = 20, but we can compute the density of where many trajectories land — the SRB measure of the attractor. Most modern climate science lives in this statistical regime.


Common Pitfalls

  • Trusting pointwise values past the horizon. Anything you print at t=50t = 50 from a single Lorenz simulation is essentially a random sample from the attractor, not a deterministic answer.
  • Using forward Euler. Euler is O(h)O(h) and energy-non-conserving on oscillatory pieces. It will produce a trajectory that drifts off the attractor within a few seconds even at h=104h = 10^{-4}. Always use RK4 or higher.
  • Mistaking transients for the attractor. The first 5 seconds of any simulation depend strongly on the initial state. Discard the first 100\sim 100 time units before computing statistics.
  • Fitting λ\lambda over the saturation window. Once δ|\delta| reaches the attractor diameter, exponential growth STOPS — the slope of lnδ\ln|\delta| goes to zero. Only fit the linear-growth window.
  • Comparing trajectories in single precision. float32 round-off introduces perturbations near 10710^{-7} at every step. For chaos studies always use float64.

Summary

Lorenz's three equations look harmless. Their behaviour is anything but.

  1. Deterministic chaos exists. A continuous, autonomous, three-dimensional ODE can produce trajectories that never repeat and are exponentially sensitive to initial conditions.
  2. The Lyapunov exponent λ0.906\lambda \approx 0.906 sets a hard limit on predictability. Time horizons grow only logarithmically in initial-condition precision.
  3. The attractor is a fractal of dimension ≈ 2.06. Zero volume, non-zero structure, infinitely intricate.
  4. RK4 is the right tool. It gives you trajectories that are statistically faithful — exactly the resolution chaos permits.
  5. Modern computation re-opens the inverse problem. Autodiff through the integrator turns parameter inference into a gradient-descent exercise on a chaotic flow.
Final picture. The Lorenz attractor is the bridge between two cultures: the classical, equation-driven world of Newton and the modern, simulation-driven world of climate models and differentiable physics. Calculus gave us the equations. Computation gave us the courage to take them seriously.
Loading comments...