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
SFI/statefunc/params.py
ADDED
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Any, Callable, Iterable, Tuple
|
|
5
|
+
|
|
6
|
+
import equinox as eqx
|
|
7
|
+
import jax.numpy as jnp
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
# ---------------------------------------------------------------------
|
|
11
|
+
# ParamSpec - static description of one parameter block
|
|
12
|
+
# ---------------------------------------------------------------------
|
|
13
|
+
@dataclass(frozen=True, slots=True)
|
|
14
|
+
class ParamSpec:
|
|
15
|
+
name: str # <-- KEY for sharing
|
|
16
|
+
shape: Tuple[int, ...]
|
|
17
|
+
dtype: Any = jnp.float32
|
|
18
|
+
init: Callable | str = "zeros" # PRNG->array or keyword
|
|
19
|
+
default: Any = None # optional concrete value (scalar or array-like)
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def size(self) -> int:
|
|
23
|
+
from functools import reduce
|
|
24
|
+
from operator import mul
|
|
25
|
+
|
|
26
|
+
return reduce(mul, self.shape, 1)
|
|
27
|
+
|
|
28
|
+
def compatible_with(self, other: "ParamSpec") -> bool:
|
|
29
|
+
"""Shareable iff shape and dtype match exactly."""
|
|
30
|
+
return (self.shape == other.shape) and (self.dtype == other.dtype)
|
|
31
|
+
|
|
32
|
+
def merged_with(self, other: "ParamSpec") -> "ParamSpec":
|
|
33
|
+
"""
|
|
34
|
+
Return a single spec representing the shared parameter.
|
|
35
|
+
Requires compatibility; keeps `self.init` by default.
|
|
36
|
+
"""
|
|
37
|
+
if not self.compatible_with(other):
|
|
38
|
+
raise ValueError(
|
|
39
|
+
f"ParamSpec mismatch for '{self.name}': {self.shape}/{self.dtype} vs {other.shape}/{other.dtype}"
|
|
40
|
+
)
|
|
41
|
+
# deterministically keep `self`, but salvage a default from `other`
|
|
42
|
+
if self.default is None and other.default is not None:
|
|
43
|
+
from dataclasses import replace
|
|
44
|
+
|
|
45
|
+
return replace(self, default=other.default)
|
|
46
|
+
return self
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class ParamSuite(eqx.Module):
|
|
50
|
+
"""Immutable container holding a set of ``ParamSpec`` objects."""
|
|
51
|
+
|
|
52
|
+
specs: tuple[ParamSpec, ...] = eqx.field(static=True)
|
|
53
|
+
_lookup: dict[str, int] = eqx.field(static=True) # name → idx
|
|
54
|
+
|
|
55
|
+
# ---------- construction ---------- #
|
|
56
|
+
def __init__(self, specs: Iterable[ParamSpec]):
|
|
57
|
+
specs = tuple(specs)
|
|
58
|
+
names = [ps.name for ps in specs]
|
|
59
|
+
if len(set(names)) != len(names):
|
|
60
|
+
dup = {n for n in names if names.count(n) > 1}
|
|
61
|
+
raise ValueError(f"Duplicate parameter names: {dup}")
|
|
62
|
+
object.__setattr__(self, "specs", specs)
|
|
63
|
+
object.__setattr__(self, "_lookup", {ps.name: i for i, ps in enumerate(specs)})
|
|
64
|
+
|
|
65
|
+
@classmethod
|
|
66
|
+
def from_specs(cls, *specs: ParamSpec) -> "ParamSuite":
|
|
67
|
+
return cls(specs)
|
|
68
|
+
|
|
69
|
+
# ---------- basic info ---------- #
|
|
70
|
+
def __iter__(self):
|
|
71
|
+
return iter(self.specs)
|
|
72
|
+
|
|
73
|
+
def __len__(self):
|
|
74
|
+
return len(self.specs)
|
|
75
|
+
|
|
76
|
+
def __getitem__(self, name: str) -> ParamSpec:
|
|
77
|
+
return self.specs[self._lookup[name]]
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def size(self) -> int:
|
|
81
|
+
return sum(ps.size for ps in self.specs)
|
|
82
|
+
|
|
83
|
+
# ---------- convenience constructors ---------- #
|
|
84
|
+
def zeros(self) -> dict[str, jnp.ndarray]:
|
|
85
|
+
"""Return a parameter dict with all values initialized to zero."""
|
|
86
|
+
return {ps.name: jnp.zeros(ps.shape, dtype=ps.dtype) for ps in self.specs}
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def has_defaults(self) -> bool:
|
|
90
|
+
"""True iff every spec in this suite carries a concrete ``default``."""
|
|
91
|
+
return len(self.specs) > 0 and all(ps.default is not None for ps in self.specs)
|
|
92
|
+
|
|
93
|
+
def defaults(self) -> dict[str, jnp.ndarray] | None:
|
|
94
|
+
"""Return a parameter dict from spec ``default`` values, or None if any
|
|
95
|
+
spec has no default. Values are broadcast to the declared shape."""
|
|
96
|
+
if not self.has_defaults:
|
|
97
|
+
return None
|
|
98
|
+
out: dict[str, jnp.ndarray] = {}
|
|
99
|
+
for ps in self.specs:
|
|
100
|
+
arr = jnp.asarray(ps.default, dtype=ps.dtype)
|
|
101
|
+
if arr.shape != ps.shape:
|
|
102
|
+
arr = jnp.broadcast_to(arr, ps.shape)
|
|
103
|
+
out[ps.name] = arr
|
|
104
|
+
return out
|
|
105
|
+
|
|
106
|
+
# ---------- param ↔ vector helpers ---------- #
|
|
107
|
+
def materialize(
|
|
108
|
+
self,
|
|
109
|
+
vector: jnp.ndarray,
|
|
110
|
+
*,
|
|
111
|
+
dtype_overrides: dict[str, jnp.dtype] | None = None,
|
|
112
|
+
):
|
|
113
|
+
if vector.ndim != 1 or vector.size != self.size:
|
|
114
|
+
raise ValueError("Flat vector has wrong length")
|
|
115
|
+
out, i = {}, 0
|
|
116
|
+
for ps in self.specs:
|
|
117
|
+
n = ps.size
|
|
118
|
+
arr = (
|
|
119
|
+
vector[i : i + n]
|
|
120
|
+
.reshape(ps.shape)
|
|
121
|
+
.astype(dtype_overrides.get(ps.name, ps.dtype) if dtype_overrides else ps.dtype)
|
|
122
|
+
)
|
|
123
|
+
out[ps.name] = arr
|
|
124
|
+
i += n
|
|
125
|
+
return out
|
|
126
|
+
|
|
127
|
+
def vectorize(self, tree: dict[str, jnp.ndarray]) -> jnp.ndarray:
|
|
128
|
+
parts = [jnp.ravel(tree[ps.name]).astype(ps.dtype) for ps in self.specs]
|
|
129
|
+
return jnp.concatenate(parts, axis=0)
|
|
130
|
+
|
|
131
|
+
# ---------- merging (parameter sharing) ---------- #
|
|
132
|
+
def merge(self, other: "ParamSuite | None") -> "ParamSuite":
|
|
133
|
+
"""
|
|
134
|
+
Union with sharing-by-name:
|
|
135
|
+
- If a name appears in both suites and specs are compatible (shape/dtype),
|
|
136
|
+
they are **tied** (kept once).
|
|
137
|
+
- If incompatible → error.
|
|
138
|
+
"""
|
|
139
|
+
if other is None:
|
|
140
|
+
return self
|
|
141
|
+
if not isinstance(other, ParamSuite):
|
|
142
|
+
raise TypeError(f"merge expects ParamSuite or None, got {type(other).__name__}")
|
|
143
|
+
if not self.specs:
|
|
144
|
+
return other
|
|
145
|
+
if not other.specs:
|
|
146
|
+
return self
|
|
147
|
+
|
|
148
|
+
left = {ps.name: ps for ps in self.specs}
|
|
149
|
+
right = {ps.name: ps for ps in other.specs}
|
|
150
|
+
|
|
151
|
+
out: dict[str, ParamSpec] = dict(left) # copy
|
|
152
|
+
for name, ps_r in right.items():
|
|
153
|
+
if name in out:
|
|
154
|
+
ps_l = out[name]
|
|
155
|
+
out[name] = ps_l.merged_with(ps_r) # validates compatibility
|
|
156
|
+
else:
|
|
157
|
+
out[name] = ps_r
|
|
158
|
+
return ParamSuite(out.values())
|
|
159
|
+
|
|
160
|
+
@classmethod
|
|
161
|
+
def merge_many(cls, *suites: "ParamSuite | None") -> "ParamSuite | None":
|
|
162
|
+
"""Merge any number of suites, sharing parameters by name (shape/dtype must match)."""
|
|
163
|
+
merged: ParamSuite | None = None
|
|
164
|
+
for s in suites:
|
|
165
|
+
if s is None:
|
|
166
|
+
continue
|
|
167
|
+
merged = s if merged is None else merged.merge(s)
|
|
168
|
+
return merged
|
|
169
|
+
|
|
170
|
+
# ---------- PyTree protocol ---------- #
|
|
171
|
+
def tree_flatten(self):
|
|
172
|
+
children = () # no array leaves
|
|
173
|
+
aux = self.specs # static
|
|
174
|
+
return children, aux
|
|
175
|
+
|
|
176
|
+
# ---------- universal parser ---------- #
|
|
177
|
+
@classmethod
|
|
178
|
+
def parse(cls, params) -> "ParamSuite | None":
|
|
179
|
+
"""
|
|
180
|
+
Normalize various user-facing descriptions into a ParamSuite.
|
|
181
|
+
|
|
182
|
+
Accepts:
|
|
183
|
+
|
|
184
|
+
- ``None`` -- returns ``None``
|
|
185
|
+
- ``ParamSuite`` -- returned as-is
|
|
186
|
+
- ``dict[name -> array | shape]`` -- infer shape/dtype
|
|
187
|
+
- ``iterable[ParamSpec]`` -- from_specs
|
|
188
|
+
- ``iterable[str]`` -- scalar specs for each name
|
|
189
|
+
|
|
190
|
+
Shapes may be ``()``, ``(k,)``, ``(m, n, ...)`` or an integer k
|
|
191
|
+
(interpreted as ``(k,)``).
|
|
192
|
+
"""
|
|
193
|
+
from collections.abc import Iterable as _Iterable
|
|
194
|
+
|
|
195
|
+
if params is None:
|
|
196
|
+
return None
|
|
197
|
+
if isinstance(params, ParamSuite):
|
|
198
|
+
return params
|
|
199
|
+
# dict path: values can be arrays (sample), shapes, ints, ParamSpec, or None→scalar
|
|
200
|
+
if isinstance(params, dict):
|
|
201
|
+
specs: list[ParamSpec] = []
|
|
202
|
+
for name, val in params.items():
|
|
203
|
+
if isinstance(val, ParamSpec):
|
|
204
|
+
specs.append(val)
|
|
205
|
+
continue
|
|
206
|
+
if hasattr(val, "shape"):
|
|
207
|
+
shape = tuple(val.shape)
|
|
208
|
+
dtype = getattr(val, "dtype", jnp.float32)
|
|
209
|
+
specs.append(ParamSpec(name, shape, dtype=dtype))
|
|
210
|
+
continue
|
|
211
|
+
if val is None:
|
|
212
|
+
specs.append(ParamSpec(name, ()))
|
|
213
|
+
continue
|
|
214
|
+
if isinstance(val, int):
|
|
215
|
+
specs.append(ParamSpec(name, (int(val),)))
|
|
216
|
+
continue
|
|
217
|
+
if isinstance(val, tuple) and all(isinstance(n, int) for n in val):
|
|
218
|
+
specs.append(ParamSpec(name, tuple(int(n) for n in val)))
|
|
219
|
+
continue
|
|
220
|
+
raise TypeError(f"ParamSuite.parse: unsupported dict value for '{name}': {type(val).__name__}")
|
|
221
|
+
return cls.from_specs(*specs)
|
|
222
|
+
# iterable path: ParamSpec or str
|
|
223
|
+
if isinstance(params, _Iterable):
|
|
224
|
+
items = list(params)
|
|
225
|
+
specs: list[ParamSpec] = []
|
|
226
|
+
for it in items:
|
|
227
|
+
if isinstance(it, ParamSpec):
|
|
228
|
+
specs.append(it)
|
|
229
|
+
elif isinstance(it, str):
|
|
230
|
+
specs.append(ParamSpec(it, ()))
|
|
231
|
+
else:
|
|
232
|
+
raise TypeError(
|
|
233
|
+
f"ParamSuite.parse: iterable must contain ParamSpec or str (got {type(it).__name__})"
|
|
234
|
+
)
|
|
235
|
+
return cls.from_specs(*specs)
|
|
236
|
+
raise TypeError(f"ParamSuite.parse: unsupported params of type {type(params).__name__}")
|
|
237
|
+
|
|
238
|
+
@classmethod
|
|
239
|
+
def tree_unflatten(cls, aux, children):
|
|
240
|
+
return cls(aux)
|
|
241
|
+
|
|
242
|
+
def coerce(
|
|
243
|
+
self,
|
|
244
|
+
params: dict,
|
|
245
|
+
*,
|
|
246
|
+
allow_scalar_for_scalar: bool = True,
|
|
247
|
+
allow_scalar_to_len1: bool = True,
|
|
248
|
+
cast_dtype: bool = True,
|
|
249
|
+
) -> dict[str, jnp.ndarray]:
|
|
250
|
+
"""
|
|
251
|
+
Normalize a user param dict into JAX arrays matching this suite.
|
|
252
|
+
|
|
253
|
+
Rules:
|
|
254
|
+
- If spec.shape == (), accept Python scalars / 0-d arrays (if allowed).
|
|
255
|
+
- If spec.shape == (1,), optionally accept a scalar and expand to (1,).
|
|
256
|
+
- Otherwise, require exact shape; dtype is cast if `cast_dtype` is True.
|
|
257
|
+
Returns a NEW dict with normalized arrays.
|
|
258
|
+
"""
|
|
259
|
+
out = {}
|
|
260
|
+
look = self._lookup
|
|
261
|
+
missing = [k for k in look if k not in params]
|
|
262
|
+
if missing:
|
|
263
|
+
raise KeyError(f"Missing params: {missing}")
|
|
264
|
+
|
|
265
|
+
for spec in self:
|
|
266
|
+
name, shape, dtype = spec.name, spec.shape, spec.dtype
|
|
267
|
+
val = params[name]
|
|
268
|
+
|
|
269
|
+
arr = jnp.asarray(val, dtype=dtype if cast_dtype else None)
|
|
270
|
+
|
|
271
|
+
# Handle scalar vs length-1 convenience
|
|
272
|
+
if shape == (): # true scalar
|
|
273
|
+
# 0-d OK; (1,) OK if allow_scalar_for_scalar and allow squeezing
|
|
274
|
+
if arr.shape == ():
|
|
275
|
+
pass
|
|
276
|
+
elif arr.shape == (1,) and allow_scalar_for_scalar:
|
|
277
|
+
arr = jnp.reshape(arr, ())
|
|
278
|
+
else:
|
|
279
|
+
raise TypeError(f"Param '{name}': expected scalar (), got {arr.shape}")
|
|
280
|
+
elif shape == (1,):
|
|
281
|
+
# accept scalar and expand to (1,) if asked
|
|
282
|
+
if arr.shape == ():
|
|
283
|
+
if allow_scalar_to_len1:
|
|
284
|
+
arr = jnp.reshape(arr, (1,))
|
|
285
|
+
else:
|
|
286
|
+
raise TypeError(f"Param '{name}': expected (1,), got scalar ()")
|
|
287
|
+
elif arr.shape == (1,):
|
|
288
|
+
pass
|
|
289
|
+
else:
|
|
290
|
+
raise TypeError(f"Param '{name}': expected {shape}, got {arr.shape}")
|
|
291
|
+
else:
|
|
292
|
+
if arr.shape != shape:
|
|
293
|
+
raise TypeError(f"Param '{name}': expected {shape}, got {arr.shape}")
|
|
294
|
+
|
|
295
|
+
# Final dtype enforcement
|
|
296
|
+
if cast_dtype:
|
|
297
|
+
arr = arr.astype(dtype)
|
|
298
|
+
elif arr.dtype != dtype:
|
|
299
|
+
raise TypeError(f"Param '{name}': expected dtype {dtype}, got {arr.dtype}")
|
|
300
|
+
|
|
301
|
+
out[name] = arr
|
|
302
|
+
return out
|
|
303
|
+
|
|
304
|
+
def stamp(self, params_dict: dict) -> tuple:
|
|
305
|
+
"""
|
|
306
|
+
Build a stable stamp for (shape, dtype) of each param in template order.
|
|
307
|
+
Caller is expected to pass an already-coerced dict.
|
|
308
|
+
"""
|
|
309
|
+
return tuple((spec.name, params_dict[spec.name].shape, params_dict[spec.name].dtype) for spec in self)
|
SFI/statefunc/psf.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""PSF façade: parametric family of state functions."""
|
|
2
|
+
|
|
3
|
+
import equinox as eqx
|
|
4
|
+
import jax
|
|
5
|
+
import jax.numpy as jnp
|
|
6
|
+
|
|
7
|
+
from .nodes import BaseNode, DerivativeNode
|
|
8
|
+
from .params import ParamSuite
|
|
9
|
+
from .stateexpr import StateExpr
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
# ============================================================================
|
|
13
|
+
# PSF – parametric state-function family
|
|
14
|
+
# ============================================================================
|
|
15
|
+
class PSF(StateExpr):
|
|
16
|
+
"""Parametric State-Function family `F(x; θ)`.
|
|
17
|
+
|
|
18
|
+
By default `drop_features=True`: when `n_features==1`, outputs **do not**
|
|
19
|
+
carry a trailing feature axis. `.d_theta()` forces `drop_features=False`.
|
|
20
|
+
|
|
21
|
+
Holds a `ParamSuite` template describing names, shapes, and dtypes of θ.
|
|
22
|
+
`__call__` evaluates `F` given a parameter dict matching the template.
|
|
23
|
+
Supports `.d_theta()` in addition to `.d_x()`/`.d_v()`.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
template: ParamSuite = eqx.field(static=True)
|
|
27
|
+
drop_features: bool = eqx.field(static=True, default=True)
|
|
28
|
+
_validated_stamp: tuple | None = eqx.field(static=True, default=None, repr=False)
|
|
29
|
+
|
|
30
|
+
def __init__(self, root: BaseNode, *, drop_features: bool = True):
|
|
31
|
+
if root.param_suite is None:
|
|
32
|
+
raise ValueError("PSF root must carry parameters")
|
|
33
|
+
super().__init__(root)
|
|
34
|
+
object.__setattr__(self, "template", root.param_suite)
|
|
35
|
+
object.__setattr__(self, "drop_features", bool(drop_features))
|
|
36
|
+
|
|
37
|
+
def _coerce_and_check(self, params, extras):
|
|
38
|
+
# Normalize/validate via the suite
|
|
39
|
+
pnorm = self.template.coerce(params)
|
|
40
|
+
stamp = tuple(sorted((k, pnorm[k].shape, pnorm[k].dtype) for k in self.template._lookup))
|
|
41
|
+
if stamp != self._validated_stamp:
|
|
42
|
+
object.__setattr__(self, "_validated_stamp", stamp)
|
|
43
|
+
# Only presence for extras here; shape/broadcast is handled in node contracts
|
|
44
|
+
self._validate_extras_presence(extras)
|
|
45
|
+
return pnorm
|
|
46
|
+
|
|
47
|
+
def __call__(self, x, *, v=None, mask=None, extras=None, params=None):
|
|
48
|
+
if params is None:
|
|
49
|
+
if self.template.size == 0:
|
|
50
|
+
params = {} # parameter-free PSF: auto-supply
|
|
51
|
+
else:
|
|
52
|
+
defaults = self.template.defaults()
|
|
53
|
+
if defaults is None:
|
|
54
|
+
raise ValueError("PSF.__call__: params are required (template has parameters without defaults).")
|
|
55
|
+
params = defaults
|
|
56
|
+
params = self._coerce_and_check(params, extras)
|
|
57
|
+
y = self._caller(x, v, mask, extras, params)
|
|
58
|
+
if self.drop_features and self.n_features == 1:
|
|
59
|
+
return jnp.squeeze(y, axis=-1)
|
|
60
|
+
return y
|
|
61
|
+
|
|
62
|
+
def bind(self, params: dict[str, jax.Array] | None = None):
|
|
63
|
+
"""Freeze parameter dict into an SF with normalized arrays.
|
|
64
|
+
|
|
65
|
+
If ``params is None``, fall back to spec defaults (``ParamSuite.defaults()``).
|
|
66
|
+
Raises if the template has parameters without defaults.
|
|
67
|
+
"""
|
|
68
|
+
from .sf import SF
|
|
69
|
+
|
|
70
|
+
if params is None:
|
|
71
|
+
defaults = self.template.defaults()
|
|
72
|
+
if defaults is None:
|
|
73
|
+
raise ValueError("PSF.bind(): params are required (template has parameters without defaults).")
|
|
74
|
+
params = defaults
|
|
75
|
+
return SF(self, params, drop_features=self.drop_features)
|
|
76
|
+
|
|
77
|
+
def d_theta(self, *, mode: str = "auto"):
|
|
78
|
+
"""Build an expression for the Jacobian w.r.t. parameters θ.
|
|
79
|
+
|
|
80
|
+
Shape effect
|
|
81
|
+
------------
|
|
82
|
+
The final axis becomes `features × n_params_total`. Batch/pdepth/rank
|
|
83
|
+
prefixes are preserved exactly.
|
|
84
|
+
|
|
85
|
+
Notes
|
|
86
|
+
-----
|
|
87
|
+
The parameter PyTree is handled leafwise; each grad leaf is flattened over
|
|
88
|
+
its param part, then all leaves are concatenated along the final axis.
|
|
89
|
+
"""
|
|
90
|
+
if self.root.param_suite is None: # unreachable: enforced by __init__
|
|
91
|
+
raise AttributeError("Expression has no parameters to differentiate")
|
|
92
|
+
node = DerivativeNode(self.root, var="theta", mode=mode)
|
|
93
|
+
return PSF(node, drop_features=False)
|
|
94
|
+
|
|
95
|
+
# ------------- SciPy helpers ---------------------------------
|
|
96
|
+
@property
|
|
97
|
+
def labels(self):
|
|
98
|
+
"""Basis labels from the underlying CoeffNode (if present)."""
|
|
99
|
+
from .nodes.ops.linear import CoeffNode
|
|
100
|
+
|
|
101
|
+
if isinstance(self.root, CoeffNode):
|
|
102
|
+
return self.root._basis_labels
|
|
103
|
+
_, labs, _ = self.root.flatten()
|
|
104
|
+
return labs
|
|
105
|
+
|
|
106
|
+
def flatten_params(self, params: dict[str, jax.Array]):
|
|
107
|
+
"""Vectorize a parameter dict according to the template order."""
|
|
108
|
+
return self.template.vectorize(params)
|
|
109
|
+
|
|
110
|
+
def unflatten_params(self, vec: jax.Array):
|
|
111
|
+
"""Materialize a parameter dict from a flat vector (inverse of `flatten_params`)."""
|
|
112
|
+
return self.template.materialize(vec)
|
SFI/statefunc/sf.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""SF façade: state function with fixed parameters."""
|
|
2
|
+
|
|
3
|
+
import equinox as eqx
|
|
4
|
+
import jax
|
|
5
|
+
import jax.numpy as jnp
|
|
6
|
+
|
|
7
|
+
from .nodes import BaseNode
|
|
8
|
+
from .psf import PSF
|
|
9
|
+
from .stateexpr import StateExpr, _specialize_node
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
# ============================================================================
|
|
13
|
+
# SF – bound (θ-fixed) state-function
|
|
14
|
+
# ============================================================================
|
|
15
|
+
class SF(StateExpr):
|
|
16
|
+
"""State-Function with θ fixed (a thin wrapper over the PSF’s root).
|
|
17
|
+
|
|
18
|
+
Behaves like a `Basis` for evaluation purposes (no `.d_theta()`), but you
|
|
19
|
+
can still build `.d_x()` / `.d_v()` expressions.
|
|
20
|
+
|
|
21
|
+
Feature axis handling mirrors the parent `PSF`:
|
|
22
|
+
if `drop_features=True` and `n_features==1`, the final axis is removed.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
params: dict[str, jax.Array] = eqx.field(static=False, repr=False)
|
|
26
|
+
_psf: PSF = eqx.field(static=True, repr=False)
|
|
27
|
+
drop_features: bool = eqx.field(static=True, default=True)
|
|
28
|
+
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
psf: PSF,
|
|
32
|
+
params: dict[str, jax.Array],
|
|
33
|
+
*,
|
|
34
|
+
drop_features: bool | None = None,
|
|
35
|
+
):
|
|
36
|
+
super().__init__(psf.root)
|
|
37
|
+
params = psf.template.coerce(params)
|
|
38
|
+
object.__setattr__(self, "params", params)
|
|
39
|
+
object.__setattr__(self, "_psf", psf)
|
|
40
|
+
object.__setattr__(
|
|
41
|
+
self,
|
|
42
|
+
"drop_features",
|
|
43
|
+
psf.drop_features if drop_features is None else bool(drop_features),
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
def __call__(self, x, *, v=None, mask=None, extras=None):
|
|
47
|
+
"""Evaluate the bound function on a **batched** input."""
|
|
48
|
+
self._validate_extras_presence(extras)
|
|
49
|
+
|
|
50
|
+
y = self._caller(x, v, mask, extras, self.params)
|
|
51
|
+
|
|
52
|
+
if self.drop_features and self.n_features == 1:
|
|
53
|
+
return jnp.squeeze(y, axis=-1)
|
|
54
|
+
return y
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def labels(self):
|
|
58
|
+
"""Basis labels propagated from the parent PSF."""
|
|
59
|
+
return self._psf.labels
|
|
60
|
+
|
|
61
|
+
def _with_node(self, new_root: BaseNode):
|
|
62
|
+
new_psf = PSF(new_root, drop_features=self.drop_features)
|
|
63
|
+
return SF(new_psf, self.params, drop_features=self.drop_features)
|
|
64
|
+
|
|
65
|
+
def specialize(self, *, dataset: int) -> "SF":
|
|
66
|
+
"""Specialize a *bound* function at condition ``dataset``.
|
|
67
|
+
|
|
68
|
+
Rewrites the graph (folding ``dataset_index``-reading leaves) and
|
|
69
|
+
projects the bound parameter values onto the shrunken template: a
|
|
70
|
+
per-condition spec whose shape loses a leading axis is sliced at
|
|
71
|
+
``dataset``; shared specs are kept verbatim.
|
|
72
|
+
"""
|
|
73
|
+
k = int(dataset)
|
|
74
|
+
new_root = _specialize_node(self.root, k)
|
|
75
|
+
new_psf = PSF(new_root, drop_features=self.drop_features)
|
|
76
|
+
new_params = _project_params(self.params, self._psf.template, new_psf.template, k)
|
|
77
|
+
return SF(new_psf, new_params, drop_features=self.drop_features)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _project_params(old_params, old_template, new_template, k: int) -> dict:
|
|
81
|
+
"""Map a bound parameter dict onto a specialized template.
|
|
82
|
+
|
|
83
|
+
For each spec in ``new_template``: keep the old value when the shape is
|
|
84
|
+
unchanged; slice the leading axis at ``k`` when specialization dropped it
|
|
85
|
+
(the per-condition case, e.g. ``(K,) -> ()``).
|
|
86
|
+
"""
|
|
87
|
+
out: dict = {}
|
|
88
|
+
for spec in new_template:
|
|
89
|
+
name = spec.name
|
|
90
|
+
if name not in old_params:
|
|
91
|
+
continue # genuinely new param (none today); leave to template defaults
|
|
92
|
+
old_val = old_params[name]
|
|
93
|
+
old_shape = tuple(old_template[name].shape)
|
|
94
|
+
new_shape = tuple(spec.shape)
|
|
95
|
+
if old_shape == new_shape:
|
|
96
|
+
out[name] = old_val
|
|
97
|
+
elif len(old_shape) == len(new_shape) + 1 and old_shape[1:] == new_shape:
|
|
98
|
+
out[name] = old_val[k] # per-condition slice
|
|
99
|
+
else:
|
|
100
|
+
raise ValueError(
|
|
101
|
+
f"Cannot project param {name!r} from shape {old_shape} to "
|
|
102
|
+
f"{new_shape} during specialize(dataset={k})."
|
|
103
|
+
)
|
|
104
|
+
return out
|