Skip to content

Commit e47e64a

Browse files
authored
Merge pull request #3 from thisiscam/examples-and-docs
Log-space Taylor override hook + API-docs reorg
2 parents 268eaba + 6b67dc8 commit e47e64a

7 files changed

Lines changed: 217 additions & 17 deletions

File tree

‎README.md‎

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,23 @@ AD* (arXiv:TBD).
1717

1818
## Install
1919

20+
Install from source with [uv](https://docs.astral.sh/uv/):
21+
2022
```bash
21-
pip install acopula
23+
git clone https://github.com/thisiscam/acopula
24+
cd acopula
25+
uv sync # add --extra examples for the plotting examples
26+
uv run python examples/01_quickstart.py
2227
```
2328

24-
`acopula` currently pins `jax>=0.8,<0.9` because it relies on JAX-internal
29+
uv is required (for now) because `acopula` depends on a patched
30+
[`oryx`](https://github.com/jax-ml/oryx) build pinned via `[tool.uv.sources]`;
31+
plain `pip install` — including `pip install git+…` — ignores that pin, pulls
32+
the stock PyPI `oryx` (incompatible with jax 0.8), and fails at import. A
33+
`pip install acopula` from PyPI is **not available yet**; it is gated on an
34+
upstream `oryx` release compatible with jax 0.8.
35+
36+
`acopula` pins `jax>=0.8,<0.9` because it relies on JAX-internal
2537
APIs in the [jet-array](https://github.com/thisiscam/jet-array) backend.
2638

2739
`acopula` uses [`oryx`](https://github.com/jax-ml/oryx) for symbolic generator

‎acopula/bell.py‎

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -958,13 +958,24 @@ def _root_assembly(
958958

959959
# Per-family Taylor override: families whose ψ has a numerically
960960
# stable series form (e.g. Laplace transforms of nonneg distributions
961-
# via the frailty representation) can provide
962-
# ``generator_taylor_coefficients(t, k_max)`` to bypass jet entirely.
963-
# This avoids the chain-rule cancellations Taylor-mode AD on ψ's
964-
# closed form produces at high derivative order — relevant for AMH
965-
# past d≈30 in float64. Returning None means "use jet".
966-
custom_taylor = root_cop.generator_taylor_coefficients(t_r, d)
967-
if custom_taylor is not None:
961+
# via the frailty representation) can provide a closed-form Taylor
962+
# expansion to bypass jet entirely. This avoids the chain-rule
963+
# cancellations Taylor-mode AD on ψ's closed form produces at high
964+
# derivative order — relevant for AMH past d≈30 in float64.
965+
# Preference order: log-space hook -> linear hook -> jet. The Bell
966+
# density consumes the coefficients as (sign, log|c|) anyway, so the
967+
# log-space hook feeds them straight in with no overflow-prone round
968+
# trip through a linear float64 value.
969+
custom_log_taylor = root_cop.log_generator_taylor_coefficients(t_r, d)
970+
custom_taylor = (None if custom_log_taylor is not None
971+
else root_cop.generator_taylor_coefficients(t_r, d))
972+
if custom_log_taylor is not None:
973+
# custom_log_taylor = (sign, log|c|), each shape (d+1,); already in
974+
# the representation the downstream signed-LSE wants.
975+
twp_sign, twp_log_abs = custom_log_taylor
976+
twp_sign = jnp.asarray(twp_sign)
977+
twp_log_abs = jnp.asarray(twp_log_abs)
978+
elif custom_taylor is not None:
968979
# custom_taylor[k] = psi^{(k)}(t_r) / k!, shape (d+1,).
969980
twp_sign = jnp.sign(custom_taylor)
970981
twp_abs = jnp.abs(custom_taylor)

‎acopula/core.py‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,12 @@ class Copula:
4949
"""
5050

5151
def generator(self, u: jax.Array) -> jax.Array: # pragma: no cover - abstract
52+
"""Return the Archimedean generator φ(u).
53+
54+
Subclasses **must** implement this. φ maps (0, 1] → [0, ∞), is
55+
strictly decreasing and convex, with φ(1) = 0. Everything else
56+
(density, inverse, sampling) is derived from it automatically.
57+
"""
5258
raise NotImplementedError
5359

5460
# Inverse of generator: φ^{-1}(t)
@@ -102,6 +108,36 @@ def generator_taylor_coefficients(
102108
"""
103109
return None # sentinel: use jet
104110

111+
def log_generator_taylor_coefficients(
112+
self, t: jax.Array, k_max: int
113+
) -> Optional[Tuple[jax.Array, jax.Array]]:
114+
"""Log-space variant of :meth:`generator_taylor_coefficients`.
115+
116+
Return ``(sign, log_abs)``, two arrays of shape ``(k_max + 1,)`` with
117+
118+
ψ^{(k)}(t) / k! == sign[k] * exp(log_abs[k]).
119+
120+
The Bell density consumes the Taylor coefficients as ``(sign, log|c|)``
121+
pairs internally, so a family whose normalized coefficients are
122+
computed in the log domain (e.g. via ``logsumexp`` over a frailty
123+
series) should return them here directly rather than through the linear
124+
:meth:`generator_taylor_coefficients` — that avoids exponentiating to a
125+
possibly-overflowing float64 only for the framework to take its log
126+
again, extending the usable derivative order at high ``d``.
127+
128+
The Bell path prefers this hook when it returns non-``None``, then falls
129+
back to the linear hook, then to jet. Returning ``None`` (the default)
130+
selects that fallback chain.
131+
132+
Args:
133+
t: scalar input point.
134+
k_max: highest derivative order (inclusive).
135+
136+
Returns:
137+
``(sign, log_abs)`` arrays of shape ``(k_max + 1,)``, or ``None``.
138+
"""
139+
return None # sentinel: fall back to the linear hook / jet
140+
105141
# For tracing: combine children (nodes/leaves) into a Node
106142
def __call__(self, children: Iterable[Union["Node", "Leaf", jax.Array]]) -> "Node":
107143
child_nodes: List[Union[Node, Leaf]] = []

‎docs/api.md‎

Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,21 @@
11
# API reference
22

3-
The public surface of `acopula`.
3+
Generated from the docstrings. The everyday surface is small: define a copula
4+
family, compile a model, then evaluate or sample.
45

5-
## Defining copulas
6+
## Defining a copula
7+
8+
A family subclasses `Copula` — usually via the `@copula` decorator for
9+
dataclass-style parameters — and implements `generator`. Everything else
10+
(density, inverse, sampling) is derived automatically. Call a copula instance
11+
on its children to build the model tree.
612

713
::: acopula.copula
814

915
::: acopula.Copula
16+
options:
17+
members:
18+
- generator
1019

1120
::: acopula.marginal
1221

@@ -15,9 +24,21 @@ The public surface of `acopula`.
1524
::: acopula.compile_model
1625

1726
::: acopula.CompiledModel
27+
options:
28+
members:
29+
- eval
30+
- sample
31+
- flatten
32+
- ll_fn
33+
- visualize
34+
- as_networkx
1835

1936
## Composition registry
2037

38+
For nested copulas that mix families, register a closed-form composition
39+
`ψ_outer⁻¹ ∘ ψ_inner`, or choose the fallback strategy used when none is
40+
registered.
41+
2142
::: acopula.register_composition
2243

2344
::: acopula.set_composition_fallback
@@ -27,3 +48,37 @@ The public surface of `acopula`.
2748
::: acopula.set_stable_log
2849

2950
::: acopula.set_compile_cache_dir
51+
52+
## Advanced: custom generator precision
53+
54+
Most families never touch these — they are optional override hooks on `Copula`
55+
for families whose high-order derivatives suffer float64 cancellation (e.g. AMH
56+
past d≈30). Each returns `None` by default, which selects Taylor-mode AD
57+
(`jet`). `generator_taylor_coefficients` (and its log-space variant
58+
`log_generator_taylor_coefficients`) is the recommended, general hook, used by
59+
the Bell density and the nesting composition. `log_generator_kth_derivative` is
60+
a legacy hook consulted only by the flat single-layer likelihood path.
61+
62+
::: acopula.Copula.generator_inv
63+
options:
64+
show_root_heading: true
65+
show_root_full_path: false
66+
heading_level: 3
67+
68+
::: acopula.Copula.generator_taylor_coefficients
69+
options:
70+
show_root_heading: true
71+
show_root_full_path: false
72+
heading_level: 3
73+
74+
::: acopula.Copula.log_generator_taylor_coefficients
75+
options:
76+
show_root_heading: true
77+
show_root_full_path: false
78+
heading_level: 3
79+
80+
::: acopula.Copula.log_generator_kth_derivative
81+
options:
82+
show_root_heading: true
83+
show_root_full_path: false
84+
heading_level: 3

‎docs/index.md‎

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,24 @@ forward pass via Taylor-mode AD, scaling polynomially in the dimension.
1111

1212
## Install
1313

14+
Install from source with [uv](https://docs.astral.sh/uv/):
15+
1416
```bash
15-
pip install acopula
17+
git clone https://github.com/thisiscam/acopula
18+
cd acopula
19+
uv sync # add --extra examples for the plotting examples
20+
uv run python examples/01_quickstart.py
1621
```
1722

18-
`acopula` pins `jax>=0.8,<0.9` (it relies on JAX-internal APIs in the
19-
[jet-array](https://github.com/thisiscam/jet-array) backend) and uses
20-
[`oryx`](https://github.com/jax-ml/oryx) for symbolic generator inversion.
23+
uv is required (for now): `acopula` depends on a patched
24+
[`oryx`](https://github.com/jax-ml/oryx) build pinned via `[tool.uv.sources]`,
25+
and plain `pip install` (including `pip install git+…`) ignores that pin, pulls
26+
the stock PyPI `oryx` (incompatible with jax 0.8), and fails at import. A PyPI
27+
`pip install acopula` is **not available yet** — it is gated on an upstream
28+
`oryx` release compatible with jax 0.8.
29+
30+
`acopula` pins `jax>=0.8,<0.9` because it relies on JAX-internal APIs in the
31+
[jet-array](https://github.com/thisiscam/jet-array) backend.
2132

2233
## Features
2334

‎mkdocs.yml‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,11 +44,20 @@ plugins:
4444
python:
4545
options:
4646
docstring_style: google
47+
docstring_section_style: table # Args/Returns as tables
4748
show_root_heading: true
48-
show_source: true
49-
members_order: source
49+
show_root_full_path: false # "Copula", not "acopula.core.Copula"
50+
show_symbol_type_heading: true # class/func/attr badges
51+
show_symbol_type_toc: true # and in the nav TOC
5052
separate_signature: true
5153
show_signature_annotations: true
54+
signature_crossrefs: true
55+
merge_init_into_class: false # CompiledModel isn't user-constructed; its __init__ leaks private fields
56+
members_order: source
57+
filters: ["!^_"] # hide private / dunder members
58+
show_source: false
59+
summary: true
60+
heading_level: 2
5261
# Emits /llms.txt (an index for LLMs/agents, per llmstxt.org) plus a single
5362
# concatenated llms-full.txt of the whole site.
5463
- llmstxt:

‎tests/test_log_taylor_override.py‎

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
"""The log-space generator_taylor_coefficients override must agree with the
2+
linear override (and with jet where jet is still accurate).
3+
4+
Regression test for ``Copula.log_generator_taylor_coefficients`` and the Bell
5+
dispatch that prefers it. Uses AMH, whose frailty series is computed in the log
6+
domain anyway, so the linear override is just the log override exponentiated.
7+
"""
8+
import jax
9+
import jax.numpy as jnp
10+
import pytest
11+
12+
jax.config.update("jax_enable_x64", True)
13+
14+
from acopula import compile_model, copula, marginal
15+
16+
17+
class _Uniform:
18+
def quantile(self, u): return u
19+
def cdf(self, x): return x
20+
def log_prob(self, x): return 0.0
21+
22+
23+
def _amh_sign_logabs(theta, t, k_max, n_terms=300):
24+
ks = jnp.arange(k_max + 1)
25+
xs = jnp.arange(1, n_terms + 1, dtype=jnp.float64)
26+
log_w = jnp.log(1.0 - theta) + (xs - 1.0) * jnp.log(theta) - t * xs
27+
log_fact_k = jax.scipy.special.gammaln(ks + 1)
28+
log_term = (ks[:, None] * jnp.log(xs)[None, :] + log_w[None, :]
29+
- log_fact_k[:, None])
30+
log_abs_c = jax.scipy.special.logsumexp(log_term, axis=1)
31+
signs = jnp.where(ks % 2 == 0, 1.0, -1.0)
32+
return signs, log_abs_c
33+
34+
35+
@copula
36+
class _AMHLinear:
37+
theta: float
38+
def generator(self, t):
39+
return (1.0 - self.theta) / (jnp.exp(t) - self.theta)
40+
def generator_taylor_coefficients(self, t, k_max, n_terms=300):
41+
signs, log_abs = _amh_sign_logabs(self.theta, t, k_max, n_terms)
42+
return signs * jnp.exp(log_abs)
43+
44+
45+
@copula
46+
class _AMHLog:
47+
theta: float
48+
def generator(self, t):
49+
return (1.0 - self.theta) / (jnp.exp(t) - self.theta)
50+
def log_generator_taylor_coefficients(self, t, k_max, n_terms=300):
51+
return _amh_sign_logabs(self.theta, t, k_max, n_terms)
52+
53+
54+
def _ll(cls, d, theta_val):
55+
def model(p, u):
56+
return cls(p[0])([marginal(_Uniform(), obs=u[i]) for i in range(d)])
57+
cm = compile_model(model, template=jnp.array([theta_val]), method="bell")
58+
u = jnp.arange(1, d + 1, dtype=jnp.float64) / (d + 1)
59+
return float(cm.eval(u, jnp.array([theta_val])))
60+
61+
62+
@pytest.mark.parametrize("d", [8, 12])
63+
def test_log_override_matches_linear(d):
64+
lin = _ll(_AMHLinear, d, 0.5)
65+
log = _ll(_AMHLog, d, 0.5)
66+
assert abs(lin - log) < 1e-9, f"d={d}: linear={lin}, log={log}"

0 commit comments

Comments
 (0)