Skip to content

Fix transition covariance for non-normal drift matrices - #46

Open
junipertcy wants to merge 4 commits into
normal-computing:mainfrom
junipertcy:fix/transition-covariance-nonnormal
Open

Fix transition covariance for non-normal drift matrices#46
junipertcy wants to merge 4 commits into
normal-computing:mainfrom
junipertcy:fix/transition-covariance-nonnormal

Conversation

@junipertcy

@junipertcy junipertcy commented Aug 23, 2026

Copy link
Copy Markdown

Edited to add: In the second commit, the covariance is now built by a block matrix exponential and doubling, not in the eigenbasis; see my comment below.

When teaching myself how thermox simulates SPU trajectories, I noticed that thermox is exact only when the transformed drift matrix $A_y = L^{-1} A L$ (with $D = LL^\intercal$, as built in thermox.preprocess) is normal. This is because thermox uses the eigendecomposition of $A_y$'s symmetric part

$$A_s = \frac{1}{2}(A_y + A_y^\intercal) = V_s \Lambda_s V_s^\intercal$$

and evaluates, with $\lambda_s$ the diagonal of $\Lambda_s$,

$$\tilde{\Sigma}_t = V_s \mathrm{diag}\left(\frac{1 - e^{-2\lambda_s t}}{2\lambda_s}\right) V_s^\intercal = \int_0^t e^{-(A_y + A_y^\intercal)s} ds$$

Whereas in principle, we should evaluate

$$\Sigma_t = \int_0^t e^{-A_y s} e^{-A_y^\intercal s} , ds$$

The two equations are equal if and only if $A_y$ is normal, that is, $A_y$ and $A_y^\intercal$ commute. But normality is a property of the transformed drift, so a symmetric $A$ is fine with $D = \sigma^2 I$ (this is why thermox.linalg.solve/inv are not affected; expnegm of a non-normal matrix is), but not with a general $D$ (unless $A$ and $D$ commute); a non-symmetric $A$ is wrong even with $D = I$.

The mean is not affected: thermox computes it from the eigendecomposition of $A_y$ itself (expm_vp). The problem is the covariance, and three functions inherit it: sample, conditional.covariance and log_prob (and its gradients).

This PR evaluates $\Sigma_t$ exactly in the eigenbasis of $A_y$ that thermox already computes for the mean:

$$\Sigma_t = V \left[ Q_{ij} \frac{1 - e^{-(\lambda_i + \bar{\lambda}_j) t}}{\lambda_i + \bar{\lambda}_j} \right]_{ij} V^{H}, \qquad A_y = V \Lambda V^{-1}, \quad Q = V^{-1} V^{-H},$$

which holds for any stable, diagonalizable $A_y$ and reduces to the old formula when $A_y$ is normal ($V$ unitary, $Q = I$). In code: transition_cov is this formula; preprocess_drift_matrix additionally stores $Q$ (noise_cov_eigbasis) and a normality flag (is_normal); transition_cov_eigh is the single place that branches on the flag, and sample, log_prob and conditional.covariance read the covariance only through it. A normal $A_y$ keeps the existing $O(d^2)$-per-step path, so nothing changes for current users. Otherwise, $\Sigma_t$ is eigendecomposed at each step, $O(d^3)$.

The new tests check covariance, log-probability and its gradient against Van Loan and Lyapunov references that do not go through thermox, and that the normal cases are unchanged. I tried to keep the edits as small as possible.

The transition covariance used by sample, log_prob and conditional.covariance
was built from the eigendecomposition of (A + A^T)/2, which is exact only when
the transformed drift D^-1/2 A D^1/2 is a normal matrix. For other drifts the
samples and log-probabilities were inexact.

- transition_cov computes int_0^dt exp(-A s) exp(-A^T s) ds exactly in the
  eigenbasis of A (any stable, diagonalizable A)
- transition_cov_eigh branches on a normality flag set in preprocessing: the
  existing O(d^2)-per-step formula for normal drifts, one eigendecomposition
  per step otherwise; sample and log_prob read the covariance only through it
- ProcessedDriftMatrix gains noise_cov_eigbasis and is_normal
- tests against Van Loan / Lyapunov references independent of thermox,
  including gradients of log_prob with respect to A; results for normal
  drifts are unchanged
