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,447 @@
|
|
|
1
|
+
# SFI/statefunc/nodes/ops/derivative.py
|
|
2
|
+
"""Derivative operator node."""
|
|
3
|
+
|
|
4
|
+
import equinox as eqx
|
|
5
|
+
import jax
|
|
6
|
+
import jax.numpy as jnp
|
|
7
|
+
from jax import lax
|
|
8
|
+
|
|
9
|
+
from ...memhint import SampleMeta, inflate_for_grad
|
|
10
|
+
from ..base import BaseNode, BaseOpNode
|
|
11
|
+
from ..contract import _ContractMixin
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# ──────────────────────────────────────────────────────────────────────────────
|
|
15
|
+
# DerivativeNode – first-order Jacobian with optional cross-particle block
|
|
16
|
+
# ──────────────────────────────────────────────────────────────────────────────
|
|
17
|
+
class DerivativeNode(BaseOpNode):
|
|
18
|
+
"""First-order derivative wrapper around *one* child node.
|
|
19
|
+
|
|
20
|
+
Parameters
|
|
21
|
+
----------
|
|
22
|
+
child : BaseNode
|
|
23
|
+
var : {'x', 'v', 'theta'}
|
|
24
|
+
Choose which variable to differentiate with respect to.
|
|
25
|
+
same_particle : bool, default False
|
|
26
|
+
Only meaningful for ``var in {'x','v'}``.
|
|
27
|
+
Requires ``child.particles_input is True`` and ``child.pdepth == 1``.
|
|
28
|
+
|
|
29
|
+
- ``True`` -> same-particle Jacobian df[i]/dx[i] (work/memory ``O(P)``).
|
|
30
|
+
- ``False`` -> full cross Jacobian df[i]/dx[j] (work/memory ``O(P**2)``).
|
|
31
|
+
|
|
32
|
+
mode : {'auto','fwd','rev'}
|
|
33
|
+
JAX autodiff flavour. ``'auto'`` maps to:
|
|
34
|
+
- x, v → ``jacfwd`` (forward mode)
|
|
35
|
+
- theta → ``jacrev`` (reverse mode)
|
|
36
|
+
|
|
37
|
+
Shapes produced
|
|
38
|
+
---------------
|
|
39
|
+
*Suffix; batch prefix unchanged.*
|
|
40
|
+
|
|
41
|
+
Let ``P_out`` denote the child's **output** particle axis count (pdepth),
|
|
42
|
+
and ``P_in`` the **input** particle axis (present **only when** the model
|
|
43
|
+
has particle inputs). Let ``dim`` be spatial dimension, ``rank`` the existing
|
|
44
|
+
spatial rank axes, and ``F`` the feature axis.
|
|
45
|
+
|
|
46
|
+
**Noninteracting case** (pdepth=0, particles_input=False):
|
|
47
|
+
|
|
48
|
+
For noninteracting nodes, inputs have shape ``batch · dim`` and outputs
|
|
49
|
+
``batch · rank … · F``. First-order derivatives w.r.t. ``x``/``v`` are computed
|
|
50
|
+
**per sample** by temporarily un-batching the input to shape ``(dim,)`` and
|
|
51
|
+
then vmapping the per-sample Jacobian back over the batch prefix. The result
|
|
52
|
+
shape is::
|
|
53
|
+
|
|
54
|
+
d_x(), d_v(): batch · dim · rank … · F
|
|
55
|
+
|
|
56
|
+
No cross-batch Jacobian axes are formed.
|
|
57
|
+
|
|
58
|
+
**Interacting case** (particles_input=True):
|
|
59
|
+
|
|
60
|
+
================ ===========================================================
|
|
61
|
+
call output suffix (after derivative)
|
|
62
|
+
================ ===========================================================
|
|
63
|
+
d_x(same=True) ``P_out^1 · dim · rank … · F`` (pdepth unchanged)
|
|
64
|
+
d_x(same=False) ``P_in · P_out^1 · dim · rank … · F`` (**pdepth → pdepth+1**; only if particles_input)
|
|
65
|
+
d_v(same=True) same as d_x(same=True)
|
|
66
|
+
d_v(same=False) same as d_x(same=False)
|
|
67
|
+
d_theta() ``rank … · (F x n_param)``
|
|
68
|
+
================ ===========================================================
|
|
69
|
+
|
|
70
|
+
Notes
|
|
71
|
+
-----
|
|
72
|
+
- Derivatives w.r.t. x/v *increase spatial rank by 1* (insert new derivative-dim
|
|
73
|
+
immediately **before** the existing rank block).
|
|
74
|
+
- For not ``same_particle`` with particle inputs, the derivative introduces a new
|
|
75
|
+
**input particle axis** positioned **before** the child's pdepth block, and
|
|
76
|
+
the node's **pdepth is incremented by 1**. If there is **no** particle input,
|
|
77
|
+
no extra axis is added and pdepth is unchanged.
|
|
78
|
+
- Theta-Jacobian is formed leafwise and concatenated along the final axis, fusing
|
|
79
|
+
(features x total_param_elems).
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
var: str = eqx.field(static=True)
|
|
83
|
+
same_particle: bool = eqx.field(static=True)
|
|
84
|
+
mode: str = eqx.field(static=True)
|
|
85
|
+
|
|
86
|
+
def __init__(
|
|
87
|
+
self,
|
|
88
|
+
child: "BaseNode",
|
|
89
|
+
*,
|
|
90
|
+
var: str, # 'x' | 'v' | 'theta'
|
|
91
|
+
same_particle: bool = False,
|
|
92
|
+
mode: str = "auto",
|
|
93
|
+
):
|
|
94
|
+
object.__setattr__(self, "child", child)
|
|
95
|
+
object.__setattr__(self, "var", var)
|
|
96
|
+
object.__setattr__(self, "same_particle", bool(same_particle))
|
|
97
|
+
object.__setattr__(self, "mode", mode)
|
|
98
|
+
|
|
99
|
+
if var not in {"x", "v", "theta"}:
|
|
100
|
+
raise ValueError("var must be 'x', 'v', or 'theta'")
|
|
101
|
+
if mode not in {"auto", "fwd", "rev"}:
|
|
102
|
+
raise ValueError("mode must be 'auto', 'fwd', or 'rev'")
|
|
103
|
+
if var == "theta" and (child.param_suite is None or child.param_suite.size == 0):
|
|
104
|
+
raise ValueError("θ-gradient requested but child has no parameters")
|
|
105
|
+
|
|
106
|
+
if var in {"x", "v"} and self.same_particle:
|
|
107
|
+
if not child.particles_input or child.pdepth != 1:
|
|
108
|
+
raise ValueError(
|
|
109
|
+
"same-particle derivative requires particles_input=True and pdepth==1 "
|
|
110
|
+
"(differentiate after slicing/selecting a single particle output)."
|
|
111
|
+
)
|
|
112
|
+
if var == "v" and not child.needs_v:
|
|
113
|
+
raise ValueError("v-gradient requested but child does not depend on v")
|
|
114
|
+
|
|
115
|
+
super().__init__(child)
|
|
116
|
+
object.__setattr__(self, "param_suite", child.param_suite)
|
|
117
|
+
|
|
118
|
+
def _merge_static(self, children):
|
|
119
|
+
ch = children[0]
|
|
120
|
+
new_rank = ch.rank + (1 if self.var in {"x", "v"} else 0)
|
|
121
|
+
|
|
122
|
+
add_p = (self.var in {"x", "v"}) and ch.particles_input and (not self.same_particle)
|
|
123
|
+
new_pdepth = ch.pdepth + (1 if add_p else 0)
|
|
124
|
+
|
|
125
|
+
new_nfeat = ch.n_features
|
|
126
|
+
if self.var == "theta":
|
|
127
|
+
assert ch.param_suite is not None
|
|
128
|
+
new_nfeat *= max(1, ch.param_suite.size)
|
|
129
|
+
|
|
130
|
+
return _ContractMixin.inherit_contract(ch, rank=new_rank, pdepth=new_pdepth, n_features=new_nfeat)
|
|
131
|
+
|
|
132
|
+
# ------------------------------------------------------------------
|
|
133
|
+
# Feature-aware flatten (auto-labelling for derivatives)
|
|
134
|
+
# ------------------------------------------------------------------
|
|
135
|
+
def flatten(self):
|
|
136
|
+
child = self.children[0]
|
|
137
|
+
funcs_ch, labels_ch, descs_ch = child.flatten()
|
|
138
|
+
|
|
139
|
+
prefix = {"x": "∂ₓ", "v": "∂ᵥ", "theta": "∂θ"}[self.var]
|
|
140
|
+
|
|
141
|
+
def _wrap(lab):
|
|
142
|
+
return lab if len(lab) == 1 else f"({lab})"
|
|
143
|
+
|
|
144
|
+
if self.var in {"x", "v"}:
|
|
145
|
+
# Feature count unchanged; just decorate labels.
|
|
146
|
+
labels = [f"{prefix}{_wrap(lab)}" for lab in labels_ch]
|
|
147
|
+
else:
|
|
148
|
+
# theta: fused axis = n_features × n_params_total
|
|
149
|
+
assert child.param_suite is not None
|
|
150
|
+
n_params = max(1, child.param_suite.size)
|
|
151
|
+
labels = [f"{prefix}({lab},{k})" for lab in labels_ch for k in range(n_params)]
|
|
152
|
+
|
|
153
|
+
n_feat = len(labels)
|
|
154
|
+
|
|
155
|
+
def _slice_feature(j):
|
|
156
|
+
return lambda x, *, v=None, mask=None, extras=None, params=None, _j=j: (
|
|
157
|
+
self(x, v=v, mask=mask, extras=extras, params=params)[..., _j]
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
funcs = [_slice_feature(j) for j in range(n_feat)]
|
|
161
|
+
descs = [
|
|
162
|
+
{"derivative": self.var, "child": d} for d in (descs_ch * (n_feat // len(descs_ch) if descs_ch else 1))
|
|
163
|
+
]
|
|
164
|
+
|
|
165
|
+
return funcs, labels, descs
|
|
166
|
+
|
|
167
|
+
def __call__(self, x, *, v=None, params=None, mask=None, extras=None):
|
|
168
|
+
"""Compute the requested first-order Jacobian with proper un-batching."""
|
|
169
|
+
self._assert_inputs(x, v, mask, extras)
|
|
170
|
+
child = self.children[0]
|
|
171
|
+
|
|
172
|
+
# pick AD flavour
|
|
173
|
+
mode = (
|
|
174
|
+
"fwd"
|
|
175
|
+
if (self.mode == "auto" and self.var in {"x", "v"})
|
|
176
|
+
else "rev"
|
|
177
|
+
if (self.mode == "auto" and self.var == "theta")
|
|
178
|
+
else self.mode
|
|
179
|
+
)
|
|
180
|
+
diff = jax.jacfwd if mode == "fwd" else jax.jacrev
|
|
181
|
+
|
|
182
|
+
# ----- θ derivative ------------------------------------------
|
|
183
|
+
if self.var == "theta":
|
|
184
|
+
|
|
185
|
+
def gθ(_x, _v, _θ):
|
|
186
|
+
return child(_x, v=_v, params=_θ, mask=mask, extras=extras)
|
|
187
|
+
|
|
188
|
+
Jtree = diff(gθ, argnums=2)(x, v, params)
|
|
189
|
+
J_leaves, treedefJ = jax.tree_util.tree_flatten(Jtree)
|
|
190
|
+
P_leaves, treedefP = jax.tree_util.tree_flatten(params)
|
|
191
|
+
if treedefJ != treedefP:
|
|
192
|
+
raise ValueError("[DerivativeNode] Jacobian tree does not match params tree structure")
|
|
193
|
+
folded = []
|
|
194
|
+
for J_leaf, p_leaf in zip(J_leaves, P_leaves):
|
|
195
|
+
pr = int(getattr(p_leaf, "ndim", 0))
|
|
196
|
+
A = (
|
|
197
|
+
jnp.reshape(J_leaf, J_leaf.shape + (1,))
|
|
198
|
+
if pr == 0
|
|
199
|
+
else jnp.reshape(J_leaf, J_leaf.shape[:-pr] + (-1,))
|
|
200
|
+
)
|
|
201
|
+
# fuse the last two axes (ranked output axis × param axis)
|
|
202
|
+
A = jnp.reshape(A, A.shape[:-2] + (A.shape[-2] * A.shape[-1],))
|
|
203
|
+
folded.append(A)
|
|
204
|
+
if not folded:
|
|
205
|
+
return child(x, v=v, params=params, mask=mask, extras=extras)
|
|
206
|
+
return jnp.concatenate(folded, axis=-1)
|
|
207
|
+
|
|
208
|
+
# ----- x/v derivative paths: un-batch then vmap back ----------------------
|
|
209
|
+
# core dims for the diff'ed argument
|
|
210
|
+
expect_particles = bool(child.particles_input)
|
|
211
|
+
core_nd = 2 if expect_particles else 1 # (P,dim) vs (dim,)
|
|
212
|
+
b_nd = max(0, x.ndim - core_nd) # how many leading batch axes in x
|
|
213
|
+
|
|
214
|
+
# Helpers to broadcast/bucket mask/extras to the batch prefix and flatten it
|
|
215
|
+
def _broadcast_prefix_to(a, bshape):
|
|
216
|
+
if a is None:
|
|
217
|
+
return None
|
|
218
|
+
if not (hasattr(a, "shape") and hasattr(a, "dtype")):
|
|
219
|
+
return a # non-array leaf stays scalar/constant
|
|
220
|
+
# prepend ones so we can broadcast to the full prefix
|
|
221
|
+
pad = len(bshape) - a.ndim if a.ndim < len(bshape) else 0
|
|
222
|
+
if pad > 0:
|
|
223
|
+
a = jnp.reshape(a, (1,) * pad + a.shape)
|
|
224
|
+
# target shape = bshape + trailing
|
|
225
|
+
trailing = a.shape[len(bshape) :]
|
|
226
|
+
target = tuple(bshape) + tuple(trailing)
|
|
227
|
+
a = jnp.broadcast_to(a, target)
|
|
228
|
+
return a
|
|
229
|
+
|
|
230
|
+
def _prep_tree_for_flat_map(tree, bshape):
|
|
231
|
+
return jax.tree_util.tree_map(lambda leaf: _broadcast_prefix_to(leaf, bshape), tree)
|
|
232
|
+
|
|
233
|
+
def _flatten_tree_prefix(tree, bshape):
|
|
234
|
+
"""Flatten the batch prefix of every leaf so it matches x_flat."""
|
|
235
|
+
|
|
236
|
+
def _flat(a):
|
|
237
|
+
if a is None:
|
|
238
|
+
return None
|
|
239
|
+
if not (hasattr(a, "shape") and hasattr(a, "dtype")):
|
|
240
|
+
return a
|
|
241
|
+
trailing = a.shape[len(bshape) :]
|
|
242
|
+
return jnp.reshape(a, (-1,) + trailing)
|
|
243
|
+
|
|
244
|
+
return jax.tree_util.tree_map(_flat, tree)
|
|
245
|
+
|
|
246
|
+
# same-particle case: only valid for interacting (already validated in __init__)
|
|
247
|
+
if self.same_particle:
|
|
248
|
+
if b_nd == 0:
|
|
249
|
+
# Direct call — _same_particle_jacobian returns contract layout
|
|
250
|
+
return child._same_particle_jacobian(x, var=self.var, v=v, params=params, mask=mask, extras=extras)
|
|
251
|
+
|
|
252
|
+
# vmap per-sample across all leading batch axes
|
|
253
|
+
bshape = x.shape[:b_nd]
|
|
254
|
+
|
|
255
|
+
# 1) Prepare/broadcast and flatten mask to batch prefix
|
|
256
|
+
mask_flat = _prep_tree_for_flat_map(mask, bshape)
|
|
257
|
+
mask_flat = _flatten_tree_prefix(mask_flat, bshape)
|
|
258
|
+
|
|
259
|
+
# 2) Flatten x (and v); extras are **closed over** unchanged.
|
|
260
|
+
x_flat = jnp.reshape(x, (-1,) + x.shape[-core_nd:])
|
|
261
|
+
v_flat = None if v is None else jnp.reshape(v, (-1,) + v.shape[-core_nd:])
|
|
262
|
+
|
|
263
|
+
in_axes_mask_sp = jax.tree_util.tree_map(lambda a: 0 if (hasattr(a, "shape")) else None, mask_flat)
|
|
264
|
+
|
|
265
|
+
def map_one_sp(xi, vi, mi):
|
|
266
|
+
return child._same_particle_jacobian(xi, var=self.var, v=vi, params=params, mask=mi, extras=extras)
|
|
267
|
+
|
|
268
|
+
vm = jax.vmap(
|
|
269
|
+
map_one_sp,
|
|
270
|
+
in_axes=(0, 0 if v_flat is not None else None, in_axes_mask_sp),
|
|
271
|
+
)
|
|
272
|
+
J_flat = vm(x_flat, v_flat, mask_flat)
|
|
273
|
+
return jnp.reshape(J_flat, bshape + J_flat.shape[1:])
|
|
274
|
+
|
|
275
|
+
# cross-particle (or noninteracting) derivative → jacobian wrt x or v
|
|
276
|
+
argnum = 0 if self.var == "x" else 1
|
|
277
|
+
|
|
278
|
+
if b_nd == 0:
|
|
279
|
+
# simple per-sample diff on (dim,) or (P,dim)
|
|
280
|
+
J = diff(
|
|
281
|
+
lambda _x, _v: child(_x, v=_v, params=params, mask=mask, extras=extras),
|
|
282
|
+
argnums=argnum,
|
|
283
|
+
)(x, v)
|
|
284
|
+
return _move_deriv_axis(
|
|
285
|
+
J,
|
|
286
|
+
rank=child.rank,
|
|
287
|
+
pdepth_old=child.pdepth,
|
|
288
|
+
particles_input=child.particles_input,
|
|
289
|
+
same_particle=False,
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
# batched case: vmap over the entire batch prefix (flattened), then restore shape
|
|
293
|
+
bshape = x.shape[:b_nd]
|
|
294
|
+
mask_b = _prep_tree_for_flat_map(mask, bshape)
|
|
295
|
+
mask_b = _flatten_tree_prefix(mask_b, bshape) # (K,N,...) → (K*N,...)
|
|
296
|
+
if mask_b is not None and hasattr(mask_b, "shape"):
|
|
297
|
+
mask_b = jnp.reshape(mask_b, (-1,) + mask_b.shape[len(bshape) :])
|
|
298
|
+
x_flat = jnp.reshape(x, (-1,) + x.shape[-core_nd:])
|
|
299
|
+
v_flat = None if v is None else jnp.reshape(v, (-1,) + v.shape[-core_nd:])
|
|
300
|
+
|
|
301
|
+
def map_one(xi, vi, mi):
|
|
302
|
+
return diff(
|
|
303
|
+
lambda _x, _v: child(_x, v=_v, params=params, mask=mi, extras=extras),
|
|
304
|
+
argnums=argnum,
|
|
305
|
+
)(xi, vi)
|
|
306
|
+
|
|
307
|
+
in_axes_mask = jax.tree_util.tree_map(lambda a: 0 if (hasattr(a, "shape")) else None, mask_b)
|
|
308
|
+
vm = jax.vmap(map_one, in_axes=(0, 0 if v_flat is not None else None, in_axes_mask))
|
|
309
|
+
J_flat = vm(x_flat, v_flat, mask_b)
|
|
310
|
+
J = jnp.reshape(J_flat, bshape + J_flat.shape[1:])
|
|
311
|
+
return _move_deriv_axis(
|
|
312
|
+
J,
|
|
313
|
+
rank=child.rank,
|
|
314
|
+
pdepth_old=child.pdepth,
|
|
315
|
+
particles_input=child.particles_input,
|
|
316
|
+
same_particle=False,
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
# Derivatives tend to keep tangents/tapes alive. Inflate conservatively in grad mode.
|
|
320
|
+
def memory_hint(
|
|
321
|
+
self,
|
|
322
|
+
*,
|
|
323
|
+
dtype=None,
|
|
324
|
+
particle_size: int | None = None,
|
|
325
|
+
sample: SampleMeta | None = None,
|
|
326
|
+
mode: str = "forward",
|
|
327
|
+
):
|
|
328
|
+
base = super().memory_hint(dtype=dtype, particle_size=particle_size, sample=sample, mode=mode)
|
|
329
|
+
return inflate_for_grad(base, factor=2.0) if mode == "grad" else base
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _same_particle_grad(g, diff, x, v, params, *, var: str = "x", **g_kwargs):
|
|
333
|
+
"""
|
|
334
|
+
Compute same-particle blocks without forming the full PxP.
|
|
335
|
+
|
|
336
|
+
The callable `g` must have signature:
|
|
337
|
+
g(x, v, params, **g_kwargs) -> y
|
|
338
|
+
and should already close over any global/edge-local context (e.g. `extras`, `mask`)
|
|
339
|
+
if not provided via `g_kwargs`.
|
|
340
|
+
|
|
341
|
+
For var == 'x': returns ∂f_i / ∂x_i
|
|
342
|
+
For var == 'v': returns ∂f_i / ∂v_i
|
|
343
|
+
|
|
344
|
+
Let x.shape == B₁ · B₂ · … · Bk · P · dim (k ≥ 0 batch axes).
|
|
345
|
+
Result shape:
|
|
346
|
+
B₁ · B₂ · … · Bk · P · dim · (rank …) · (feature?)
|
|
347
|
+
|
|
348
|
+
Implementation detail:
|
|
349
|
+
- vmap across ALL batch axes (k nested vmaps), then over the particle index.
|
|
350
|
+
- “Added particle axis” elsewhere refers to input particles and only matters
|
|
351
|
+
when `particles_input=True`.
|
|
352
|
+
"""
|
|
353
|
+
*batch_shape, P, dim = x.shape
|
|
354
|
+
particles_axis = -2 # P is last-but-one
|
|
355
|
+
|
|
356
|
+
def per_batch(_x, _v):
|
|
357
|
+
def inner(i):
|
|
358
|
+
if var == "x":
|
|
359
|
+
yi = lax.dynamic_index_in_dim(_x, i, axis=particles_axis, keepdims=False) # (dim,)
|
|
360
|
+
|
|
361
|
+
def repl(y_local):
|
|
362
|
+
x_mod = lax.dynamic_update_index_in_dim(_x, y_local, i, axis=particles_axis)
|
|
363
|
+
# NOTE: extras/mask are expected to be captured by `g` or provided via **g_kwargs
|
|
364
|
+
y_full = g(x_mod, v=_v, params=params, **g_kwargs) # shape: P_out(=1) · rank … · F
|
|
365
|
+
y_i = lax.dynamic_index_in_dim(y_full, i, axis=0, keepdims=False)
|
|
366
|
+
return y_i
|
|
367
|
+
|
|
368
|
+
return diff(repl)(yi) # dim × rank … × F
|
|
369
|
+
elif var == "v":
|
|
370
|
+
if _v is None:
|
|
371
|
+
raise ValueError("same-particle v-derivative requires v")
|
|
372
|
+
yi = lax.dynamic_index_in_dim(_v, i, axis=particles_axis, keepdims=False) # (dim,)
|
|
373
|
+
|
|
374
|
+
def repl(y_local):
|
|
375
|
+
v_mod = lax.dynamic_update_index_in_dim(_v, y_local, i, axis=particles_axis)
|
|
376
|
+
y_full = g(_x, v=v_mod, params=params, **g_kwargs)
|
|
377
|
+
y_i = lax.dynamic_index_in_dim(y_full, i, axis=0, keepdims=False)
|
|
378
|
+
return y_i
|
|
379
|
+
|
|
380
|
+
return diff(repl)(yi) # dim × rank … × F
|
|
381
|
+
else:
|
|
382
|
+
raise ValueError("var must be 'x' or 'v' for same-particle")
|
|
383
|
+
|
|
384
|
+
# use int32 indices for JAX gather niceness
|
|
385
|
+
return jax.vmap(inner)(jnp.arange(P, dtype=jnp.int32)) # P × dim × rank … × F
|
|
386
|
+
|
|
387
|
+
# vmap over ALL batch axes (k nested vmaps)
|
|
388
|
+
def vmap_n(f, n, has_v):
|
|
389
|
+
gfun = f
|
|
390
|
+
for _ in range(n):
|
|
391
|
+
gfun = jax.vmap(gfun, in_axes=(0, 0 if has_v else None))
|
|
392
|
+
return gfun
|
|
393
|
+
|
|
394
|
+
vmapped = vmap_n(per_batch, len(batch_shape), v is not None)
|
|
395
|
+
return vmapped(x, v)
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def _move_deriv_axis(
|
|
399
|
+
arr,
|
|
400
|
+
*,
|
|
401
|
+
rank: int,
|
|
402
|
+
pdepth_old: int,
|
|
403
|
+
particles_input: bool,
|
|
404
|
+
same_particle: bool,
|
|
405
|
+
):
|
|
406
|
+
"""
|
|
407
|
+
Re-arrange the raw Jacobian from JAX to the contract:
|
|
408
|
+
|
|
409
|
+
output (full cross, only if particles_input=True):
|
|
410
|
+
batch · [P_in]^1 · [P_out]^{pdepth_old} · dim · rank · F
|
|
411
|
+
and the node's new pdepth is (pdepth_old + 1)
|
|
412
|
+
|
|
413
|
+
output (same-particle):
|
|
414
|
+
batch · [P_out]^{pdepth_old} · dim · rank · F
|
|
415
|
+
(no new particle axis; pdepth unchanged)
|
|
416
|
+
|
|
417
|
+
Parameters
|
|
418
|
+
----------
|
|
419
|
+
arr : jnp.ndarray # output of jacfwd / jacrev
|
|
420
|
+
rank : int # number of spatial rank axes in the child
|
|
421
|
+
pdepth_old : int # child's output particle-depth
|
|
422
|
+
particles_input : bool # whether inputs have a P axis
|
|
423
|
+
same_particle : bool # if True, produce the same-particle layout
|
|
424
|
+
"""
|
|
425
|
+
nd = arr.ndim
|
|
426
|
+
has_pin = particles_input and (not same_particle)
|
|
427
|
+
|
|
428
|
+
# Raw jacfwd layout assumption:
|
|
429
|
+
# B · P_out^{pdepth_old} · rank · F · [P_in?] · dim
|
|
430
|
+
n_tail = rank + 1 + (1 if has_pin else 0) + 1 # rank + F + P_in? + dim
|
|
431
|
+
B = nd - (pdepth_old + n_tail)
|
|
432
|
+
|
|
433
|
+
idx_batch = list(range(0, B))
|
|
434
|
+
idx_pout = list(range(B, B + pdepth_old))
|
|
435
|
+
idx_rank = list(range(B + pdepth_old, B + pdepth_old + rank))
|
|
436
|
+
idx_feat = [B + pdepth_old + rank]
|
|
437
|
+
idx_pin = [B + pdepth_old + rank + 1] if has_pin else []
|
|
438
|
+
idx_dim = [nd - 1]
|
|
439
|
+
|
|
440
|
+
if has_pin:
|
|
441
|
+
# Target: B · P_in · P_out^{pdepth_old} · dim · rank · F
|
|
442
|
+
order = idx_batch + idx_pin + idx_pout + idx_dim + idx_rank + idx_feat
|
|
443
|
+
else:
|
|
444
|
+
# Target: B · P_out^{pdepth_old} · dim · rank · F
|
|
445
|
+
order = idx_batch + idx_pout + idx_dim + idx_rank + idx_feat
|
|
446
|
+
|
|
447
|
+
return jnp.transpose(arr, order)
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# SFI/statefunc/nodes/ops/einsum.py
|
|
2
|
+
"""Einsum contraction operator node."""
|
|
3
|
+
|
|
4
|
+
import itertools
|
|
5
|
+
|
|
6
|
+
import equinox as eqx
|
|
7
|
+
import jax.numpy as jnp
|
|
8
|
+
|
|
9
|
+
from ..base import BaseNode, BaseOpNode
|
|
10
|
+
|
|
11
|
+
# helper letters for generating spatial tokens (implementation detail)
|
|
12
|
+
_LETTERS = "mnopqrstuvwxyz" # 14 letters ⇒ rank ≤ 14 each is fine
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _rank_letters(rank: int, offset: int = 0) -> str:
|
|
16
|
+
"""Return `rank` distinct lower-case letters starting at `offset`."""
|
|
17
|
+
if rank + offset > len(_LETTERS):
|
|
18
|
+
raise ValueError("Rank exceeds available einsum letters")
|
|
19
|
+
return _LETTERS[offset : offset + rank]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# ──────────────────────────────────────────────────────────────────────────────
|
|
23
|
+
# EinsumNode – generic contraction over spatial (rank) axes
|
|
24
|
+
# ──────────────────────────────────────────────────────────────────────────────
|
|
25
|
+
class EinsumNode(BaseOpNode):
|
|
26
|
+
"""
|
|
27
|
+
Generic einsum contraction of *N* sub-bases along their **rank axes**.
|
|
28
|
+
|
|
29
|
+
Notes
|
|
30
|
+
-----
|
|
31
|
+
* Specs are spatial-only (no ellipses); batch is implicit.
|
|
32
|
+
* Validation happens in `contract.merge_contract`. This node only implements
|
|
33
|
+
the execution and internal axis wiring.
|
|
34
|
+
|
|
35
|
+
Parameters
|
|
36
|
+
----------
|
|
37
|
+
*children : BaseNode
|
|
38
|
+
spec : str
|
|
39
|
+
Einstein notation **with LOWERCASE letters only** - one comma-separated
|
|
40
|
+
operand per child, followed by '->' and the RHS letters. **No ellipses**:
|
|
41
|
+
batch/particle axes are implicit and auto-injected.
|
|
42
|
+
|
|
43
|
+
Examples (spatial-only specs):
|
|
44
|
+
"m,n->mn" vector x vector outer
|
|
45
|
+
"m,m->" dot / trace
|
|
46
|
+
",n->n" scalar x vector
|
|
47
|
+
"mn,n->m" tensor x vector
|
|
48
|
+
"m,n,p->mnp" 3-way outer product
|
|
49
|
+
"mn,mp->np" contraction on first index
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
CONTRACT_MODE = "einsum"
|
|
53
|
+
_BASIS_LETTERS_UPPER = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
|
54
|
+
|
|
55
|
+
spec: str = eqx.field(static=True)
|
|
56
|
+
_einsum: str = eqx.field(static=True, repr=False)
|
|
57
|
+
|
|
58
|
+
# ------------------------------------------------------------------
|
|
59
|
+
def __init__(self, *children: BaseNode, spec: str):
|
|
60
|
+
# spec is assumed validated by contract
|
|
61
|
+
object.__setattr__(self, "spec", spec.replace(" ", ""))
|
|
62
|
+
super().__init__(*children)
|
|
63
|
+
|
|
64
|
+
lhs, rhs = self.spec.split("->")
|
|
65
|
+
lhs_ops = lhs.split(",")
|
|
66
|
+
|
|
67
|
+
# Build internal einsum by:
|
|
68
|
+
# - prefixing '...' on every operand and RHS for batch
|
|
69
|
+
# - appending unique per-child feature letters to operands
|
|
70
|
+
# - appending the concatenated feature letters on RHS
|
|
71
|
+
if len(children) > len(self._BASIS_LETTERS_UPPER):
|
|
72
|
+
raise ValueError("Too many children for auto basis letters (limit 26)")
|
|
73
|
+
|
|
74
|
+
embl_ops = [f"...{op}{self._BASIS_LETTERS_UPPER[i]}" for i, op in enumerate(lhs_ops)]
|
|
75
|
+
rhs_with_batch = f"...{rhs}{self._BASIS_LETTERS_UPPER[: len(children)]}"
|
|
76
|
+
einsum_full = ",".join(embl_ops) + "->" + rhs_with_batch
|
|
77
|
+
object.__setattr__(self, "_einsum", einsum_full)
|
|
78
|
+
|
|
79
|
+
def with_children(self, new_children):
|
|
80
|
+
return EinsumNode(*new_children, spec=self.spec)
|
|
81
|
+
|
|
82
|
+
# ------------------------------------------------------------------
|
|
83
|
+
def _op(self, outs, *, params):
|
|
84
|
+
y = jnp.einsum(self._einsum, *outs)
|
|
85
|
+
# collapse the per-child feature/basis axes into one
|
|
86
|
+
if len(self.children) > 1:
|
|
87
|
+
y = y.reshape(y.shape[: -len(self.children)] + (-1,))
|
|
88
|
+
return y
|
|
89
|
+
|
|
90
|
+
# ------------------------------------------------------------------
|
|
91
|
+
# flatten = Cartesian product of leaf callables --------------------
|
|
92
|
+
def flatten(self):
|
|
93
|
+
funcs_lists, label_lists, desc_lists = zip(*(ch.flatten() for ch in self.children))
|
|
94
|
+
|
|
95
|
+
funcs, labels, descs = [], [], []
|
|
96
|
+
for idx_combo in itertools.product(*[range(len(lst)) for lst in funcs_lists]):
|
|
97
|
+
|
|
98
|
+
def _prod(X, *, v=None, mask=None, extras=None, params=None, _idx=idx_combo):
|
|
99
|
+
parts = [
|
|
100
|
+
funcs_lists[k][_idx[k]](X, v=v, mask=mask, extras=extras, params=params)[..., None]
|
|
101
|
+
for k in range(len(_idx))
|
|
102
|
+
]
|
|
103
|
+
res = jnp.einsum(self._einsum, *parts)
|
|
104
|
+
return res.squeeze(tuple(range(-len(_idx), 0))) # drop len-1 axes
|
|
105
|
+
|
|
106
|
+
label = "·".join(
|
|
107
|
+
(f"({label_lists[k][idx]})" if any(c in label_lists[k][idx] for c in "+-·") else label_lists[k][idx])
|
|
108
|
+
for k, idx in enumerate(idx_combo)
|
|
109
|
+
)
|
|
110
|
+
desc = tuple(desc_lists[k][idx] for k, idx in enumerate(idx_combo))
|
|
111
|
+
|
|
112
|
+
funcs.append(_prod)
|
|
113
|
+
labels.append(label)
|
|
114
|
+
descs.append(desc)
|
|
115
|
+
|
|
116
|
+
return funcs, labels, descs
|
|
117
|
+
|
|
118
|
+
# ------------------------------------------------------------------
|
|
119
|
+
def _tree_id(self):
|
|
120
|
+
return hash((self.spec, tuple(ch._tree_id() for ch in self.children)))
|