Matrix Exponential vs Numerical Integration

From Euler steps to exp(At), and how to learn linear dynamics

Author

Etienne Meunier

Published

November 3, 2026

Forecasting a linear dynamic

Introducing the dynamics

In this tutorial we study the linear ODE

\[\frac{dz}{dt} = Az, \quad z(0) = z_0\]

where \(A \in \mathbb{R}^{2 \times 2}\). The behavior of trajectories is entirely governed by the eigenvalues of \(A\). We illustrate with three canonical cases:

Case Matrix Eigenvalues Behavior
Stable node \(A_1 = \begin{pmatrix}-2 & 0 \\ 0 & -0.5\end{pmatrix}\) \(-2,\ -0.5\) Trajectories collapse to the origin along eigenvector directions
Center \(A_2 = \begin{pmatrix}0 & -2 \\ 2 & 0\end{pmatrix}\) \(\pm 2i\) Closed orbits, energy is conserved
Stable degenerate node \(A_3 = \begin{pmatrix}-1 & 1 \\ 0 & -1\end{pmatrix}\) \(-1\) (double) Converges to origin with a \(t e^{-t}\) transient; non-diagonalizable
imports
import numpy as np
import torch
import matplotlib.pyplot as plt
from torchdiffeq import odeint
A1 = torch.tensor([[-2.0, 0.0],
                   [ 0.0,-0.5]], dtype=torch.float64)   # stable node

A2 = torch.tensor([[ 0.0,-2.0],
                   [ 2.0, 0.0]], dtype=torch.float64)   # center

A3 = torch.tensor([[-1.0, 1.0],
                   [ 0.0,-1.0]], dtype=torch.float64)   # stable degenerate node (Jordan block)

cases = [("Stable node",            A1),
         ("Center",                 A2),
         ("Stable degenerate node", A3)]

For a real \(2 \times 2\) matrix, the characteristic polynomial is

\[\det(A - \lambda I) = \lambda^2 - \operatorname{tr}(A)\,\lambda + \det(A) = 0\]

with discriminant \(\delta = \operatorname{tr}(A)^2 - 4\det(A)\). The three cases are:

\[\lambda = \frac{\operatorname{tr}(A)}{2} \pm \begin{cases} \frac{1}{2}\sqrt{\delta} & \delta > 0 \quad \text{(two real eigenvalues)} \\ \frac{i}{2}\sqrt{-\delta} & \delta < 0 \quad \text{(complex conjugate pair)} \\ 0 & \delta = 0 \quad \text{(one repeated real eigenvalue)} \end{cases}\]

Complex eigenvalues always come as conjugate pairs \(a \pm bi\), so it is impossible to have one purely real and one purely imaginary eigenvalue for a real matrix.

Computation: how to evaluate \(e^{At}z_0\)

Computing \(e^{At}z_0\) is the inner loop of both simulation and learning — every gradient step requires evaluating it, possibly for many values of \(t\) or many initial conditions \(z_0\). The right method depends on three questions:

  1. Does cost depend on \(t\)? Some methods pay a fixed upfront cost; others get slower as \(t\) grows.
  2. Many \(t\), one \(z_0\)? E.g. predicting a full trajectory from a single initial condition.
  3. One \(t\), many \(z_0\)? E.g. a batch of initial conditions all propagated to the same horizon.

We compare four methods on these axes. All code uses a shared setup:

A_ex  = A2
z0_ex = torch.tensor([1.0, 0.5], dtype=torch.float64)
t_ex  = torch.tensor(3.0,        dtype=torch.float64)

Diagonalization

If \(A = VDV^{-1}\), then \(e^{At} = V e^{Dt} V^{-1}\), where \(e^{Dt}\) is cheap to compute (diagonal).

For non-diagonalizable \(A\) this fails; Schur decomposition is the fallback.

L, V = torch.linalg.eig(A_ex)
assert torch.linalg.cond(V) < 1e10, 'Matrix A is not diagonalizable'