@junipertcy

Copy link
Copy Markdown
Author

I also looked at why the old tests did not catch this. test_log_prob_numeric in tests/test_log_prob.py is the only upstream test whose reference does not come from thermox itself (it integrates the covariance numerically), and its case is non-normal: A symmetric $A$ with a dense $D$, where the covariance is 3.5% off. It asserts rtol=1e-2, though, and the actual discrepancy on main is below $2 \times 10^{-3}$. The reason is that the samples come from thermox's own model; the expected log-likelihood is stationary at the data-generating distribution, so the first-order error averages out over the transitions and what remains (a KL divergence) is second order in the covariance error. With this PR it is about $10^{-6}$, so rtol=1e-4 would fail on main but pass here.

test_mean_and_cov in tests/test_conditional.py does use a non-symmetric $A$ (covariance 3 to 4% off), but it compares conditional.covariance with samples drawn by thermox from the same formula, so the two sides move together and the test cannot see the error. I changed it to compare against expm and Van Loan references instead. Lastly, examples/matrix_exponentials is asymmetric by intent, but it uses orthogonal matrices, which are normal, so it happened to be exact.

@junipertcy

Copy link
Copy Markdown
Author

The script below reproduces the numbers above on main and on this branch.

Standalone check (jax + thermox only): relative Frobenius error of the transition covariance against references that do not go through thermox
"""Relative Frobenius error of thermox's transition covariance against references
that do not go through thermox. Run it on main and on this branch."""
import jax, jax.numpy as jnp, thermox
jax.config.update("jax_enable_x64", True)

def van_loan(A, D, t):  # Sigma_t = int_0^t e^{-As} D e^{-A^T s} ds (block exponential)
    d = A.shape[0]
    F = jax.scipy.linalg.expm(jnp.block([[-A, D], [jnp.zeros((d, d)), A.T]]) * t)
    return F[:d, d:] @ jax.scipy.linalg.expm(-A.T * t)

def lyapunov(A, D):  # stationary covariance: A S + S A^T = D
    d = A.shape[0]; I = jnp.eye(d)
    K = jnp.kron(I, A) + jnp.kron(A, I)
    return jnp.linalg.solve(K, D.reshape(-1, order="F")).reshape(d, d, order="F")

def relerr(X, ref):
    return float(jnp.linalg.norm(X - ref) / jnp.linalg.norm(ref))

def is_normal(A, D):  # of the transformed drift A_y = L^{-1} A L, D = L L^T
    L = jnp.linalg.cholesky(D); Ay = jnp.linalg.solve(L, A @ L)
    return float(jnp.linalg.norm(Ay @ Ay.T - Ay.T @ Ay) / jnp.linalg.norm(Ay) ** 2) < 1e-10

A_sym = jnp.array([[3.0, 2, 1], [2, 4, 2], [1, 2, 5]])
D_dense = jnp.array([[1.0, 0.3, -0.1], [0.3, 1, 0.2], [-0.1, 0.2, 1]])
cases = [
    ("symmetric A, D = I",                          A_sym, jnp.eye(3)),
    ("rotation-like A = [[1,2],[-2,1]], D = I",     jnp.array([[1.0, 2], [-2, 1]]), jnp.eye(2)),
    ("symmetric A, D = diag(1,4,9)",                A_sym, jnp.diag(jnp.array([1.0, 4, 9]))),
    ("symmetric A, dense D   (test_log_prob)",      A_sym, D_dense),
    ("A = [[3,2.5],[2,4]], D = 2I  (test_conditional)", jnp.array([[3.0, 2.5], [2, 4]]), 2 * jnp.eye(2)),
    ("triangular A, D = I",                         jnp.array([[2.0, 1.5, 0], [0, 3, 1.5], [0, 0, 4]]), jnp.eye(3)),
]
print(f"{'case':50s} {'A_y normal':>10s} {'Sigma_t(0.7)':>13s} {'Sigma_inf':>10s} {'sampled Sigma_inf':>18s}")
for name, A, D in cases:
    d = A.shape[0]; S_inf = lyapunov(A, D)
    e_t = relerr(thermox.conditional.covariance(0.7, A, D), van_loan(A, D, 0.7))
    e_inf = relerr(thermox.conditional.covariance(200.0, A, D), S_inf)
    ts = jnp.arange(0.0, 10000.0, 0.5)
    xs = thermox.sample(jax.random.PRNGKey(0), ts, jnp.zeros(d), A, jnp.zeros(d), D)
    e_mc = relerr(jnp.cov(xs[2000:].T), S_inf)
    print(f"{name:50s} {str(is_normal(A, D)):>10s} {e_t:13.1e} {e_inf:10.1e} {e_mc:18.1e}")

