Skip to content

Commit d91475d

Browse files
committed
Factor the transition operator once on uniform grids
1 parent e0908a6 commit d91475d

5 files changed

Lines changed: 267 additions & 25 deletions

File tree

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ To collect samples from this process, define sampling times `ts`, initial state
99
```python
1010
thermox.sample(key, ts, x0, A, b, D)
1111
```
12-
Samples are then collected by exact diagonalization (therefore there is no discretization error) and JAX scans. This holds for any stable drift matrix `A` (not necessarily symmetric or normal) and any positive definite diffusion matrix `D`; when `D^{-1/2} A D^{1/2}` is a normal matrix a cheaper O(d^2)-per-step formula is used automatically.
12+
Samples are then collected by exact diagonalization (therefore there is no discretization error) and JAX scans. This holds for any stable drift matrix `A` (not necessarily symmetric or normal) and any positive definite diffusion matrix `D`; when `D^{-1/2} A D^{1/2}` is a normal matrix, or the time grid is uniform, sampling stays O(d^2) per step.
1313

1414
You can access log-probabilities of the OU process by running `thermox.log_prob`:
1515

‎tests/test_nonnormal.py‎

Lines changed: 116 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import pytest
99

1010
import thermox
11+
from thermox.sampler import _scan_linear_recurrence, uniform_dt
1112
from thermox.utils import preprocess_drift_matrix
1213

1314
jax.config.update("jax_enable_x64", True)
@@ -98,21 +99,29 @@ def transition_logpdf(x1, x0, dt):
9899
)
99100

100101

102+
GRIDS = [
103+
pytest.param(
104+
jnp.array([0.0, 0.1, 0.5, 0.6, 1.4]), id="non-uniform"
105+
), # per-step path
106+
pytest.param(jnp.arange(0.0, 1.5, 0.1), id="uniform"), # factor-once path
107+
]
108+
109+
110+
@pytest.mark.parametrize("ts", GRIDS)
101111
@pytest.mark.parametrize("A,D", NONNORMAL_CASES)
102-
def test_log_prob_matches_reference_gaussian(A, D):
112+
def test_log_prob_matches_reference_gaussian(A, D, ts):
103113
d = A.shape[0]
104114
b = jnp.arange(1.0, d + 1.0)
105-
ts = jnp.array([0.0, 0.1, 0.5, 0.6, 1.4])
106115
xs = jax.random.normal(jax.random.PRNGKey(3), (len(ts), d))
107116
ref = reference_log_prob(ts, xs, A, b, D)
108117
assert jnp.isclose(thermox.log_prob(ts, xs, A, b, D), ref, rtol=1e-8)
109118

110119

120+
@pytest.mark.parametrize("ts", GRIDS)
111121
@pytest.mark.parametrize("A,D", NONNORMAL_CASES)
112-
def test_log_prob_grad_wrt_drift_matches_reference(A, D):
122+
def test_log_prob_grad_wrt_drift_matches_reference(A, D, ts):
113123
d = A.shape[0]
114124
b = jnp.arange(1.0, d + 1.0)
115-
ts = jnp.array([0.0, 0.1, 0.5, 0.6, 1.4])
116125
xs = jax.random.normal(jax.random.PRNGKey(3), (len(ts), d))
117126
g = jax.grad(lambda A: thermox.log_prob(ts, xs, A, b, D))(A)
118127
g_ref = jax.grad(lambda A: reference_log_prob(ts, xs, A, b, D))(A)
@@ -134,9 +143,11 @@ def test_log_prob_grad_symmetric_parametrization_matches_reference():
134143
assert relerr(g, g_ref) < 1e-8
135144

136145