# Note that V and L entries are potentially complex, although z_t are real in any way as A is real entry
# Thus to allow computation you need to convert the initial condition to complex and then you can safely convert back to real

z_t  = (V @ torch.diag(torch.exp(L * t_ex)) @ torch.linalg.inv(V) @ z0_ex.cdouble()).real

✓ once you have computed the diagonal form you can evaluate the exponential for free for any \(z_0\) and \(t\)

✗ \(A\) needs to be diagonalizable

Approximate \(e^{At}\)

Approximates \(e^{At}\) as a ratio of matrix polynomials with a scaling-and-squaring step for large \(\|At\|\).

expAt = torch.linalg.matrix_exp(A_ex * t_ex)   # (Apparently what is done in pytorch is similar to Padé)
z_t   = expAt @ z0_ex                            # reuse for any z0

✓ works for any matrix (even non-diagonalizable)

✓ once you have computed \(e^{At}\) you can evaluate \(e^{At}z_0\) for free for any \(z_0\)

✗ need to restart the approximation for every new \(t\)

Approximate \(e^{At}z_0\)

We remember that :

\[ \exp(At)z_0 = \sum_{k=0}^{\infty} \frac{1}{k!} A^k t^k z_0 \]

And that the terms of the sum eventually decay (as \(k\) increases the \(k!\) term dominates \(\|A^k z_0\|_2 \leq \sigma_{\max}^k \|z_0\|_2\))

Thus, rather than forming the full \(n \times n\) matrix \(e^{At}\), we could build a basis as \(\mathcal{K}_m = \text{span}\{z_0,\, Az_0,\, A^2z_0,\,\ldots\}\) of dimension \(m \ll n\) and compute :

\[e^{At}z_0 \approx \sum_{j=0}^{M} \frac{1}{j!} K_j t^j\]

This is the general idea, in practice, the Krylov method builds an orthonormal basis of \(\mathcal{K}_m\) to avoid the numerical instability of summing the raw \(A^j z_0\) terms directly.

PyTorch currently has no built-in function for this, but we can use the one from scipy:

from scipy.sparse.linalg import expm_multiply
z_t = expm_multiply(A_ex.numpy() * t_ex.item(), z0_ex.numpy())

✓ works for any matrix (even non-diagonalizable)

✓ once you have computed the basis you can evaluate \(e^{At}z_0\) for free for any \(t\)

✗ need to rebuild the basis for every new \(z_0\)

Numerical integration (odeint)

Solves \(\dot{z} = Az\) by stepping through intermediate times from \(z_0\) to \(t\). Step size is constrained by the stiffness of \(A\) (ratio of largest to smallest \(|\lambda|\)). Naturally efficient for successive \(t\) values on the same trajectory since all intermediate states are already computed.

z_t    = odeint(lambda t, z: A_ex @ z, z0_ex, torch.tensor([0.0, t_ex]))[-1]

✓ works with \(\dot{z}(t) = A(t) z(t)\) (time-varying linear dynamics)

✓ works for any matrix (even non-diagonalizable), although slow if \(\kappa(A)\) is large

✗ need to restart for every new \(t\) and \(z_0\)

Summary and decision tree

Scenario Diagonalization Padé Krylov OdeInt
Many \(t\), many \(z_0\) ✓ ✗ ✗ ✗
One \(t\), many \(z_0\) ✓ ✓ ✗ ✗
Many \(t\), one \(z_0\) ✓ ✗ ✓ ✗
Successive \(t\), one \(z_0\) ✓ ✗ ✗ ✓

flowchart LR
    A["dz/dt = A·z"] --> B{"A constant?"}
    B -- No --> C["odeint"]
    B -- Yes --> D{"A large\nand sparse?"}
    D -- Yes --> E{"Many z₀?"}
    E -- Yes --> F["odeint"]
    E -- No  --> G["Krylov"]
    D -- No  --> H{"Many t or z₀?"}
    H -- Yes --> I["Diagonalization"]
    H -- No  --> J["Padé"]