Skip to content

Commit ac51a59

Browse files
thisiscamclaude
andcommitted
sampler: thread survival flag through CompiledModel.sample
Under compile_model(..., survival=True) the likelihood interprets leaves as S(T) = 1 - F(T) and scores the copula on the joint survival function. The samplers ignored this: both _sample_once (Marshall-Olkin) and _sample_once_rosenblatt produced U_leaves jointly distributed as the copula, then _reconstruct_obs_from_marginals inverted via F^{-1}(U) regardless of the flag — so synthetic data came from a distribution with correct marginals but with the copula applied to F rather than S, swapping the tails relative to the model the likelihood was optimised for. Fix: persist survival on CompiledModel, thread to both samplers, forward to _reconstruct_obs_from_marginals -> _marginal_quantiles_chunked where the per-leaf inversion becomes F^{-1}(1 - U) when survival=True. Adds 5 round-trip tests covering both sampler methods, the flag plumbing, and a behavioural likelihood-vs-sample consistency check. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 858ed27 commit ac51a59

3 files changed

Lines changed: 177 additions & 6 deletions

File tree

‎acopula/compile.py‎

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -209,12 +209,19 @@ class CompiledModel:
209209
with_censored_mask: True iff this compilation supports a
210210
per-observation censoring mask as a third argument to
211211
``ll_fn``.
212+
survival: True iff this compilation interprets observations as
213+
survival data — the copula models the joint survival
214+
``S(T) = 1 - F(T)``. Stored here so ``sample()`` can apply
215+
the matching ``F^{-1}(1 - U)`` inversion at the leaves and
216+
return data drawn from the same joint distribution that
217+
the likelihood scores.
212218
"""
213219

214220
graph: Any
215221
param_shape: Tuple[int, ...]
216222
obs_shape: Tuple[int, ...]
217223
with_censored_mask: bool
224+
survival: bool
218225
_model_fn: Callable
219226
_flatten_fn: Callable
220227
_ll_fn_jit: Callable
@@ -267,7 +274,13 @@ def sample(
267274
max_cdf_x: float = 1e6,
268275
method: str = "marshall_olkin",
269276
) -> jax.Array:
270-
"""Generate ``n`` samples from the copula at ``params``."""
277+
"""Generate ``n`` samples from the copula at ``params``.
278+
279+
Respects the ``survival`` flag set at compile time — when
280+
``survival=True`` the leaf inversion uses ``F^{-1}(1 - U)`` so
281+
the samples are drawn from the same joint distribution that
282+
the likelihood scores.
283+
"""
271284
from .core import _sample_once, _sample_once_rosenblatt
272285

273286
flat = self._flatten_fn(params)
@@ -276,11 +289,13 @@ def sample(
276289
def sample_one(sample_key):
277290
return _sample_once(
278291
self.graph, sample_key, flat, post_widder_k, max_cdf_x,
292+
survival=self.survival,
279293
)
280294
elif method == "rosenblatt":
281295
def sample_one(sample_key):
282296
return _sample_once_rosenblatt(
283297
self.graph, sample_key, flat,
298+
survival=self.survival,
284299
)
285300
else:
286301
raise ValueError(
@@ -391,6 +406,7 @@ def compile_model(
391406
param_shape=param_shape,
392407
obs_shape=obs_shape,
393408
with_censored_mask=with_censored_mask,
409+
survival=survival,
394410
_model_fn=model_fn,
395411
_flatten_fn=flatten,
396412
_ll_fn_jit=ll_fn_jit,

‎acopula/core.py‎

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -461,9 +461,16 @@ def _marginal_quantiles_chunked(
461461
leaves_in_order: List[Leaf],
462462
params: jax.Array,
463463
U_leaves: jax.Array,
464+
survival: bool = False,
464465
) -> Tuple[List[Tuple[int, ...]], jax.Array]:
465466
"""Compute marginal quantiles for each marginal leaf, grouped by dist_type.
466467
468+
When ``survival=True``, the leaf uniforms ``U_leaves`` are interpreted
469+
as survival probabilities ``S(t) = 1 - F(t)`` rather than CDFs, so we
470+
invert via ``F^{-1}(1 - U)`` to recover ``T``. This matches the
471+
likelihood's ``survival=True`` convention (where ``u = 1 - F(t)``
472+
feeds the copula).
473+
467474
Returns:
468475
obs_indices: list of obs_index tuples in the same order as returned values
469476
obs_values_arr: concatenated array of quantile values
@@ -514,11 +521,14 @@ def _marginal_quantiles_chunked(
514521

515522
starts_arr = jnp.array(starts, dtype=jnp.int32)
516523
u_arr = U_leaves[jnp.array(leaf_is, dtype=jnp.int32)]
524+
# Survival: copula uniforms encode S(T) = 1 - F(T), so the obs
525+
# value T = F^{-1}(1 - U). We apply the flip *before* the quantile.
526+
u_arr_for_q = 1.0 - u_arr if survival else u_arr
517527

518528
if rep_spec is None:
519529
# No dynamic params: instantiate once, then vmap quantile
520530
dist_obj = dist_type(**static_kwargs)
521-
obs_vals = jax.vmap(dist_obj.quantile)(u_arr)
531+
obs_vals = jax.vmap(dist_obj.quantile)(u_arr_for_q)
522532
else:
523533
# Dynamic params: vmap over leaves; each leaf slices params and rebuilds dist
524534
size = rep_spec.size
@@ -530,7 +540,7 @@ def quantile_one(start_i, u_i):
530540
dist_obj = dist_type(**dyn_kwargs, **static_kwargs)
531541
return dist_obj.quantile(u_i)
532542

533-
obs_vals = jax.vmap(quantile_one)(starts_arr, u_arr)
543+
obs_vals = jax.vmap(quantile_one)(starts_arr, u_arr_for_q)
534544

535545
obs_values_list.append(obs_vals)
536546

@@ -562,6 +572,7 @@ def _reconstruct_obs_from_marginals(
562572
graph: Node,
563573
params: jax.Array,
564574
U_leaves: jax.Array,
575+
survival: bool = False,
565576
) -> jax.Array:
566577
"""Reconstruct an obs-shaped tensor from marginal leaves."""
567578
leaves_in_order = _collect_leaves_in_order(graph)
@@ -575,6 +586,7 @@ def _reconstruct_obs_from_marginals(
575586
leaves_in_order=leaves_in_order,
576587
params=params,
577588
U_leaves=U_leaves,
589+
survival=survival,
578590
)
579591
return _scatter_obs_values(
580592
obs_shape=obs_shape,
@@ -1658,6 +1670,7 @@ def _sample_once(
16581670
params: jax.Array,
16591671
post_widder_k: int,
16601672
max_cdf_x: float,
1673+
survival: bool = False,
16611674
) -> jax.Array:
16621675
"""Sample one observation using Marshall-Olkin algorithm.
16631676
@@ -1667,6 +1680,12 @@ def _sample_once(
16671680
3. Build global parameter indexers
16681681
4. Sample V values top-down (internal nodes)
16691682
5. Transform leaves bottom-up (from uniform to final)
1683+
1684+
When ``survival=True``, the leaf-to-obs inversion uses
1685+
``F^{-1}(1 - U)`` instead of ``F^{-1}(U)``, so the returned
1686+
observations are jointly distributed with the copula on
1687+
``S(T) = 1 - F(T)`` — matching the likelihood when
1688+
``compile_model(..., survival=True)``.
16701689
"""
16711690
# Step 1: Compute schedule
16721691
schedule_result = _compute_sampling_schedule(graph)
@@ -1725,7 +1744,7 @@ def _sample_once(
17251744
total_nodes,
17261745
)
17271746
return _reconstruct_obs_from_marginals(
1728-
graph=graph, params=params, U_leaves=U_leaves
1747+
graph=graph, params=params, U_leaves=U_leaves, survival=survival,
17291748
)
17301749

17311750

@@ -2405,6 +2424,7 @@ def _sample_once_rosenblatt(
24052424
graph: Node,
24062425
key: jax.Array,
24072426
params: jax.Array,
2427+
survival: bool = False,
24082428
) -> jax.Array:
24092429
"""Sample one observation using the Rosenblatt transform.
24102430
@@ -2450,7 +2470,7 @@ def _sample_once_rosenblatt(
24502470
U_leaves = U_leaves.at[leaf_idx].set(w[i])
24512471

24522472
return _reconstruct_obs_from_marginals(
2453-
graph=graph, params=params, U_leaves=U_leaves
2473+
graph=graph, params=params, U_leaves=U_leaves, survival=survival,
24542474
)
24552475

24562476
# ---- Nested copula: Bell polynomial-based Rosenblatt (arbitrary depth) ----
@@ -2669,7 +2689,7 @@ def _trace_assembly(start_node, start_t, start_order, start_beta):
26692689
_process_subtree(graph)
26702690

26712691
return _reconstruct_obs_from_marginals(
2672-
graph=graph, params=params, U_leaves=U_leaves
2692+
graph=graph, params=params, U_leaves=U_leaves, survival=survival,
26732693
)
26742694

26752695

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
"""Sampler/likelihood consistency under ``survival=True``.
2+
3+
Until this fix, ``CompiledModel.sample()`` ignored the ``survival``
4+
flag set at compile time: the sampler returned ``T = F^{-1}(U)`` while
5+
the likelihood expected ``U = S(T) = 1 - F(T)`` to be jointly Clayton.
6+
The data drawn from sample() therefore did not come from the
7+
distribution the likelihood scored, with right and left tails swapped
8+
on each margin.
9+
10+
These tests verify the round-trip:
11+
* the survival flag is persisted on CompiledModel;
12+
* survival vs CDF samples drawn from the same key are not bit-equal;
13+
* likelihood scores at the bulk of survival-sampled points are
14+
higher than at points far from that bulk.
15+
"""
16+
from __future__ import annotations
17+
18+
import jax
19+
import jax.numpy as jnp
20+
import jax.random as jrandom
21+
import numpy as np
22+
import pytest
23+
24+
from acopula import compile_model, copula, marginal
25+
26+
27+
jax.config.update("jax_enable_x64", True)
28+
29+
30+
@copula
31+
class Clayton:
32+
theta: float
33+
34+
def generator(self, t: jax.Array) -> jax.Array:
35+
return jnp.power(1.0 + t, -1.0 / self.theta)
36+
37+
def generator_inv(self, u: jax.Array) -> jax.Array:
38+
return jnp.power(u, -self.theta) - 1.0
39+
40+
41+
# Custom Exponential margin with the API expected by acopula's leaves.
42+
class Exponential:
43+
def __init__(self, rate=1.0):
44+
self.rate = rate
45+
46+
@property
47+
def parameters(self):
48+
return {}
49+
50+
def cdf(self, t):
51+
return 1.0 - jnp.exp(-self.rate * t)
52+
53+
def quantile(self, u):
54+
# F^-1(u) = -log(1-u) / rate. We use log1p to keep precision near u=0.
55+
return -jnp.log1p(-u) / self.rate
56+
57+
def log_prob(self, t):
58+
return jnp.log(self.rate) - self.rate * t
59+
60+
61+
def _build_model():
62+
def model(params, obs):
63+
c = Clayton(params[0])
64+
return c([
65+
marginal(Exponential(rate=0.5), obs=obs[i]) for i in range(2)
66+
])
67+
init = jnp.array([2.5])
68+
return model, init
69+
70+
71+
def test_survival_flag_persisted_on_compiled_model():
72+
model, params = _build_model()
73+
cm = compile_model(model, template=params, survival=True)
74+
assert cm.survival is True
75+
cm2 = compile_model(model, template=params, survival=False)
76+
assert cm2.survival is False
77+
78+
79+
def test_survival_sampler_differs_from_non_survival():
80+
"""survival vs CDF should produce different samples under the same key.
81+
For Exponential, F^-1(1-u) = -log(u)/rate ≠ F^-1(u) = -log(1-u)/rate."""
82+
model, params = _build_model()
83+
cm_surv = compile_model(model, template=params, survival=True)
84+
cm_cdf = compile_model(model, template=params, survival=False)
85+
key = jrandom.PRNGKey(2)
86+
s_surv = np.asarray(cm_surv.sample(key, 32, params, method="rosenblatt"))
87+
s_cdf = np.asarray(cm_cdf.sample(key, 32, params, method="rosenblatt"))
88+
assert not np.allclose(s_surv, s_cdf), \
89+
"survival and CDF samples coincide — flag not threaded through"
90+
91+
92+
def test_survival_sampler_returns_positive_observations():
93+
model, params = _build_model()
94+
cm = compile_model(model, template=params, survival=True)
95+
key = jrandom.PRNGKey(0)
96+
samples = np.asarray(cm.sample(key, 100, params, method="rosenblatt"))
97+
assert samples.shape == (100, 2)
98+
assert np.all(np.isfinite(samples)), "non-finite samples"
99+
assert np.all(samples > 0), \
100+
f"non-positive samples (min={samples.min()}) — survival inversion broken"
101+
102+
103+
def test_survival_likelihood_higher_at_sampled_bulk():
104+
"""Likelihood at sampled points should average higher than likelihood at
105+
points far outside the typical mass — a behavioural consistency check."""
106+
model, params = _build_model()
107+
cm = compile_model(model, template=params, survival=True)
108+
key = jrandom.PRNGKey(1)
109+
samples = np.asarray(cm.sample(key, 200, params, method="rosenblatt"))
110+
111+
sample_lls = np.asarray(jax.vmap(
112+
lambda x: cm.eval(x, params))(jnp.asarray(samples[:50])))
113+
114+
# Far points: triple the largest sample on each margin.
115+
far = jnp.full((50, 2), float(samples.max()) * 3.0)
116+
far_lls = np.asarray(jax.vmap(
117+
lambda x: cm.eval(x, params))(far))
118+
119+
assert sample_lls.mean() > far_lls.mean(), (
120+
f"sample mean ll {sample_lls.mean():.3f} not > far mean ll "
121+
f"{far_lls.mean():.3f}"
122+
)
123+
124+
125+
def test_survival_marshall_olkin_also_threads_flag():
126+
"""The fix touches both samplers; verify Marshall-Olkin path too."""
127+
model, params = _build_model()
128+
cm_surv = compile_model(model, template=params, survival=True)
129+
cm_cdf = compile_model(model, template=params, survival=False)
130+
key = jrandom.PRNGKey(7)
131+
s_surv = np.asarray(cm_surv.sample(key, 32, params, method="marshall_olkin"))
132+
s_cdf = np.asarray(cm_cdf.sample(key, 32, params, method="marshall_olkin"))
133+
assert not np.allclose(s_surv, s_cdf), \
134+
"marshall_olkin: survival and CDF samples coincide — flag not threaded"
135+
assert np.all(s_surv > 0), "marshall_olkin survival samples not positive"

0 commit comments

Comments
 (0)