88import pytest
99
1010import thermox
11+ from thermox .sampler import _scan_linear_recurrence , uniform_dt
1112from thermox .utils import preprocess_drift_matrix
1213
1314jax .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 )
0 commit comments