137-
def test_sample_covariance_matches_lyapunov_for_anisotropic_noise():
146+
@pytest.mark.parametrize("jitter", [0.0, 0.5], ids=["uniform-grid", "jittered-grid"])
147+
def test_sample_covariance_matches_lyapunov_for_anisotropic_noise(jitter):
138148
A, D = A_SYM, D_DIAG
139149
ts = jnp.arange(0.0, 20000.0, 0.5)
150+
ts = jnp.sort(ts + jitter * jax.random.uniform(jax.random.PRNGKey(1), ts.shape))
140151
xs = thermox.sample(jax.random.PRNGKey(0), ts, jnp.zeros(3), A, jnp.zeros(3), D)
141152
emp = jnp.cov(xs[2000:].T)
142153
# Monte Carlo error of the sample covariance is ~1e-2 here; the symmetric-part
@@ -189,3 +200,103 @@ def test_preprocess_flags_normality():
189200
# symmetric A becomes non-normal after transforming with anisotropic D
190201
A_y, _ = thermox.preprocess(A_SYM, D_DIAG)
191202
assert not bool(A_y.is_normal)
203+
204+
205+
def ill_conditioned_eigenvectors_case():
206+
# d = 12, diag(1..3) plus 6 x a strictly upper triangular Gaussian: eigenvector
207+
# condition number ~1e9, where a formula in the eigenbasis of A loses everything.
208+
d = 12
209+
upper = jnp.triu(jax.random.normal(jax.random.PRNGKey(0), (d, d)), 1)
210+
return jnp.diag(jnp.linspace(1.0, 3.0, d)) + 6.0 * upper
211+
212+
213+
@pytest.mark.parametrize("t", [0.01, 0.3])
214+
def test_covariance_accurate_for_ill_conditioned_eigenvectors(t):
215+
A = ill_conditioned_eigenvectors_case()
216+
D = jnp.eye(A.shape[0])
217+
assert jnp.linalg.cond(jnp.linalg.eig(A)[1]) > 1e8
218+
cov = thermox.conditional.covariance(t, A, D)
219+
assert relerr(cov, van_loan_covariance(A, D, t)) < 1e-12
220+
221+
222+
def test_covariance_is_zero_at_t_zero():
223+
A = ill_conditioned_eigenvectors_case()
224+
cov = thermox.conditional.covariance(0.0, A, jnp.eye(A.shape[0]))
225+
assert jnp.all(cov == 0.0)
226+
227+
228+
def linalg_grid(burnin, num_samples=100, dt=0.1):
229+
# The grid thermox.linalg builds: x0 at time 0, one gap of burnin * dt, then dt.
230+
ts = jnp.arange(burnin, burnin + num_samples + 1) * dt
231+
return jnp.concatenate([jnp.array([0]), ts])
232+
233+
234+
@pytest.mark.parametrize(
235+
"ts",
236+
[
237+
pytest.param(jnp.arange(0, 1, 0.01), id="readme-arange"),
238+
pytest.param(linalg_grid(0), id="linalg-burnin-0"),
239+
pytest.param(linalg_grid(1), id="linalg-burnin-1"),
240+
pytest.param(linalg_grid(5), id="linalg-burnin-5"),
241+
pytest.param((jnp.arange(0, 10001) * 0.1).astype(jnp.float32), id="float32"),
242+
pytest.param(jnp.linspace(0, 100, 300), id="linspace"),
243+
],
244+
)
245+
def test_uniform_dt_accepts_grids_uniform_up_to_rounding(ts):
246+
is_uniform, dt = uniform_dt(ts)
247+
assert bool(is_uniform)
248+
assert jnp.isclose(dt, ts[2] - ts[1], rtol=1e-6)
249+
250+
251+
def test_uniform_dt_rejects_jittered_grid():
252+
ts = jnp.arange(0, 100, 0.1)
253+
ts = jnp.sort(ts + jax.random.uniform(jax.random.PRNGKey(0), ts.shape) * 0.1)
254+
assert not bool(uniform_dt(ts)[0])
255+
256+
257+
def contracting_matrix(key, d=4):
258+
return jax.scipy.linalg.expm(
259+
-(jax.random.normal(key, (d, d)) / d**0.5 + 3 * jnp.eye(d))
260+
)
261+
262+
263+
@pytest.mark.parametrize("n", [1, 2, 3, 4, 5, 1000, 1001])
264+
def test_scan_linear_recurrence_matches_sequential_scan(n):
265+
k1, k2, k3 = jax.random.split(jax.random.PRNGKey(n), 3)
266+
E = contracting_matrix(k1)
267+
y0 = jax.random.normal(k2, (4,))
268+
u = jax.random.normal(k3, (n, 4))
269+
_, ys = jax.lax.scan(lambda y, u_k: (E @ y + u_k,) * 2, y0, u)
270+
assert jnp.allclose(_scan_linear_recurrence(E, y0, u), ys, rtol=1e-12, atol=1e-12)
271+
272+
273+
def test_uniform_grid_engines_agree_for_ill_conditioned_eigenvectors():
274+
# On a uniform grid both engines apply one exp(-A dt); propagated through the
275+
# eigenbasis of A (per-step path) they disagree at ~1e-10 for this family.
276+
A = ill_conditioned_eigenvectors_case()
277+
d = A.shape[0]
278+
ts = jnp.arange(0.0, 1.0, 0.05)
279+
key = jax.random.PRNGKey(0)
280+
x0, b, D = jnp.ones(d), jnp.zeros(d), jnp.eye(d)
281+
xa = thermox.sample(key, ts, x0, A, b, D, associative_scan=True)
282+
xs = thermox.sample(key, ts, x0, A, b, D, associative_scan=False)
283+
assert relerr(xa, xs) < 1e-12
284+
285+
286+
def test_log_prob_on_uniform_grid_exact_for_ill_conditioned_eigenvectors():
287+
A = ill_conditioned_eigenvectors_case()
288+
d = A.shape[0]
289+
ts = jnp.arange(0.0, 1.0, 0.05)
290+
x0, b, D = jnp.ones(d), jnp.zeros(d), jnp.eye(d)
291+
xs = thermox.sample(jax.random.PRNGKey(0), ts, x0, A, b, D)
292+
lp = thermox.log_prob(ts, xs, A, b, D)
293+
ref = reference_log_prob(ts, xs, A, b, D)
294+
assert jnp.abs(lp - ref) / jnp.abs(ref) < 1e-11
295+
296+
297+
def test_linalg_expm_of_nonsymmetric_matrix():
298+
# expnegm's whitened drift is non-normal for a non-symmetric input: on
299+
# upstream main this estimate is off by 0.6; Monte Carlo noise is ~0.01.
300+
M = jnp.array([[-1.0, 3.0], [0.0, -2.0]])
301+
est = thermox.linalg.expm(M, num_samples=100000, dt=0.1, burnin=0, alpha=1.0)
302+
assert jnp.allclose(est, jax.scipy.linalg.expm(M), atol=1e-1)

