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. |
|
@SamDuffield The PR is complete and ready for review. I rewrote the description so it matches the code as it is now (four commits). I have no further changes planned. Anything from here will be in response to your review. I am around this week and will answer within a day. |
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 ($\Sigma_t$ is a few percent for the non-normal cases in the test suite and 17% for a symmetric $A$ with $D = \mathrm{diag}(1, 4, 9)$ . The numbers and the script are in this comment, and the one before it explains why the old tests did not catch it.
thermoxcomputes it from the eigendecomposition ofexpm_vp). The problem is the covariance, and three functions inherit it:sample,conditional.covarianceandlog_prob(and its gradients). Onmain, the relative error ofWhat this PR does
In short: the covariance is exact for any stable drift; the cost is$O(d^3 + T d^2)$ on uniform grids and $O(U d^3 + T d^2)$ on any other grid, with $U$ a constant (53 for
sample, a few hundred forlog_prob); normal drifts take the old path and give the same results as before.Below,$\Sigma(t)$ is the $\Sigma_t$ above (the code calls it $E(t) = e^{-A_y t}$ is the propagator (
cov(t)), andE).For a normal$A_y$ , a single eigendecomposition serves every gap, and this is what $A_y$ the matrices $\Sigma(t)$ share no fixed eigenbasis, so an exact sampler appears to require its own $O(d^3)$ factorization at every step. The way out rests on two observations. The first is a composition law, $\Sigma(a + b) = \Sigma(b) + E(b) \Sigma(a) E(b)^\intercal$ , by which the operators of a few elementary gaps generate the operators of many. The second is smoothness: $\log\det\Sigma(t)$ and $\Sigma(t)^{-1}$ vary smoothly with the gap, so they can be interpolated across a grid instead of factored at each of its steps. The first observation gives the sampler, the second the density.
thermoxhas always exploited. For a non-normalBoth constructions need$\Sigma(t)$ at arbitrary $t$ , evaluated stably, and this is the one new primitive. $h = dt / 2^{12}$ and applies the composition law twelve times, returning $E(dt)$ and $\Sigma(dt)$ with no eigendecomposition; the result is exact for any stable $A_y$ , including $dt = 0$ , and accurate for $|A_y| dt$ up to about $10^5$ . $\mathrm{cond}(V)^2$ , so it is gone.)
transition_expm_and_cov(A, dt)evaluates Van Loan's block exponential at the fine scaleconditional.covariancenow calls it directly, andpreprocess_drift_matrixgains one flag,is_normal. (The eigenbasis formula I posted first turned out to amplify rounding byOn top of this primitive,
sampleandlog_probchoose one of three paths per call (alax.switchonis_normaland on whether the grid is uniform):thermox.linalgand the examples all use one step size. The operator is built once and applied to every step;uniform_dttolerates floating-point rounding and lets the first gap differ, so the[0, burnin * dt, dt, ...]grids thatthermox.linalgbuilds qualify, and the cost issamplebuilds a dyadic ladder. Although a grid oflog_probuses Chebyshev interpolation. The density needslog_probfalls back to oneeighper step atTwo remarks concern gradients, which users take through$d = 64$ , $T = 10^4$ drops to 1.3 GB on the interpolation path (from ~10 GB across runs) and to 0.5 GB on the uniform path (from 9.9 GB). Second, gradients follow the branch taken: at an exactly normal $A_y$ they are those of the old formula, which sees $A_y$ only through $(A_y + A_y^\intercal)/2$ ; this set has measure zero and the behavior is the same on
log_probfor maximum-likelihood estimation (the use the README advertises andtest_MLEexercises). First,condandswitchkeep every branch's residuals for the backward pass, so the per-step branch used to be stored even when another path ran; it now sits underjax.checkpoint, and the peak memory ofjax.grad(log_prob)atmain(noted in the docstring oftransition_cov_eigh).Cost
mainsamplelog_probTests
Every reference in$\Sigma_\infty - e^{-At} \Sigma_\infty e^{-A^\intercal t}$ , and the three are cross-checked against one another before use. Against them the tests verify the covariance, $A$ and to $T$ grows.
tests/test_nonnormal.py(37 tests) is computed independently, by Van Loan's block exponential, by the Lyapunov equation, or from the stationary identitylog_prob, and their gradients with respect tots, and the law of the ladder's draws, whitened step by step over 20 000 irregular gaps. The safeguards are exercised too: each kind of grid reaches its intended path, the fallback is bitwise equal to the per-step path when the check declines a drift, and the number of operators built stays constant astests/test_conditional.pynow compares againstexpmand Van Loan rather than againstthermox's own samples. The full suite, 114 tests, passes on Python 3.12 with jax 0.11.1 and on Python 3.10 with jax 0.6.2, and pre-commit is clean.Unchanged for current users
The interface does not change: no new arguments, and the grid is detected automatically. For normal drifts the outputs are unchanged up to rounding; I compared$10^{-14}$ , bitwise for a symmetric $A$ with $D = I$ . $A$ through the new routine, agrees to $4 \times 10^{-13}$ . What users see of the change is one cost clause in the docstrings of
sample,log_proband its gradient withmainon uniform and irregular grids, and they agree toconditional.covariance, which now reaches everysampleandlog_proband one sentence in the README.Limits
log_probis then correct attransition_expm_and_covreturns NaN beyondexpm_vp), as onmain.Measurements (Apple M4, CPU, float64, jit, best of 3)
Accuracy. The covariance the ladder composes is exact to rounding: against Van Loan on a 300-step jittered grid, the max relative error is$1.1 \times 10^{-12}$ at $d = 16$ and $2.2 \times 10^{-12}$ at $d = 64$ , where the symmetric-part formula of $7.9 \times 10^{-2}$ on the same steps. A 40 000-step ladder trajectory at $d = 8$ reproduces the Lyapunov stationary covariance to $9.9 \times 10^{-3}$ , at the Monte Carlo floor of about $10^{-2}$ . The interpolation is as accurate as its nodes: the relative error of $1.1 \times 10^{-13}$ against the independent reference on every family where the check passes (symmetric; $\mathrm{cond}(V) = 10$ ; stiff, $10^4$ ; triangular with $\mathrm{cond}(V) = 1.6 \times 10^5$ ; $M / \sqrt{d} + 1.1 I$ at $d = 64$ ); the check declines an oscillatory family and $\mathrm{cond}(V) = 1000$ , which take the per-step path.
mainis off by up tolog_probis at mostSampling time. On a grid where every gap differs, the ladder sits an order of magnitude below the per-step path and about thirteen times above the normal-drift floor: at$d = 64$ and $T = 1000$ it takes 0.08 s, against 0.75 s for the per-step path on the same inputs and 0.006 s for upstream's path on a normal drift. At $d = 256$ the ladder takes 0.89 s, and at $d = 512$ , 4.9 s.
Density time. The interpolation's advantage grows with$T$ , because its once-only part barely moves: at $d = 64$ it takes 0.21 s against 0.85 s for the per-step path on the same inputs at $T = 1000$ (gradients 0.90 s), and 0.42 s against 8.6 s at $T = 10^4$ (gradient 1.5 s); at $d = 256$ and $T = 1000$ , 8.0 s against 19.6 s. The band count behind $U$ grows slowly, from 10 at $T = 100$ to 23 at $T = 10^5$ on grids of sorted uniform random times ($U = 170$ and $391$ ).
Uniform grids. The operator-once path runs at or below upstream's normal-drift path itself: at$T = 10^4$ and $d = 64$ , $d = 1024$ , 4.4 s and 3.9 s.
sampletakes 0.012 s andlog_prob0.007 s (normal-drift path, 0.020 s and 0.075 s; per-step path, 9.4 s and 9.3 s); atMemory. Peak memory of$d = 64$ and $T = 10^4$ : 1.3 GB on the interpolation path, 0.5 GB on the uniform path, 7.3 GB on the per-step path.
jax.grad(log_prob)at