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,448 @@
|
|
|
1
|
+
# underdamped.py
|
|
2
|
+
"""
|
|
3
|
+
Underdamped Langevin simulator (velocity-Verlet-like, generic F(x,v) and D(x[,v])).
|
|
4
|
+
|
|
5
|
+
This mirrors :mod:`overdamped` as closely as possible, but simulates the
|
|
6
|
+
phase-space SDE
|
|
7
|
+
|
|
8
|
+
dx = v dt
|
|
9
|
+
dv = F(x, v) dt + sqrt(2 D(x, v)) dW
|
|
10
|
+
|
|
11
|
+
where diffusion acts on *velocity* increments. The returned
|
|
12
|
+
:class:`~SFI.trajectory.collection.TrajectoryCollection` stores **positions
|
|
13
|
+
only** by design.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from typing import Any, Dict, Optional
|
|
20
|
+
|
|
21
|
+
import jax
|
|
22
|
+
import jax.numpy as jnp
|
|
23
|
+
from jax import jit, lax, random
|
|
24
|
+
|
|
25
|
+
from SFI.statefunc import PSF, SF
|
|
26
|
+
from SFI.utils.maths import as_default_float
|
|
27
|
+
|
|
28
|
+
from .base import Array, DiffusionKind, LangevinBase
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class UnderdampedProcess(LangevinBase):
|
|
33
|
+
"""Underdamped Langevin simulator.
|
|
34
|
+
|
|
35
|
+
Parameters
|
|
36
|
+
----------
|
|
37
|
+
F : PSF | SF
|
|
38
|
+
Force model with `rank=vector`, `needs_v=True`, and `pdepth∈{0,1}`.
|
|
39
|
+
If a PSF is provided, bind parameters via :meth:`set_params` prior to
|
|
40
|
+
simulation.
|
|
41
|
+
D : float | Array | PSF | SF
|
|
42
|
+
Diffusion model acting on velocities: scalar σ (interpreted as σ·I),
|
|
43
|
+
constant (d×d) matrix, or a PSF/SF with `rank=matrix`.
|
|
44
|
+
If provided as PSF/SF, it may depend on (x) or (x, v), controlled by
|
|
45
|
+
its `needs_v` flag.
|
|
46
|
+
|
|
47
|
+
Notes
|
|
48
|
+
-----
|
|
49
|
+
This class does **not** insert particle axes; it follows the `pdepth`
|
|
50
|
+
convention of the statefunc objects, similarly to :class:`OverdampedProcess`.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
# Whether the (state-dependent) diffusion SF requires v.
|
|
54
|
+
_D_needs_v: bool = False
|
|
55
|
+
# Bound diffusion callable (only used for eager structural-extras preparation).
|
|
56
|
+
_D_sf: Optional[SF] = None
|
|
57
|
+
|
|
58
|
+
# ------------------------------- API --------------------------------
|
|
59
|
+
def initialize(self, x0: Array, v0: Optional[Array] = None) -> None:
|
|
60
|
+
"""Initialize the process state.
|
|
61
|
+
|
|
62
|
+
Parameters
|
|
63
|
+
----------
|
|
64
|
+
x0 : Array
|
|
65
|
+
Initial position. Must satisfy:
|
|
66
|
+
- If `F.pdepth == 0`: shape (d,)
|
|
67
|
+
- If `F.pdepth == 1`: shape (P, d)
|
|
68
|
+
v0 : Array, optional
|
|
69
|
+
Initial velocity. Must have the same shape as `x0`. Defaults to 0.
|
|
70
|
+
"""
|
|
71
|
+
self._normalize_basis_to_psf()
|
|
72
|
+
self._assert_force_contract(self.F)
|
|
73
|
+
|
|
74
|
+
# Canonicalize dtype (single normalization point; see
|
|
75
|
+
# OverdampedProcess.initialize for rationale).
|
|
76
|
+
x0 = as_default_float(x0)
|
|
77
|
+
v0 = as_default_float(v0)
|
|
78
|
+
|
|
79
|
+
# Deduce (P, d) from x0
|
|
80
|
+
if x0.ndim == 1:
|
|
81
|
+
d = int(x0.shape[0])
|
|
82
|
+
P = None
|
|
83
|
+
elif x0.ndim == 2:
|
|
84
|
+
P = int(x0.shape[0])
|
|
85
|
+
d = int(x0.shape[1])
|
|
86
|
+
else:
|
|
87
|
+
raise ValueError("x0 must have shape (d,) or (P, d).")
|
|
88
|
+
|
|
89
|
+
f_pdepth = getattr(self.F, "pdepth", None)
|
|
90
|
+
if f_pdepth not in (0, 1):
|
|
91
|
+
raise ValueError("F.pdepth must be 0 or 1 for underdamped simulations.")
|
|
92
|
+
if f_pdepth == 0 and x0.ndim != 1:
|
|
93
|
+
if x0.shape[0] == 1 and x0.ndim == 2:
|
|
94
|
+
x0 = x0[0]
|
|
95
|
+
if v0 is not None and v0.ndim == 2 and v0.shape[0] == 1:
|
|
96
|
+
v0 = v0[0]
|
|
97
|
+
else:
|
|
98
|
+
raise ValueError("F expects no particle axis (pdepth=0): x0 must be (d,).")
|
|
99
|
+
if f_pdepth == 1 and x0.ndim != 2:
|
|
100
|
+
raise ValueError("F expects a particle axis (pdepth=1): x0 must be (P, d).")
|
|
101
|
+
|
|
102
|
+
if v0 is None:
|
|
103
|
+
v0 = jnp.zeros_like(x0)
|
|
104
|
+
if v0.shape != x0.shape:
|
|
105
|
+
raise ValueError(f"v0.shape must match x0.shape; got v0={v0.shape}, x0={x0.shape}.")
|
|
106
|
+
|
|
107
|
+
# Bind models
|
|
108
|
+
self._F_sf = self._bind_force()
|
|
109
|
+
if isinstance(self.D, (PSF, SF)):
|
|
110
|
+
if isinstance(self.D, SF):
|
|
111
|
+
self._D_sf = self.D
|
|
112
|
+
else:
|
|
113
|
+
if self.theta_D is None:
|
|
114
|
+
raise ValueError("Diffusion PSF not bound: call set_params(theta_D=...).")
|
|
115
|
+
self._D_sf = self.D.bind(self.theta_D) # type: ignore[attr-defined]
|
|
116
|
+
self._D_needs_v = bool(getattr(self.D, "needs_v", False))
|
|
117
|
+
else:
|
|
118
|
+
self._D_sf = None
|
|
119
|
+
self._D_needs_v = False
|
|
120
|
+
|
|
121
|
+
# Prepare structural extras once for all bound expressions.
|
|
122
|
+
# (The base helper caches preparation globally; pass the full list here.)
|
|
123
|
+
self._invalidate_prepared_extras()
|
|
124
|
+
exprs = [self._F_sf] + ([self._D_sf] if self._D_sf is not None else [])
|
|
125
|
+
extras = self._prepare_model_extras(x_probe=x0, v_probe=v0, exprs=exprs)
|
|
126
|
+
|
|
127
|
+
# Validate force output shape
|
|
128
|
+
f_out = self._F_sf(x0, v=v0, extras=extras)
|
|
129
|
+
if f_out.shape != x0.shape:
|
|
130
|
+
raise ValueError(f"Force output shape {f_out.shape} does not match input shape {x0.shape}.")
|
|
131
|
+
|
|
132
|
+
# Diffusion (constant vs state-dependent)
|
|
133
|
+
self._check_diffusion_contract(self.D, d=d, f_pdepth=int(f_pdepth))
|
|
134
|
+
self._setup_diffusion(d=d, with_v=self._D_needs_v)
|
|
135
|
+
|
|
136
|
+
# Persist runtime state/metadata basics
|
|
137
|
+
self._x = x0
|
|
138
|
+
self._v = v0
|
|
139
|
+
num_particles = int(P) if P is not None else 1
|
|
140
|
+
self.metadata.clear()
|
|
141
|
+
self.metadata.update(
|
|
142
|
+
dict(
|
|
143
|
+
kind="underdamped",
|
|
144
|
+
dimension=d,
|
|
145
|
+
pdepth=int(f_pdepth),
|
|
146
|
+
num_particles=num_particles,
|
|
147
|
+
x0=x0,
|
|
148
|
+
v0=v0,
|
|
149
|
+
)
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
def simulate(
|
|
153
|
+
self,
|
|
154
|
+
dt: float,
|
|
155
|
+
Nsteps: int,
|
|
156
|
+
key: Array,
|
|
157
|
+
*,
|
|
158
|
+
oversampling: int = 4,
|
|
159
|
+
prerun: int = 0,
|
|
160
|
+
jit_compile: bool = True,
|
|
161
|
+
compute_observables: bool = False,
|
|
162
|
+
):
|
|
163
|
+
"""Run the integrator and return a :class:`TrajectoryCollection` of positions.
|
|
164
|
+
|
|
165
|
+
Parameters
|
|
166
|
+
----------
|
|
167
|
+
dt
|
|
168
|
+
Time step between recorded frames.
|
|
169
|
+
Nsteps
|
|
170
|
+
Number of recorded time steps.
|
|
171
|
+
key
|
|
172
|
+
PRNG key for the simulation.
|
|
173
|
+
oversampling
|
|
174
|
+
Number of velocity-Verlet substeps between recorded frames.
|
|
175
|
+
The effective substep size is ``dt / oversampling``.
|
|
176
|
+
Although all integrators have a consistent continuous limit, they
|
|
177
|
+
introduce short-range, algorithm-specific temporal correlations at
|
|
178
|
+
the scale of a single step. Downsampling by recording only every
|
|
179
|
+
``oversampling``-th substep ensures these artefacts never reach
|
|
180
|
+
the inference layer. The default of 4 is a safe minimum for
|
|
181
|
+
typical use; increase it when ``dt`` is large or the process
|
|
182
|
+
varies rapidly.
|
|
183
|
+
prerun
|
|
184
|
+
Number of recorded steps to discard as burn-in.
|
|
185
|
+
jit_compile
|
|
186
|
+
If True, JIT-compile the single-step integrator before scanning.
|
|
187
|
+
compute_observables
|
|
188
|
+
Not yet implemented for the underdamped case.
|
|
189
|
+
|
|
190
|
+
Returns
|
|
191
|
+
-------
|
|
192
|
+
TrajectoryCollection
|
|
193
|
+
A collection with a single dataset containing the positions only
|
|
194
|
+
(velocities are not stored by design). The underlying dataset has:
|
|
195
|
+
|
|
196
|
+
- ``X`` of shape ``(Nsteps, d)`` or ``(Nsteps, P, d)``,
|
|
197
|
+
- metadata combining model info (kind, dimension, pdepth, etc.)
|
|
198
|
+
and run info (dt, Nsteps, oversampling, prerun).
|
|
199
|
+
"""
|
|
200
|
+
if self._F_sf is None:
|
|
201
|
+
raise RuntimeError("Call initialize(x0, v0=...) before simulate().")
|
|
202
|
+
if compute_observables:
|
|
203
|
+
raise NotImplementedError(
|
|
204
|
+
"Underdamped observables are not implemented in this variant, "
|
|
205
|
+
"because velocities are intentionally not stored."
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
# Split extras into static values and per-frame schedules.
|
|
209
|
+
static_extras, schedules, eg_out, el_out = self._materialize_step_extras(dt=dt, Nsteps=Nsteps)
|
|
210
|
+
|
|
211
|
+
step = self._make_step(static_extras)
|
|
212
|
+
if jit_compile:
|
|
213
|
+
step = jit(step)
|
|
214
|
+
|
|
215
|
+
# Carry is (x, v), but we record positions only.
|
|
216
|
+
traj_x, final_state, info = self._scan_positions(
|
|
217
|
+
(self._x, self._v),
|
|
218
|
+
step_fn=step,
|
|
219
|
+
dt=dt,
|
|
220
|
+
Nsteps=Nsteps,
|
|
221
|
+
oversampling=oversampling,
|
|
222
|
+
prerun=prerun,
|
|
223
|
+
key=key,
|
|
224
|
+
schedules=schedules or None,
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
self._x, self._v = final_state
|
|
228
|
+
|
|
229
|
+
run_meta: Dict[str, Any] = dict(self.metadata)
|
|
230
|
+
run_meta.update(dict(dt=float(dt), **info))
|
|
231
|
+
if schedules:
|
|
232
|
+
run_meta["time_dependent_extras"] = sorted(schedules)
|
|
233
|
+
|
|
234
|
+
return self._traj_to_collection(
|
|
235
|
+
traj_x, dt=dt, meta=run_meta, extras_global_out=eg_out, extras_local_out=el_out
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
# ----------------------------- Internals -----------------------------
|
|
239
|
+
def _make_step(self, extras=None):
|
|
240
|
+
r"""Create the substep function (kick–drift–kick, velocity-Verlet-like).
|
|
241
|
+
|
|
242
|
+
.. physics:: Velocity-Verlet integrator (underdamped)
|
|
243
|
+
:label: velocity-verlet-underdamped
|
|
244
|
+
:category: Simulation
|
|
245
|
+
|
|
246
|
+
Stochastic splitting (kick–drift–kick):
|
|
247
|
+
|
|
248
|
+
.. math::
|
|
249
|
+
|
|
250
|
+
v_{1/2} &= v + \tfrac{1}{2}\mathrm{d}t\, F(x,v)
|
|
251
|
+
+ \sqrt{\mathrm{d}t/2}\; B(x,v)\,\xi_1 \\
|
|
252
|
+
x' &= x + \mathrm{d}t\, v_{1/2} \\
|
|
253
|
+
v' &= v_{1/2} + \tfrac{1}{2}\mathrm{d}t\, F(x', v_{1/2})
|
|
254
|
+
+ \sqrt{\mathrm{d}t/2}\; B(x', v_{1/2})\,\xi_2
|
|
255
|
+
|
|
256
|
+
Preserves the symplectic structure of the deterministic part.
|
|
257
|
+
"""
|
|
258
|
+
F = self._F_sf
|
|
259
|
+
if extras is None:
|
|
260
|
+
extras = self._model_extras()
|
|
261
|
+
static_extras = extras
|
|
262
|
+
kind = self._D_kind
|
|
263
|
+
Bc = self._B_const
|
|
264
|
+
Bf = self._B_fn
|
|
265
|
+
D_needs_v = self._D_needs_v
|
|
266
|
+
|
|
267
|
+
if F is None:
|
|
268
|
+
raise RuntimeError("Force not bound. Did you call initialize() after set_params()?")
|
|
269
|
+
|
|
270
|
+
def _eval_B(x: Array, v: Array, ex) -> Array:
|
|
271
|
+
if kind is DiffusionKind.STATE_FUNC:
|
|
272
|
+
if Bf is None:
|
|
273
|
+
raise RuntimeError("State-dependent diffusion not initialized.")
|
|
274
|
+
return Bf(x, v, extras=ex) if D_needs_v else Bf(x, extras=ex)
|
|
275
|
+
if Bc is None:
|
|
276
|
+
raise RuntimeError("Constant diffusion not initialized.")
|
|
277
|
+
return Bc
|
|
278
|
+
|
|
279
|
+
def _apply(B: Array, xi: Array) -> Array:
|
|
280
|
+
# Be permissive: if B happens to be (d,d), treat it as constant.
|
|
281
|
+
return self._apply_B(B, xi, state_dependent=(B.ndim > 2))
|
|
282
|
+
|
|
283
|
+
def step(state, ddt: float, key: Array, sched=None):
|
|
284
|
+
x, v = state
|
|
285
|
+
k1, k2 = random.split(key)
|
|
286
|
+
# Both half-kicks use the frame's scheduled value (zeroth-order
|
|
287
|
+
# hold: the protocol is piecewise constant by definition).
|
|
288
|
+
ex = static_extras if sched is None else {**(static_extras or {}), **sched}
|
|
289
|
+
|
|
290
|
+
# --- First half-kick
|
|
291
|
+
a0 = F(x, v=v, extras=ex)
|
|
292
|
+
B0 = _eval_B(x, v, ex)
|
|
293
|
+
xi1 = self._noise(k1, v)
|
|
294
|
+
dv1 = _apply(B0, xi1)
|
|
295
|
+
v_half = v + 0.5 * ddt * a0 + jnp.sqrt(ddt / 2.0) * dv1
|
|
296
|
+
|
|
297
|
+
# --- Drift
|
|
298
|
+
x_new = x + ddt * v_half
|
|
299
|
+
|
|
300
|
+
# --- Second half-kick
|
|
301
|
+
a1 = F(x_new, v=v_half, extras=ex)
|
|
302
|
+
B1 = _eval_B(x_new, v_half, ex)
|
|
303
|
+
xi2 = self._noise(k2, v)
|
|
304
|
+
dv2 = _apply(B1, xi2)
|
|
305
|
+
v_new = v_half + 0.5 * ddt * a1 + jnp.sqrt(ddt / 2.0) * dv2
|
|
306
|
+
|
|
307
|
+
return (x_new, v_new)
|
|
308
|
+
|
|
309
|
+
return step
|
|
310
|
+
|
|
311
|
+
@staticmethod
|
|
312
|
+
def _scan_positions(
|
|
313
|
+
initial_state,
|
|
314
|
+
*,
|
|
315
|
+
step_fn,
|
|
316
|
+
dt: float,
|
|
317
|
+
Nsteps: int,
|
|
318
|
+
oversampling: int,
|
|
319
|
+
prerun: int,
|
|
320
|
+
key: Array,
|
|
321
|
+
schedules=None,
|
|
322
|
+
):
|
|
323
|
+
"""Scan loop that records positions only (carry contains (x,v)).
|
|
324
|
+
|
|
325
|
+
``schedules`` follows the same per-frame contract as
|
|
326
|
+
:meth:`LangevinBase._scan`.
|
|
327
|
+
"""
|
|
328
|
+
if oversampling < 1:
|
|
329
|
+
raise ValueError("oversampling must be >= 1")
|
|
330
|
+
|
|
331
|
+
ddt = dt / float(oversampling)
|
|
332
|
+
scheduled = bool(schedules)
|
|
333
|
+
|
|
334
|
+
if scheduled:
|
|
335
|
+
sched0 = {k: v[0] for k, v in schedules.items()}
|
|
336
|
+
|
|
337
|
+
def one_substep_at(sched):
|
|
338
|
+
def one_substep(carry, _):
|
|
339
|
+
st, k = carry
|
|
340
|
+
k, sub = random.split(k)
|
|
341
|
+
st = step_fn(st, ddt, sub, sched)
|
|
342
|
+
return (st, k), None
|
|
343
|
+
|
|
344
|
+
return one_substep
|
|
345
|
+
|
|
346
|
+
def one_recorded_step(carry, sched_t):
|
|
347
|
+
st, k = carry
|
|
348
|
+
(st, k), _ = lax.scan(one_substep_at(sched_t), (st, k), None, length=oversampling)
|
|
349
|
+
return (st, k), st[0]
|
|
350
|
+
|
|
351
|
+
if prerun > 0:
|
|
352
|
+
(state, key), _ = lax.scan(
|
|
353
|
+
lambda c, _: one_recorded_step(c, sched0), (initial_state, key), None, length=prerun
|
|
354
|
+
)
|
|
355
|
+
else:
|
|
356
|
+
state = initial_state
|
|
357
|
+
|
|
358
|
+
if Nsteps > 0:
|
|
359
|
+
from SFI.langevin.base import LangevinBase
|
|
360
|
+
|
|
361
|
+
xs = LangevinBase._shift_schedules(schedules)
|
|
362
|
+
(state, key), traj_x = lax.scan(one_recorded_step, (state, key), xs, length=Nsteps)
|
|
363
|
+
else:
|
|
364
|
+
x_example = initial_state[0]
|
|
365
|
+
traj_x = jax.tree_util.tree_map(lambda a: a[None, ...][:0], x_example)
|
|
366
|
+
|
|
367
|
+
else:
|
|
368
|
+
|
|
369
|
+
def one_substep(carry, _):
|
|
370
|
+
st, k = carry
|
|
371
|
+
k, sub = random.split(k)
|
|
372
|
+
st = step_fn(st, ddt, sub)
|
|
373
|
+
return (st, k), None
|
|
374
|
+
|
|
375
|
+
def one_recorded_step_plain(carry, _):
|
|
376
|
+
st, k = carry
|
|
377
|
+
(st, k), _ = lax.scan(one_substep, (st, k), None, length=oversampling)
|
|
378
|
+
# record positions only
|
|
379
|
+
return (st, k), st[0]
|
|
380
|
+
|
|
381
|
+
if prerun > 0:
|
|
382
|
+
(state, key), _ = lax.scan(one_recorded_step_plain, (initial_state, key), None, length=prerun)
|
|
383
|
+
else:
|
|
384
|
+
state = initial_state
|
|
385
|
+
|
|
386
|
+
if Nsteps > 0:
|
|
387
|
+
(state, key), traj_x = lax.scan(one_recorded_step_plain, (state, key), None, length=Nsteps)
|
|
388
|
+
else:
|
|
389
|
+
x_example = initial_state[0]
|
|
390
|
+
traj_x = jax.tree_util.tree_map(lambda a: a[None, ...][:0], x_example)
|
|
391
|
+
|
|
392
|
+
info = {
|
|
393
|
+
"Nsteps": int(Nsteps),
|
|
394
|
+
"oversampling": int(oversampling),
|
|
395
|
+
"prerun": int(prerun),
|
|
396
|
+
}
|
|
397
|
+
return traj_x, state, info
|
|
398
|
+
|
|
399
|
+
# --------------------------- Validations -----------------------------
|
|
400
|
+
@staticmethod
|
|
401
|
+
def _assert_force_contract(F: PSF | SF) -> None:
|
|
402
|
+
"""Validate force has the right rank/dim flags for underdamped use."""
|
|
403
|
+
needs_v = getattr(F, "needs_v", False)
|
|
404
|
+
if not needs_v:
|
|
405
|
+
raise ValueError("Underdamped force must require velocity (needs_v=True).")
|
|
406
|
+
rank = getattr(F, "rank", None)
|
|
407
|
+
if rank != 1:
|
|
408
|
+
raise ValueError("Force must have rank=vector.")
|
|
409
|
+
dim = getattr(F, "dim", None)
|
|
410
|
+
if dim is None or not isinstance(dim, int):
|
|
411
|
+
raise ValueError("Force must declare an integer `dim` attribute.")
|
|
412
|
+
pdepth = getattr(F, "pdepth", None)
|
|
413
|
+
if pdepth not in (0, 1):
|
|
414
|
+
raise ValueError("Force `pdepth` must be 0 or 1 for underdamped simulations.")
|
|
415
|
+
|
|
416
|
+
@staticmethod
|
|
417
|
+
def _check_diffusion_contract(D, *, d: int, f_pdepth: int) -> None:
|
|
418
|
+
"""Validate diffusion against the force contract, allowing benign broadcast."""
|
|
419
|
+
from SFI.langevin.noise import NoiseModel
|
|
420
|
+
|
|
421
|
+
if isinstance(D, NoiseModel):
|
|
422
|
+
if D.dim != d:
|
|
423
|
+
raise ValueError(f"NoiseModel n_fields={D.dim} must match force dim={d}.")
|
|
424
|
+
return
|
|
425
|
+
if isinstance(D, (int, float)):
|
|
426
|
+
return
|
|
427
|
+
if isinstance(D, jnp.ndarray) and D.ndim == 2:
|
|
428
|
+
if D.shape != (d, d):
|
|
429
|
+
raise ValueError(f"Constant diffusion must be shape (d,d)={(d, d)}, got {D.shape}.")
|
|
430
|
+
return
|
|
431
|
+
|
|
432
|
+
if not isinstance(D, (PSF, SF)):
|
|
433
|
+
raise TypeError("Diffusion must be a float, (d×d) array, PSF/SF, or NoiseModel.")
|
|
434
|
+
|
|
435
|
+
rank = getattr(D, "rank", None)
|
|
436
|
+
if rank != 2:
|
|
437
|
+
raise ValueError("Diffusion PSF/SF must have rank=matrix.")
|
|
438
|
+
dim = getattr(D, "dim", None)
|
|
439
|
+
if dim != d:
|
|
440
|
+
raise ValueError(f"Diffusion dim={dim} must match force dim={d}.")
|
|
441
|
+
d_pdepth = getattr(D, "pdepth", None)
|
|
442
|
+
if d_pdepth not in (0, 1):
|
|
443
|
+
raise ValueError("Diffusion `pdepth` must be 0 or 1.")
|
|
444
|
+
if not (d_pdepth == f_pdepth or d_pdepth == 0):
|
|
445
|
+
raise ValueError(
|
|
446
|
+
f"Incompatible pdepth: force pdepth={f_pdepth}, diffusion pdepth={d_pdepth}. "
|
|
447
|
+
"Only equal depths or diffusion depth=0 (broadcast) are allowed."
|
|
448
|
+
)
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# SFI/statefunc/__init__.py
|
|
2
|
+
"""
|
|
3
|
+
High-level API for state functions.
|
|
4
|
+
|
|
5
|
+
Public classes:
|
|
6
|
+
- Basis: deterministic dictionary façade
|
|
7
|
+
- PSF: parametric family F(x; θ)
|
|
8
|
+
- SF: state-function with fixed θ
|
|
9
|
+
- Interactor: local interaction expression (pre-dispatch)
|
|
10
|
+
- StateExpr: immutable expression tree base class
|
|
11
|
+
|
|
12
|
+
Factory helpers:
|
|
13
|
+
- make_basis, make_psf, make_sf, make_interactor
|
|
14
|
+
|
|
15
|
+
Control:
|
|
16
|
+
- set_jit: enable/disable JIT on __call__
|
|
17
|
+
|
|
18
|
+
Power-user primitives:
|
|
19
|
+
- Rank, ParamSpec, ParamSuite
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from .basis import Basis
|
|
23
|
+
from .core.runtime import set_jit
|
|
24
|
+
from .factory import make_basis, make_interactor, make_psf, make_sf
|
|
25
|
+
from .interactor import Interactor
|
|
26
|
+
from .nodes import Rank
|
|
27
|
+
from .params import ParamSpec, ParamSuite
|
|
28
|
+
from .psf import PSF
|
|
29
|
+
from .sf import SF
|
|
30
|
+
from .stateexpr import StateExpr
|
|
31
|
+
|
|
32
|
+
__all__ = [
|
|
33
|
+
# façades
|
|
34
|
+
"Basis",
|
|
35
|
+
"PSF",
|
|
36
|
+
"SF",
|
|
37
|
+
"Interactor",
|
|
38
|
+
"StateExpr",
|
|
39
|
+
"set_jit",
|
|
40
|
+
# factory
|
|
41
|
+
"make_basis",
|
|
42
|
+
"make_psf",
|
|
43
|
+
"make_sf",
|
|
44
|
+
"make_interactor",
|
|
45
|
+
# power-user primitives
|
|
46
|
+
"Rank",
|
|
47
|
+
"ParamSpec",
|
|
48
|
+
"ParamSuite",
|
|
49
|
+
]
|
SFI/statefunc/basis.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Basis façade: dictionary of deterministic functions."""
|
|
2
|
+
|
|
3
|
+
from .psf import PSF
|
|
4
|
+
from .stateexpr import StateExpr
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
# ============================================================================
|
|
8
|
+
# BASIS – deterministic dictionary
|
|
9
|
+
# ============================================================================
|
|
10
|
+
class Basis(StateExpr):
|
|
11
|
+
"""Deterministic dictionary façade (no parameters)."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, root):
|
|
14
|
+
if root.param_suite is not None:
|
|
15
|
+
raise ValueError("Basis root must be parameter-free")
|
|
16
|
+
super().__init__(root)
|
|
17
|
+
|
|
18
|
+
def __call__(self, x, *, v=None, mask=None, extras=None):
|
|
19
|
+
"""Evaluate the basis on a single or batched input.
|
|
20
|
+
|
|
21
|
+
Parameters
|
|
22
|
+
----------
|
|
23
|
+
x : array
|
|
24
|
+
If `particles_input=False`: shape `batch · dim`.
|
|
25
|
+
If `particles_input=True`: shape `batch · P · dim`.
|
|
26
|
+
`batch` can be empty (single input).
|
|
27
|
+
v : array | None
|
|
28
|
+
Optional velocity matching `x.shape` (required if `needs_v=True`).
|
|
29
|
+
mask : array | scalar | None
|
|
30
|
+
Broadcasts to the prefix of `x` **including** the particle axis.
|
|
31
|
+
Boolean or numeric masks are accepted.
|
|
32
|
+
extras : dict | None
|
|
33
|
+
Optional per-batch metadata. Presence is enforced according to the
|
|
34
|
+
aggregated node requirements; values must broadcast over **batch only**.
|
|
35
|
+
|
|
36
|
+
Returns
|
|
37
|
+
-------
|
|
38
|
+
array
|
|
39
|
+
Shape `batch · [P]^pdepth · (dim)^rank · n_features`.
|
|
40
|
+
"""
|
|
41
|
+
self._validate_extras_presence(extras)
|
|
42
|
+
return self._caller(x, v, mask, extras, None)
|
|
43
|
+
|
|
44
|
+
# convenience
|
|
45
|
+
@property
|
|
46
|
+
def labels(self):
|
|
47
|
+
_, labs, _ = self.root.flatten()
|
|
48
|
+
return labs
|
|
49
|
+
|
|
50
|
+
def __len__(self):
|
|
51
|
+
return self.n_features
|
|
52
|
+
|
|
53
|
+
# Upgrade-aware dispatch: if a derived node carries parameters,
|
|
54
|
+
# the result is a PSF (e.g. Basis * PSF, PSF - Basis, …).
|
|
55
|
+
def _with_node(self, new_root):
|
|
56
|
+
if new_root.param_suite is not None:
|
|
57
|
+
return PSF(new_root)
|
|
58
|
+
return Basis(new_root)
|
|
59
|
+
|
|
60
|
+
# factory → linear-coeff PSF
|
|
61
|
+
def to_psf(self, coeff_key: str = "coeff", drop_features=True):
|
|
62
|
+
"""
|
|
63
|
+
Return a **parametric state function** whose value is a linear combination
|
|
64
|
+
of this Basis' features:
|
|
65
|
+
|
|
66
|
+
F(x; θ) = Σ_j θ_j · f_j(x)
|
|
67
|
+
|
|
68
|
+
Note that use cases are rare within SFI, since the PSF's features axis is typically
|
|
69
|
+
used for nonlinearities and/or vector/tensor components. But this can be useful for
|
|
70
|
+
quick prototyping of linear models, benchmark comparisons of linear vs nonlinear
|
|
71
|
+
solvers, or as a building block for more complex PSFs.
|
|
72
|
+
|
|
73
|
+
Parameters
|
|
74
|
+
----------
|
|
75
|
+
coeff_key : str
|
|
76
|
+
Key name for the coefficient vector in the parameter dict.
|
|
77
|
+
drop_features: bool
|
|
78
|
+
Whether to remove the trailing size-1 feature axis (default True).
|
|
79
|
+
|
|
80
|
+
Notes
|
|
81
|
+
-----
|
|
82
|
+
The resulting `PSF` shares the same spatial contract (rank/dim/pdepth,
|
|
83
|
+
particles_input) as this `Basis`, and does not have a features axis.
|
|
84
|
+
"""
|
|
85
|
+
from .nodes import CoeffNode
|
|
86
|
+
|
|
87
|
+
return PSF(CoeffNode(self.root, coeff_key=coeff_key), drop_features=drop_features)
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
from typing import Any, Callable, Dict
|
|
2
|
+
|
|
3
|
+
import equinox as eqx
|
|
4
|
+
|
|
5
|
+
# === JIT control & harness ====================================================
|
|
6
|
+
_JIT_ENABLED = True
|
|
7
|
+
|
|
8
|
+
# Per-root compiled function cache. Keyed by object identity of `root`.
|
|
9
|
+
# We intentionally use identity (id(root)) rather than a structural hash to
|
|
10
|
+
# keep lookups O(1) and avoid expensive tree-walk hashing or serialisation.
|
|
11
|
+
_COMPILED_CACHE: Dict[int, Callable[..., Any]] = {}
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def set_jit(enabled: bool = True):
|
|
15
|
+
"""Globally enable/disable JIT for Basis/PSF/SF __call__."""
|
|
16
|
+
global _JIT_ENABLED
|
|
17
|
+
_JIT_ENABLED = bool(enabled)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _eager_eval(root, x, v, mask, extras, params):
|
|
21
|
+
# Plain Python call; used when JIT is disabled (set_jit(False)).
|
|
22
|
+
return root(x, params=params, v=v, mask=mask, extras=extras)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _compiled_for_root(root) -> Callable[..., Any]:
|
|
26
|
+
"""
|
|
27
|
+
Return a compiled callable specialised to the given `root`.
|
|
28
|
+
The compiled function *closes over* `root`, so `root` is static
|
|
29
|
+
without being an argument (reduces tracing/dispatch overhead).
|
|
30
|
+
"""
|
|
31
|
+
key = id(root)
|
|
32
|
+
fn = _COMPILED_CACHE.get(key)
|
|
33
|
+
if fn is not None:
|
|
34
|
+
return fn
|
|
35
|
+
|
|
36
|
+
# Compile once for this root: dynamic args are (x, v, mask, extras, params)
|
|
37
|
+
@eqx.filter_jit
|
|
38
|
+
def _call(x, v, mask, extras, params):
|
|
39
|
+
return root(x, params=params, v=v, mask=mask, extras=extras)
|
|
40
|
+
|
|
41
|
+
_COMPILED_CACHE[key] = _call
|
|
42
|
+
return _call
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _jitted_eval(root, x, v, mask, extras, params):
|
|
46
|
+
"""Dispatch to the per-root compiled callable (JIT-enabled path)."""
|
|
47
|
+
return _compiled_for_root(root)(x, v, mask, extras, params)
|