‎thermox/prob.py‎

Lines changed: 46 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import jax.numpy as jnp
2-
from jax.lax import fori_loop
2+
from jax.lax import cond, fori_loop
33
from jax import Array, vmap
44

55
from thermox.utils import (
@@ -8,7 +8,12 @@
88
ProcessedDriftMatrix,
99
ProcessedDiffusionMatrix,
1010
)
11-
from thermox.sampler import expm_vp, transition_cov_eigh
11+
from thermox.sampler import (
12+
expm_vp,
13+
transition_cov_eigh,
14+
transition_expm_and_cov,
15+
uniform_dt,
16+
)
1217

1318

1419
def log_prob(
@@ -28,7 +33,8 @@ def log_prob(
2833
Assumes x(t_0) is given deterministically.
2934
3035
Preprocessing (diagonalisation) costs O(d^3) and evaluation then costs O(T * d^2),
31-
where T=len(ts), or O(T * d^3) when D^-0.5 @ A @ D^0.5 is not a normal matrix.
36+
where T=len(ts); when D^-0.5 @ A @ D^0.5 is not a normal matrix,
37+
O(d^3 log T + T * d^2) on a uniform time grid and O(T * d^3) otherwise.
3238
3339
By default, this function does the preprocessing on A and D before the evaluation.
3440
However, the preprocessing can be done externally using thermox.preprocess
@@ -65,7 +71,22 @@ def log_prob_identity_diffusion(
6571
) -> float:
6672
if isinstance(A, Array):
6773
A = preprocess_drift_matrix(A)
74+
if len(ts) < 3:
75+
return _log_prob_identity_diffusion_stepwise(ts, xs, A, b)
76+
# A non-normal A on a uniform grid: build the transition operator once.
77+
is_uniform, dt = uniform_dt(ts)
78+
return cond(
79+
is_uniform & ~A.is_normal,
80+
lambda ts, xs, A, b: _log_prob_identity_diffusion_uniform(ts, xs, A, b, dt),
81+
_log_prob_identity_diffusion_stepwise,
82+
ts,
83+
xs,
84+
A,
85+
b,
86+
)
87+
6888

89+
def _log_prob_identity_diffusion_stepwise(ts, xs, A, b):
6990
def transition_mean(y, dt):
7091
return b + expm_vp(A, y - b, dt)
7192

@@ -89,3 +110,25 @@ def mahalanobis_and_log_det(w, U):
89110
)
90111

91112
return log_prob_val.real
113+
114+
115+
def _log_prob_identity_diffusion_uniform(ts, xs, A, b, dt):
116+
"""log_prob_identity_diffusion on a uniform grid: the residuals of all
117+
steps with gap dt at once, one eigendecomposition of their common
118+
covariance, and one term for the first gap."""
119+
E1, cov1 = transition_expm_and_cov(A.val, ts[1] - ts[0])
120+
E, cov = transition_expm_and_cov(A.val, dt)
121+
residuals1 = xs[1] - b - E1 @ (xs[0] - b)
122+
residuals = xs[2:] - b - (xs[1:-1] - b) @ E.T
123+
124+
def log_density(cov, r):
125+
w, U = jnp.linalg.eigh(cov)
126+
w = jnp.where(w < 1e-20, 1e-20, w)
127+
z = (r @ U) / jnp.sqrt(w)
128+
n, d = r.shape
129+
return (
130+
-jnp.sum(z * z) / 2
131+
- n * (jnp.sum(jnp.log(w)) + d * jnp.log(2 * jnp.pi)) / 2
132+
)
133+
134+
return log_density(cov1, residuals1[None]) + log_density(cov, residuals)

‎thermox/sampler.py‎

Lines changed: 103 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,8 @@ def sample(
2727
by using exact diagonalization.
2828
2929
Preprocessing (diagonalization) costs O(d^3) and sampling costs O(T * d^2),
30-
where T=len(ts), or O(T * d^3) when D^-0.5 @ A @ D^0.5 is not a normal matrix.
30+
where T=len(ts); when D^-0.5 @ A @ D^0.5 is not a normal matrix,
31+
O(d^3 log T + T * d^2) on a uniform time grid and O(T * d^3) otherwise.
3132
3233
If associative_scan=True then jax.lax.associative_scan is used which will run in
3334
time O((T/p + log(T)) * d^2) on a GPU/TPU with p cores, still with
@@ -69,10 +70,26 @@ def sample_identity_diffusion(
6970
b: Array,
7071
associative_scan: bool = True,
7172
) -> Array:
73+
if isinstance(A, Array):
74+
A = preprocess_drift_matrix(A)
7275
if associative_scan:
73-
return _sample_identity_diffusion_associative_scan(key, ts, x0, A, b)
76+
stepwise = _sample_identity_diffusion_associative_scan
7477
else:
75-
return _sample_identity_diffusion_scan(key, ts, x0, A, b)
78+
stepwise = _sample_identity_diffusion_scan
79+
if len(ts) < 3:
80+
return stepwise(key, ts, x0, A, b)
81+
# A non-normal A on a uniform grid: build the transition operator once.
82+
is_uniform, dt = uniform_dt(ts)
83+
return jax.lax.cond(
84+
is_uniform & ~A.is_normal,
85+
lambda *args: _sample_identity_diffusion_uniform(*args, dt, associative_scan),
86+
stepwise,
87+
key,
88+
ts,
89+
x0,
90+
A,
91+
b,
92+
)
7693

7794

7895
def expm_vp(A, v, dt):
@@ -82,16 +99,31 @@ def expm_vp(A, v, dt):
8299
return out.real
83100

84101

102+
def transition_expm_and_cov(A, dt, n_doublings=12):
103+
"""exp(-A dt) and int_0^dt exp(-A s) exp(-A^T s) ds for a d x d matrix A,
104+
without an eigendecomposition: Van Loan's block exponential at
105+
h = dt / 2**n_doublings, then n_doublings steps of E(2h) = E(h)^2 and
106+
cov(2h) = cov(h) + E(h) cov(h) E(h)^T. Exact for any stable A, including
107+
dt = 0, for ||A|| dt up to about 1e5 with the default n_doublings.
108+
"""
109+
d = A.shape[0]
110+
h = dt / 2**n_doublings
111+
zeros, eye = jnp.zeros((d, d), dtype=A.dtype), jnp.eye(d, dtype=A.dtype)
112+
# h is small, so expm needs few squarings; its loop always runs max_squarings.
113+
F = jax.scipy.linalg.expm(jnp.block([[-A, eye], [zeros, A.T]]) * h, max_squarings=4)
114+
E = F[:d, :d]
115+
cov = F[:d, d:] @ E.T
116+
for _ in range(n_doublings):
117+
cov = cov + E @ cov @ E.T
118+
E = E @ E
119+
return E, 0.5 * (cov + cov.T)
120+
121+
85122
def transition_cov(A, dt):
86123
"""Covariance of x_dt given x_0 for dx = -A x dt + dW, i.e.
87-
int_0^dt exp(-A s) exp(-A^T s) ds, computed in the eigenbasis of A.
88-
Exact for any stable, diagonalizable A.
124+
int_0^dt exp(-A s) exp(-A^T s) ds. Exact for any stable A.
89125
"""
90-
eigvals_sum = A.eigvals[:, None] + A.eigvals.conj()[None, :]
91-
integral = -jnp.expm1(-eigvals_sum * dt) / eigvals_sum
92-
cov = A.eigvecs @ (A.noise_cov_eigbasis * integral) @ A.eigvecs.conj().T
93-
cov = cov.real
94-
return 0.5 * (cov + cov.T)
126+
return transition_expm_and_cov(A.val, dt)[1]
95127

96128

97129
def transition_cov_eigh(A, dt, apply=lambda w, U: (w, U)):
@@ -130,6 +162,67 @@ def transition_cov_sqrt_vp(A, v, dt):
130162
)
131163

132164

165+
def uniform_dt(ts):
166+
"""Whether the time grid is uniform after its first gap, up to floating-point
167+
rounding of the time stamps, and that step. The first gap is free so that
168+
the grids built by thermox.linalg ([0, burnin * dt, dt, ...]) qualify.
169+
"""
170+
n = len(ts) - 2
171+
dt = (ts[-1] - ts[1]) / n
172+
fitted = ts[1] + dt * jnp.arange(n + 1)
173+
eps = jnp.finfo(jnp.result_type(ts, float)).eps
174+
is_uniform = jnp.max(jnp.abs(ts[1:] - fitted)) <= 1e3 * eps * jnp.max(jnp.abs(ts))
175+
return is_uniform, dt
176+
177+
178+
def _scan_linear_recurrence(E, y0, u):
179+
"""y_k = E y_{k-1} + u_k for k = 1, ..., n, computed like
180+
jax.lax.associative_scan but with the level's power of E passed down: a
181+
combine at depth j applies E ** (2 ** j), one matmul per level, so the
182+
scan costs O(d^3 log n + n d^2) time and O(d^2 log n) memory. Carrying
183+
the power inside the scanned elements instead would store one d x d
184+
matrix per step, O(n d^2) memory.
185+
"""
186+
187+
def scan(elems, M):
188+
# The recursion of jax.lax.associative_scan: combine adjacent pairs,
189+
# recurse on the pairs, fill in the even positions, interleave.
190+
m = elems.shape[0]
191+
if m < 2:
192+
return elems
193+
reduced = elems[0:-1:2] @ M.T + elems[1::2]
194+
odd = scan(reduced, M @ M)
195+
even = jnp.concatenate(
196+
[elems[:1], (odd[:-1] if m % 2 == 0 else odd) @ M.T + elems[2::2]]
197+
)
198+
# Interleave [even0, odd0, even1, odd1, ...]; len(even) is len(odd) or len(odd) + 1.
199+
same = even.shape[0] == odd.shape[0]
200+
zero, rest = jnp.zeros((), even.dtype), [(0, 0, 0)] * (even.ndim - 1)
201+
return jax.lax.pad(even, zero, [(0, int(same), 1)] + rest) + jax.lax.pad(
202+
odd, zero, [(1, int(not same), 1)] + rest
203+
)
204+
205+
return scan(jnp.concatenate([y0[None], u]), E)[1:]
206+
207+
208+
def _sample_identity_diffusion_uniform(key, ts, x0, A, b, dt, associative_scan):
209+
# One transition operator for the first gap, one for dt, applied to the
210+
# same draws as the per-step engines.
211+
E1, cov1 = transition_expm_and_cov(A.val, ts[1] - ts[0])
212+
E, cov = transition_expm_and_cov(A.val, dt)
213+
# The one-sided factor U sqrt(w) of the per-step path, once per operator.
214+
w1, U1 = jnp.linalg.eigh(cov1)
215+
w, U = jnp.linalg.eigh(cov)
216+
z = jax.random.normal(key, (len(ts) - 1,) + x0.shape)
217+
y1 = E1 @ (x0 - b) + U1 @ (jnp.sqrt(jnp.maximum(w1, 0.0)) * z[0])
218+
u = (z[1:] * jnp.sqrt(jnp.maximum(w, 0.0))) @ U.T
219+
if associative_scan:
220+
ys = _scan_linear_recurrence(E, y1, u)
221+
else:
222+
_, ys = jax.lax.scan(lambda y, u_k: (E @ y + u_k,) * 2, y1, u)
223+
return jnp.concatenate([x0[None], y1[None] + b, ys + b])
224+
225+
133226
def _sample_identity_diffusion_scan(
134227
key: Array,
135228
ts: Array,

0 commit comments

Comments
 (0)