Note
Go to the end to download the full example code.
Linear ODE: exact Lyapunov spectrum#
The simplest possible correctness check for the Benettin/QR engine: for a
linear system x' = A x, the Lyapunov spectrum is exactly the real
parts of the eigenvalues of A – no chaos, no literature value to
trust, just linear algebra. See Tier 0.1 of
the validation guide.
The system. A here is diagonal with entries -1, -2, -5, so the
trajectory decays to the origin along the coordinate axes, and the
eigenvalues (already real, since A is diagonal) are exactly -1, -2, -5.
Every solution contracts, so all three exponents are negative – there is
no chaos to detect, which is the point: this case isolates the numerics of
the Lyapunov engine from any question about the dynamics themselves.
The method (Benettin/QR). Alongside the state x(t), lyapax evolves
a set of d tangent vectors under the linearized dynamics dY/dt = A
Y (for this linear system the “linearization” is just A itself).
Left alone, all tangent vectors collapse onto the single fastest-growing
(here, slowest-decaying) direction and numerically underflow. To prevent
that, every renorm_every steps the tangent matrix Y is
QR-decomposed, Y = Q R; Q (an orthonormal basis) replaces Y for
the next stretch, and the log of the diagonal of R gives that
stretch’s contribution to each exponent. Averaging those contributions
over time and dividing by elapsed time gives the running estimate plotted
below, which converges to Re(eigvals(A)) as the average washes out
the initial transient.
import os
import time
os.environ["JAX_PLATFORMS"] = "cpu"
import jax
import jax.numpy as jnp
import matplotlib.pyplot as plt
import numpy as np
jax.config.update("jax_enable_x64", True)
from lyapax import systems
from lyapax.core import lyapunov_spectrum, ode_problem
Distinct real eigenvalues, no coupling, no chaos. ode_problem turns
the continuous-time rhs into a fixed-dt update (integrator=
"rk4" by default) and bundles state0/dt alongside it, so
lyapunov_spectrum – the Benettin/QR engine described above – only
needs n_steps and the run controls, not a second copy of dt.
A = jnp.diag(jnp.array([-1.0, -2.0, -5.0]))
rhs = systems.linear_system(A)
dt = 1e-3
problem = ode_problem(rhs, state0=jnp.array([0.3, -0.2, 0.5]), dt=dt)
t0 = time.perf_counter()
result = lyapunov_spectrum(
problem, n_steps=20_000, renorm_every=10, t_transient=5.0,
)
elapsed = time.perf_counter() - t0
expected = np.array([-1.0, -2.0, -5.0])
estimate = np.array(result.exponents)
print(f"exact eigenvalues: {expected}")
print(f"lyapax estimate: {estimate}")
print(f"max abs error: {np.max(np.abs(estimate - expected)):.2e}")
print(f"wall time (incl. JIT): {elapsed:.2f}s")
exact eigenvalues: [-1. -2. -5.]
lyapax estimate: [-1.00000095 -1.99999905 -5. ]
max abs error: 9.45e-07
wall time (incl. JIT): 0.67s
Convergence of the running estimate toward the exact eigenvalues. The transient (first 5 time units, not shown) is where the randomly initialized tangent vectors align to the eigendirections – see the note on this in src/lyapax/core.py.
fig, ax = plt.subplots(figsize=(6, 4))
history = np.array(result.history)
times = np.array(result.times)
for i, lam in enumerate(expected):
ax.plot(times, history[:, i], label=rf"$\lambda_{i + 1}$ estimate", color=f"C{i}")
ax.axhline(lam, color=f"C{i}", linestyle="--", alpha=0.5)
ax.set_xlabel("time (post-transient)")
ax.set_ylabel("running Lyapunov exponent estimate")
ax.set_title("Linear ODE: convergence to exact eigenvalues")
ax.legend()
fig.tight_layout()
plt.show()

Total running time of the script: (0 minutes 1.137 seconds)