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,530 @@
|
|
|
1
|
+
import math
|
|
2
|
+
from typing import Any, Callable, Literal, Optional
|
|
3
|
+
|
|
4
|
+
import equinox as eqx
|
|
5
|
+
import jax
|
|
6
|
+
import jax.numpy as jnp
|
|
7
|
+
from jaxtyping import Array
|
|
8
|
+
|
|
9
|
+
from ..memhint import MemHint, SampleMeta, default_leaf_hint, itemsize_of, resolve_P
|
|
10
|
+
from ..params import ParamSuite
|
|
11
|
+
from .base import BaseNode
|
|
12
|
+
from .contract import Rank
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
# -----------------------------------------------------------------------
|
|
16
|
+
# helper: leaf‑local fill policy (root already did a "zero" premask) -----
|
|
17
|
+
# -----------------------------------------------------------------------
|
|
18
|
+
def _apply_fill(arr: Array, mask: Optional[Array], policy: str) -> Array:
|
|
19
|
+
"""Optionally replace masked entries before dangerous maths.
|
|
20
|
+
|
|
21
|
+
Gradient behaviour
|
|
22
|
+
------------------
|
|
23
|
+
``jnp.where(mask, arr, fill)`` naturally provides zero tangents for the
|
|
24
|
+
masked branch in both forward and reverse mode (the fill value is a
|
|
25
|
+
constant with zero tangent). We therefore do **not** wrap the filled
|
|
26
|
+
array in ``stop_gradient`` — doing so would kill the Jacobian for
|
|
27
|
+
*all* entries (including active, non-masked ones), breaking
|
|
28
|
+
``jacfwd``-based derivative nodes such as ``basis.d_x()``.
|
|
29
|
+
|
|
30
|
+
If the user-function has a singularity at the fill value (e.g. ``1/x``
|
|
31
|
+
filled with 0), use ``fill_policy='eps'`` instead.
|
|
32
|
+
"""
|
|
33
|
+
if mask is None or policy == "zero":
|
|
34
|
+
return arr
|
|
35
|
+
|
|
36
|
+
# Guard: squeeze trailing singleton dims from mask so that it matches
|
|
37
|
+
# arr's non-spatial prefix. This prevents `mask[..., None]` from
|
|
38
|
+
# broadcasting *into* arr and inflating its shape (e.g. mask (1,) with
|
|
39
|
+
# arr (d,) would produce (1, d) without this squeeze).
|
|
40
|
+
if hasattr(mask, "ndim"):
|
|
41
|
+
prefix_ndim = arr.ndim - 1 # everything except the spatial dim
|
|
42
|
+
while mask.ndim > prefix_ndim and mask.shape[-1] == 1:
|
|
43
|
+
mask = mask[..., 0]
|
|
44
|
+
|
|
45
|
+
if policy == "eps":
|
|
46
|
+
eps = jnp.finfo(arr.dtype).eps
|
|
47
|
+
return jnp.where(mask[..., None], arr, eps)
|
|
48
|
+
if policy == "zerostop":
|
|
49
|
+
return jnp.where(mask[..., None], arr, 0)
|
|
50
|
+
if policy == "nanstop":
|
|
51
|
+
return jnp.where(mask[..., None], arr, jnp.nan)
|
|
52
|
+
if callable(policy):
|
|
53
|
+
return policy(arr, mask)
|
|
54
|
+
raise ValueError(f"Unknown fill policy {policy!r}")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# ---------------------------------------------------------------------------
|
|
58
|
+
# Generic primitive leaf – deterministic *or* parametric
|
|
59
|
+
# ---------------------------------------------------------------------------
|
|
60
|
+
class BaseLeaf(BaseNode):
|
|
61
|
+
"""
|
|
62
|
+
One callable that returns **n_features** stacked on the last axis.
|
|
63
|
+
|
|
64
|
+
Parameters
|
|
65
|
+
----------
|
|
66
|
+
func : Callable
|
|
67
|
+
Signature may include any subset of
|
|
68
|
+
|
|
69
|
+
(x, v=None, mask=None, extras=None, params=None)
|
|
70
|
+
|
|
71
|
+
- in any capitalisation. ``params`` is omitted if
|
|
72
|
+
``param_suite is None`` (deterministic leaf).
|
|
73
|
+
param_suite : ParamSuite | None
|
|
74
|
+
* ``None`` → deterministic dictionary element
|
|
75
|
+
* ParamSuite → parameters are required at call time
|
|
76
|
+
rank, dim, pdepth, n_features, needs_v
|
|
77
|
+
Static contract.
|
|
78
|
+
labels : tuple[str, ...] - human-readable titles, length == n_features
|
|
79
|
+
descriptor : Any - optional JSON-serialisable metadata
|
|
80
|
+
fill_policy : "zero" | "eps" | "zerostop" | "nanstop" | Callable
|
|
81
|
+
extras_keys : tuple[str, ...] - required keys in the `extras` mapping
|
|
82
|
+
|
|
83
|
+
Mask handling
|
|
84
|
+
-------------
|
|
85
|
+
``fill_policy`` controls how masked entries are preprocessed **before**
|
|
86
|
+
calling your function:
|
|
87
|
+
|
|
88
|
+
* ``"zero"`` : no-op (pass inputs through unchanged).
|
|
89
|
+
* ``"eps"`` : replace masked entries by machine epsilon of the dtype (safe for logs/div).
|
|
90
|
+
* ``"zerostop"`` : replace masked entries by 0 (fill value is a constant, so
|
|
91
|
+
``jnp.where`` gives zero tangent for masked entries).
|
|
92
|
+
* ``"nanstop"`` : replace masked entries by NaN (fail-fast in JITed code; same
|
|
93
|
+
zero-tangent property as ``"zerostop"``).
|
|
94
|
+
* ``callable`` : custom policy ``fn(arr, mask) -> arr`` applied elementwise.
|
|
95
|
+
|
|
96
|
+
``mask`` must broadcast to the prefix of ``x`` **including** the particle axis, i.e.
|
|
97
|
+
``batch·P`` if ``particles_input=True`` or ``batch`` otherwise.
|
|
98
|
+
|
|
99
|
+
Extras policy
|
|
100
|
+
-------------
|
|
101
|
+
If a leaf declares ``extras``, values are read from the ``extras`` mapping at call time.
|
|
102
|
+
All extras are treated as **global constants**: they are closed over inside the vmap and
|
|
103
|
+
passed verbatim to every per-sample call. No shape-broadcasting is attempted.
|
|
104
|
+
|
|
105
|
+
Named requirements can be declared via ``extras_keys=("alpha","beta",...)``. If the function
|
|
106
|
+
declares an ``extras`` argument but no keys are specified, we only enforce **presence** of the
|
|
107
|
+
mapping (no specific keys).
|
|
108
|
+
|
|
109
|
+
"""
|
|
110
|
+
|
|
111
|
+
# ---------------- static fields ----------------
|
|
112
|
+
func: Callable = eqx.field(static=True)
|
|
113
|
+
param_suite: ParamSuite | None = eqx.field(static=True, default=None)
|
|
114
|
+
labels: tuple[str, ...] = eqx.field(static=True, default=())
|
|
115
|
+
descriptor: Any = eqx.field(static=True, default=None)
|
|
116
|
+
fill_policy: str | Callable = eqx.field(static=True, default="zerostop")
|
|
117
|
+
extras_keys: tuple[str, ...] = eqx.field(static=True, default=())
|
|
118
|
+
particle_extras: tuple[str, ...] = eqx.field(static=True, default=())
|
|
119
|
+
particles_input: bool = eqx.field(static=True, default=False)
|
|
120
|
+
# Optional ``k -> StateExpr`` recipe folding this leaf at condition ``k``
|
|
121
|
+
# (used by ``StateExpr.specialize``; see per_dataset_scalar/dataset_indicator).
|
|
122
|
+
specialize_at: Any = eqx.field(static=True, repr=False, default=None)
|
|
123
|
+
_signature: Any = eqx.field(static=True, repr=False, default=None)
|
|
124
|
+
_sig_params: set[str] = eqx.field(static=True, repr=False, default_factory=set)
|
|
125
|
+
_sig_param_map: dict[str, str] = eqx.field(static=True, repr=False, default_factory=dict)
|
|
126
|
+
|
|
127
|
+
# -------------- constructor --------------------
|
|
128
|
+
def __init__(
|
|
129
|
+
self,
|
|
130
|
+
*,
|
|
131
|
+
func: Callable,
|
|
132
|
+
rank: Rank,
|
|
133
|
+
dim: int | None,
|
|
134
|
+
n_features: int,
|
|
135
|
+
pdepth: int = 0,
|
|
136
|
+
particles_input: bool = False,
|
|
137
|
+
needs_v: bool = False,
|
|
138
|
+
param_suite=None,
|
|
139
|
+
labels: tuple[str, ...] = (),
|
|
140
|
+
descriptor: Any = "custom",
|
|
141
|
+
fill_policy: str | Callable = "zerostop",
|
|
142
|
+
extras_keys: tuple[str, ...] = (),
|
|
143
|
+
particle_extras: tuple[str, ...] = (),
|
|
144
|
+
specialize_at: Callable | None = None,
|
|
145
|
+
):
|
|
146
|
+
# static contract
|
|
147
|
+
object.__setattr__(self, "specialize_at", specialize_at)
|
|
148
|
+
object.__setattr__(self, "rank", rank)
|
|
149
|
+
object.__setattr__(self, "dim", dim)
|
|
150
|
+
object.__setattr__(self, "pdepth", pdepth)
|
|
151
|
+
object.__setattr__(self, "needs_v", needs_v)
|
|
152
|
+
object.__setattr__(self, "particles_input", particles_input)
|
|
153
|
+
object.__setattr__(self, "n_features", int(n_features))
|
|
154
|
+
|
|
155
|
+
# static metadata
|
|
156
|
+
object.__setattr__(self, "func", func)
|
|
157
|
+
object.__setattr__(self, "param_suite", param_suite)
|
|
158
|
+
object.__setattr__(self, "descriptor", descriptor)
|
|
159
|
+
object.__setattr__(self, "fill_policy", fill_policy)
|
|
160
|
+
# normalize and store extras_keys
|
|
161
|
+
if extras_keys is None:
|
|
162
|
+
extras_keys = ()
|
|
163
|
+
elif isinstance(extras_keys, str):
|
|
164
|
+
extras_keys = (extras_keys,)
|
|
165
|
+
extras_keys = tuple(k for k in extras_keys if k != "*") # no wildcard sentinel
|
|
166
|
+
object.__setattr__(self, "extras_keys", extras_keys)
|
|
167
|
+
object.__setattr__(self, "particle_extras", tuple(particle_extras or ()))
|
|
168
|
+
|
|
169
|
+
if labels and len(labels) != n_features:
|
|
170
|
+
raise ValueError("labels length must equal n_features")
|
|
171
|
+
object.__setattr__(self, "labels", tuple(labels or [f"f{j}" for j in range(n_features)]))
|
|
172
|
+
|
|
173
|
+
# --- signature analysis (single pass) --------------------------------
|
|
174
|
+
import inspect as _inspect
|
|
175
|
+
|
|
176
|
+
sig = _inspect.signature(func)
|
|
177
|
+
object.__setattr__(self, "_signature", sig)
|
|
178
|
+
|
|
179
|
+
# canonical-name → original-name (case-insensitive lookup)
|
|
180
|
+
lower2orig = {p.name.lower(): p.name for p in sig.parameters.values()}
|
|
181
|
+
object.__setattr__(self, "_sig_params", set(lower2orig))
|
|
182
|
+
object.__setattr__(self, "_sig_param_map", lower2orig)
|
|
183
|
+
|
|
184
|
+
params_by = {p.name: p for p in sig.parameters.values()}
|
|
185
|
+
|
|
186
|
+
# needs_v → function must actually accept 'v'
|
|
187
|
+
if self.needs_v and ("v" not in params_by):
|
|
188
|
+
raise ValueError(f"{type(self).__name__}: needs_v=True but the function does not declare 'v'.")
|
|
189
|
+
|
|
190
|
+
# extras_keys → function must actually accept 'extras'
|
|
191
|
+
if self.extras_keys and ("extras" not in params_by):
|
|
192
|
+
raise ValueError(f"{type(self).__name__}: extras_keys provided but the function has no 'extras' parameter.")
|
|
193
|
+
|
|
194
|
+
# ParamLeaf must accept 'params' if it actually has parameters
|
|
195
|
+
has_params_kw = "params" in params_by
|
|
196
|
+
has_nonempty_suite = (self.param_suite is not None) and (getattr(self.param_suite, "size", 0) > 0)
|
|
197
|
+
if has_nonempty_suite and (not has_params_kw):
|
|
198
|
+
raise ValueError(
|
|
199
|
+
f"{type(self).__name__}: function must declare a 'params' keyword to build a non-empty ParamLeaf."
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
# extras presence policy
|
|
203
|
+
has_extras_param = "extras" in params_by
|
|
204
|
+
if has_extras_param:
|
|
205
|
+
required = tuple(self.extras_keys) if self.extras_keys else ()
|
|
206
|
+
else:
|
|
207
|
+
required = ()
|
|
208
|
+
object.__setattr__(self, "extras_required", required)
|
|
209
|
+
|
|
210
|
+
# ---- static contract sanity ----
|
|
211
|
+
if not isinstance(self.pdepth, int) or self.pdepth < 0:
|
|
212
|
+
raise ValueError(f"{type(self).__name__}: pdepth must be a non-negative int, got {self.pdepth!r}")
|
|
213
|
+
if not isinstance(self.rank, int) or self.rank < 0:
|
|
214
|
+
raise ValueError(f"{type(self).__name__}: rank must be a non-negative int, got {self.rank!r}")
|
|
215
|
+
if not isinstance(self.n_features, int) or self.n_features < 1:
|
|
216
|
+
raise ValueError(f"{type(self).__name__}: n_features must be a positive int, got {self.n_features!r}")
|
|
217
|
+
if (self.dim is not None) and (not isinstance(self.dim, int) or self.dim < 1):
|
|
218
|
+
raise ValueError(f"{type(self).__name__}: dim must be None or a positive int, got {self.dim!r}")
|
|
219
|
+
if (not self.particles_input) and (self.pdepth > 0):
|
|
220
|
+
raise ValueError(
|
|
221
|
+
f"{type(self).__name__}: pdepth>0 requires particles_input=True "
|
|
222
|
+
"(cannot create particle axes without a particle input axis)."
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
# -------------- call ---------------------------
|
|
226
|
+
def __call__(self, x, *, params=None, v=None, mask=None, extras=None):
|
|
227
|
+
self._assert_inputs(x, v, mask, extras)
|
|
228
|
+
x_safe = _apply_fill(x, mask, self.fill_policy)
|
|
229
|
+
|
|
230
|
+
y = self._apply_user_func(x_safe, v=v, mask=mask, extras=extras, params=params)
|
|
231
|
+
|
|
232
|
+
self._assert_outputs(x, y)
|
|
233
|
+
return y
|
|
234
|
+
|
|
235
|
+
# -----------------------------------------------------------------
|
|
236
|
+
# Canonicalise shapes, batch-vmap once, call user func
|
|
237
|
+
# -----------------------------------------------------------------
|
|
238
|
+
def _apply_user_func(
|
|
239
|
+
self,
|
|
240
|
+
x,
|
|
241
|
+
*,
|
|
242
|
+
v=None,
|
|
243
|
+
mask=None,
|
|
244
|
+
extras=None,
|
|
245
|
+
params=None,
|
|
246
|
+
):
|
|
247
|
+
"""
|
|
248
|
+
Ensure the user-provided function sees a single sample:
|
|
249
|
+
- x: (dim,) or (P, dim) if particles_input=True
|
|
250
|
+
- v: same shape as x (only if the function declares 'v')
|
|
251
|
+
- mask: scalar or (P,) (only if the function declares 'mask')
|
|
252
|
+
- extras: per-sample dict (only if the function declares 'extras')
|
|
253
|
+
- params: dict (if the function declares 'params', even for BasisLeaf)
|
|
254
|
+
|
|
255
|
+
Batching over any leading axes is handled via a single vmap.
|
|
256
|
+
"""
|
|
257
|
+
import jax.numpy as jnp
|
|
258
|
+
|
|
259
|
+
# ---------- 1. Expected inner shapes ----------
|
|
260
|
+
inner_ndim = 1 + int(self.particles_input) # dim (+P)
|
|
261
|
+
if x.ndim < inner_ndim:
|
|
262
|
+
if self.dim == 1 and x.ndim == inner_ndim - 1:
|
|
263
|
+
x = jnp.expand_dims(x, axis=-1)
|
|
264
|
+
if v is not None:
|
|
265
|
+
v = jnp.expand_dims(v, axis=-1)
|
|
266
|
+
else:
|
|
267
|
+
raise ValueError(
|
|
268
|
+
f"{type(self).__name__}: input x.ndim={x.ndim} too small for particles_input={self.particles_input}"
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
batch_shape = x.shape[:-inner_ndim] # tuple of batch dims
|
|
272
|
+
inner_shape = x.shape[-inner_ndim:]
|
|
273
|
+
flat_B = int(math.prod(batch_shape)) if batch_shape else 1
|
|
274
|
+
|
|
275
|
+
# ---------- 2. Flatten batch ----------
|
|
276
|
+
def _flatten(arr):
|
|
277
|
+
return None if arr is None else arr.reshape((flat_B,) + inner_shape)
|
|
278
|
+
|
|
279
|
+
x_f = _flatten(x)
|
|
280
|
+
v_f = _flatten(v)
|
|
281
|
+
m_f = None
|
|
282
|
+
if mask is not None:
|
|
283
|
+
# Let mask carry only its own dims; library already checked broadcast.
|
|
284
|
+
# We broadcast mask over the full batch prefix and then flatten.
|
|
285
|
+
mask_arr = jnp.asarray(mask)
|
|
286
|
+
# Pad to at least len(batch_shape) dims
|
|
287
|
+
pad = max(0, len(batch_shape) - mask_arr.ndim)
|
|
288
|
+
mask_arr = jnp.reshape(mask_arr, (1,) * pad + mask_arr.shape)
|
|
289
|
+
# Broadcast the first len(batch_shape) dims to batch_shape (remaining dims unchanged)
|
|
290
|
+
target = tuple(batch_shape) + mask_arr.shape[len(batch_shape) :]
|
|
291
|
+
mask_arr = jnp.broadcast_to(mask_arr, target)
|
|
292
|
+
m_f = mask_arr.reshape((flat_B,) + mask_arr.shape[len(batch_shape) :])
|
|
293
|
+
|
|
294
|
+
# ---------- 3. Prepare extras ------------
|
|
295
|
+
# Policy:
|
|
296
|
+
# - extras are GLOBAL constants (closed over), not vmapped — except
|
|
297
|
+
# keys declared in ``particle_extras`` whose leading axes match
|
|
298
|
+
# the batch (the dispatcher's per-edge gathered arrays): those
|
|
299
|
+
# are vmapped alongside x so the user function sees its own
|
|
300
|
+
# ``(K, ...)`` slice per sample.
|
|
301
|
+
e_f = None
|
|
302
|
+
e_mapped = None
|
|
303
|
+
if "extras" in self._sig_params:
|
|
304
|
+
if extras is None:
|
|
305
|
+
e_f = None
|
|
306
|
+
else:
|
|
307
|
+
# If keys were declared, keep those; otherwise pass the whole mapping.
|
|
308
|
+
keys = self.extras_keys if getattr(self, "extras_keys", ()) else tuple(extras.keys())
|
|
309
|
+
e_all = {k: extras[k] for k in keys}
|
|
310
|
+
pkeys = set(getattr(self, "particle_extras", ()) or ())
|
|
311
|
+
nb = len(batch_shape)
|
|
312
|
+
|
|
313
|
+
def _as_mapped(val):
|
|
314
|
+
"""Reshape a declared per-sample extra to (flat_B, ...).
|
|
315
|
+
|
|
316
|
+
Accepts a full batch-prefix match, or a trailing-axes
|
|
317
|
+
match (e.g. a per-particle ``(N, ...)`` array under a
|
|
318
|
+
batched ``(K, N)`` evaluation), broadcast over the
|
|
319
|
+
leading batch dims. Returns None when the value does
|
|
320
|
+
not align (treated as a constant).
|
|
321
|
+
"""
|
|
322
|
+
if not hasattr(val, "shape") or nb == 0:
|
|
323
|
+
return None
|
|
324
|
+
shape = tuple(val.shape)
|
|
325
|
+
if shape[:nb] == tuple(batch_shape):
|
|
326
|
+
return val.reshape((flat_B,) + shape[nb:])
|
|
327
|
+
for k_off in range(1, nb):
|
|
328
|
+
lead = nb - k_off
|
|
329
|
+
if shape[:lead] == tuple(batch_shape[k_off:]):
|
|
330
|
+
rest = shape[lead:]
|
|
331
|
+
tgt = tuple(batch_shape) + rest
|
|
332
|
+
return jnp.broadcast_to(val, tgt).reshape((flat_B,) + rest)
|
|
333
|
+
return None
|
|
334
|
+
|
|
335
|
+
e_mapped = {}
|
|
336
|
+
for k in pkeys & set(e_all):
|
|
337
|
+
mapped = _as_mapped(e_all[k])
|
|
338
|
+
if mapped is not None:
|
|
339
|
+
e_mapped[k] = mapped
|
|
340
|
+
e_f = {k: v for k, v in e_all.items() if k not in e_mapped}
|
|
341
|
+
if not e_mapped:
|
|
342
|
+
e_mapped = None
|
|
343
|
+
|
|
344
|
+
# ---------- 4. Batched caller ----------
|
|
345
|
+
|
|
346
|
+
def _caller(x_, v_, mask_, e_m):
|
|
347
|
+
kwargs = {}
|
|
348
|
+
if "v" in self._sig_params:
|
|
349
|
+
kwargs[self._sig_param_map["v"]] = v_
|
|
350
|
+
if "mask" in self._sig_params:
|
|
351
|
+
kwargs[self._sig_param_map["mask"]] = mask_
|
|
352
|
+
if "extras" in self._sig_params:
|
|
353
|
+
ex = e_f if e_m is None else {**(e_f or {}), **e_m}
|
|
354
|
+
kwargs[self._sig_param_map["extras"]] = ex
|
|
355
|
+
if "params" in self._sig_params:
|
|
356
|
+
kwargs[self._sig_param_map["params"]] = params
|
|
357
|
+
return self.func(x_, **kwargs)
|
|
358
|
+
|
|
359
|
+
in_axes = (
|
|
360
|
+
0,
|
|
361
|
+
0 if v_f is not None else None,
|
|
362
|
+
0 if m_f is not None else None,
|
|
363
|
+
0 if e_mapped is not None else None,
|
|
364
|
+
)
|
|
365
|
+
y_f = jax.vmap(_caller, in_axes=in_axes)(x_f, v_f, m_f, e_mapped)
|
|
366
|
+
|
|
367
|
+
# ---------- 5. Restore batch shape ----------
|
|
368
|
+
y = y_f.reshape(batch_shape + y_f.shape[1:])
|
|
369
|
+
|
|
370
|
+
# ---------- 6. Contract check (strict pdepth) + auto feature axis ----------
|
|
371
|
+
trail = y_f.shape[1:] # excluding the vmapped axis
|
|
372
|
+
P = inner_shape[0] if (self.particles_input and x.ndim >= 2) else None
|
|
373
|
+
|
|
374
|
+
particle_block = (P,) * int(self.pdepth) if self.pdepth else ()
|
|
375
|
+
rank_block = (self.dim,) * int(self.rank) if int(self.rank) else ()
|
|
376
|
+
base_suffix = particle_block + rank_block
|
|
377
|
+
|
|
378
|
+
if trail == base_suffix + (self.n_features,):
|
|
379
|
+
return y
|
|
380
|
+
if self.n_features == 1 and trail == base_suffix:
|
|
381
|
+
return y[..., None] # auto-insert singleton feature axis
|
|
382
|
+
|
|
383
|
+
raise ValueError(
|
|
384
|
+
f"[{type(self).__name__}] output shape mismatch.\n"
|
|
385
|
+
f" expected suffix : {base_suffix + (self.n_features,)}\n"
|
|
386
|
+
f" got : {trail}"
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
# -------------- flatten ------------------------
|
|
390
|
+
def flatten(self):
|
|
391
|
+
funcs = [
|
|
392
|
+
lambda _x, *, v=None, mask=None, extras=None, params=None, _j=j: # noqa: E731
|
|
393
|
+
self(_x, v=v, mask=mask, extras=extras, params=params)[..., _j]
|
|
394
|
+
for j in range(self.n_features)
|
|
395
|
+
]
|
|
396
|
+
descs = [self.descriptor] * self.n_features
|
|
397
|
+
return funcs, list(self.labels), descs
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
# ---------------------------------------------------------------------------
|
|
401
|
+
# Leaf aliases for intelligible construction
|
|
402
|
+
# ---------------------------------------------------------------------------
|
|
403
|
+
class SimpleLeaf(BaseLeaf):
|
|
404
|
+
"""
|
|
405
|
+
Standard leaf: never reads or produces a particle axis.
|
|
406
|
+
Treats any particle dimension in `x` as batch and vmaps over it.
|
|
407
|
+
Output suffix is (``*rank``, n_features) with feature last.
|
|
408
|
+
"""
|
|
409
|
+
|
|
410
|
+
def __init__(self, **kwargs):
|
|
411
|
+
if kwargs.get("particles_input", False):
|
|
412
|
+
raise ValueError("SimpleLeaf forbids particles_input=True (it never sees P).")
|
|
413
|
+
if kwargs.get("pdepth", 0) != 0:
|
|
414
|
+
raise ValueError("SimpleLeaf requires pdepth=0 (it never produces a particle axis).")
|
|
415
|
+
kwargs["particles_input"] = False
|
|
416
|
+
kwargs["pdepth"] = 0
|
|
417
|
+
super().__init__(**kwargs)
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
class InteractionLeaf(BaseLeaf):
|
|
421
|
+
"""
|
|
422
|
+
Local interaction leaf: **consumes** a `(K, dim)` particle slice and returns
|
|
423
|
+
feature-last output with **no** particle axis (pdepth=0).
|
|
424
|
+
|
|
425
|
+
Modes
|
|
426
|
+
-----
|
|
427
|
+
- mode="fixed", K=int → assert `x.shape[-2] == K`
|
|
428
|
+
- mode="variable", Kmax=int → assert `x.shape[-2] == Kmax`, pass (optional)
|
|
429
|
+
1D mask of length Kmax to the user func.
|
|
430
|
+
|
|
431
|
+
Extras semantics
|
|
432
|
+
----------------
|
|
433
|
+
This leaf supports explicit separation of extras kinds (declared at construction time):
|
|
434
|
+
- ``global_extras`` : keys whose values are **global constants** for the call
|
|
435
|
+
(e.g., PBC box). They are passed through verbatim.
|
|
436
|
+
- ``particle_extras`` : keys whose values are **per-particle** arrays (shape ``(P, ...)``).
|
|
437
|
+
The dispatcher is responsible for **gathering** these into the
|
|
438
|
+
local K-order before calling the leaf. The leaf sees per-sample
|
|
439
|
+
payload only (no batch axes).
|
|
440
|
+
"""
|
|
441
|
+
|
|
442
|
+
mode: Literal["fixed", "variable"] = eqx.field(static=True)
|
|
443
|
+
K: int | None = eqx.field(static=True, default=None)
|
|
444
|
+
Kmax: int | None = eqx.field(static=True, default=None)
|
|
445
|
+
|
|
446
|
+
def __init__(
|
|
447
|
+
self,
|
|
448
|
+
*,
|
|
449
|
+
mode: Literal["fixed", "variable"],
|
|
450
|
+
K: int | None = None,
|
|
451
|
+
Kmax: int | None = None,
|
|
452
|
+
func,
|
|
453
|
+
dim: int,
|
|
454
|
+
rank: "Rank",
|
|
455
|
+
n_features: int = 1,
|
|
456
|
+
needs_v: bool = False,
|
|
457
|
+
param_suite=None,
|
|
458
|
+
labels: tuple[str, ...] = (),
|
|
459
|
+
descriptor=None,
|
|
460
|
+
fill_policy: str | Callable = "zero",
|
|
461
|
+
extras_keys: tuple[str, ...] = (),
|
|
462
|
+
particle_extras: tuple[str, ...] = (),
|
|
463
|
+
):
|
|
464
|
+
if mode == "fixed":
|
|
465
|
+
if K is None:
|
|
466
|
+
raise ValueError("InteractionLeaf(mode='fixed') requires K.")
|
|
467
|
+
elif mode == "variable":
|
|
468
|
+
if Kmax is None:
|
|
469
|
+
raise ValueError("InteractionLeaf(mode='variable') requires Kmax.")
|
|
470
|
+
# A safer default for ragged padding
|
|
471
|
+
if fill_policy == "zero":
|
|
472
|
+
fill_policy = "zerostop"
|
|
473
|
+
else:
|
|
474
|
+
raise ValueError(f"Unknown mode {mode!r}")
|
|
475
|
+
|
|
476
|
+
super().__init__(
|
|
477
|
+
func=func,
|
|
478
|
+
rank=rank,
|
|
479
|
+
dim=dim,
|
|
480
|
+
n_features=n_features,
|
|
481
|
+
pdepth=0, # never *produces* a particle axis
|
|
482
|
+
particles_input=True, # but it *consumes* (K?, dim)
|
|
483
|
+
needs_v=needs_v,
|
|
484
|
+
param_suite=param_suite,
|
|
485
|
+
labels=labels,
|
|
486
|
+
descriptor=descriptor,
|
|
487
|
+
fill_policy=fill_policy,
|
|
488
|
+
extras_keys=extras_keys,
|
|
489
|
+
)
|
|
490
|
+
object.__setattr__(self, "mode", mode)
|
|
491
|
+
object.__setattr__(self, "K", None if mode != "fixed" else int(K))
|
|
492
|
+
object.__setattr__(self, "Kmax", None if mode != "variable" else int(Kmax))
|
|
493
|
+
# NOTE: must be set *after* super().__init__, which resets the
|
|
494
|
+
# base-class default to ().
|
|
495
|
+
if particle_extras is None:
|
|
496
|
+
particle_extras = ()
|
|
497
|
+
object.__setattr__(self, "particle_extras", tuple(particle_extras))
|
|
498
|
+
|
|
499
|
+
def __call__(self, x: Array, *, params=None, v=None, mask=None, extras=None) -> Array:
|
|
500
|
+
if x.ndim < 2:
|
|
501
|
+
raise ValueError(f"{type(self).__name__} expects (..., K, dim); got {x.shape}")
|
|
502
|
+
Kin = x.shape[-2]
|
|
503
|
+
if self.mode == "fixed":
|
|
504
|
+
if Kin != self.K:
|
|
505
|
+
raise ValueError(f"{type(self).__name__} expected K={self.K}, got {Kin}")
|
|
506
|
+
else: # variable
|
|
507
|
+
if Kin != self.Kmax:
|
|
508
|
+
raise ValueError(f"{type(self).__name__} expected Kmax={self.Kmax}, got {Kin}")
|
|
509
|
+
# If the user func declared `mask`, BaseLeaf will route it; fill_policy applies.
|
|
510
|
+
return super().__call__(x, params=params, v=v, mask=mask, extras=extras)
|
|
511
|
+
|
|
512
|
+
# Interaction leaves often need a small live working set of the gathered neighborhood.
|
|
513
|
+
# Count it conservatively as K*dim elements in addition to the output buffer.
|
|
514
|
+
def memory_hint(
|
|
515
|
+
self,
|
|
516
|
+
*,
|
|
517
|
+
dtype=None,
|
|
518
|
+
particle_size: int | None = None,
|
|
519
|
+
sample: SampleMeta | None = None,
|
|
520
|
+
mode: str = "forward",
|
|
521
|
+
) -> MemHint:
|
|
522
|
+
P = resolve_P(particle_size, sample)
|
|
523
|
+
base = default_leaf_hint(self, dtype=dtype, particle_size=P, mode=mode)
|
|
524
|
+
dim = int(getattr(self, "dim", 0) or 0)
|
|
525
|
+
Kwork = self.K if self.mode == "fixed" else self.Kmax
|
|
526
|
+
wset = (int(Kwork) * dim * itemsize_of(dtype)) if (Kwork is not None and dim) else 0
|
|
527
|
+
return MemHint(
|
|
528
|
+
per_sample_bytes=base.per_sample_bytes + wset,
|
|
529
|
+
persistent_bytes=base.persistent_bytes,
|
|
530
|
+
)
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# SFI/statefunc/nodes/ops/__init__.py
|
|
2
|
+
"""
|
|
3
|
+
Operator node classes.
|
|
4
|
+
|
|
5
|
+
These are the building blocks for composite state expressions.
|
|
6
|
+
Most users will never need them directly — they’re primarily
|
|
7
|
+
for developers extending `SFI.statefunc`.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from .concat import ConcatNode
|
|
11
|
+
from .derivative import DerivativeNode
|
|
12
|
+
from .einsum import EinsumNode
|
|
13
|
+
from .linear import CoeffNode, DenseNode
|
|
14
|
+
from .mapn import MapNNode
|
|
15
|
+
from .reshape_rank import ReshapeRankNode
|
|
16
|
+
from .slice import SliceFeaturesNode
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"ConcatNode",
|
|
20
|
+
"MapNNode",
|
|
21
|
+
"EinsumNode",
|
|
22
|
+
"DenseNode",
|
|
23
|
+
"CoeffNode",
|
|
24
|
+
"SliceFeaturesNode",
|
|
25
|
+
"DerivativeNode",
|
|
26
|
+
"ReshapeRankNode",
|
|
27
|
+
]
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# SFI/statefunc/nodes/ops/concat.py
|
|
2
|
+
"""Concatenation operator node."""
|
|
3
|
+
|
|
4
|
+
import jax.numpy as jnp
|
|
5
|
+
|
|
6
|
+
from ..base import BaseOpNode
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
# ──────────────────────────────────────────────────────────────────────────────
|
|
10
|
+
# ConcatNode – feature-axis concatenation
|
|
11
|
+
# ──────────────────────────────────────────────────────────────────────────────
|
|
12
|
+
class ConcatNode(BaseOpNode):
|
|
13
|
+
"""
|
|
14
|
+
Concatenate the **feature axes** of several sub-bases.
|
|
15
|
+
|
|
16
|
+
All children must share the same *spatial* contract
|
|
17
|
+
``(rank, dim, pdepth)``; the resulting node inherits that contract
|
|
18
|
+
while its ``n_features`` is the sum of the children's features.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
CONTRACT_MODE = "concat"
|
|
22
|
+
|
|
23
|
+
# combine tensors
|
|
24
|
+
def _op(self, outs, *, params):
|
|
25
|
+
return jnp.concatenate(outs, axis=-1)
|
|
26
|
+
|
|
27
|
+
def with_children(self, new_children):
|
|
28
|
+
return ConcatNode(*new_children)
|