2121
2222jax .config .update ("jax_enable_x64" , True )
2323
24- from acopula import compile_model , defmodel , marginal , copula
24+ from acopula import compile_model , marginal , copula
2525from tensorflow_probability .substrates import jax as tfp
2626
2727Uniform = tfp .distributions .Uniform
@@ -47,7 +47,6 @@ def generator(self, t):
4747def test_log_jet_matches_raw_benign (d , u_value ):
4848 """At moderate d and non-extreme u, log_jet must agree with raw to
4949 rel err 1e-10 on both forward and gradient."""
50- @defmodel
5150 def model (theta , obs ):
5251 return Frank (theta [0 ])([
5352 marginal (Uniform (0.0 , 1.0 ), obs = obs [j ]) for j in range (d )
@@ -72,14 +71,17 @@ def model(theta, obs):
7271# Stability on pathological inputs (the bug log-jet exists to fix)
7372# ---------------------------------------------------------------------------
7473
75- def test_log_jet_finite_grad_at_d75_extreme ():
76- """Flat Frank at d=75 with all leaves at u=1e-3 makes raw produce
77- NaN gradient via the (denormal Taylor coefficient → 1/x → inf →
78- 0×inf in dot_general) cascade. log_jet must produce a finite
79- gradient."""
74+ def test_log_jet_matches_mpmath_at_d75_extreme ():
75+ """Flat Frank at d=75 with all leaves at u=1e-3 sits in the high-d
76+ extreme regime where the raw bell scan suffers catastrophic
77+ cancellation (NaN gradient on GPU; finite-but-wrong forward on CPU,
78+ off from truth by ~10^3). log_jet is the correct path here, and
79+ must match a 200-digit mpmath reference computed via Frank's
80+ closed-form psi^{(d)} expansion in Stirling numbers."""
81+ from mpmath import mp , mpf , exp , log , fabs , fsum , factorial , stirling2
82+
8083 d = 75
8184
82- @defmodel
8385 def model (theta , obs ):
8486 return Frank (theta [0 ])([
8587 marginal (Uniform (0.0 , 1.0 ), obs = obs [j ]) for j in range (d )
@@ -88,27 +90,77 @@ def model(theta, obs):
8890 init = jnp .array ([1.2 ])
8991 obs = jnp .full ((d ,), 1e-3 )
9092
91- cm_raw = compile_model (model , template = init , method = "bell" )
9293 cm_log = compile_model (model , template = init , method = "bell" , log_jet = True )
9394
94- g_raw = jax .grad (cm_raw .ll_fn , argnums = 1 )(obs , init )
95- g_log = jax .grad (cm_log .ll_fn , argnums = 1 )(obs , init )
96-
97- # Document the bug: raw produces NaN here.
98- assert bool (jnp .any (jnp .isnan (g_raw ))), \
99- "raw should NaN at d=75 flat-Frank with all u=1e-3 — if this " \
100- "starts passing, the bug got fixed elsewhere; either way, log_jet " \
101- "should still be finite."
102- assert bool (jnp .all (jnp .isfinite (g_log ))), \
103- f"log_jet grad must be finite, got { g_log } "
104-
105-
106- def test_log_jet_finite_grad_nested_sp500_pattern ():
107- """11x9 nested Frank with 5/9 extreme leaves per sector. This is
108- the structure that originally triggered the sp500 d=99 failure."""
95+ # 200-digit mpmath reference. Frank's psi^{(d)}(t) has the closed form
96+ # psi^{(d)}(t) = ((-1)^d / theta) * sum_{k=1}^{d} (k-1)! S(d,k) (x/(1-x))^k
97+ # with x = (1 - e^{-theta}) e^{-t}. Single-layer log-density:
98+ # log c = log|psi^{(d)}(t)| + sum_j log|d psi^{-1}/du(u_j)|
99+ # where t = sum_j psi^{-1}(u_j).
100+ mp .dps = 200
101+ th = mpf ("1.2" )
102+ u_val = mpf ("0.001" )
103+
104+ def _alpha (t ):
105+ return 1 - exp (- t )
106+
107+ def _psi_inv (u , t ):
108+ return - log ((1 - exp (- t * u )) / _alpha (t ))
109+
110+ def _log_psi_d_at_t (t_val , t , n ):
111+ x = _alpha (t ) * exp (- t_val )
112+ one_minus_x = 1 - x
113+ terms = [factorial (k - 1 ) * stirling2 (n , k ) * (x / one_minus_x ) ** k
114+ for k in range (1 , n + 1 )]
115+ psi_d = ((- 1 ) ** n / t ) * fsum (terms )
116+ return log (fabs (psi_d ))
117+
118+ def _log_psi_inv_prime (u , t ):
119+ return log (fabs (t * exp (- t * u ) / (1 - exp (- t * u ))))
120+
121+ def _log_density (t ):
122+ t_inner = d * _psi_inv (u_val , t )
123+ return _log_psi_d_at_t (t_inner , t , d ) + d * _log_psi_inv_prime (u_val , t )
124+
125+ ll_ref = float (_log_density (th ))
126+ eps = mpf ("1e-30" )
127+ g_ref = float ((_log_density (th + eps ) - _log_density (th - eps )) / (2 * eps ))
128+
129+ ll_log = float (cm_log .ll_fn (obs , init ))
130+ g_log = float (jax .grad (cm_log .ll_fn , argnums = 1 )(obs , init )[0 ])
131+
132+ np .testing .assert_allclose (
133+ ll_log , ll_ref , rtol = 1e-8 ,
134+ err_msg = f"log_jet forward { ll_log } vs mpmath { ll_ref } " ,
135+ )
136+ np .testing .assert_allclose (
137+ g_log , g_ref , rtol = 1e-8 ,
138+ err_msg = f"log_jet grad { g_log } vs mpmath { g_ref } " ,
139+ )
140+
141+
142+ def test_log_jet_robust_at_nested_sp500_pattern ():
143+ """11x9 nested Frank with 5/9 extreme leaves per sector — the d=99
144+ structure that originally triggered the sp500 NaN. Raw bell scan is
145+ unreliable here in two platform-dependent ways, both manifestations of
146+ the same catastrophic-cancellation fragility in the polynomial-powering
147+ accumulator:
148+
149+ - On GPU: raw forward stays approximately correct, but reverse-mode
150+ autodiff hits 0 * inf inside dot_general and produces NaN gradients.
151+ - On CPU: forward already loses precision in the polynomial scan
152+ (BLAS denormal handling differs from CUDA), so the wrong-but-finite
153+ forward propagates to a wrong-but-finite gradient.
154+
155+ log_jet is robust on both platforms. Its correctness is anchored by
156+ `test_log_jet_matches_mpmath_at_d75_extreme`, which validates the same
157+ Bell-polynomial machinery against a 200-digit mpmath reference. An
158+ mpmath reference for the nested 11x9 case would require reimplementing
159+ acopula's nested scan at arbitrary precision, so we use log_jet as the
160+ de-facto reference here and lock in the *finite-ness* of its gradient
161+ plus the *unreliability* of raw."""
109162 n_sec , sec_size = 11 , 9
110163
111- @defmodel
112164 def model (theta , obs ):
113165 outer = Frank (theta [0 ])
114166 return outer ([
@@ -125,23 +177,30 @@ def model(theta, obs):
125177 cm_raw = compile_model (model , template = init , method = "bell" )
126178 cm_log = compile_model (model , template = init , method = "bell" , log_jet = True )
127179
128- # Forward agreement (raw forward stays finite even where backward NaNs).
129- # Looser tolerance than the benign case: log-space accumulation
130- # through ~100 signed-logsumexp ops on extreme inputs picks up a
131- # few ULPs of drift per op. 1e-7 rel tolerance is the realistic
132- # bound for this kind of high-d extreme-input pipeline.
133180 ll_raw = cm_raw .ll_fn (obs , init )
134181 ll_log = cm_log .ll_fn (obs , init )
135- np .testing .assert_allclose (float (ll_log ), float (ll_raw ), rtol = 1e-7 )
136-
137182 g_raw = jax .grad (cm_raw .ll_fn , argnums = 1 )(obs , init )
138183 g_log = jax .grad (cm_log .ll_fn , argnums = 1 )(obs , init )
139184
140- assert bool (jnp .any (jnp .isnan (g_raw ))), \
141- "raw should NaN on the nested 11x9 pattern with 5 extremes per sector"
185+ # Primary correctness: log_jet stays finite at the extreme.
142186 assert bool (jnp .all (jnp .isfinite (g_log ))), \
143187 f"log_jet grad must be finite, got { g_log } "
144188
189+ # Secondary regression lock: raw is unreliable here. Either failure
190+ # mode counts as "unreliable" — the test passes as long as raw fails
191+ # in one of the two documented ways, and raises if raw silently starts
192+ # producing the right answer (which would mean either the underlying
193+ # bug got fixed elsewhere or the test point drifted out of the
194+ # extreme regime).
195+ raw_nan_grad = bool (jnp .any (jnp .isnan (g_raw )))
196+ raw_wrong_forward = not bool (jnp .allclose (ll_log , ll_raw , rtol = 1e-3 ))
197+ assert raw_nan_grad or raw_wrong_forward , (
198+ f"raw should be unreliable at this point: expected NaN gradient "
199+ f"(GPU) or forward disagreement with log_jet by > 1e-3 (CPU). "
200+ f"Got ll_raw={ float (ll_raw ):.6f} , ll_log={ float (ll_log ):.6f} , "
201+ f"g_raw[0]={ float (g_raw [0 ]):.6f} , g_log[0]={ float (g_log [0 ]):.6f} ."
202+ )
203+
145204
146205# ---------------------------------------------------------------------------
147206# Regression: log_jet=False (default) is byte-identical to before
@@ -152,7 +211,6 @@ def test_default_path_unchanged():
152211 — the log_jet plumbing is invisible when not opted into."""
153212 d = 30
154213
155- @defmodel
156214 def model (theta , obs ):
157215 return Frank (theta [0 ])([
158216 marginal (Uniform (0.0 , 1.0 ), obs = obs [j ]) for j in range (d )
0 commit comments