Fix transition covariance for non-normal drift matrices - #46
Conversation
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
|
I also looked at why the old tests did not catch this.
|
|
The script below reproduces the numbers above on 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 On this branch: The last column is the empirical covariance of a |
|
Hi!! Thanks for investigating and highlighting this! The |
2e3a750 to
d91475d
Compare
|
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 This second commit is larger than the first, so here is the map when you review: Let me know if you find anything. |
|
Yeah I'd say that supporting non-uniform grids is a requirement |
|
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
|
Update: My idea did not work. I wanted to work in the eigenbasis of 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 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. |
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$A_y = L^{-1} A L$ (with $D = LL^\intercal$ , as built in $A_y$ 's symmetric part
thermoxsimulates SPU trajectories, I noticed thatthermoxis exact only when the transformed drift matrixthermox.preprocess) is normal. This is becausethermoxuses the eigendecomposition ofand evaluates, with$\lambda_s$ the diagonal of $\Lambda_s$ ,
Whereas in principle, we should evaluate
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 $D$ (unless $A$ and $D$ commute); a non-symmetric $A$ is wrong even with $D = I$ .
thermox.linalg.solve/invare not affected;expnegmof a non-normal matrix is), but not with a generalThe mean is not affected:$A_y$ itself (
thermoxcomputes it from the eigendecomposition ofexpm_vp). The problem is the covariance, and three functions inherit it:sample,conditional.covarianceandlog_prob(and its gradients).This PR evaluates$\Sigma_t$ exactly in the eigenbasis of $A_y$ that
thermoxalready computes for the mean: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: $Q$ ($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)$ .
transition_covis this formula;preprocess_drift_matrixadditionally storesnoise_cov_eigbasis) and a normality flag (is_normal);transition_cov_eighis the single place that branches on the flag, andsample,log_probandconditional.covarianceread the covariance only through it. A normalThe 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.