jact 0.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
jact/__init__.py ADDED
@@ -0,0 +1,76 @@
1
+ """jact — JAX-based transition probability computation for multi-state models.
2
+
3
+ A framework for computing transition probabilities in semi-Markov
4
+ multi-state models with duration-dependent transition intensities.
5
+
6
+ Example
7
+ -------
8
+ >>> import jact
9
+ >>> import jax.numpy as jnp
10
+ >>>
11
+ >>> state_space = jact.StateSpace(
12
+ ... states=["healthy", "disabled", "dead"],
13
+ ... transitions=[
14
+ ... ("healthy", "disabled"),
15
+ ... ("healthy", "dead"),
16
+ ... ("disabled", "dead"),
17
+ ... ],
18
+ ... )
19
+ >>>
20
+ >>> model = state_space.build(
21
+ ... transitions={
22
+ ... ("healthy", "disabled"): onset_fn,
23
+ ... ("healthy", "dead"): mortality_fn,
24
+ ... ("disabled", "dead"): disabled_mort_fn,
25
+ ... }
26
+ ... )
27
+ >>>
28
+ >>> result = model.solve(
29
+ ... initial="healthy",
30
+ ... horizon=10,
31
+ ... steps_per_unit=12,
32
+ ... age=ages,
33
+ ... )
34
+ """
35
+
36
+ from importlib.metadata import PackageNotFoundError, version
37
+
38
+ from . import callbacks
39
+ from .cashflows import (
40
+ ByKind,
41
+ ByState,
42
+ CashflowDeclaration,
43
+ Group,
44
+ Raw,
45
+ ScheduledEvent,
46
+ StateRate,
47
+ Total,
48
+ TransitionLump,
49
+ )
50
+ from .initial_distribution import InitialDistribution
51
+ from .model import Model
52
+ from .solver import solve
53
+ from .state_space import StateSpace
54
+
55
+ try:
56
+ __version__ = version("jact")
57
+ except PackageNotFoundError:
58
+ __version__ = "0.1.0"
59
+
60
+ __all__ = [
61
+ "__version__",
62
+ "StateSpace",
63
+ "Model",
64
+ "InitialDistribution",
65
+ "solve",
66
+ "callbacks",
67
+ "StateRate",
68
+ "TransitionLump",
69
+ "ScheduledEvent",
70
+ "Raw",
71
+ "Group",
72
+ "Total",
73
+ "ByState",
74
+ "ByKind",
75
+ "CashflowDeclaration",
76
+ ]
jact/callbacks.py ADDED
@@ -0,0 +1,185 @@
1
+ """Probability callbacks for extracting results from the solver state."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Callable, NamedTuple, Union
6
+
7
+ import jax
8
+ import jax.numpy as jnp
9
+
10
+ __all__ = [
11
+ "PointMass",
12
+ "StateCarry",
13
+ "density",
14
+ "density_probability",
15
+ "full",
16
+ "marginal_components",
17
+ "none_callback",
18
+ "point_mass",
19
+ "state_probability",
20
+ "resolve_callback",
21
+ ]
22
+
23
+
24
+ def _validate_shape(
25
+ name: str,
26
+ value: jnp.ndarray,
27
+ expected_shape: tuple[int, ...],
28
+ ) -> None:
29
+ shape = jnp.shape(value)
30
+ if shape != expected_shape:
31
+ raise ValueError(
32
+ f"{name} must have shape {expected_shape}, got {shape}."
33
+ )
34
+
35
+
36
+ def _validate_non_negative_if_concrete(value: jnp.ndarray) -> None:
37
+ try:
38
+ arr = jnp.asarray(value)
39
+ if bool(jnp.any(arr < 0)):
40
+ raise ValueError("value must be non-negative.")
41
+ except Exception as exc: # pragma: no cover - tracer path
42
+ if "tracer" not in type(exc).__name__.lower():
43
+ try:
44
+ message = str(exc).lower()
45
+ except Exception: # pragma: no cover
46
+ message = ""
47
+ if "tracer" not in message and "concret" not in message:
48
+ raise
49
+
50
+
51
+ @jax.tree_util.register_pytree_node_class
52
+ class PointMass:
53
+ """Per-individual point mass carried along a characteristic."""
54
+
55
+ __slots__ = ("value", "d_0", "log_value")
56
+
57
+ def __init__(
58
+ self,
59
+ value: jnp.ndarray,
60
+ d_0: jnp.ndarray,
61
+ log_value: jnp.ndarray | None = None,
62
+ ):
63
+ value_shape = jnp.shape(value)
64
+ _validate_shape("d_0", d_0, value_shape)
65
+ if log_value is not None:
66
+ _validate_shape("log_value", log_value, value_shape)
67
+ _validate_non_negative_if_concrete(value)
68
+ self.value = value
69
+ self.d_0 = d_0
70
+ self.log_value = (
71
+ jnp.where(value > 0, jnp.log(value), -jnp.inf)
72
+ if log_value is None
73
+ else log_value
74
+ )
75
+
76
+ def tree_flatten(self):
77
+ return (self.value, self.d_0, self.log_value), None
78
+
79
+ @classmethod
80
+ def tree_unflatten(cls, _, children):
81
+ value, d_0, log_value = children
82
+ self = cls.__new__(cls)
83
+ self.value = value
84
+ self.d_0 = d_0
85
+ self.log_value = log_value
86
+ return self
87
+
88
+
89
+ class StateCarry(NamedTuple):
90
+ """Per-state solver carry."""
91
+
92
+ density: jnp.ndarray
93
+ point_mass: PointMass | None
94
+
95
+
96
+ @jax.jit
97
+ def none_callback(state: tuple[StateCarry, ...]):
98
+ """Return nothing. Use when only side effects of the solve matter."""
99
+ return None
100
+
101
+
102
+ @jax.jit
103
+ def full(state: tuple[StateCarry, ...]):
104
+ """Return the full pytree state unchanged."""
105
+ return state
106
+
107
+
108
+ @jax.jit
109
+ def marginal_components(state: tuple[StateCarry, ...]):
110
+ """Marginalize density over duration while keeping point masses explicit."""
111
+ return tuple(
112
+ StateCarry(
113
+ density=jnp.sum(carry.density, axis=-1),
114
+ point_mass=carry.point_mass,
115
+ )
116
+ for carry in state
117
+ )
118
+
119
+
120
+ @jax.jit
121
+ def state_probability(state: tuple[StateCarry, ...]):
122
+ """Return total state occupancy after marginalizing over duration."""
123
+ return jnp.stack(
124
+ tuple(
125
+ jnp.sum(carry.density, axis=-1)
126
+ if carry.point_mass is None
127
+ else jnp.sum(carry.density, axis=-1) + carry.point_mass.value
128
+ for carry in state
129
+ ),
130
+ axis=-1,
131
+ )
132
+
133
+
134
+ @jax.jit
135
+ def point_mass(state: tuple[StateCarry, ...]):
136
+ """Return only the point-mass component per state."""
137
+ return tuple(carry.point_mass for carry in state)
138
+
139
+
140
+ @jax.jit
141
+ def density(state: tuple[StateCarry, ...]):
142
+ """Return only the absolutely continuous density per state."""
143
+ return tuple(carry.density for carry in state)
144
+
145
+
146
+ @jax.jit
147
+ def density_probability(state: tuple[StateCarry, ...]):
148
+ """Return the duration-marginal density, restacked across states."""
149
+ return jnp.stack(
150
+ tuple(jnp.sum(carry.density, axis=-1) for carry in state),
151
+ axis=-1,
152
+ )
153
+
154
+
155
+ _CALLBACKS = {
156
+ "none": none_callback,
157
+ "full": full,
158
+ "marginal_components": marginal_components,
159
+ "state_probability": state_probability,
160
+ "point_mass": point_mass,
161
+ "density": density,
162
+ "density_probability": density_probability,
163
+ }
164
+
165
+
166
+ def resolve_callback(
167
+ callback: Union[None, str, Callable[[tuple[StateCarry, ...]], Any]],
168
+ ) -> Callable[[tuple[StateCarry, ...]], Any]:
169
+ """Resolve a callback specification to a callable."""
170
+ if callback is None:
171
+ return none_callback
172
+ if callable(callback):
173
+ return callback
174
+ if isinstance(callback, str):
175
+ if callback not in _CALLBACKS:
176
+ available = ", ".join(sorted(_CALLBACKS.keys()))
177
+ raise ValueError(
178
+ f"Unknown callback '{callback}'. "
179
+ f"Available callbacks: {available}"
180
+ )
181
+ return _CALLBACKS[callback]
182
+ raise TypeError(
183
+ "callback must be None, a string, or a callable, "
184
+ f"got {type(callback)}"
185
+ )
jact/cashflows.py ADDED
@@ -0,0 +1,276 @@
1
+ """Cashflow declarations and solve-time views."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from numbers import Number
7
+ from typing import Any, Callable, Mapping, Sequence
8
+
9
+ import jax.numpy as jnp
10
+
11
+ Scalar = int | float
12
+
13
+ __all__ = [
14
+ "ByKind",
15
+ "ByState",
16
+ "CashflowDeclaration",
17
+ "Group",
18
+ "Raw",
19
+ "ScheduledEvent",
20
+ "StateRate",
21
+ "Total",
22
+ "TransitionLump",
23
+ ]
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class StateRate:
28
+ """Payment-rate callables attached to occupied states."""
29
+
30
+ payments: Mapping[str, Callable[..., jnp.ndarray]]
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class TransitionLump:
35
+ """Lump-sum payment callables attached to transitions."""
36
+
37
+ payments: Mapping[tuple[str, str], Callable[..., jnp.ndarray]]
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class ScheduledEvent:
42
+ """State-conditioned payments at deterministic event times."""
43
+
44
+ when: Callable[..., jnp.ndarray]
45
+ payments: Mapping[str, Callable[..., jnp.ndarray]]
46
+
47
+
48
+ @dataclass(frozen=True)
49
+ class Raw:
50
+ """Return one raw component or all raw components."""
51
+
52
+ name: str | None = None
53
+ weight: Callable[..., jnp.ndarray] | Scalar | None = None
54
+ terminal: bool = False
55
+
56
+
57
+ @dataclass(frozen=True)
58
+ class Group:
59
+ """Return the sum of selected raw components."""
60
+
61
+ members: Sequence[str]
62
+ weight: Callable[..., jnp.ndarray] | Scalar | None = None
63
+ terminal: bool = False
64
+
65
+
66
+ @dataclass(frozen=True)
67
+ class Total:
68
+ """Return the sum of all raw components."""
69
+
70
+ weight: Callable[..., jnp.ndarray] | Scalar | None = None
71
+ terminal: bool = False
72
+
73
+
74
+ @dataclass(frozen=True)
75
+ class ByState:
76
+ """Return cashflows split by reachable state."""
77
+
78
+ weight: Callable[..., jnp.ndarray] | Scalar | None = None
79
+ terminal: bool = False
80
+
81
+
82
+ @dataclass(frozen=True)
83
+ class ByKind:
84
+ """Return cashflows split by component kind."""
85
+
86
+ weight: Callable[..., jnp.ndarray] | Scalar | None = None
87
+ terminal: bool = False
88
+
89
+
90
+ @dataclass(frozen=True)
91
+ class CashflowDeclaration:
92
+ """Validated cashflow components bound to a state-space topology."""
93
+
94
+ state_space: Any
95
+ components: tuple[tuple[str, StateRate | TransitionLump | ScheduledEvent], ...]
96
+
97
+ @property
98
+ def names(self) -> tuple[str, ...]:
99
+ return tuple(name for name, _ in self.components)
100
+
101
+ def component(self, name: str) -> StateRate | TransitionLump | ScheduledEvent:
102
+ for component_name, component in self.components:
103
+ if component_name == name:
104
+ return component
105
+ raise ValueError(f"Unknown cashflow component '{name}'.")
106
+
107
+
108
+ def _check_component_name(name: Any) -> None:
109
+ if not isinstance(name, str) or not name:
110
+ raise ValueError("cashflow component names must be non-empty strings.")
111
+
112
+
113
+ def _check_callable(value: Any, field: str) -> None:
114
+ if not callable(value):
115
+ raise TypeError(f"{field} must be callable.")
116
+
117
+
118
+ def _validate_payment_mapping(payments: Any, field: str) -> Mapping[Any, Any]:
119
+ if not isinstance(payments, Mapping) or not payments:
120
+ raise ValueError(f"{field} must be a non-empty mapping.")
121
+ for fn in payments.values():
122
+ _check_callable(fn, f"{field} values")
123
+ return dict(payments)
124
+
125
+
126
+ def _is_scalar_array_like(value: Any) -> bool:
127
+ if value is None:
128
+ return False
129
+ try:
130
+ return bool(jnp.asarray(value).ndim == 0)
131
+ except Exception:
132
+ return False
133
+
134
+
135
+ def _normalise_weight(weight: Any) -> Any:
136
+ if weight is None:
137
+ return None
138
+ if _is_scalar_array_like(weight):
139
+ return jnp.asarray(weight).item()
140
+ return weight
141
+
142
+
143
+ def validate_cashflow_components(
144
+ state_space: Any,
145
+ components: Mapping[str, StateRate | TransitionLump | ScheduledEvent],
146
+ ) -> CashflowDeclaration:
147
+ """Validate and freeze a component mapping for a state space."""
148
+ if not isinstance(components, Mapping) or not components:
149
+ raise ValueError("cashflows() requires a non-empty component mapping.")
150
+
151
+ frozen: list[tuple[str, StateRate | TransitionLump | ScheduledEvent]] = []
152
+ seen: set[str] = set()
153
+ for name, component in components.items():
154
+ _check_component_name(name)
155
+ if name in seen:
156
+ raise ValueError(f"Duplicate cashflow component name '{name}'.")
157
+ seen.add(name)
158
+
159
+ if isinstance(component, StateRate):
160
+ payments = _validate_payment_mapping(
161
+ component.payments,
162
+ f"StateRate('{name}').payments",
163
+ )
164
+ for state in payments:
165
+ state_space._check_state(state)
166
+ frozen_component = StateRate(payments=payments)
167
+ elif isinstance(component, TransitionLump):
168
+ payments = _validate_payment_mapping(
169
+ component.payments,
170
+ f"TransitionLump('{name}').payments",
171
+ )
172
+ for transition in payments:
173
+ if (
174
+ not isinstance(transition, tuple)
175
+ or len(transition) != 2
176
+ or not state_space.has_transition(*transition)
177
+ ):
178
+ raise ValueError(
179
+ f"TransitionLump('{name}') references unknown "
180
+ f"transition {transition!r}."
181
+ )
182
+ frozen_component = TransitionLump(payments=payments)
183
+ elif isinstance(component, ScheduledEvent):
184
+ _check_callable(component.when, f"ScheduledEvent('{name}').when")
185
+ payments = _validate_payment_mapping(
186
+ component.payments,
187
+ f"ScheduledEvent('{name}').payments",
188
+ )
189
+ for state in payments:
190
+ state_space._check_state(state)
191
+ frozen_component = ScheduledEvent(
192
+ when=component.when,
193
+ payments=payments,
194
+ )
195
+ else:
196
+ raise TypeError(
197
+ "cashflow components must be StateRate, TransitionLump, "
198
+ f"or ScheduledEvent; got {type(component)}."
199
+ )
200
+ frozen.append((name, frozen_component))
201
+
202
+ return CashflowDeclaration(state_space=state_space, components=tuple(frozen))
203
+
204
+
205
+ def _validate_view_common(view: Any) -> None:
206
+ if not isinstance(view.terminal, bool):
207
+ raise TypeError("cashflow view terminal must be a bool.")
208
+ weight = view.weight
209
+ if weight is None or callable(weight) or isinstance(weight, Number):
210
+ return
211
+ if _is_scalar_array_like(weight):
212
+ return
213
+ raise TypeError("cashflow view weight must be None, a scalar, or callable.")
214
+
215
+
216
+ def validate_cashflow_views(
217
+ declaration: CashflowDeclaration,
218
+ views: Mapping[str, Raw | Group | Total | ByState | ByKind] | None,
219
+ ) -> tuple[tuple[str, Raw | Group | Total | ByState | ByKind], ...]:
220
+ """Validate and freeze solve-time cashflow views."""
221
+ if views is None:
222
+ views = {"raw": Raw()}
223
+ if not isinstance(views, Mapping):
224
+ raise TypeError("cashflow_views must be a mapping or None.")
225
+
226
+ component_names = set(declaration.names)
227
+ frozen: list[tuple[str, Raw | Group | Total | ByState | ByKind]] = []
228
+ seen: set[str] = set()
229
+ for name, view in views.items():
230
+ if not isinstance(name, str) or not name:
231
+ raise ValueError("cashflow view names must be non-empty strings.")
232
+ if name in seen:
233
+ raise ValueError(f"Duplicate cashflow view name '{name}'.")
234
+ seen.add(name)
235
+
236
+ if isinstance(view, Raw):
237
+ _validate_view_common(view)
238
+ view = Raw(
239
+ name=view.name,
240
+ weight=_normalise_weight(view.weight),
241
+ terminal=view.terminal,
242
+ )
243
+ if view.name is not None and view.name not in component_names:
244
+ raise ValueError(
245
+ f"Raw view '{name}' references unknown component "
246
+ f"'{view.name}'."
247
+ )
248
+ elif isinstance(view, Group):
249
+ _validate_view_common(view)
250
+ if isinstance(view.members, str) or not view.members:
251
+ raise ValueError(f"Group view '{name}' requires members.")
252
+ members = tuple(view.members)
253
+ for member in members:
254
+ if member not in component_names:
255
+ raise ValueError(
256
+ f"Group view '{name}' references unknown component "
257
+ f"'{member}'."
258
+ )
259
+ view = Group(
260
+ members=members,
261
+ weight=_normalise_weight(view.weight),
262
+ terminal=view.terminal,
263
+ )
264
+ elif isinstance(view, (Total, ByState, ByKind)):
265
+ _validate_view_common(view)
266
+ view = type(view)(
267
+ weight=_normalise_weight(view.weight),
268
+ terminal=view.terminal,
269
+ )
270
+ else:
271
+ raise TypeError(
272
+ "cashflow views must be Raw, Group, Total, ByState, or ByKind; "
273
+ f"got {type(view)}."
274
+ )
275
+ frozen.append((name, view))
276
+ return tuple(frozen)