On main:

case                                               A_y normal  Sigma_t(0.7)  Sigma_inf  sampled Sigma_inf
symmetric A, D = I                                       True       2.2e-15    5.3e-16            1.6e-02
rotation-like A = [[1,2],[-2,1]], D = I                  True       1.5e-16    0.0e+00            7.2e-03
symmetric A, D = diag(1,4,9)                            False       1.7e-01    2.6e-01            1.2e-01
symmetric A, dense D   (test_log_prob)                  False       3.5e-02    4.7e-02            2.9e-02
A = [[3,2.5],[2,4]], D = 2I  (test_conditional)         False       3.1e-02    4.0e-02            1.4e-02
triangular A, D = I                                     False       6.3e-02    9.6e-02            3.3e-02

On this branch:

case                                               A_y normal  Sigma_t(0.7)  Sigma_inf  sampled Sigma_inf
symmetric A, D = I                                       True       2.8e-15    1.2e-15            1.6e-02
rotation-like A = [[1,2],[-2,1]], D = I                  True       2.9e-16    2.4e-16            7.2e-03
symmetric A, D = diag(1,4,9)                            False       2.4e-15    4.8e-16            1.2e-02
symmetric A, dense D   (test_log_prob)                  False       2.2e-15    1.2e-15            1.3e-02
A = [[3,2.5],[2,4]], D = 2I  (test_conditional)         False       6.6e-16    2.5e-16            2.6e-03
triangular A, D = I                                     False       7.3e-16    5.7e-16            1.7e-02

The last column is the empirical covariance of a thermox.sample trajectory (18k points), so it carries about 1e-2 of Monte Carlo noise on top of any bias; the first two rows show that noise floor.

@SamDuffield

Copy link
Copy Markdown
Contributor

Hi!! Thanks for investigating and highlighting this! The $O(Td^3)$ cost is scaring me though, do you think there might be a way to support non-normal matrices with cost $O(d^3 + Td^2)$ ?

@junipertcy
junipertcy force-pushed the fix/transition-covariance-nonnormal branch from 2e3a750 to d91475d Compare August 26, 2026 06:10
@junipertcy

Copy link
Copy Markdown
Author

Hey Sam, thanks for the speedy reply. Yes! When the time grid is uniform, the transition operator can be built once, from the block matrix exponential, without using the eigenvectors, and applied to every step by a matmul. So a non-normal matrix costs $O(d^3 + T d^2)$, or $O(d^3 \log T + T d^2)$ with the associative scan. Whether we can improve it for arbitrary grids is open though. Is sampling on non-uniform grids something you expect to need?

This second commit is larger than the first, so here is the map when you review: sample and log_prob now first check whether the time grid is uniform after its first gap (like the burn-in grids in thermox.linalg). If it is, and the transformed drift is not normal, we build the transition operator once in transition_expm_and_cov and apply it to every step: for sample with a scan, either the sequential one or a tree, and for log_prob with a single matmul over all steps, since the density needs no scan. I left everything else as it was, and the normal path still agrees with main to rounding.

Let me know if you find anything.

@SamDuffield

Copy link
Copy Markdown
Contributor

Yeah I'd say that supporting non-uniform grids is a requirement

@junipertcy

Copy link
Copy Markdown
Author

Good challenge! I might have a idea (solving a smaller optimization problem at every time step). Let me dig around and update.

On a non-uniform grid, sampling with a non-normal drift factored the
transition covariance at every step, O(T d^3). The noise of every step is
now composed from a fixed set of transition operators, one per binary digit
of the gaps, using cov(a + b) = cov(b) + E(b) cov(a) E(b)^T; the mean is
propagated through the eigenbasis as before.

- _ladder_lattice writes the gaps as integers on a power-of-two lattice
  (2^(e - 52) in float64, 2^(e - 23) in float32), exact to the rounding of
  the time stamps
