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/__init__.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""
|
|
2
|
+
SFI – Stochastic Force Inference - main entry point
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import logging as _logging
|
|
6
|
+
import os as _os
|
|
7
|
+
from importlib.metadata import version as _v
|
|
8
|
+
|
|
9
|
+
# ---------------------------------------------------------------------------
|
|
10
|
+
# JAX persistent compilation cache (opt-in)
|
|
11
|
+
# ---------------------------------------------------------------------------
|
|
12
|
+
# Loading cached XLA executables triggers a per-hit C++ warning from the
|
|
13
|
+
# PjRt-IFRT layer ("Assume version compatibility …"), so the cache is OFF
|
|
14
|
+
# by default. Enable it by setting SFI_JAX_CACHE_DIR to a directory path,
|
|
15
|
+
# e.g. export SFI_JAX_CACHE_DIR=~/.cache/sfi/jax_cache
|
|
16
|
+
# XLA compilations dominate small-data runtime (~5 s for lorenz_demo T=100);
|
|
17
|
+
# caching cuts repeat runs to ~1 s.
|
|
18
|
+
_cache_dir = _os.environ.get("SFI_JAX_CACHE_DIR", "")
|
|
19
|
+
if _cache_dir:
|
|
20
|
+
import jax
|
|
21
|
+
|
|
22
|
+
jax.config.update("jax_compilation_cache_dir", _cache_dir)
|
|
23
|
+
# Many SFI compilations are 50–500 ms each; the default 1 s threshold
|
|
24
|
+
# would skip them, defeating the purpose of the cache.
|
|
25
|
+
jax.config.update("jax_persistent_cache_min_compile_time_secs", 0)
|
|
26
|
+
del jax
|
|
27
|
+
del _cache_dir
|
|
28
|
+
|
|
29
|
+
# Library-level null handler (silences output unless the user configures logging)
|
|
30
|
+
_logging.getLogger(__name__).addHandler(_logging.NullHandler())
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def enable_logging(level: str = "INFO") -> None:
|
|
34
|
+
"""Quick helper to turn on SFI log output.
|
|
35
|
+
|
|
36
|
+
>>> import SFI
|
|
37
|
+
>>> SFI.enable_logging() # INFO-level messages
|
|
38
|
+
>>> SFI.enable_logging("DEBUG") # everything
|
|
39
|
+
"""
|
|
40
|
+
_logger = _logging.getLogger(__name__)
|
|
41
|
+
_logger.setLevel(getattr(_logging, level.upper(), _logging.INFO))
|
|
42
|
+
if not any(
|
|
43
|
+
isinstance(h, _logging.StreamHandler) for h in _logger.handlers if not isinstance(h, _logging.NullHandler)
|
|
44
|
+
):
|
|
45
|
+
_handler = _logging.StreamHandler()
|
|
46
|
+
_handler.setFormatter(_logging.Formatter("[SFI %(levelname)s] %(message)s"))
|
|
47
|
+
_logger.addHandler(_handler)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# Public API --------------------------------------------------------------
|
|
51
|
+
from . import bases, diagnostics, inference, integrate, langevin, trajectory, utils
|
|
52
|
+
|
|
53
|
+
# Convenience re-exports so users can write ``from SFI import ...``
|
|
54
|
+
from .diagnostics import DiagnosticsReport, DynamicsOrderReport, assess, classify_dynamics
|
|
55
|
+
from .inference import (
|
|
56
|
+
InferenceResultSF,
|
|
57
|
+
OverdampedLangevinInference,
|
|
58
|
+
UnderdampedLangevinInference,
|
|
59
|
+
)
|
|
60
|
+
from .statefunc import PSF, SF, Basis, make_sf
|
|
61
|
+
from .trajectory import TrajectoryCollection, TrajectoryDataset
|
|
62
|
+
|
|
63
|
+
__version__ = _v("StochasticForceInference")
|
|
64
|
+
del _v
|
SFI/bases/__init__.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# SFI/bases/__init__.py
|
|
2
|
+
"""
|
|
3
|
+
SFI.bases
|
|
4
|
+
=========
|
|
5
|
+
|
|
6
|
+
Library of ready-made basis builders on top of :mod:`SFI.statefunc`.
|
|
7
|
+
|
|
8
|
+
Submodules
|
|
9
|
+
----------
|
|
10
|
+
- monomials : scalar (or lifted) monomial families in x and/or v.
|
|
11
|
+
- constants : structural bases: ones, unit vectors, identity/symmetric matrices.
|
|
12
|
+
- linear : coordinate-extraction helpers (X, V, x_coordinate, ...).
|
|
13
|
+
- pairs : pair-interaction toolkit: radial kernels, PBC, heading vectors.
|
|
14
|
+
- spde : composable spatial operators (Laplacian, Gradient, Divergence, Curl, ...) on regular grids.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from .constants import (
|
|
18
|
+
constant_array,
|
|
19
|
+
dataset_indicator,
|
|
20
|
+
extra_scalar,
|
|
21
|
+
identity_matrix_basis,
|
|
22
|
+
named_scalar,
|
|
23
|
+
named_scalars,
|
|
24
|
+
ones_basis,
|
|
25
|
+
per_dataset_scalar,
|
|
26
|
+
symmetric_matrix_basis,
|
|
27
|
+
time_fourier,
|
|
28
|
+
unit_vector_basis,
|
|
29
|
+
)
|
|
30
|
+
from .linear import (
|
|
31
|
+
V,
|
|
32
|
+
X,
|
|
33
|
+
field_component,
|
|
34
|
+
frame,
|
|
35
|
+
linear_basis,
|
|
36
|
+
unit_axes,
|
|
37
|
+
v_components,
|
|
38
|
+
v_coordinate,
|
|
39
|
+
v_coordinates,
|
|
40
|
+
x_components,
|
|
41
|
+
x_coordinate,
|
|
42
|
+
x_coordinates,
|
|
43
|
+
)
|
|
44
|
+
from .monomials import monomials_degree, monomials_up_to
|
|
45
|
+
|
|
46
|
+
__all__ = [
|
|
47
|
+
# monomials
|
|
48
|
+
"monomials_degree",
|
|
49
|
+
"monomials_up_to",
|
|
50
|
+
# constants / structural
|
|
51
|
+
"ones_basis",
|
|
52
|
+
"unit_vector_basis",
|
|
53
|
+
"identity_matrix_basis",
|
|
54
|
+
"symmetric_matrix_basis",
|
|
55
|
+
"constant_array",
|
|
56
|
+
"named_scalar",
|
|
57
|
+
"named_scalars",
|
|
58
|
+
"extra_scalar",
|
|
59
|
+
"time_fourier",
|
|
60
|
+
"per_dataset_scalar",
|
|
61
|
+
"dataset_indicator",
|
|
62
|
+
# linear / coordinates
|
|
63
|
+
"linear_basis",
|
|
64
|
+
"X",
|
|
65
|
+
"V",
|
|
66
|
+
"x_coordinate",
|
|
67
|
+
"x_coordinates",
|
|
68
|
+
"field_component",
|
|
69
|
+
"v_coordinate",
|
|
70
|
+
"v_coordinates",
|
|
71
|
+
# component / axis unpackers + frame bundle
|
|
72
|
+
"x_components",
|
|
73
|
+
"v_components",
|
|
74
|
+
"unit_axes",
|
|
75
|
+
"frame",
|
|
76
|
+
]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# Lazy imports for submodules that have heavier dependencies
|
|
80
|
+
def __getattr__(name):
|
|
81
|
+
if name in ("pairs", "spde"):
|
|
82
|
+
import importlib
|
|
83
|
+
|
|
84
|
+
return importlib.import_module(f".{name}", __name__)
|
|
85
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
SFI/bases/constants.py
ADDED
|
@@ -0,0 +1,492 @@
|
|
|
1
|
+
from typing import Sequence
|
|
2
|
+
|
|
3
|
+
import jax.numpy as jnp
|
|
4
|
+
|
|
5
|
+
from SFI.statefunc import Basis, Rank
|
|
6
|
+
from SFI.statefunc.nodes import SimpleLeaf
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def ones_basis(dim: int, pdepth: int = 0) -> Basis:
|
|
10
|
+
def _ones(x):
|
|
11
|
+
return jnp.ones((*x.shape[:-1], 1))
|
|
12
|
+
|
|
13
|
+
return Basis(
|
|
14
|
+
SimpleLeaf(
|
|
15
|
+
func=_ones,
|
|
16
|
+
n_features=1,
|
|
17
|
+
labels=("1",),
|
|
18
|
+
descriptor="scalar-one",
|
|
19
|
+
dim=dim,
|
|
20
|
+
rank=Rank.SCALAR,
|
|
21
|
+
pdepth=pdepth,
|
|
22
|
+
)
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def constant_array(A, *, label: str = "const", descriptor: str = "constant-array", as_sf=True):
|
|
27
|
+
"""
|
|
28
|
+
Constant basis/sf with a single feature whose value is a fixed tensor A of shape
|
|
29
|
+
``(dim,)*rank`` (rank inferred from ``A.ndim``, dim from ``A.shape[0]``).
|
|
30
|
+
|
|
31
|
+
- Errors if A is not a hypercube tensor (all axes same length).
|
|
32
|
+
- Broadcasts over batch/particles: output shape is
|
|
33
|
+
``(*x.shape[:-1], (dim,)*rank, 1)``.
|
|
34
|
+
"""
|
|
35
|
+
A = jnp.asarray(A)
|
|
36
|
+
if A.ndim == 0:
|
|
37
|
+
raise ValueError("Use ones_basis()/scalar constants for rank=SCALAR.")
|
|
38
|
+
if len(set(A.shape)) != 1:
|
|
39
|
+
raise ValueError(f"A must have shape (dim,)*rank (hypercube). Got {A.shape}.")
|
|
40
|
+
|
|
41
|
+
dim = int(A.shape[0])
|
|
42
|
+
rank = int(A.ndim)
|
|
43
|
+
|
|
44
|
+
def _const(x):
|
|
45
|
+
# x: (..., dim). We broadcast A over all leading axes of x (particles/time/...)
|
|
46
|
+
lead = x.shape[:-1]
|
|
47
|
+
return jnp.broadcast_to(A[..., None], (*lead, *A.shape, 1))
|
|
48
|
+
|
|
49
|
+
leaf = SimpleLeaf(
|
|
50
|
+
func=_const,
|
|
51
|
+
n_features=1,
|
|
52
|
+
labels=(label,),
|
|
53
|
+
descriptor=descriptor,
|
|
54
|
+
dim=dim,
|
|
55
|
+
rank=rank,
|
|
56
|
+
pdepth=0,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
B = Basis(leaf)
|
|
60
|
+
if as_sf:
|
|
61
|
+
return B.to_psf().bind(params={"coeff": 1.0})
|
|
62
|
+
return B
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def unit_vector_basis(dim: int, axes: Sequence[int] | None = None) -> Basis:
|
|
66
|
+
if axes is None:
|
|
67
|
+
axes = list(range(dim))
|
|
68
|
+
|
|
69
|
+
labels = [f"e{i}" for i in axes]
|
|
70
|
+
|
|
71
|
+
def _f(x):
|
|
72
|
+
out = jnp.zeros((dim, len(axes)))
|
|
73
|
+
for i, a in enumerate(axes):
|
|
74
|
+
out = out.at[a, i].set(1)
|
|
75
|
+
return out
|
|
76
|
+
|
|
77
|
+
return Basis(
|
|
78
|
+
SimpleLeaf(
|
|
79
|
+
func=_f,
|
|
80
|
+
n_features=len(axes),
|
|
81
|
+
labels=tuple(labels),
|
|
82
|
+
descriptor="unit-vector-set",
|
|
83
|
+
dim=dim,
|
|
84
|
+
rank=Rank.VECTOR,
|
|
85
|
+
pdepth=0,
|
|
86
|
+
particles_input=False,
|
|
87
|
+
needs_v=False,
|
|
88
|
+
)
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def identity_matrix_basis(dim: int, pdepth: int = 0) -> Basis:
|
|
93
|
+
def _f(x):
|
|
94
|
+
eye = jnp.eye(dim)
|
|
95
|
+
return jnp.broadcast_to(eye, (*x.shape[:-1], dim, dim))
|
|
96
|
+
|
|
97
|
+
return Basis(
|
|
98
|
+
SimpleLeaf(
|
|
99
|
+
func=_f,
|
|
100
|
+
n_features=1,
|
|
101
|
+
labels=("I",),
|
|
102
|
+
descriptor="identity",
|
|
103
|
+
dim=dim,
|
|
104
|
+
rank=Rank.MATRIX,
|
|
105
|
+
pdepth=pdepth,
|
|
106
|
+
)
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def symmetric_matrix_basis(dim: int, pdepth: int = 0) -> Basis:
|
|
111
|
+
"""Constant symmetric-matrix templates spanning the space of real symmetric
|
|
112
|
+
``dim × dim`` matrices.
|
|
113
|
+
|
|
114
|
+
For ``dim=d`` there are ``d(d+1)/2`` features: one per upper-triangle
|
|
115
|
+
entry ``(i,j)`` with ``i <= j``. Each feature is a ``(dim, dim)``
|
|
116
|
+
matrix: ``S_{(i,j)} = δ_{ia}δ_{jb} + δ_{ib}δ_{ja}`` (so the
|
|
117
|
+
off-diagonal templates equal 1 in both symmetric slots, diagonal
|
|
118
|
+
templates equal 1 on the diagonal).
|
|
119
|
+
|
|
120
|
+
Rank is ``MATRIX`` (2), and the output shape is ``(dim, dim, F)``.
|
|
121
|
+
"""
|
|
122
|
+
pairs = [(i, j) for i in range(dim) for j in range(i, dim)]
|
|
123
|
+
labels = [f"S{i}{j}" for (i, j) in pairs]
|
|
124
|
+
|
|
125
|
+
# Pre-build the (dim, dim, F) template at module level
|
|
126
|
+
import numpy as _np # deferred: only needed at build time, not at JAX eval time
|
|
127
|
+
|
|
128
|
+
tpl = _np.zeros((dim, dim, len(pairs)), dtype="float32")
|
|
129
|
+
for k, (i, j) in enumerate(pairs):
|
|
130
|
+
tpl[i, j, k] = 1.0
|
|
131
|
+
tpl[j, i, k] = 1.0 # symmetric: both slots
|
|
132
|
+
tpl_jnp = jnp.array(tpl)
|
|
133
|
+
|
|
134
|
+
def _f(x):
|
|
135
|
+
return tpl_jnp
|
|
136
|
+
|
|
137
|
+
return Basis(
|
|
138
|
+
SimpleLeaf(
|
|
139
|
+
func=_f,
|
|
140
|
+
n_features=len(pairs),
|
|
141
|
+
labels=tuple(labels),
|
|
142
|
+
descriptor="symmetric-matrix-templates",
|
|
143
|
+
dim=dim,
|
|
144
|
+
rank=Rank.MATRIX,
|
|
145
|
+
pdepth=pdepth,
|
|
146
|
+
particles_input=False,
|
|
147
|
+
needs_v=False,
|
|
148
|
+
)
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
# ---------------------------------------------------------------------------
|
|
153
|
+
# Named scalar parameters (rank-0 PSFs whose value is a single named param)
|
|
154
|
+
# ---------------------------------------------------------------------------
|
|
155
|
+
def named_scalar(name: str, default=None, *, dim: int | None = None, label: str | None = None):
|
|
156
|
+
"""Rank-0, 1-feature PSF whose value is a single named scalar parameter.
|
|
157
|
+
|
|
158
|
+
The returned PSF carries a single :class:`ParamSpec` with shape ``()``,
|
|
159
|
+
optional ``default``, and label ``label or name``.
|
|
160
|
+
|
|
161
|
+
Parameters
|
|
162
|
+
----------
|
|
163
|
+
name : str
|
|
164
|
+
Parameter name; also the default feature label.
|
|
165
|
+
default : scalar or None
|
|
166
|
+
Optional default value. When set, the PSF can be evaluated, bound,
|
|
167
|
+
or passed to a simulator without explicit ``params``.
|
|
168
|
+
dim : int or None
|
|
169
|
+
Spatial dimensionality; ``None`` (default) lets it be inferred at
|
|
170
|
+
first call (the value is independent of ``x``).
|
|
171
|
+
label : str or None
|
|
172
|
+
Optional human-readable feature label (defaults to ``name``).
|
|
173
|
+
|
|
174
|
+
Examples
|
|
175
|
+
--------
|
|
176
|
+
>>> sigma = named_scalar("sigma", default=20.0)
|
|
177
|
+
>>> sigma() # uses default
|
|
178
|
+
Array(20., dtype=float32)
|
|
179
|
+
>>> sigma(params={"sigma": 30.}) # explicit override
|
|
180
|
+
Array(30., dtype=float32)
|
|
181
|
+
"""
|
|
182
|
+
from ..statefunc import make_psf
|
|
183
|
+
from ..statefunc.params import ParamSpec
|
|
184
|
+
|
|
185
|
+
spec = ParamSpec(name, (), default=default)
|
|
186
|
+
|
|
187
|
+
def _f(x, *, params):
|
|
188
|
+
return params[name]
|
|
189
|
+
|
|
190
|
+
return make_psf(
|
|
191
|
+
_f,
|
|
192
|
+
dim=dim,
|
|
193
|
+
rank=0,
|
|
194
|
+
n_features=1,
|
|
195
|
+
labels=[label or name],
|
|
196
|
+
descriptor=f"named-scalar({name})",
|
|
197
|
+
params=[spec],
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def extra_scalar(name: str, *, dim: int | None = None, label: str | None = None):
|
|
202
|
+
"""Rank-0, 1-feature Basis whose value is read from ``extras[name]``.
|
|
203
|
+
|
|
204
|
+
The compositional symbol for data-carried quantities: an extra
|
|
205
|
+
(per-experiment constant, a time-dependent drive delivered per frame,
|
|
206
|
+
a per-particle property) becomes an expression that composes with
|
|
207
|
+
``x_components`` / ``unit_axes`` / ``named_scalar`` through the usual
|
|
208
|
+
algebra — and, being a parameter-free :class:`Basis`, slots directly
|
|
209
|
+
into linear-estimator dictionaries.
|
|
210
|
+
|
|
211
|
+
Parameters
|
|
212
|
+
----------
|
|
213
|
+
name : str
|
|
214
|
+
Extras key to read; also the default feature label.
|
|
215
|
+
dim : int or None
|
|
216
|
+
Spatial dimensionality; ``None`` (default) lets it be inferred at
|
|
217
|
+
first call (the value is independent of ``x``).
|
|
218
|
+
label : str or None
|
|
219
|
+
Optional human-readable feature label (defaults to ``name``).
|
|
220
|
+
|
|
221
|
+
Examples
|
|
222
|
+
--------
|
|
223
|
+
>>> from SFI.bases import X, extra_scalar
|
|
224
|
+
>>> k_t = extra_scalar("k_drive") # delivered by the dataset
|
|
225
|
+
>>> B = X(dim=2) & (k_t * X(dim=2)) # static + driven trap terms
|
|
226
|
+
>>> # simulate: OverdampedProcess(F=B, theta_F=jnp.array([-1.0, -1.0]))
|
|
227
|
+
>>> # infer: inf.infer_force_linear(B)
|
|
228
|
+
|
|
229
|
+
Notes
|
|
230
|
+
-----
|
|
231
|
+
At inference time the trajectory layer materializes ``extras[name]``
|
|
232
|
+
per frame (slicing :class:`~SFI.trajectory.TimeSeriesExtra` values);
|
|
233
|
+
in simulation, ``set_extras`` accepts the same time-dependent forms.
|
|
234
|
+
"""
|
|
235
|
+
from ..statefunc import make_basis
|
|
236
|
+
|
|
237
|
+
def _f(x, *, extras):
|
|
238
|
+
return jnp.asarray(extras[name])[None]
|
|
239
|
+
|
|
240
|
+
return make_basis(
|
|
241
|
+
_f,
|
|
242
|
+
dim=dim,
|
|
243
|
+
rank=0,
|
|
244
|
+
n_features=1,
|
|
245
|
+
labels=[label or name],
|
|
246
|
+
descriptor=f"extra-scalar({name})",
|
|
247
|
+
extras_keys=(name,),
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def time_fourier(
|
|
252
|
+
n_modes: int,
|
|
253
|
+
period: float | None = None,
|
|
254
|
+
*,
|
|
255
|
+
dim: int | None = None,
|
|
256
|
+
label: str | None = None,
|
|
257
|
+
):
|
|
258
|
+
r"""Rank-0 time-Fourier dictionary read from the reserved ``time`` extra.
|
|
259
|
+
|
|
260
|
+
Emits ``1 + 2 * n_modes`` parameter-free features
|
|
261
|
+
|
|
262
|
+
.. math::
|
|
263
|
+
|
|
264
|
+
\bigl\{\,1,\; \cos(k\omega t),\; \sin(k\omega t)\,\bigr\}_{k=1}^{n_\text{modes}},
|
|
265
|
+
\qquad \omega = 2\pi / P,
|
|
266
|
+
|
|
267
|
+
evaluated at each frame's absolute time ``t`` — the auto-injected
|
|
268
|
+
``time`` extra (see :meth:`TrajectoryDataset.build_extras`), so no
|
|
269
|
+
bookkeeping is required. Tensor it with a spatial basis to learn an
|
|
270
|
+
*unknown* time-dependent force field by expansion: ``time_fourier(4) *
|
|
271
|
+
X(dim=1)`` recovers a time-varying stiffness :math:`k(t)`, and
|
|
272
|
+
``time_fourier(4) * unit_vector_basis(1)`` a moving trap centre.
|
|
273
|
+
Sparse selection (:term:`PASTIS`) keeps only the harmonics the data
|
|
274
|
+
support.
|
|
275
|
+
|
|
276
|
+
Parameters
|
|
277
|
+
----------
|
|
278
|
+
n_modes : int
|
|
279
|
+
Number of harmonics; produces ``1 + 2 * n_modes`` features.
|
|
280
|
+
period : float or None
|
|
281
|
+
Fundamental period :math:`P`. If ``None`` (default), it defaults
|
|
282
|
+
to the **full trajectory duration** (read from the auto-injected
|
|
283
|
+
``duration`` extra), i.e. the fundamental frequency is the inverse
|
|
284
|
+
of the total observation time.
|
|
285
|
+
dim : int or None
|
|
286
|
+
Spatial dimensionality; ``None`` lets it be inferred (the value is
|
|
287
|
+
independent of ``x``).
|
|
288
|
+
label : str or None
|
|
289
|
+
Optional label prefix for the features.
|
|
290
|
+
|
|
291
|
+
Examples
|
|
292
|
+
--------
|
|
293
|
+
>>> from SFI.bases import X, time_fourier
|
|
294
|
+
>>> B = time_fourier(4) * X(dim=1) # learn k(t) over the trajectory
|
|
295
|
+
>>> inf.infer_force_linear(B)
|
|
296
|
+
"""
|
|
297
|
+
from ..statefunc import make_basis
|
|
298
|
+
|
|
299
|
+
if n_modes < 1:
|
|
300
|
+
raise ValueError("time_fourier requires n_modes >= 1")
|
|
301
|
+
|
|
302
|
+
keys = ("time",) if period is not None else ("time", "duration")
|
|
303
|
+
n_feat = 1 + 2 * n_modes
|
|
304
|
+
pre = (label + " ") if label else ""
|
|
305
|
+
labels = [f"{pre}1"]
|
|
306
|
+
for k in range(1, n_modes + 1):
|
|
307
|
+
labels += [f"{pre}cos({k}wt)", f"{pre}sin({k}wt)"]
|
|
308
|
+
|
|
309
|
+
ks = jnp.arange(1, n_modes + 1, dtype=float)
|
|
310
|
+
|
|
311
|
+
def _f(x, *, extras):
|
|
312
|
+
t = jnp.asarray(extras["time"], dtype=float)
|
|
313
|
+
P = period if period is not None else jnp.asarray(extras["duration"], dtype=float)
|
|
314
|
+
ang = ks * (2.0 * jnp.pi / P) * t # (n_modes,)
|
|
315
|
+
cs = jnp.stack([jnp.cos(ang), jnp.sin(ang)], axis=-1).reshape(-1) # (2 n_modes,)
|
|
316
|
+
return jnp.concatenate([jnp.ones((1,), dtype=cs.dtype), cs]) # (1 + 2 n_modes,)
|
|
317
|
+
|
|
318
|
+
return make_basis(
|
|
319
|
+
_f,
|
|
320
|
+
dim=dim,
|
|
321
|
+
rank=0,
|
|
322
|
+
n_features=n_feat,
|
|
323
|
+
labels=labels,
|
|
324
|
+
descriptor=f"time-fourier(n={n_modes})",
|
|
325
|
+
extras_keys=keys,
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def per_dataset_scalar(name: str, n_datasets: int, default=None, *, dim: int | None = None):
|
|
330
|
+
"""Rank-0, 1-feature PSF whose value is dataset-specific.
|
|
331
|
+
|
|
332
|
+
Carries a parameter array of shape ``(n_datasets,)`` and reads the
|
|
333
|
+
entry of the current dataset through the reserved
|
|
334
|
+
``extras["dataset_index"]`` (injected automatically by
|
|
335
|
+
:class:`~SFI.trajectory.TrajectoryCollection`). Compose it with
|
|
336
|
+
shared :func:`named_scalar` terms to fit pooled multi-experiment
|
|
337
|
+
models where part of the parameters is experiment-specific and the
|
|
338
|
+
rest is shared.
|
|
339
|
+
|
|
340
|
+
Parameters
|
|
341
|
+
----------
|
|
342
|
+
name : str
|
|
343
|
+
Parameter name (one entry per dataset).
|
|
344
|
+
n_datasets : int
|
|
345
|
+
Number of datasets in the collection the model will be fit on.
|
|
346
|
+
default : scalar or array of shape (n_datasets,), optional
|
|
347
|
+
Optional default value(s); a scalar is broadcast.
|
|
348
|
+
dim : int or None
|
|
349
|
+
Spatial dimensionality (``None`` → inferred).
|
|
350
|
+
|
|
351
|
+
Notes
|
|
352
|
+
-----
|
|
353
|
+
Indexed parameter access is nonlinear in the bookkeeping sense, so
|
|
354
|
+
the parametric estimators fit models containing this primitive on
|
|
355
|
+
the L-BFGS path. For the **linear estimators**, use the one-hot
|
|
356
|
+
route instead: :func:`dataset_indicator`.
|
|
357
|
+
"""
|
|
358
|
+
from ..statefunc import make_psf
|
|
359
|
+
from ..statefunc.params import ParamSpec
|
|
360
|
+
|
|
361
|
+
if default is not None:
|
|
362
|
+
default = jnp.broadcast_to(jnp.asarray(default, dtype=float), (n_datasets,))
|
|
363
|
+
spec = ParamSpec(name, (int(n_datasets),), default=default)
|
|
364
|
+
|
|
365
|
+
def _f(x, *, params, extras):
|
|
366
|
+
return params[name][extras["dataset_index"]]
|
|
367
|
+
|
|
368
|
+
def _specialize_at(k, _name=name, _dim=dim, _default=default):
|
|
369
|
+
# Condition-k slice: the (n_datasets,) param collapses to a scalar and
|
|
370
|
+
# the dataset_index read disappears (see StateExpr.specialize).
|
|
371
|
+
d_k = None if _default is None else jnp.asarray(_default)[int(k)]
|
|
372
|
+
spec_k = ParamSpec(_name, (), default=d_k)
|
|
373
|
+
|
|
374
|
+
def _fk(x, *, params):
|
|
375
|
+
return params[_name]
|
|
376
|
+
|
|
377
|
+
return make_psf(
|
|
378
|
+
_fk,
|
|
379
|
+
dim=_dim,
|
|
380
|
+
rank=0,
|
|
381
|
+
n_features=1,
|
|
382
|
+
labels=[_name],
|
|
383
|
+
descriptor=f"per-dataset-scalar({_name})@{int(k)}",
|
|
384
|
+
params=[spec_k],
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
return make_psf(
|
|
388
|
+
_f,
|
|
389
|
+
dim=dim,
|
|
390
|
+
rank=0,
|
|
391
|
+
n_features=1,
|
|
392
|
+
labels=[name],
|
|
393
|
+
descriptor=f"per-dataset-scalar({name})",
|
|
394
|
+
params=[spec],
|
|
395
|
+
extras_keys=("dataset_index",),
|
|
396
|
+
specialize_at=_specialize_at,
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def dataset_indicator(n_datasets: int, *, dim: int | None = None):
|
|
401
|
+
"""Rank-0 Basis of ``n_datasets`` one-hot features ``1{dataset == d}``.
|
|
402
|
+
|
|
403
|
+
The **linear-estimator** route to per-dataset coefficients: multiply
|
|
404
|
+
a feature by the indicator and concatenate, and each dataset gets an
|
|
405
|
+
independent linear coefficient for that feature (the Gram is
|
|
406
|
+
block-diagonal across datasets), PASTIS-prunable like any feature.
|
|
407
|
+
|
|
408
|
+
.. code-block:: python
|
|
409
|
+
|
|
410
|
+
B = B_shared & (dataset_indicator(n) * X(dim))
|
|
411
|
+
|
|
412
|
+
Reads the reserved ``extras["dataset_index"]`` injected by
|
|
413
|
+
:class:`~SFI.trajectory.TrajectoryCollection`.
|
|
414
|
+
"""
|
|
415
|
+
|
|
416
|
+
from ..statefunc import make_basis
|
|
417
|
+
|
|
418
|
+
n = int(n_datasets)
|
|
419
|
+
|
|
420
|
+
def _f(x, *, extras):
|
|
421
|
+
return (jnp.arange(n) == extras["dataset_index"]).astype(float)
|
|
422
|
+
|
|
423
|
+
def _specialize_at(k, _n=n, _dim=dim):
|
|
424
|
+
# Condition-k one-hot becomes a constant vector; no dataset_index read.
|
|
425
|
+
onehot = (jnp.arange(_n) == int(k)).astype(float)
|
|
426
|
+
|
|
427
|
+
def _fk(x):
|
|
428
|
+
return onehot
|
|
429
|
+
|
|
430
|
+
return make_basis(
|
|
431
|
+
_fk,
|
|
432
|
+
dim=_dim,
|
|
433
|
+
rank=0,
|
|
434
|
+
n_features=_n,
|
|
435
|
+
labels=[f"ds{i}" for i in range(_n)],
|
|
436
|
+
descriptor=f"dataset-indicator({_n})@{int(k)}",
|
|
437
|
+
)
|
|
438
|
+
|
|
439
|
+
return make_basis(
|
|
440
|
+
_f,
|
|
441
|
+
dim=dim,
|
|
442
|
+
rank=0,
|
|
443
|
+
n_features=n,
|
|
444
|
+
labels=[f"ds{i}" for i in range(n)],
|
|
445
|
+
descriptor=f"dataset-indicator({n})",
|
|
446
|
+
extras_keys=("dataset_index",),
|
|
447
|
+
specialize_at=_specialize_at,
|
|
448
|
+
)
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
# Per-particle inferred parameters ("params_local paralleling extras_local")
|
|
452
|
+
# are expressed inside interactor kernels: declare the reserved
|
|
453
|
+
# ``particle_index`` extra as particle-aligned and index a (P,)-shaped
|
|
454
|
+
# parameter with it —
|
|
455
|
+
#
|
|
456
|
+
# def local(Xk, *, params, extras):
|
|
457
|
+
# mob = params["mob"][extras["particle_index"]] # (K,) per edge
|
|
458
|
+
# ...
|
|
459
|
+
# inter = make_interactor(local, ..., params={"mob": (P,)},
|
|
460
|
+
# extras_keys=("particle_index",),
|
|
461
|
+
# particle_extras=("particle_index",))
|
|
462
|
+
#
|
|
463
|
+
# See :func:`SFI.statefunc.make_interactor`.
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def named_scalars(*args, **kwargs):
|
|
467
|
+
"""Unpack named scalar parameters, one PSF per name.
|
|
468
|
+
|
|
469
|
+
Two equivalent call styles, both with deterministic ordering:
|
|
470
|
+
|
|
471
|
+
Positional names (no defaults)::
|
|
472
|
+
|
|
473
|
+
sigma, rho, beta = named_scalars("sigma", "rho", "beta")
|
|
474
|
+
|
|
475
|
+
Keyword names with defaults (Python preserves call-site order)::
|
|
476
|
+
|
|
477
|
+
sigma, rho, beta = named_scalars(sigma=20.0, rho=8.0, beta=2.0)
|
|
478
|
+
|
|
479
|
+
Returns
|
|
480
|
+
-------
|
|
481
|
+
tuple[PSF, ...]
|
|
482
|
+
One :class:`PSF` per name, in the order given.
|
|
483
|
+
"""
|
|
484
|
+
if args and kwargs:
|
|
485
|
+
raise TypeError("named_scalars: pass either positional names OR keyword name=default, not both.")
|
|
486
|
+
if kwargs:
|
|
487
|
+
return tuple(named_scalar(name, default=val) for name, val in kwargs.items())
|
|
488
|
+
if not args:
|
|
489
|
+
raise TypeError("named_scalars: at least one name is required.")
|
|
490
|
+
if not all(isinstance(a, str) for a in args):
|
|
491
|
+
raise TypeError("named_scalars: positional arguments must be parameter names (str).")
|
|
492
|
+
return tuple(named_scalar(name) for name in args)
|