StochasticForceInference 2.0.0__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.
- SFI/__init__.py +64 -0
- SFI/bases/__init__.py +85 -0
- SFI/bases/constants.py +492 -0
- SFI/bases/linear.py +325 -0
- SFI/bases/monomials.py +218 -0
- SFI/bases/pairs.py +998 -0
- SFI/bases/spde.py +1537 -0
- SFI/diagnostics/__init__.py +60 -0
- SFI/diagnostics/assess.py +87 -0
- SFI/diagnostics/dynamics_order.py +621 -0
- SFI/diagnostics/plotting.py +226 -0
- SFI/diagnostics/report.py +238 -0
- SFI/diagnostics/residual_tests.py +395 -0
- SFI/diagnostics/residuals.py +688 -0
- SFI/inference/__init__.py +58 -0
- SFI/inference/base.py +1460 -0
- SFI/inference/optimizers.py +200 -0
- SFI/inference/overdamped.py +1214 -0
- SFI/inference/parametric_core/__init__.py +34 -0
- SFI/inference/parametric_core/covariance.py +232 -0
- SFI/inference/parametric_core/driver.py +272 -0
- SFI/inference/parametric_core/flow.py +149 -0
- SFI/inference/parametric_core/flow_multi.py +362 -0
- SFI/inference/parametric_core/flow_ud.py +168 -0
- SFI/inference/parametric_core/jacobians.py +540 -0
- SFI/inference/parametric_core/objective.py +286 -0
- SFI/inference/parametric_core/objective_ud.py +253 -0
- SFI/inference/parametric_core/precision.py +229 -0
- SFI/inference/parametric_core/solve.py +763 -0
- SFI/inference/result.py +362 -0
- SFI/inference/serialization.py +245 -0
- SFI/inference/sparse/__init__.py +67 -0
- SFI/inference/sparse/base.py +43 -0
- SFI/inference/sparse/beam.py +303 -0
- SFI/inference/sparse/greedy.py +151 -0
- SFI/inference/sparse/hillclimb.py +307 -0
- SFI/inference/sparse/lasso.py +178 -0
- SFI/inference/sparse/metrics.py +78 -0
- SFI/inference/sparse/result.py +278 -0
- SFI/inference/sparse/scorer.py +323 -0
- SFI/inference/sparse/stlsq.py +165 -0
- SFI/inference/sparsity.py +40 -0
- SFI/inference/underdamped.py +1355 -0
- SFI/integrate/__init__.py +38 -0
- SFI/integrate/api.py +920 -0
- SFI/integrate/integrand.py +402 -0
- SFI/integrate/rk4.py +156 -0
- SFI/integrate/timeops.py +174 -0
- SFI/langevin/__init__.py +29 -0
- SFI/langevin/base.py +863 -0
- SFI/langevin/chunked.py +225 -0
- SFI/langevin/noise.py +446 -0
- SFI/langevin/overdamped.py +560 -0
- SFI/langevin/underdamped.py +448 -0
- SFI/statefunc/__init__.py +49 -0
- SFI/statefunc/basis.py +87 -0
- SFI/statefunc/core/runtime.py +47 -0
- SFI/statefunc/factory.py +346 -0
- SFI/statefunc/interactor.py +90 -0
- SFI/statefunc/layout/__init__.py +29 -0
- SFI/statefunc/layout/_base.py +186 -0
- SFI/statefunc/layout/_eval_compiler.py +453 -0
- SFI/statefunc/layout/_fd_atoms.py +196 -0
- SFI/statefunc/layout/_grid.py +878 -0
- SFI/statefunc/layout/_sectors.py +166 -0
- SFI/statefunc/memhint.py +222 -0
- SFI/statefunc/nodes/__init__.py +56 -0
- SFI/statefunc/nodes/base.py +318 -0
- SFI/statefunc/nodes/contract.py +422 -0
- SFI/statefunc/nodes/interactions/__init__.py +23 -0
- SFI/statefunc/nodes/interactions/dispatcher.py +1365 -0
- SFI/statefunc/nodes/interactions/prepare.py +161 -0
- SFI/statefunc/nodes/interactions/specs.py +362 -0
- SFI/statefunc/nodes/interactions/stencils.py +718 -0
- SFI/statefunc/nodes/leaf.py +530 -0
- SFI/statefunc/nodes/ops/__init__.py +27 -0
- SFI/statefunc/nodes/ops/concat.py +28 -0
- SFI/statefunc/nodes/ops/derivative.py +447 -0
- SFI/statefunc/nodes/ops/einsum.py +120 -0
- SFI/statefunc/nodes/ops/linear.py +153 -0
- SFI/statefunc/nodes/ops/mapn.py +138 -0
- SFI/statefunc/nodes/ops/reshape_rank.py +158 -0
- SFI/statefunc/nodes/ops/slice.py +83 -0
- SFI/statefunc/params.py +309 -0
- SFI/statefunc/psf.py +112 -0
- SFI/statefunc/sf.py +104 -0
- SFI/statefunc/stateexpr.py +1243 -0
- SFI/statefunc/structexpr.py +1003 -0
- SFI/trajectory/__init__.py +31 -0
- SFI/trajectory/collection.py +1122 -0
- SFI/trajectory/dataset.py +1164 -0
- SFI/trajectory/degrade.py +1108 -0
- SFI/trajectory/io.py +1027 -0
- SFI/trajectory/reserved_extras.py +129 -0
- SFI/utils/__init__.py +17 -0
- SFI/utils/formatting.py +308 -0
- SFI/utils/maths.py +185 -0
- SFI/utils/neighbors.py +162 -0
- SFI/utils/plotting.py +1936 -0
- stochasticforceinference-2.0.0.dist-info/METADATA +203 -0
- stochasticforceinference-2.0.0.dist-info/RECORD +104 -0
- stochasticforceinference-2.0.0.dist-info/WHEEL +5 -0
- stochasticforceinference-2.0.0.dist-info/licenses/LICENSE +21 -0
- stochasticforceinference-2.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import TYPE_CHECKING, Any, Dict, Mapping, Optional, Sequence, Set, Tuple
|
|
5
|
+
|
|
6
|
+
if TYPE_CHECKING:
|
|
7
|
+
from .timeops import TimeOp
|
|
8
|
+
|
|
9
|
+
import jax
|
|
10
|
+
import jax.numpy as jnp
|
|
11
|
+
import opt_einsum as oe # required
|
|
12
|
+
|
|
13
|
+
__all__ = ["ExprOperand", "TimeOperand", "ConstOperand", "Term", "Integrand"]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class ExprOperand:
|
|
18
|
+
"""
|
|
19
|
+
Wrap a state expression evaluated at x (and optionally v).
|
|
20
|
+
|
|
21
|
+
Contract
|
|
22
|
+
--------
|
|
23
|
+
expr(x, v=..., mask=..., extras=..., params=...) -> array
|
|
24
|
+
- features sit on the last axis
|
|
25
|
+
- 'mask' is forwarded from the 'mask_out' stream when present
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
expr: object
|
|
29
|
+
x: "TimeOp"
|
|
30
|
+
v: Optional["TimeOp"] = None
|
|
31
|
+
# Template / default parameters. Call-time params may override this.
|
|
32
|
+
params_template: Optional[Mapping] = None
|
|
33
|
+
alias: str = "E"
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def required_extras(self) -> Tuple[str, ...]:
|
|
37
|
+
"""Keys this expression demands in the extras mapping."""
|
|
38
|
+
re = getattr(self.expr, "required_extras", ()) or ()
|
|
39
|
+
return tuple(re)
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def requires(self) -> Set[str]:
|
|
43
|
+
req = set(self.x.requires)
|
|
44
|
+
if self.v is not None:
|
|
45
|
+
req |= set(self.v.requires)
|
|
46
|
+
if self.required_extras:
|
|
47
|
+
req.add("extras") # ensure collection streams extras
|
|
48
|
+
return req
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True)
|
|
52
|
+
class TimeOperand:
|
|
53
|
+
"""Time operand (scalar/vector/tensor) built from streams via a TimeOp."""
|
|
54
|
+
|
|
55
|
+
op: "TimeOp"
|
|
56
|
+
alias: str = "T"
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def requires(self) -> Set[str]:
|
|
60
|
+
return set(self.op.requires)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(frozen=True)
|
|
64
|
+
class ConstOperand:
|
|
65
|
+
"""Constant array captured by the program (e.g. A_inv)."""
|
|
66
|
+
|
|
67
|
+
value: jnp.ndarray
|
|
68
|
+
alias: str = "C"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass(frozen=True)
|
|
72
|
+
class Term:
|
|
73
|
+
"""
|
|
74
|
+
One einsum contraction over the named operands.
|
|
75
|
+
|
|
76
|
+
Axis conventions
|
|
77
|
+
----------------
|
|
78
|
+
i : particle axis (kept until the integrator reduces over i)
|
|
79
|
+
m,n,... : spatial indices
|
|
80
|
+
a,b,... : feature axes (always last on any ExprOperand output)
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
eq: str
|
|
84
|
+
ops: Tuple[str, ...]
|
|
85
|
+
scale: float = 1.0
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class Integrand:
|
|
89
|
+
"""
|
|
90
|
+
Compose state expressions and time operands via einsum per time slice.
|
|
91
|
+
|
|
92
|
+
``require()`` -> ``Set[str]``
|
|
93
|
+
Stream keys needed to evaluate this program on one time slice.
|
|
94
|
+
|
|
95
|
+
``__call__(**streams)`` -> ``jnp.ndarray``
|
|
96
|
+
Evaluate on one time slice. The collection handles mask and reduction.
|
|
97
|
+
|
|
98
|
+
``estimate_bytes_per_sample(sample_streams)`` -> ``Optional[int]``
|
|
99
|
+
Upper bound on per-particle bytes using a real sample (via opt_einsum).
|
|
100
|
+
"""
|
|
101
|
+
|
|
102
|
+
def __init__(
|
|
103
|
+
self,
|
|
104
|
+
exprs: Sequence[ExprOperand] = (),
|
|
105
|
+
times: Sequence[TimeOperand] = (),
|
|
106
|
+
consts: Sequence[ConstOperand] = (),
|
|
107
|
+
terms: Sequence[Term] = (),
|
|
108
|
+
):
|
|
109
|
+
self._check_aliases(exprs, times, consts)
|
|
110
|
+
self._exprs: Dict[str, ExprOperand] = {e.alias: e for e in exprs}
|
|
111
|
+
self._times: Dict[str, TimeOperand] = {t.alias: t for t in times}
|
|
112
|
+
self._consts: Dict[str, ConstOperand] = {c.alias: c for c in consts}
|
|
113
|
+
self._terms: Tuple[Term, ...] = tuple(terms)
|
|
114
|
+
|
|
115
|
+
@staticmethod
|
|
116
|
+
def _check_aliases(
|
|
117
|
+
exprs: Sequence[ExprOperand],
|
|
118
|
+
times: Sequence[TimeOperand],
|
|
119
|
+
consts: Sequence[ConstOperand],
|
|
120
|
+
) -> None:
|
|
121
|
+
seen: Dict[str, str] = {}
|
|
122
|
+
groups = [("expr", exprs), ("time", times), ("const", consts)]
|
|
123
|
+
for kind, ops in groups:
|
|
124
|
+
for op in ops:
|
|
125
|
+
alias = op.alias # type: ignore[union-attr]
|
|
126
|
+
if alias in seen:
|
|
127
|
+
raise ValueError(
|
|
128
|
+
f"Duplicate alias {alias!r}: already registered as "
|
|
129
|
+
f"{seen[alias]}, now also as {kind}."
|
|
130
|
+
)
|
|
131
|
+
seen[alias] = kind
|
|
132
|
+
|
|
133
|
+
@staticmethod
|
|
134
|
+
def _merge_operand_dicts(
|
|
135
|
+
d1: Dict[str, Any],
|
|
136
|
+
d2: Dict[str, Any],
|
|
137
|
+
kind: str,
|
|
138
|
+
) -> Dict[str, Any]:
|
|
139
|
+
"""Merge two alias→operand dicts; allow same-object sharing, raise on conflict."""
|
|
140
|
+
merged = dict(d1)
|
|
141
|
+
for alias, op in d2.items():
|
|
142
|
+
if alias in merged:
|
|
143
|
+
if merged[alias] is not op:
|
|
144
|
+
raise ValueError(
|
|
145
|
+
f"Cannot add Integrands: alias {alias!r} maps to "
|
|
146
|
+
f"different {kind} operands in the two summands."
|
|
147
|
+
)
|
|
148
|
+
else:
|
|
149
|
+
merged[alias] = op
|
|
150
|
+
return merged
|
|
151
|
+
|
|
152
|
+
# ---------- integration API ----------
|
|
153
|
+
def require(self) -> Set[str]:
|
|
154
|
+
req = set()
|
|
155
|
+
for e in self._exprs.values():
|
|
156
|
+
req |= e.requires
|
|
157
|
+
for t in self._times.values():
|
|
158
|
+
req |= t.requires
|
|
159
|
+
return req
|
|
160
|
+
|
|
161
|
+
def required_extras(self) -> Set[str]:
|
|
162
|
+
keys: Set[str] = set()
|
|
163
|
+
for E in self._exprs.values():
|
|
164
|
+
keys.update(E.required_extras)
|
|
165
|
+
return keys
|
|
166
|
+
|
|
167
|
+
def __call__(self, *, params: Optional[Any] = None, **streams):
|
|
168
|
+
bufs: Dict[str, jnp.ndarray] = {}
|
|
169
|
+
|
|
170
|
+
# --- early extras validation (fail fast, clear errors)
|
|
171
|
+
extras = streams.get("extras", None)
|
|
172
|
+
for a, E in self._exprs.items():
|
|
173
|
+
if E.required_extras:
|
|
174
|
+
try:
|
|
175
|
+
# StateExpr provides the validator
|
|
176
|
+
E.expr._validate_extras_presence(extras) # type: ignore[attr-defined]
|
|
177
|
+
except KeyError:
|
|
178
|
+
avail = sorted(list(extras.keys())) if isinstance(extras, Mapping) else None
|
|
179
|
+
msg = f"[{a}] missing extras {list(E.required_extras)}"
|
|
180
|
+
if avail is not None:
|
|
181
|
+
msg += f"; available: {avail}"
|
|
182
|
+
raise KeyError(msg) from None
|
|
183
|
+
|
|
184
|
+
x = E.x(**streams)
|
|
185
|
+
v = None if E.v is None else E.v(**streams)
|
|
186
|
+
kwargs: Dict[str, Any] = {}
|
|
187
|
+
if v is not None:
|
|
188
|
+
kwargs["v"] = v
|
|
189
|
+
if "mask_out" in streams and streams["mask_out"] is not None:
|
|
190
|
+
kwargs["mask"] = streams["mask_out"]
|
|
191
|
+
if "extras" in streams:
|
|
192
|
+
kwargs["extras"] = streams["extras"]
|
|
193
|
+
|
|
194
|
+
# Parameter routing:
|
|
195
|
+
# - if params is not None: use it as the actual parameter object
|
|
196
|
+
# - else: fall back to the operand's params_template
|
|
197
|
+
par = E.params_template if params is None else params
|
|
198
|
+
if par is not None:
|
|
199
|
+
kwargs["params"] = par
|
|
200
|
+
|
|
201
|
+
bufs[a] = E.expr(x, **kwargs)
|
|
202
|
+
|
|
203
|
+
# time operands
|
|
204
|
+
for a, T in self._times.items():
|
|
205
|
+
bufs[a] = T.op(**streams)
|
|
206
|
+
|
|
207
|
+
# constants
|
|
208
|
+
for a, C in self._consts.items():
|
|
209
|
+
bufs[a] = C.value
|
|
210
|
+
|
|
211
|
+
# contractions
|
|
212
|
+
out = None
|
|
213
|
+
for term in self._terms:
|
|
214
|
+
args = [bufs[a] for a in term.ops]
|
|
215
|
+
val = jnp.einsum(term.eq, *args)
|
|
216
|
+
out = term.scale * val if out is None else out + term.scale * val
|
|
217
|
+
return out
|
|
218
|
+
|
|
219
|
+
def batch_call(self, *, params: Optional[Any] = None, **streams):
|
|
220
|
+
"""Evaluate with streams that carry a leading batch (K) axis.
|
|
221
|
+
|
|
222
|
+
This is the batched counterpart of :meth:`__call__`. Streams such as
|
|
223
|
+
``X`` have shape ``(K, N, d)`` instead of ``(N, d)``, and ``dt`` has
|
|
224
|
+
shape ``(K,)`` instead of being a scalar.
|
|
225
|
+
|
|
226
|
+
State-expression operands receive the full ``(K, N, d)`` tensor and
|
|
227
|
+
handle arbitrary leading batch dimensions internally (the leaf's
|
|
228
|
+
``_apply_user_func`` flattens the batch prefix and uses a single
|
|
229
|
+
``jax.vmap``).
|
|
230
|
+
|
|
231
|
+
Time operands (e.g. velocity) are evaluated on the batch streams
|
|
232
|
+
directly; the batch-safe ``velocity`` TimeOp handles dt broadcasting.
|
|
233
|
+
|
|
234
|
+
Einsum contractions are vmapped over the leading K axis so that
|
|
235
|
+
existing subscript strings (which reference particle/spatial/feature
|
|
236
|
+
axes only) work unchanged.
|
|
237
|
+
|
|
238
|
+
Returns
|
|
239
|
+
-------
|
|
240
|
+
jax.Array
|
|
241
|
+
Result with a leading ``K`` axis. Shape is
|
|
242
|
+
``(K, <per-row output shape>)``.
|
|
243
|
+
"""
|
|
244
|
+
bufs: Dict[str, jnp.ndarray] = {}
|
|
245
|
+
|
|
246
|
+
# --- extras validation (same as __call__) ---
|
|
247
|
+
extras = streams.get("extras", None)
|
|
248
|
+
for a, E in self._exprs.items():
|
|
249
|
+
if E.required_extras:
|
|
250
|
+
try:
|
|
251
|
+
E.expr._validate_extras_presence(extras)
|
|
252
|
+
except KeyError:
|
|
253
|
+
avail = sorted(list(extras.keys())) if isinstance(extras, Mapping) else None
|
|
254
|
+
msg = f"[{a}] missing extras {list(E.required_extras)}"
|
|
255
|
+
if avail is not None:
|
|
256
|
+
msg += f"; available: {avail}"
|
|
257
|
+
raise KeyError(msg) from None
|
|
258
|
+
|
|
259
|
+
x = E.x(**streams) # (K, N, d)
|
|
260
|
+
v = None if E.v is None else E.v(**streams) # (K, N, d) or None
|
|
261
|
+
kwargs: Dict[str, Any] = {}
|
|
262
|
+
if v is not None:
|
|
263
|
+
kwargs["v"] = v
|
|
264
|
+
if "mask_out" in streams and streams["mask_out"] is not None:
|
|
265
|
+
kwargs["mask"] = streams["mask_out"] # (K, N)
|
|
266
|
+
if "extras" in streams:
|
|
267
|
+
kwargs["extras"] = streams["extras"]
|
|
268
|
+
|
|
269
|
+
par = E.params_template if params is None else params
|
|
270
|
+
if par is not None:
|
|
271
|
+
kwargs["params"] = par
|
|
272
|
+
|
|
273
|
+
bufs[a] = E.expr(x, **kwargs)
|
|
274
|
+
# Output: (K, N, ..., F) or (K, ..., F) depending on pdepth/rank
|
|
275
|
+
|
|
276
|
+
# --- time operands: call directly if batch_safe, else vmap over K ---
|
|
277
|
+
for a, T in self._times.items():
|
|
278
|
+
if getattr(T.op, "batch_safe", False):
|
|
279
|
+
# TimeOp already handles leading batch dims — call directly
|
|
280
|
+
bufs[a] = T.op(**streams)
|
|
281
|
+
else:
|
|
282
|
+
req_keys = T.op.requires
|
|
283
|
+
req_streams = {k: streams[k] for k in req_keys if k in streams}
|
|
284
|
+
# Separate batched (ndim≥1) vs scalar entries for in_axes
|
|
285
|
+
in_axes_t = {k: 0 if v.ndim >= 1 else None for k, v in req_streams.items()}
|
|
286
|
+
bufs[a] = jax.vmap(lambda s: T.op(**s), in_axes=(in_axes_t,))(req_streams)
|
|
287
|
+
|
|
288
|
+
# --- constants ---
|
|
289
|
+
for a, C in self._consts.items():
|
|
290
|
+
bufs[a] = C.value
|
|
291
|
+
|
|
292
|
+
# --- contractions: vmap each einsum over the leading K axis ---
|
|
293
|
+
const_aliases = frozenset(self._consts.keys())
|
|
294
|
+
out = None
|
|
295
|
+
for term in self._terms:
|
|
296
|
+
operands = [bufs[a] for a in term.ops]
|
|
297
|
+
# Constants have no K axis → in_axes=None; others → 0
|
|
298
|
+
in_axes = tuple(None if a in const_aliases else 0 for a in term.ops)
|
|
299
|
+
val = jax.vmap(
|
|
300
|
+
lambda *args, _eq=term.eq: jnp.einsum(_eq, *args),
|
|
301
|
+
in_axes=in_axes,
|
|
302
|
+
)(*operands)
|
|
303
|
+
out = term.scale * val if out is None else out + term.scale * val
|
|
304
|
+
|
|
305
|
+
return out
|
|
306
|
+
|
|
307
|
+
# ---------- sugar for linear combos ----------
|
|
308
|
+
def __add__(self, other: "Integrand") -> "Integrand":
|
|
309
|
+
me = self._merge_operand_dicts(self._exprs, other._exprs, "expr")
|
|
310
|
+
mt = self._merge_operand_dicts(self._times, other._times, "time")
|
|
311
|
+
mc = self._merge_operand_dicts(self._consts, other._consts, "const")
|
|
312
|
+
return Integrand(
|
|
313
|
+
exprs=list(me.values()),
|
|
314
|
+
times=list(mt.values()),
|
|
315
|
+
consts=list(mc.values()),
|
|
316
|
+
terms=[*self._terms, *other._terms],
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
def __radd__(self, other): # allow sum([...], start=0)
|
|
320
|
+
return self if other == 0 else NotImplemented
|
|
321
|
+
|
|
322
|
+
def __mul__(self, alpha: float) -> "Integrand":
|
|
323
|
+
return Integrand(
|
|
324
|
+
exprs=self._exprs.values(),
|
|
325
|
+
times=self._times.values(),
|
|
326
|
+
consts=self._consts.values(),
|
|
327
|
+
terms=[Term(eq=t.eq, ops=t.ops, scale=t.scale * alpha) for t in self._terms],
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
__rmul__ = __mul__
|
|
331
|
+
|
|
332
|
+
# ---------- memory hint on a real sample ----------
|
|
333
|
+
def estimate_bytes_per_sample(
|
|
334
|
+
self,
|
|
335
|
+
sample_streams: Mapping[str, jnp.ndarray],
|
|
336
|
+
*,
|
|
337
|
+
dtypesize: int = 4,
|
|
338
|
+
) -> Optional[int]:
|
|
339
|
+
"""
|
|
340
|
+
Conservative upper bound in bytes per **time-sample** (one k-row).
|
|
341
|
+
Uses StateExpr static hints + shapes-only einsum path. No evaluation.
|
|
342
|
+
"""
|
|
343
|
+
_X = sample_streams.get("X", None)
|
|
344
|
+
_dtype = None if _X is None else _X.dtype
|
|
345
|
+
P = int(sample_streams["N_total"]) # use provided, conservative
|
|
346
|
+
|
|
347
|
+
# ---- shapes without evaluation
|
|
348
|
+
def expr_shape(E) -> tuple[int, ...]:
|
|
349
|
+
p = int(getattr(E.expr, "pdepth", 0))
|
|
350
|
+
r = int(getattr(E.expr, "rank", 0))
|
|
351
|
+
d = int(getattr(E.expr, "dim", 0))
|
|
352
|
+
F = int(getattr(E.expr, "n_features", 1))
|
|
353
|
+
paxes = () if p == 0 else (P,) * p # full particle axes (per-sample)
|
|
354
|
+
return (*paxes, *(d,) * r, F)
|
|
355
|
+
|
|
356
|
+
def time_shape(T) -> tuple[int, ...]:
|
|
357
|
+
arr = T.op(**sample_streams) # shape-only probe
|
|
358
|
+
return tuple(arr.shape)
|
|
359
|
+
|
|
360
|
+
def const_shape(C) -> tuple[int, ...]:
|
|
361
|
+
return tuple(C.value.shape)
|
|
362
|
+
|
|
363
|
+
shapes = {}
|
|
364
|
+
for a, E in self._exprs.items():
|
|
365
|
+
shapes[a] = expr_shape(E)
|
|
366
|
+
for a, T in self._times.items():
|
|
367
|
+
shapes[a] = time_shape(T)
|
|
368
|
+
for a, C in self._consts.items():
|
|
369
|
+
shapes[a] = const_shape(C)
|
|
370
|
+
|
|
371
|
+
def match_len(shape: tuple[int, ...], subscript: str) -> tuple[int, ...]:
|
|
372
|
+
L = len(subscript)
|
|
373
|
+
return (*shape, *([1] * (L - len(shape)))) if len(shape) < L else shape
|
|
374
|
+
|
|
375
|
+
def size_for_term(t: Term) -> int:
|
|
376
|
+
lhs = t.eq.split("->", 1)[0]
|
|
377
|
+
subs = [s.strip() for s in lhs.split(",")]
|
|
378
|
+
ops_shapes = [match_len(shapes[a], subs[i]) for i, a in enumerate(t.ops)]
|
|
379
|
+
try:
|
|
380
|
+
_, info = oe.contract_path(t.eq, *ops_shapes, shapes=True, optimize="greedy")
|
|
381
|
+
if hasattr(info, "largest_intermediate"):
|
|
382
|
+
return int(info.largest_intermediate) * int(dtypesize)
|
|
383
|
+
if hasattr(info, "intermediate_shapes") and info.intermediate_shapes:
|
|
384
|
+
mx = max(int(jnp.prod(jnp.array(sh))) for sh in info.intermediate_shapes) # type: ignore
|
|
385
|
+
return mx * int(dtypesize)
|
|
386
|
+
except Exception:
|
|
387
|
+
pass
|
|
388
|
+
total_el = sum(int(jnp.prod(jnp.array(s))) for s in ops_shapes)
|
|
389
|
+
return total_el * int(dtypesize)
|
|
390
|
+
|
|
391
|
+
peak_terms = max((size_for_term(t) for t in self._terms), default=0)
|
|
392
|
+
|
|
393
|
+
state_bytes = sum(int(jnp.prod(jnp.array(shapes[a]))) * int(dtypesize) for a in self._exprs.keys())
|
|
394
|
+
|
|
395
|
+
expr_transient = 0
|
|
396
|
+
for E in self._exprs.values():
|
|
397
|
+
estimator = getattr(E.expr, "estimate_bytes_per_sample", None)
|
|
398
|
+
if callable(estimator):
|
|
399
|
+
expr_transient += int(estimator(dtype=_dtype, particle_size=P))
|
|
400
|
+
|
|
401
|
+
total = expr_transient + state_bytes + peak_terms
|
|
402
|
+
return int(total) if total > 0 else None
|
SFI/integrate/rk4.py
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
# RK4 ODE flow integrator for parametric windowed inference.
|
|
2
|
+
"""
|
|
3
|
+
rk4.py
|
|
4
|
+
======
|
|
5
|
+
Classical fourth-order Runge-Kutta integrator in JAX, suitable for
|
|
6
|
+
differentiating through the ODE flow via ``jax.jacobian`` / ``jax.hessian``.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import jax
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"rk4_step",
|
|
13
|
+
"euler_step",
|
|
14
|
+
"euler_flow",
|
|
15
|
+
"ode_flow",
|
|
16
|
+
"select_flow",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def rk4_step(f, x, h):
|
|
21
|
+
"""One classical fourth-order Runge-Kutta step.
|
|
22
|
+
|
|
23
|
+
Parameters
|
|
24
|
+
----------
|
|
25
|
+
f : callable (d,) → (d,)
|
|
26
|
+
Autonomous vector field, JAX-traceable.
|
|
27
|
+
x : array (d,)
|
|
28
|
+
Current state.
|
|
29
|
+
h : scalar
|
|
30
|
+
Step size.
|
|
31
|
+
|
|
32
|
+
Returns
|
|
33
|
+
-------
|
|
34
|
+
x_new : array (d,)
|
|
35
|
+
State after one RK4 step.
|
|
36
|
+
"""
|
|
37
|
+
k1 = f(x)
|
|
38
|
+
k2 = f(x + 0.5 * h * k1)
|
|
39
|
+
k3 = f(x + 0.5 * h * k2)
|
|
40
|
+
k4 = f(x + h * k3)
|
|
41
|
+
return x + (h / 6.0) * (k1 + 2.0 * k2 + 2.0 * k3 + k4)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def euler_step(f, x, h):
|
|
45
|
+
"""One forward-Euler step.
|
|
46
|
+
|
|
47
|
+
Parameters
|
|
48
|
+
----------
|
|
49
|
+
f : callable (d,) → (d,)
|
|
50
|
+
Autonomous vector field, JAX-traceable.
|
|
51
|
+
x : array (d,)
|
|
52
|
+
Current state.
|
|
53
|
+
h : scalar
|
|
54
|
+
Step size.
|
|
55
|
+
|
|
56
|
+
Returns
|
|
57
|
+
-------
|
|
58
|
+
x_new : array (d,)
|
|
59
|
+
State after one Euler step.
|
|
60
|
+
"""
|
|
61
|
+
return x + h * f(x)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _generic_flow(step_fn, f, x0, dt, n_substeps):
|
|
65
|
+
"""Integrate dx/dt = f(x) from *x0* over total time *dt* using *step_fn*.
|
|
66
|
+
|
|
67
|
+
Uses ``jax.lax.scan`` so the full computation is JAX-traceable and
|
|
68
|
+
differentiable.
|
|
69
|
+
"""
|
|
70
|
+
if n_substeps < 1:
|
|
71
|
+
raise ValueError(
|
|
72
|
+
f"n_substeps must be >= 1, got {n_substeps}. "
|
|
73
|
+
"Use integrator='euler' with n_substeps=1 for the "
|
|
74
|
+
"single-step Euler mode."
|
|
75
|
+
)
|
|
76
|
+
h = dt / n_substeps
|
|
77
|
+
|
|
78
|
+
def body(x, _):
|
|
79
|
+
return step_fn(f, x, h), None
|
|
80
|
+
|
|
81
|
+
x_final, _ = jax.lax.scan(body, x0, None, length=n_substeps)
|
|
82
|
+
return x_final
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def euler_flow(f, x0, dt, n_substeps):
|
|
86
|
+
"""Integrate dx/dt = f(x) from *x0* over total time *dt* using Euler.
|
|
87
|
+
|
|
88
|
+
Uses *n_substeps* forward-Euler micro-steps each of size
|
|
89
|
+
``h = dt / n_substeps``. The loop is implemented via
|
|
90
|
+
``jax.lax.scan`` so the full computation is JAX-traceable and
|
|
91
|
+
differentiable.
|
|
92
|
+
|
|
93
|
+
Parameters
|
|
94
|
+
----------
|
|
95
|
+
f : callable (d,) → (d,)
|
|
96
|
+
Drift vector field (parameters should already be closed over).
|
|
97
|
+
x0 : array (d,)
|
|
98
|
+
Initial state.
|
|
99
|
+
dt : scalar
|
|
100
|
+
Total integration interval.
|
|
101
|
+
n_substeps : int (**static** Python int, not a JAX tracer)
|
|
102
|
+
Number of Euler micro-steps. Must be a compile-time constant.
|
|
103
|
+
|
|
104
|
+
Returns
|
|
105
|
+
-------
|
|
106
|
+
x_final : array (d,)
|
|
107
|
+
State at time *dt*.
|
|
108
|
+
"""
|
|
109
|
+
return _generic_flow(euler_step, f, x0, dt, n_substeps)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def ode_flow(f, x0, dt, n_substeps):
|
|
113
|
+
"""Integrate dx/dt = f(x) from *x0* over total time *dt*.
|
|
114
|
+
|
|
115
|
+
Uses *n_substeps* RK4 micro-steps each of size ``h = dt / n_substeps``.
|
|
116
|
+
The loop is implemented via ``jax.lax.scan`` so the full computation is
|
|
117
|
+
JAX-traceable and differentiable.
|
|
118
|
+
|
|
119
|
+
Parameters
|
|
120
|
+
----------
|
|
121
|
+
f : callable (d,) → (d,)
|
|
122
|
+
Drift vector field (parameters should already be closed over).
|
|
123
|
+
x0 : array (d,)
|
|
124
|
+
Initial state.
|
|
125
|
+
dt : scalar
|
|
126
|
+
Total integration interval.
|
|
127
|
+
n_substeps : int (**static** Python int, not a JAX tracer)
|
|
128
|
+
Number of RK4 micro-steps. Must be a compile-time constant.
|
|
129
|
+
Must be >= 1; use ``integrator='euler'`` for the Euler path.
|
|
130
|
+
|
|
131
|
+
Returns
|
|
132
|
+
-------
|
|
133
|
+
x_final : array (d,)
|
|
134
|
+
State at time *dt*.
|
|
135
|
+
"""
|
|
136
|
+
return _generic_flow(rk4_step, f, x0, dt, n_substeps)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def select_flow(integrator: str):
|
|
140
|
+
"""Return the ODE flow function for the given integrator name.
|
|
141
|
+
|
|
142
|
+
Parameters
|
|
143
|
+
----------
|
|
144
|
+
integrator : {'rk4', 'euler'}
|
|
145
|
+
|
|
146
|
+
Returns
|
|
147
|
+
-------
|
|
148
|
+
flow : callable
|
|
149
|
+
Either :func:`ode_flow` or :func:`euler_flow`.
|
|
150
|
+
"""
|
|
151
|
+
if integrator == "rk4":
|
|
152
|
+
return ode_flow
|
|
153
|
+
elif integrator == "euler":
|
|
154
|
+
return euler_flow
|
|
155
|
+
else:
|
|
156
|
+
raise ValueError(f"Unknown integrator {integrator!r}; expected 'rk4' or 'euler'")
|