- _ladder_noise builds M + 1 operator pairs (expm and Cholesky) once and
  applies each to the steps whose gap has that bit set: O(d^3 M + T d^2 M)
- sample_identity_diffusion dispatches: normal drift -> existing path,
  uniform grid -> transition operator once (previous commit), otherwise the
  ladder; both scan engines supported
- tests: composed covariances against the block-exponential reference to
  1e-12, whitened draws over 20 000 irregular steps, engine agreement, vmap
  over keys, zero gaps, operator count independent of T, dispatch, and the
  exactness of the lattice; results for normal drifts and uniform grids are
  unchanged
- d = 64, T = 1000 (CPU, float64): 0.08 s, against 0.75 s for the per-step
  path and 0.006 s for the normal-drift path
On a non-uniform grid, log_prob with a non-normal drift factored the
transition covariance at every step, O(T d^3). The two quantities the
density needs, log det cov(dt) and cov(dt)^-1, are smooth functions of the
gap, so they are now interpolated across the gaps: one panel per octave of
the gaps, 17 Chebyshev nodes per panel, one covariance and eigh per node
built once, and a 17-term sum per step. A run-time check on the last two
Chebyshev coefficients of both series (below 1e-10 of the largest in
float64, 3e-4 in float32, above the covariance routine's own accuracy)
guards the interpolation; when it fails, log_prob takes the existing
per-step path.

- _log_prob_panels: octave panels from frexp (exact edges), the cosine
  transform to Chebyshev coefficients, T_n by the three-term recurrence
  (finite gradients with respect to ts), the check, and the fallback
- log_prob_identity_diffusion dispatches: normal drift -> existing path,
  uniform grid -> transition operator once (second commit), otherwise the
  panels with the per-step path as fallback
- _log_prob_identity_diffusion_stepwise is wrapped in jax.checkpoint: cond
  and switch keep every branch's residuals for the backward pass, so
  without it the per-step factorizations were stored even when another
  path ran (grad memory at d = 64, T = 10 000: 9-15 GB -> 1.3 GB on the
  new path, 9.9 GB -> 0.5 GB on the uniform path); the normal-drift
  gradient recomputes its forward pass in exchange, within 15 % of before
  on CPU
- tests: values against the per-step reference to 1e-10 (float64) and
  3e-4 (float32), gradients with respect to the drift and to ts against
  independent references, an oscillatory drift and a zero gap declined
  with the fallback bitwise equal to the per-step path, operator counts
  independent of T, dispatch; results for normal drifts and uniform grids
  are unchanged
- d = 64, T = 1000 (CPU, float64): 0.21 s against 0.85 s for the per-step
  path; T = 10 000: 0.42 s against 8.5 s; gradients 0.90 s and 1.5 s
@junipertcy

Copy link
Copy Markdown
Author

Update: My idea did not work. I wanted to work in the eigenbasis of $A_y$, but then realized that $\Sigma_t$ will span the the whole $S_d(\mathbb{R})$, so full rank, dim = $d(d+1)/2$. Propagating $\Sigma_t$ across time makes it rotate. That's two dense matrices multiplying with each other. Without assumptions on $A_y$, there is no "smaller optimization problem".

And then I learned that we can use established tricks on exact discretization of linear SDEs (because the transition operators compose along the semigroup) and approximation theory, respectively, to reach the bound that you are asking for. Specifically, we used "dyadic ladder" for sampling and "piecewise Chebyshev interpolation" for the probability.

This gives $O( U d^3 + T d^2 )$ for both. For sampling, $U$ is a true constant. ($U$ is the number of binary digits; float64 has $U=53$ and float32 has $U=24$). For probability density, $U \sim \log T$ because it depends on how spread out the time gaps are. I measured that $U$ can go from 170 at $T = 100$ to about 390 at $T = 10^5$. There's a hard cap of $U = 17 \times 53$ though because we will hit the number of significant binary digits (that's $53$) of a float64, and we always use $17$ nodes to fit each time piece for a similar reason.

This PR has been a wild ride for me! Please ask me questions. Not this weekend, but next week I will have time to iron out the missing details. As of now, the PR is backward compatible, and can take non-normal matrices at uniform or non-uniform grids.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants