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/langevin/chunked.py
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
"""Chunked simulation with periodic neighbor-list rebuilds.
|
|
2
|
+
|
|
3
|
+
Provides :func:`simulate_chunked`, a thin wrapper around
|
|
4
|
+
:meth:`OverdampedProcess.simulate` that breaks a long simulation into
|
|
5
|
+
shorter chunks and rebuilds the CSR neighbor list (via
|
|
6
|
+
:func:`~SFI.utils.neighbors.build_neighbor_csr`) between chunks.
|
|
7
|
+
|
|
8
|
+
This avoids the O(N²) cost of ``AutoPairs`` for large particle systems
|
|
9
|
+
while keeping the extras interface exactly as-is.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from typing import Optional
|
|
15
|
+
|
|
16
|
+
import jax
|
|
17
|
+
import jax.numpy as jnp
|
|
18
|
+
import numpy as np
|
|
19
|
+
|
|
20
|
+
from SFI.trajectory.collection import TrajectoryCollection
|
|
21
|
+
from SFI.utils.neighbors import build_neighbor_csr, pad_neighbor_csr
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def simulate_chunked(
|
|
25
|
+
proc,
|
|
26
|
+
dt: float,
|
|
27
|
+
Nsteps: int,
|
|
28
|
+
key,
|
|
29
|
+
*,
|
|
30
|
+
cutoff: float,
|
|
31
|
+
box: np.ndarray,
|
|
32
|
+
skin: float = 0.0,
|
|
33
|
+
rebuild_every: int = 50,
|
|
34
|
+
save_every: Optional[int] = None,
|
|
35
|
+
spatial_dims: slice = slice(None, 2),
|
|
36
|
+
indptr_key: str = "indptr",
|
|
37
|
+
indices_key: str = "indices",
|
|
38
|
+
nnz_safety: float = 1.25,
|
|
39
|
+
oversampling: int = 1,
|
|
40
|
+
prerun: int = 0,
|
|
41
|
+
compute_observables: bool = False,
|
|
42
|
+
jit_compile: bool = True,
|
|
43
|
+
verbose: bool = False,
|
|
44
|
+
) -> TrajectoryCollection:
|
|
45
|
+
"""Run a chunked overdamped simulation with periodic neighbor rebuilds.
|
|
46
|
+
|
|
47
|
+
Parameters
|
|
48
|
+
----------
|
|
49
|
+
proc : OverdampedProcess
|
|
50
|
+
An initialized process whose force uses
|
|
51
|
+
``dispatch_pairs_from_extras(indptr_key, indices_key)``.
|
|
52
|
+
dt : float
|
|
53
|
+
Time step.
|
|
54
|
+
Nsteps : int
|
|
55
|
+
Total number of steps.
|
|
56
|
+
key : jax PRNG key
|
|
57
|
+
Random key for the simulation.
|
|
58
|
+
cutoff : float
|
|
59
|
+
Cutoff radius for neighbor list construction.
|
|
60
|
+
box : array-like, shape ``(d,)``
|
|
61
|
+
Periodic box lengths.
|
|
62
|
+
skin : float
|
|
63
|
+
Verlet skin width. The neighbor list is built with radius
|
|
64
|
+
``cutoff + skin`` so that particles drifting into range between
|
|
65
|
+
rebuilds are already included. After each chunk the maximum
|
|
66
|
+
particle displacement is checked; a warning is printed if it
|
|
67
|
+
exceeds ``skin / 2`` (the Verlet safety threshold).
|
|
68
|
+
rebuild_every : int
|
|
69
|
+
Number of simulation steps between neighbor-list rebuilds.
|
|
70
|
+
save_every : int, optional
|
|
71
|
+
Number of simulation steps per output dataset. If *None*,
|
|
72
|
+
defaults to ``rebuild_every``. Must be a multiple of
|
|
73
|
+
``rebuild_every``.
|
|
74
|
+
spatial_dims : slice
|
|
75
|
+
Slice into the state vector that selects spatial coordinates
|
|
76
|
+
(default: first two components ``[:2]``).
|
|
77
|
+
indptr_key, indices_key : str
|
|
78
|
+
Extras keys for the CSR neighbor list.
|
|
79
|
+
nnz_safety : float
|
|
80
|
+
Fraction by which ``max_nnz`` is enlarged beyond the initial
|
|
81
|
+
neighbor count to absorb fluctuations (default 1.25 = 25%).
|
|
82
|
+
oversampling, prerun, compute_observables, jit_compile
|
|
83
|
+
Forwarded to ``proc.simulate()``.
|
|
84
|
+
verbose : bool
|
|
85
|
+
Print progress info.
|
|
86
|
+
|
|
87
|
+
Returns
|
|
88
|
+
-------
|
|
89
|
+
TrajectoryCollection
|
|
90
|
+
Concatenated trajectory from all chunks.
|
|
91
|
+
"""
|
|
92
|
+
# Time-dependent extras are not supported here: simulate() is re-invoked
|
|
93
|
+
# per rebuild chunk, which would mis-slice frame-aligned schedules.
|
|
94
|
+
from SFI.trajectory.dataset import TimeSeriesExtra
|
|
95
|
+
|
|
96
|
+
for src in (proc.extras_global, proc.extras_local):
|
|
97
|
+
for k, v in (src or {}).items():
|
|
98
|
+
if isinstance(v, TimeSeriesExtra) or (callable(v) and not hasattr(v, "func")):
|
|
99
|
+
raise NotImplementedError(
|
|
100
|
+
f"simulate_chunked does not support time-dependent extras (got {k!r}); "
|
|
101
|
+
"use proc.simulate() directly."
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
box = np.asarray(box, dtype=np.float64)
|
|
105
|
+
cutoff_list = cutoff + skin # Verlet list radius
|
|
106
|
+
|
|
107
|
+
if save_every is None:
|
|
108
|
+
save_every = rebuild_every
|
|
109
|
+
if save_every % rebuild_every != 0:
|
|
110
|
+
raise ValueError(f"save_every ({save_every}) must be a multiple of rebuild_every ({rebuild_every})")
|
|
111
|
+
rebuilds_per_save = save_every // rebuild_every
|
|
112
|
+
|
|
113
|
+
# --- initial neighbor list ---
|
|
114
|
+
positions = np.asarray(proc._x) # (P, d) or (d,)
|
|
115
|
+
# Assumes layout (P, d) for ndim==2 or (d,) for ndim==1; (d, P) would silently mislabel.
|
|
116
|
+
pos_spatial = positions[:, spatial_dims] if positions.ndim == 2 else positions[spatial_dims]
|
|
117
|
+
indptr, indices = build_neighbor_csr(pos_spatial, cutoff_list, box)
|
|
118
|
+
pos_at_rebuild = pos_spatial.copy() # track displacements
|
|
119
|
+
|
|
120
|
+
# Fixed max_nnz with safety margin
|
|
121
|
+
max_nnz = max(int(len(indices) * nnz_safety), len(indices) + 1)
|
|
122
|
+
indptr_pad, indices_pad = pad_neighbor_csr(indptr, indices, max_nnz)
|
|
123
|
+
|
|
124
|
+
# Merge CSR into existing extras (don't clobber other keys)
|
|
125
|
+
base_extras = dict(proc.extras_global or {})
|
|
126
|
+
base_extras[indptr_key] = jnp.array(indptr_pad)
|
|
127
|
+
base_extras[indices_key] = jnp.array(indices_pad)
|
|
128
|
+
proc.set_extras(extras_global=base_extras)
|
|
129
|
+
|
|
130
|
+
# --- chunk the simulation ---
|
|
131
|
+
n_rebuilds = max(1, (Nsteps + rebuild_every - 1) // rebuild_every)
|
|
132
|
+
remaining = Nsteps
|
|
133
|
+
collections = [] # final output datasets
|
|
134
|
+
sub_collections = [] # accumulate rebuild-chunks within a save-chunk
|
|
135
|
+
step_done = 0
|
|
136
|
+
max_disp_in_save = 0.0 # track across rebuilds within a save-chunk
|
|
137
|
+
|
|
138
|
+
for rebuild_i in range(n_rebuilds):
|
|
139
|
+
chunk_steps = min(rebuild_every, remaining)
|
|
140
|
+
if chunk_steps <= 0:
|
|
141
|
+
break
|
|
142
|
+
|
|
143
|
+
key, sub_key = jax.random.split(key)
|
|
144
|
+
|
|
145
|
+
coll = proc.simulate(
|
|
146
|
+
dt,
|
|
147
|
+
Nsteps=chunk_steps,
|
|
148
|
+
key=sub_key,
|
|
149
|
+
oversampling=oversampling,
|
|
150
|
+
prerun=prerun if rebuild_i == 0 else 0,
|
|
151
|
+
compute_observables=compute_observables,
|
|
152
|
+
jit_compile=jit_compile,
|
|
153
|
+
)
|
|
154
|
+
sub_collections.append(coll)
|
|
155
|
+
remaining -= chunk_steps
|
|
156
|
+
step_done += chunk_steps
|
|
157
|
+
|
|
158
|
+
# Emit a save-chunk when we've accumulated enough rebuilds
|
|
159
|
+
if (rebuild_i + 1) % rebuilds_per_save == 0 or remaining <= 0:
|
|
160
|
+
# Merge sub-collection X arrays into one contiguous dataset.
|
|
161
|
+
# Each sub-collection has one dataset with X shape (T_sub, N, d)
|
|
162
|
+
# where T_sub = rebuild_every (no duplicate frames).
|
|
163
|
+
X_parts = [np.asarray(sc.datasets[0].X) for sc in sub_collections]
|
|
164
|
+
X_merged = np.concatenate(X_parts, axis=0)
|
|
165
|
+
if verbose:
|
|
166
|
+
print(f" merged {len(sub_collections)} sub-chunks → X shape {X_merged.shape}")
|
|
167
|
+
merged = TrajectoryCollection.from_arrays(
|
|
168
|
+
X=X_merged,
|
|
169
|
+
dt=dt,
|
|
170
|
+
)
|
|
171
|
+
collections.append(merged)
|
|
172
|
+
sub_collections = []
|
|
173
|
+
|
|
174
|
+
if verbose:
|
|
175
|
+
print(f" chunk {len(collections)}: {step_done}/{Nsteps} steps")
|
|
176
|
+
|
|
177
|
+
# Rebuild neighbors from updated positions
|
|
178
|
+
if remaining > 0:
|
|
179
|
+
positions = np.asarray(proc._x) # (P, d) or (d,)
|
|
180
|
+
pos_spatial = positions[:, spatial_dims] if positions.ndim == 2 else positions[spatial_dims]
|
|
181
|
+
|
|
182
|
+
# --- Verlet displacement check ---
|
|
183
|
+
disp = pos_spatial - pos_at_rebuild
|
|
184
|
+
if box is not None:
|
|
185
|
+
disp = disp - box * np.round(disp / box)
|
|
186
|
+
max_disp = float(np.sqrt((disp * disp).sum(axis=-1)).max())
|
|
187
|
+
max_disp_in_save = max(max_disp_in_save, max_disp)
|
|
188
|
+
if skin > 0 and max_disp > skin / 2:
|
|
189
|
+
import warnings
|
|
190
|
+
|
|
191
|
+
warnings.warn(
|
|
192
|
+
f"Rebuild {rebuild_i}: max displacement {max_disp:.3f} "
|
|
193
|
+
f"> skin/2 = {skin / 2:.3f}. Neighbor list may have "
|
|
194
|
+
f"missed interactions. Increase skin or decrease "
|
|
195
|
+
f"rebuild_every.",
|
|
196
|
+
stacklevel=2,
|
|
197
|
+
)
|
|
198
|
+
if verbose and (rebuild_i + 1) % rebuilds_per_save == 0:
|
|
199
|
+
print(f" max displacement = {max_disp_in_save:.3f} (skin/2 = {skin / 2:.3f})")
|
|
200
|
+
max_disp_in_save = 0.0
|
|
201
|
+
|
|
202
|
+
indptr, indices = build_neighbor_csr(pos_spatial, cutoff_list, box)
|
|
203
|
+
pos_at_rebuild = pos_spatial.copy()
|
|
204
|
+
|
|
205
|
+
# Grow max_nnz if needed
|
|
206
|
+
if len(indices) > max_nnz:
|
|
207
|
+
max_nnz = int(len(indices) * nnz_safety)
|
|
208
|
+
if verbose:
|
|
209
|
+
print(f" max_nnz grew to {max_nnz}")
|
|
210
|
+
|
|
211
|
+
indptr_pad, indices_pad = pad_neighbor_csr(indptr, indices, max_nnz)
|
|
212
|
+
|
|
213
|
+
# Update extras, preserving all non-CSR keys
|
|
214
|
+
updated_extras = dict(proc.extras_global or {})
|
|
215
|
+
updated_extras[indptr_key] = jnp.array(indptr_pad)
|
|
216
|
+
updated_extras[indices_key] = jnp.array(indices_pad)
|
|
217
|
+
proc.set_extras(extras_global=updated_extras)
|
|
218
|
+
|
|
219
|
+
# Free old JIT caches to prevent GPU memory buildup
|
|
220
|
+
jax.clear_caches()
|
|
221
|
+
|
|
222
|
+
# --- concatenate chunks ---
|
|
223
|
+
if len(collections) == 1:
|
|
224
|
+
return collections[0]
|
|
225
|
+
return collections[0].concat(collections[1:], weights="pool")
|
SFI/langevin/noise.py
ADDED
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Noise models for Langevin simulators.
|
|
3
|
+
|
|
4
|
+
This module provides a hierarchy of noise models that go beyond the
|
|
5
|
+
simple diagonal diffusion ``D = σ I``. In particular, it supports
|
|
6
|
+
**conserved noise** needed by SPDE models such as Active Model B+, where
|
|
7
|
+
the noise takes the form ``∇·(σ η)`` and preserves the spatial integral
|
|
8
|
+
of the field.
|
|
9
|
+
|
|
10
|
+
Class hierarchy
|
|
11
|
+
---------------
|
|
12
|
+
- :class:`NoiseModel` — abstract base;
|
|
13
|
+
- :class:`WhiteNoise` — i.i.d. per-site Gaussian (recovers current behaviour);
|
|
14
|
+
- :class:`ConservedNoise` — ``sqrt(-σ² ∇²) ξ`` via FFT on a periodic grid;
|
|
15
|
+
- :class:`CompositeNoise` — different noise models on different field components.
|
|
16
|
+
|
|
17
|
+
Usage
|
|
18
|
+
-----
|
|
19
|
+
Pass a ``NoiseModel`` instance as ``D=`` to :class:`~SFI.langevin.OverdampedProcess`
|
|
20
|
+
(or any ``LangevinBase`` subclass). The simulator detects the noise model and
|
|
21
|
+
delegates noise generation to it instead of the traditional ``sqrt(2D)·ξ`` path.
|
|
22
|
+
|
|
23
|
+
.. code-block:: python
|
|
24
|
+
|
|
25
|
+
from SFI.langevin.noise import ConservedNoise
|
|
26
|
+
|
|
27
|
+
noise = ConservedNoise(sigma=0.3, grid_shape=(64, 64), dx=1.0)
|
|
28
|
+
proc = OverdampedProcess(BASIS, D=noise)
|
|
29
|
+
proc.set_params(theta_F=theta)
|
|
30
|
+
proc.set_extras(extras_global=box_extras)
|
|
31
|
+
proc.initialize(X0)
|
|
32
|
+
coll = proc.simulate(dt=0.02, Nsteps=3000, key=key, oversampling=4)
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
import abc
|
|
38
|
+
from typing import List, Sequence, Tuple, Union
|
|
39
|
+
|
|
40
|
+
import jax.numpy as jnp
|
|
41
|
+
from jax import random
|
|
42
|
+
|
|
43
|
+
Array = jnp.ndarray
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# ============================================================================
|
|
47
|
+
# Abstract base
|
|
48
|
+
# ============================================================================
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class NoiseModel(abc.ABC):
|
|
52
|
+
"""Abstract base for noise models used by Langevin simulators.
|
|
53
|
+
|
|
54
|
+
Subclasses must implement :meth:`sample` and :meth:`effective_D_per_site`.
|
|
55
|
+
The simulator calls ``sample(key, x, extras)`` once per Euler–Maruyama
|
|
56
|
+
substep to obtain the noise increment (already scaled by ``sqrt(2)`` so
|
|
57
|
+
that the step becomes ``x += dt*F + sqrt(dt)*sample(key, x, extras)``).
|
|
58
|
+
|
|
59
|
+
Parameters
|
|
60
|
+
----------
|
|
61
|
+
n_fields : int
|
|
62
|
+
Number of field components per grid site (= ``dim`` in the force
|
|
63
|
+
contract). E.g. 1 for a scalar field, 2 for a 2-component system.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
def __init__(self, *, n_fields: int) -> None:
|
|
67
|
+
self._n_fields = n_fields
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def n_fields(self) -> int:
|
|
71
|
+
"""Number of field components per site."""
|
|
72
|
+
return self._n_fields
|
|
73
|
+
|
|
74
|
+
# Alias for the force-contract convention
|
|
75
|
+
@property
|
|
76
|
+
def dim(self) -> int:
|
|
77
|
+
return self._n_fields
|
|
78
|
+
|
|
79
|
+
@abc.abstractmethod
|
|
80
|
+
def sample(self, key: Array, x: Array, extras: dict) -> Array:
|
|
81
|
+
r"""Draw one noise increment.
|
|
82
|
+
|
|
83
|
+
Parameters
|
|
84
|
+
----------
|
|
85
|
+
key : PRNG key
|
|
86
|
+
x : Array, shape ``(P, d)`` or ``(d,)``
|
|
87
|
+
Current state (used only for shape; not accessed by white/conserved
|
|
88
|
+
noise, but may be needed by multiplicative noise subclasses).
|
|
89
|
+
extras : dict
|
|
90
|
+
Process extras (contains grid geometry, etc.).
|
|
91
|
+
|
|
92
|
+
Returns
|
|
93
|
+
-------
|
|
94
|
+
Array, same shape as *x*
|
|
95
|
+
Noise increment already multiplied by ``sqrt(2)`` so the
|
|
96
|
+
integrator applies ``x += dt*F + sqrt(dt) * sample(...)``.
|
|
97
|
+
"""
|
|
98
|
+
...
|
|
99
|
+
|
|
100
|
+
@abc.abstractmethod
|
|
101
|
+
def effective_D_per_site(self, extras: dict) -> Array:
|
|
102
|
+
r"""Return an approximate per-site diffusion matrix ``(d, d)``.
|
|
103
|
+
|
|
104
|
+
This is used by the inference pipeline as a *pragmatic approximation*
|
|
105
|
+
when the noise is not white-in-space. For ``WhiteNoise(σ)`` this
|
|
106
|
+
returns exactly ``σ·I``. For ``ConservedNoise`` it returns the
|
|
107
|
+
spatially-averaged effective variance per site.
|
|
108
|
+
|
|
109
|
+
Returns
|
|
110
|
+
-------
|
|
111
|
+
Array, shape ``(d, d)``
|
|
112
|
+
"""
|
|
113
|
+
...
|
|
114
|
+
|
|
115
|
+
@property
|
|
116
|
+
def noise_kind(self) -> str:
|
|
117
|
+
"""Short string tag for the noise type."""
|
|
118
|
+
return self.__class__.__name__
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
# ============================================================================
|
|
122
|
+
# White noise (recovers current constant-scalar behaviour)
|
|
123
|
+
# ============================================================================
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class WhiteNoise(NoiseModel):
|
|
127
|
+
r"""Spatially uncorrelated Gaussian noise: ``B = sqrt(2σ) I``.
|
|
128
|
+
|
|
129
|
+
Each grid site receives i.i.d. ``N(0, 2σ dt)`` noise per component.
|
|
130
|
+
This recovers the current ``D = σ`` (scalar constant) behaviour.
|
|
131
|
+
|
|
132
|
+
Parameters
|
|
133
|
+
----------
|
|
134
|
+
sigma : float
|
|
135
|
+
Scalar diffusion coefficient (the *D* in ``dx = F dt + sqrt(2D) dW``).
|
|
136
|
+
n_fields : int
|
|
137
|
+
Number of field components per site.
|
|
138
|
+
"""
|
|
139
|
+
|
|
140
|
+
def __init__(self, sigma: float, *, n_fields: int = 1) -> None:
|
|
141
|
+
super().__init__(n_fields=n_fields)
|
|
142
|
+
if sigma < 0:
|
|
143
|
+
raise ValueError(f"sigma must be non-negative, got {sigma}")
|
|
144
|
+
self._sigma = float(sigma)
|
|
145
|
+
# Precompute sqrt(2σ) for efficiency
|
|
146
|
+
self._sqrt2sigma = float(jnp.sqrt(2.0 * sigma))
|
|
147
|
+
|
|
148
|
+
@property
|
|
149
|
+
def sigma(self) -> float:
|
|
150
|
+
return self._sigma
|
|
151
|
+
|
|
152
|
+
def sample(self, key: Array, x: Array, extras: dict) -> Array:
|
|
153
|
+
xi = random.normal(key, shape=x.shape)
|
|
154
|
+
return self._sqrt2sigma * xi
|
|
155
|
+
|
|
156
|
+
def effective_D_per_site(self, extras: dict) -> Array:
|
|
157
|
+
return self._sigma * jnp.eye(self._n_fields)
|
|
158
|
+
|
|
159
|
+
def __repr__(self) -> str:
|
|
160
|
+
return f"WhiteNoise(sigma={self._sigma}, n_fields={self._n_fields})"
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
# ============================================================================
|
|
164
|
+
# Conserved noise (sqrt(-σ² ∇²) via FFT on periodic grids)
|
|
165
|
+
# ============================================================================
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _build_freq_amplitudes(
|
|
169
|
+
grid_shape: Sequence[int],
|
|
170
|
+
dx: Union[float, Sequence[float]],
|
|
171
|
+
ndim: int,
|
|
172
|
+
) -> Array:
|
|
173
|
+
r"""Build the Fourier-space multiplier ``|k|`` for conserved noise.
|
|
174
|
+
|
|
175
|
+
For a periodic grid with spacing *dx*, the wavenumbers along axis α are
|
|
176
|
+
|
|
177
|
+
.. math::
|
|
178
|
+
|
|
179
|
+
k_\alpha = \frac{2\pi\,n_\alpha}{N_\alpha\,\Delta x_\alpha}
|
|
180
|
+
|
|
181
|
+
and the multiplier is ``|k| = sqrt(sum_α k_α²)``, which corresponds to
|
|
182
|
+
the operator ``sqrt(-∇²)`` in Fourier space.
|
|
183
|
+
|
|
184
|
+
We use ``rfft`` along the last spatial axis, so the returned array has
|
|
185
|
+
shape ``(N_0, N_1, ..., N_{d-2}, N_{d-1}//2+1)`` for an *ndim*-D grid.
|
|
186
|
+
|
|
187
|
+
The ``k = 0`` mode is set to zero (conserved noise has zero mean).
|
|
188
|
+
|
|
189
|
+
Returns
|
|
190
|
+
-------
|
|
191
|
+
Array, real, shape matching rfft output
|
|
192
|
+
``|k|`` on the half-complex grid.
|
|
193
|
+
"""
|
|
194
|
+
grid_shape = tuple(int(n) for n in grid_shape)
|
|
195
|
+
if isinstance(dx, (int, float)):
|
|
196
|
+
dx_arr = [float(dx)] * ndim
|
|
197
|
+
else:
|
|
198
|
+
dx_arr = [float(d) for d in dx]
|
|
199
|
+
|
|
200
|
+
# Build k² = sum_α k_α²
|
|
201
|
+
k_sq = jnp.zeros(grid_shape[:-1] + (grid_shape[-1] // 2 + 1,))
|
|
202
|
+
for axis in range(ndim):
|
|
203
|
+
N = grid_shape[axis]
|
|
204
|
+
if axis < ndim - 1:
|
|
205
|
+
# Full-size axis: use fftfreq
|
|
206
|
+
freq = jnp.fft.fftfreq(N, d=dx_arr[axis]) # shape (N,)
|
|
207
|
+
else:
|
|
208
|
+
# Last axis: rfft convention → only non-negative frequencies
|
|
209
|
+
freq = jnp.fft.rfftfreq(N, d=dx_arr[axis]) # shape (N//2+1,)
|
|
210
|
+
|
|
211
|
+
k_alpha = 2 * jnp.pi * freq # angular wavenumber
|
|
212
|
+
|
|
213
|
+
# Broadcast to the full rfft grid shape
|
|
214
|
+
shape = [1] * ndim
|
|
215
|
+
if axis < ndim - 1:
|
|
216
|
+
shape[axis] = N
|
|
217
|
+
else:
|
|
218
|
+
shape[axis] = grid_shape[-1] // 2 + 1
|
|
219
|
+
k_alpha = k_alpha.reshape(shape)
|
|
220
|
+
|
|
221
|
+
k_sq = k_sq + k_alpha**2
|
|
222
|
+
|
|
223
|
+
# |k| = sqrt(k²), with k=0 mode zeroed out
|
|
224
|
+
k_abs = jnp.sqrt(k_sq)
|
|
225
|
+
return k_abs
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
class ConservedNoise(NoiseModel):
|
|
229
|
+
r"""Conserved (divergence-form) noise on a periodic square grid.
|
|
230
|
+
|
|
231
|
+
Implements noise of the form
|
|
232
|
+
|
|
233
|
+
.. math::
|
|
234
|
+
|
|
235
|
+
\eta(x, t) = \nabla \cdot \bigl(\sigma\, \vec{\Lambda}(x,t)\bigr)
|
|
236
|
+
|
|
237
|
+
where :math:`\vec{\Lambda}` is spatiotemporal white vector noise.
|
|
238
|
+
In Fourier space this is equivalent to multiplying each mode by
|
|
239
|
+
:math:`|k|`:
|
|
240
|
+
|
|
241
|
+
.. math::
|
|
242
|
+
|
|
243
|
+
\hat{\eta}_k = \sigma\,|k|\,\hat{\xi}_k
|
|
244
|
+
|
|
245
|
+
This noise **conserves the spatial average** of the field
|
|
246
|
+
(:math:`\sum_i \phi_i` is a constant of the noise process), as
|
|
247
|
+
required by Model B / Active Model B+ dynamics.
|
|
248
|
+
|
|
249
|
+
Parameters
|
|
250
|
+
----------
|
|
251
|
+
sigma : float
|
|
252
|
+
Noise amplitude (the :math:`\sigma` in the equations above).
|
|
253
|
+
This is the *continuum* amplitude; the grid discretisation is
|
|
254
|
+
handled internally.
|
|
255
|
+
grid_shape : sequence of int
|
|
256
|
+
Grid dimensions ``(Nx, Ny, ...)`` — must match the simulation grid.
|
|
257
|
+
dx : float or sequence of float
|
|
258
|
+
Grid spacing (uniform or per-axis).
|
|
259
|
+
n_fields : int
|
|
260
|
+
Number of field components per site.
|
|
261
|
+
|
|
262
|
+
Notes
|
|
263
|
+
-----
|
|
264
|
+
The ``sample`` method uses real FFT (``rfftn`` / ``irfftn``) for
|
|
265
|
+
efficiency. It draws white noise in real space, transforms to
|
|
266
|
+
Fourier space, multiplies by :math:`\sigma\,|k|\,\sqrt{2/\Delta V}`
|
|
267
|
+
(where :math:`\Delta V = \prod \Delta x_\alpha` is the cell volume),
|
|
268
|
+
and transforms back.
|
|
269
|
+
|
|
270
|
+
The factor :math:`1/\sqrt{\Delta V}` provides the correct continuum
|
|
271
|
+
limit: the noise covariance
|
|
272
|
+
:math:`\langle\eta_i\,\eta_j\rangle = -\sigma^2 \nabla^2 \delta_{ij} / \Delta V`
|
|
273
|
+
is independent of grid resolution when *sigma* is held fixed.
|
|
274
|
+
"""
|
|
275
|
+
|
|
276
|
+
def __init__(
|
|
277
|
+
self,
|
|
278
|
+
sigma: float,
|
|
279
|
+
*,
|
|
280
|
+
grid_shape: Sequence[int],
|
|
281
|
+
dx: Union[float, Sequence[float]] = 1.0,
|
|
282
|
+
n_fields: int = 1,
|
|
283
|
+
) -> None:
|
|
284
|
+
super().__init__(n_fields=n_fields)
|
|
285
|
+
if sigma < 0:
|
|
286
|
+
raise ValueError(f"sigma must be non-negative, got {sigma}")
|
|
287
|
+
self._sigma = float(sigma)
|
|
288
|
+
self._grid_shape = tuple(int(n) for n in grid_shape)
|
|
289
|
+
self._ndim = len(self._grid_shape)
|
|
290
|
+
|
|
291
|
+
if isinstance(dx, (int, float)):
|
|
292
|
+
self._dx = tuple([float(dx)] * self._ndim)
|
|
293
|
+
else:
|
|
294
|
+
self._dx = tuple(float(d) for d in dx)
|
|
295
|
+
|
|
296
|
+
self._P = 1
|
|
297
|
+
for n in self._grid_shape:
|
|
298
|
+
self._P *= n
|
|
299
|
+
|
|
300
|
+
# Cell volume for continuum-limit normalisation
|
|
301
|
+
dV = 1.0
|
|
302
|
+
for d in self._dx:
|
|
303
|
+
dV *= d
|
|
304
|
+
self._dV = dV
|
|
305
|
+
|
|
306
|
+
# Precompute |k| array for rfft
|
|
307
|
+
self._k_abs = _build_freq_amplitudes(self._grid_shape, self._dx, self._ndim)
|
|
308
|
+
|
|
309
|
+
# Combined multiplier: sigma * |k| * sqrt(2 / dV)
|
|
310
|
+
# The sqrt(2) enters because the integrator step is
|
|
311
|
+
# x += sqrt(dt) * sample(...)
|
|
312
|
+
# and we need <sample_i sample_j> = 2 * D_eff * delta_{ij}
|
|
313
|
+
# where D_eff is the noise operator.
|
|
314
|
+
self._multiplier = self._sigma * self._k_abs * jnp.sqrt(2.0 / self._dV)
|
|
315
|
+
|
|
316
|
+
# Effective per-site D for inference approximation:
|
|
317
|
+
# Var(noise_i) = sigma² * <|k|²> / dV
|
|
318
|
+
# where <|k|²> = (1/N) sum_k |k|² (excluding k=0)
|
|
319
|
+
k_sq_mean = float(jnp.mean(self._k_abs**2))
|
|
320
|
+
self._D_eff = float(self._sigma**2 * k_sq_mean / self._dV)
|
|
321
|
+
|
|
322
|
+
@property
|
|
323
|
+
def sigma(self) -> float:
|
|
324
|
+
return self._sigma
|
|
325
|
+
|
|
326
|
+
@property
|
|
327
|
+
def grid_shape(self) -> Tuple[int, ...]:
|
|
328
|
+
return self._grid_shape
|
|
329
|
+
|
|
330
|
+
def sample(self, key: Array, x: Array, extras: dict) -> Array:
|
|
331
|
+
r"""Draw one conserved-noise increment.
|
|
332
|
+
|
|
333
|
+
Steps:
|
|
334
|
+
1. Draw white noise ξ ~ N(0,1) on the grid, shape (Nx, Ny, ..., d)
|
|
335
|
+
2. rFFT along spatial axes
|
|
336
|
+
3. Multiply by :math:`\sigma\,|k|\,\sqrt{2/\Delta V}` (the ``_multiplier``)
|
|
337
|
+
4. iFFT back to real space
|
|
338
|
+
5. Flatten back to (P, d)
|
|
339
|
+
|
|
340
|
+
The k=0 mode of ``_multiplier`` is zero, so sum_i η_i = 0 exactly.
|
|
341
|
+
"""
|
|
342
|
+
d = self._n_fields
|
|
343
|
+
grid_d = self._grid_shape + (d,)
|
|
344
|
+
|
|
345
|
+
# Draw white noise on the grid
|
|
346
|
+
xi = random.normal(key, shape=grid_d)
|
|
347
|
+
|
|
348
|
+
# FFT axes = spatial only (not the field axis)
|
|
349
|
+
fft_axes = tuple(range(self._ndim))
|
|
350
|
+
|
|
351
|
+
# Forward real FFT along spatial axes
|
|
352
|
+
xi_hat = jnp.fft.rfftn(xi, axes=fft_axes)
|
|
353
|
+
|
|
354
|
+
# Multiply by the precomputed |k|*sigma*sqrt(2/dV)
|
|
355
|
+
# multiplier shape: (Nx, ..., Ny//2+1) — broadcast over field axis d
|
|
356
|
+
eta_hat = xi_hat * self._multiplier[..., None]
|
|
357
|
+
|
|
358
|
+
# Inverse real FFT
|
|
359
|
+
eta = jnp.fft.irfftn(eta_hat, s=self._grid_shape, axes=fft_axes)
|
|
360
|
+
|
|
361
|
+
# Flatten spatial axes: (Nx, Ny, ..., d) → (P, d)
|
|
362
|
+
return eta.reshape(self._P, d)
|
|
363
|
+
|
|
364
|
+
def effective_D_per_site(self, extras: dict) -> Array:
|
|
365
|
+
return self._D_eff * jnp.eye(self._n_fields)
|
|
366
|
+
|
|
367
|
+
def __repr__(self) -> str:
|
|
368
|
+
return (
|
|
369
|
+
f"ConservedNoise(sigma={self._sigma}, "
|
|
370
|
+
f"grid_shape={self._grid_shape}, dx={self._dx}, "
|
|
371
|
+
f"n_fields={self._n_fields})"
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
# ============================================================================
|
|
376
|
+
# Composite noise (different models on different field components)
|
|
377
|
+
# ============================================================================
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
class CompositeNoise(NoiseModel):
|
|
381
|
+
r"""Apply different noise models to different field components.
|
|
382
|
+
|
|
383
|
+
Useful when some fields have conserved dynamics (e.g. concentration)
|
|
384
|
+
and others have non-conserved dynamics (e.g. velocity).
|
|
385
|
+
|
|
386
|
+
Parameters
|
|
387
|
+
----------
|
|
388
|
+
components : list of ``(NoiseModel, field_indices)`` pairs
|
|
389
|
+
Each element specifies a noise model and the field indices it
|
|
390
|
+
applies to. ``field_indices`` is a list of ints. Together the
|
|
391
|
+
indices must cover ``range(n_fields)`` exactly once.
|
|
392
|
+
|
|
393
|
+
Example
|
|
394
|
+
-------
|
|
395
|
+
>>> conserved = ConservedNoise(sigma=0.3, grid_shape=(64, 64), n_fields=1)
|
|
396
|
+
>>> white = WhiteNoise(sigma=0.1, n_fields=2)
|
|
397
|
+
>>> composite = CompositeNoise(
|
|
398
|
+
... components=[(conserved, [0]), (white, [1, 2])],
|
|
399
|
+
... n_fields=3,
|
|
400
|
+
... )
|
|
401
|
+
"""
|
|
402
|
+
|
|
403
|
+
def __init__(
|
|
404
|
+
self,
|
|
405
|
+
*,
|
|
406
|
+
components: List[Tuple[NoiseModel, List[int]]],
|
|
407
|
+
n_fields: int,
|
|
408
|
+
) -> None:
|
|
409
|
+
super().__init__(n_fields=n_fields)
|
|
410
|
+
|
|
411
|
+
# Validate coverage
|
|
412
|
+
all_indices: set[int] = set()
|
|
413
|
+
for model, indices in components:
|
|
414
|
+
idx_set = set(indices)
|
|
415
|
+
if idx_set & all_indices:
|
|
416
|
+
raise ValueError(f"Overlapping field indices: {idx_set & all_indices}")
|
|
417
|
+
all_indices |= idx_set
|
|
418
|
+
if all_indices != set(range(n_fields)):
|
|
419
|
+
raise ValueError(f"Field indices must cover range({n_fields}), got {sorted(all_indices)}")
|
|
420
|
+
|
|
421
|
+
self._components = components
|
|
422
|
+
|
|
423
|
+
def sample(self, key: Array, x: Array, extras: dict) -> Array:
|
|
424
|
+
result = jnp.zeros_like(x)
|
|
425
|
+
for i, (model, indices) in enumerate(self._components):
|
|
426
|
+
key, sub = random.split(key)
|
|
427
|
+
# Extract the sub-state for this component's fields
|
|
428
|
+
idx = jnp.array(indices)
|
|
429
|
+
x_sub = x[..., idx]
|
|
430
|
+
noise_sub = model.sample(sub, x_sub, extras)
|
|
431
|
+
# Place back into the full field
|
|
432
|
+
result = result.at[..., idx].set(noise_sub)
|
|
433
|
+
return result
|
|
434
|
+
|
|
435
|
+
def effective_D_per_site(self, extras: dict) -> Array:
|
|
436
|
+
D = jnp.zeros((self._n_fields, self._n_fields))
|
|
437
|
+
for model, indices in self._components:
|
|
438
|
+
D_sub = model.effective_D_per_site(extras)
|
|
439
|
+
for i_local, i_global in enumerate(indices):
|
|
440
|
+
for j_local, j_global in enumerate(indices):
|
|
441
|
+
D = D.at[i_global, j_global].set(D_sub[i_local, j_local])
|
|
442
|
+
return D
|
|
443
|
+
|
|
444
|
+
def __repr__(self) -> str:
|
|
445
|
+
parts = [f"({m!r}, {idx})" for m, idx in self._components]
|
|
446
|
+
return f"CompositeNoise(components=[{', '.join(parts)}], n_fields={self._n_fields})"
|