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/integrate/api.py
ADDED
|
@@ -0,0 +1,920 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Integration runtime: vmapped over time indices with dataset-owned producers.
|
|
3
|
+
|
|
4
|
+
Contract
|
|
5
|
+
--------
|
|
6
|
+
- `collection.peek_row(require=...)` returns a single-row sample mapping for
|
|
7
|
+
memory sizing.
|
|
8
|
+
- `collection.iter_slices(require=..., bytes_hint=..., chunk_target_bytes=..., subsampling=..., context=...)`
|
|
9
|
+
yields dictionaries with:
|
|
10
|
+
|
|
11
|
+
- "producer": Callable[[t], row] — JAX-traceable single-t builder,
|
|
12
|
+
- "t_idx": jax.Array[int32] — indices for this chunk,
|
|
13
|
+
- "weight": float — dataset-level weight in [0,1],
|
|
14
|
+
- "dataset_index": int — for bookkeeping.
|
|
15
|
+
- `program` implements:
|
|
16
|
+
- `require() -> set[str]` of streams (plus "extras" if needed),
|
|
17
|
+
- `estimate_bytes_per_sample(sample_row) -> Optional[int]`,
|
|
18
|
+
- `__call__(**streams)` for one time slice; for the parametric route
|
|
19
|
+
it additionally supports a keyword-only argument `params`.
|
|
20
|
+
|
|
21
|
+
This module provides:
|
|
22
|
+
|
|
23
|
+
- `integrate(...)`: one-off integration using an `Integrand` `program`
|
|
24
|
+
(backwards compatible front-end).
|
|
25
|
+
- `make_parametric_integrator(...)`: build a reusable, jittable integrator
|
|
26
|
+
for a parameterised `Integrand`, with a clear separation between host-side
|
|
27
|
+
planning and JAX-side runtime.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
from dataclasses import dataclass
|
|
33
|
+
from typing import Any, Callable, Dict, Mapping, Optional, Tuple
|
|
34
|
+
|
|
35
|
+
import jax
|
|
36
|
+
import jax.numpy as jnp
|
|
37
|
+
|
|
38
|
+
Row = Mapping[str, Any]
|
|
39
|
+
Producer = Callable[[int], Row]
|
|
40
|
+
BatchProducer = Callable[[jnp.ndarray], Row]
|
|
41
|
+
RowEval = Callable[[Row, Any], jnp.ndarray]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True)
|
|
45
|
+
class ChunkSpec:
|
|
46
|
+
"""One time-chunk (possibly padded) for a given dataset."""
|
|
47
|
+
|
|
48
|
+
dataset_index: int
|
|
49
|
+
weight: float
|
|
50
|
+
t_block: jnp.ndarray # shape (K_chunk,)
|
|
51
|
+
valid_block: jnp.ndarray # shape (K_chunk,), bool
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True)
|
|
55
|
+
class IntegrationPlan:
|
|
56
|
+
"""
|
|
57
|
+
Host-side integration plan.
|
|
58
|
+
|
|
59
|
+
Contains:
|
|
60
|
+
- producers: per-dataset single-t row builders,
|
|
61
|
+
- batch_producers: per-dataset batch-t row builders,
|
|
62
|
+
- chunks: padded time blocks with validity masks and weights,
|
|
63
|
+
- reduction semantics and memory hints.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
producers: Dict[int, Producer]
|
|
67
|
+
batch_producers: Dict[int, BatchProducer]
|
|
68
|
+
chunks: Tuple[ChunkSpec, ...]
|
|
69
|
+
reduce: str
|
|
70
|
+
reduce_over_particles: bool
|
|
71
|
+
weight_by_dt: bool
|
|
72
|
+
bytes_hint: Optional[int]
|
|
73
|
+
K_fixed: Optional[int]
|
|
74
|
+
context: Optional[str]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# ---------------------------------------------------------------------------
|
|
78
|
+
# Planning
|
|
79
|
+
# ---------------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _build_plan(
|
|
83
|
+
collection,
|
|
84
|
+
program,
|
|
85
|
+
*,
|
|
86
|
+
reduce: str,
|
|
87
|
+
reduce_over_particles: bool,
|
|
88
|
+
weight_by_dt: bool = True,
|
|
89
|
+
subsampling: int,
|
|
90
|
+
chunk_target_bytes: int,
|
|
91
|
+
context: Optional[str],
|
|
92
|
+
bytes_per_sample: Optional[int] = None,
|
|
93
|
+
) -> IntegrationPlan:
|
|
94
|
+
"""
|
|
95
|
+
Plan an integration once on the host side.
|
|
96
|
+
|
|
97
|
+
Computes:
|
|
98
|
+
- required streams from `program.require()`,
|
|
99
|
+
- one real sample to estimate bytes per sample,
|
|
100
|
+
- a fixed chunk size K for stable vmapped kernels,
|
|
101
|
+
- the list of ChunkSpec and per-dataset producers.
|
|
102
|
+
"""
|
|
103
|
+
# Required streams; include sentinel for dt window if datasets support it.
|
|
104
|
+
require = set(program.require())
|
|
105
|
+
require.add("__dt__") # dataset.valid_indices should treat as offsets (0,+1)
|
|
106
|
+
|
|
107
|
+
# Size hint from a real row
|
|
108
|
+
try:
|
|
109
|
+
sample_row = collection.peek_row(require=require, context=context)
|
|
110
|
+
except ValueError:
|
|
111
|
+
# ``peek_row`` raises ValueError both when no dataset has a usable time
|
|
112
|
+
# window (the legitimate empty-plan case) and when materialising a row
|
|
113
|
+
# genuinely fails (e.g. a malformed extra). Only the former should be
|
|
114
|
+
# swallowed into an empty plan — masking the latter turns an informative
|
|
115
|
+
# error into a cryptic downstream crash (a scalar Gram fed to swapaxes).
|
|
116
|
+
if any(ds.valid_indices(require).size > 0 for ds in collection.datasets):
|
|
117
|
+
raise
|
|
118
|
+
# No dataset has valid rows for these requirements
|
|
119
|
+
return IntegrationPlan(
|
|
120
|
+
producers={},
|
|
121
|
+
batch_producers={},
|
|
122
|
+
chunks=tuple(),
|
|
123
|
+
reduce=reduce,
|
|
124
|
+
reduce_over_particles=reduce_over_particles,
|
|
125
|
+
weight_by_dt=weight_by_dt,
|
|
126
|
+
bytes_hint=None,
|
|
127
|
+
K_fixed=None,
|
|
128
|
+
context=context,
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
if bytes_per_sample is not None:
|
|
132
|
+
bytes_hint = int(bytes_per_sample)
|
|
133
|
+
else:
|
|
134
|
+
estimator = getattr(program, "estimate_bytes_per_sample", None)
|
|
135
|
+
if callable(estimator):
|
|
136
|
+
bytes_hint = estimator(sample_row)
|
|
137
|
+
else:
|
|
138
|
+
bytes_hint = None
|
|
139
|
+
|
|
140
|
+
# Derive fixed K per chunk from hint
|
|
141
|
+
if not bytes_hint or bytes_hint <= 0:
|
|
142
|
+
K_fixed: Optional[int] = None
|
|
143
|
+
else:
|
|
144
|
+
# Conservative: at least one row
|
|
145
|
+
K_fixed = max(1, int(chunk_target_bytes // int(bytes_hint)))
|
|
146
|
+
|
|
147
|
+
producers: Dict[int, Producer] = {}
|
|
148
|
+
batch_producers: Dict[int, BatchProducer] = {}
|
|
149
|
+
chunks: list[ChunkSpec] = []
|
|
150
|
+
|
|
151
|
+
# Collect all payloads first so we can cap K_fixed at the actual data size.
|
|
152
|
+
# Without this cap, tiny datasets get absurdly padded (e.g. 19 valid indices
|
|
153
|
+
# padded to 6.4M when bytes_hint is small and chunk_target_bytes is large).
|
|
154
|
+
payloads = list(
|
|
155
|
+
collection.iter_slices(
|
|
156
|
+
require=require,
|
|
157
|
+
bytes_hint=bytes_hint,
|
|
158
|
+
chunk_target_bytes=chunk_target_bytes,
|
|
159
|
+
subsampling=subsampling,
|
|
160
|
+
context=context,
|
|
161
|
+
)
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
if K_fixed is not None and payloads:
|
|
165
|
+
max_payload = max(int(p["t_idx"].shape[0]) for p in payloads)
|
|
166
|
+
K_fixed = min(K_fixed, max_payload)
|
|
167
|
+
|
|
168
|
+
for payload in payloads:
|
|
169
|
+
ds_idx: int = int(payload["dataset_index"])
|
|
170
|
+
producer = payload["producer"]
|
|
171
|
+
t_idx = payload["t_idx"]
|
|
172
|
+
weight = float(payload.get("weight", 1.0))
|
|
173
|
+
|
|
174
|
+
if ds_idx not in producers:
|
|
175
|
+
producers[ds_idx] = producer
|
|
176
|
+
# Build the matching batch producer from the underlying dataset
|
|
177
|
+
ds = collection.datasets[ds_idx]
|
|
178
|
+
batch_producers[ds_idx] = ds.make_batch_producer(
|
|
179
|
+
require,
|
|
180
|
+
include_mask=True,
|
|
181
|
+
include_dt=True,
|
|
182
|
+
context=context,
|
|
183
|
+
force_dt_keys={"dt"},
|
|
184
|
+
dataset_index=collection.dataset_index(ds_idx),
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
if K_fixed is None:
|
|
188
|
+
# No padding, one chunk per payload
|
|
189
|
+
t_block = t_idx
|
|
190
|
+
valid_block = jnp.ones_like(t_idx, dtype=bool)
|
|
191
|
+
chunks.append(
|
|
192
|
+
ChunkSpec(
|
|
193
|
+
dataset_index=ds_idx,
|
|
194
|
+
weight=weight,
|
|
195
|
+
t_block=t_block,
|
|
196
|
+
valid_block=valid_block,
|
|
197
|
+
)
|
|
198
|
+
)
|
|
199
|
+
else:
|
|
200
|
+
# Split t_idx into blocks of size K_fixed and pad the last one
|
|
201
|
+
K_total = int(t_idx.shape[0])
|
|
202
|
+
for start in range(0, K_total, K_fixed):
|
|
203
|
+
stop = min(start + K_fixed, K_total)
|
|
204
|
+
cur = t_idx[start:stop]
|
|
205
|
+
K = int(cur.shape[0])
|
|
206
|
+
pad = K_fixed - K
|
|
207
|
+
|
|
208
|
+
if pad > 0:
|
|
209
|
+
pad_idx = jnp.pad(cur, (0, pad), mode="edge")
|
|
210
|
+
valid = jnp.concatenate([jnp.ones((K,), dtype=bool), jnp.zeros((pad,), dtype=bool)])
|
|
211
|
+
else:
|
|
212
|
+
pad_idx = cur
|
|
213
|
+
valid = jnp.ones((K_fixed,), dtype=bool)
|
|
214
|
+
|
|
215
|
+
chunks.append(
|
|
216
|
+
ChunkSpec(
|
|
217
|
+
dataset_index=ds_idx,
|
|
218
|
+
weight=weight,
|
|
219
|
+
t_block=pad_idx,
|
|
220
|
+
valid_block=valid,
|
|
221
|
+
)
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
return IntegrationPlan(
|
|
225
|
+
producers=producers,
|
|
226
|
+
batch_producers=batch_producers,
|
|
227
|
+
chunks=tuple(chunks),
|
|
228
|
+
reduce=reduce,
|
|
229
|
+
reduce_over_particles=reduce_over_particles,
|
|
230
|
+
weight_by_dt=weight_by_dt,
|
|
231
|
+
bytes_hint=bytes_hint,
|
|
232
|
+
K_fixed=K_fixed,
|
|
233
|
+
context=context,
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
# ---------------------------------------------------------------------------
|
|
238
|
+
# Core row kernel and runner (shared)
|
|
239
|
+
# ---------------------------------------------------------------------------
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _row_kernel(
|
|
243
|
+
row_eval: RowEval,
|
|
244
|
+
row: Row,
|
|
245
|
+
theta: Any,
|
|
246
|
+
*,
|
|
247
|
+
reduce_over_particles: bool,
|
|
248
|
+
weight_by_dt: bool = True,
|
|
249
|
+
) -> Tuple[jnp.ndarray, jnp.ndarray]:
|
|
250
|
+
"""
|
|
251
|
+
Single-row kernel.
|
|
252
|
+
|
|
253
|
+
``row_eval(row, theta) -> y``, same semantics as
|
|
254
|
+
``program(**row)`` in the non-parametric case.
|
|
255
|
+
Returns ``(weighted_value, dt_eff)`` where ``weighted_value``
|
|
256
|
+
already includes dt when *weight_by_dt* is True.
|
|
257
|
+
"""
|
|
258
|
+
y = row_eval(row, theta)
|
|
259
|
+
|
|
260
|
+
# Optional particle reduction
|
|
261
|
+
if reduce_over_particles:
|
|
262
|
+
if y.ndim == 0:
|
|
263
|
+
raise ValueError("reduce_over_particles=True but row_eval returned a scalar.")
|
|
264
|
+
m = row.get("mask_out", None)
|
|
265
|
+
if m is not None:
|
|
266
|
+
if y.ndim == 0 or y.shape[0] != m.shape[0]:
|
|
267
|
+
raise ValueError(
|
|
268
|
+
"mask_out mismatch: row_eval must return an array with "
|
|
269
|
+
f"leading particle axis of size {m.shape[0]} when mask_out is present."
|
|
270
|
+
)
|
|
271
|
+
mexp = m.reshape((m.shape[0],) + (1,) * (y.ndim - 1))
|
|
272
|
+
y = jnp.where(mexp, y, 0.0)
|
|
273
|
+
y = jnp.sum(y, axis=0)
|
|
274
|
+
|
|
275
|
+
dt = row["dt"]
|
|
276
|
+
if weight_by_dt:
|
|
277
|
+
dteff = dt * row["N_active"]
|
|
278
|
+
y_w = y * dt
|
|
279
|
+
else:
|
|
280
|
+
dteff = row["N_active"]
|
|
281
|
+
y_w = y
|
|
282
|
+
return y_w, dteff
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _run_plan_core(
|
|
286
|
+
plan: IntegrationPlan,
|
|
287
|
+
row_eval: RowEval,
|
|
288
|
+
theta: Any,
|
|
289
|
+
) -> Tuple[jnp.ndarray, jnp.ndarray]:
|
|
290
|
+
"""
|
|
291
|
+
Core runtime: integrates according to a pre-built IntegrationPlan and a given row_eval.
|
|
292
|
+
|
|
293
|
+
Returns ``(acc, Teff_total)`` where:
|
|
294
|
+
|
|
295
|
+
- ``acc`` is the sum over chunks of ``y_w``,
|
|
296
|
+
- ``Teff_total`` is the sum over chunks of effective exposure.
|
|
297
|
+
|
|
298
|
+
Suitable for jitting when ``row_eval`` and ``theta`` are JAX-traceable.
|
|
299
|
+
"""
|
|
300
|
+
if not plan.chunks:
|
|
301
|
+
zero = jnp.asarray(0.0, dtype=jnp.float32)
|
|
302
|
+
return zero, zero
|
|
303
|
+
|
|
304
|
+
reduce_over_particles = plan.reduce_over_particles
|
|
305
|
+
weight_by_dt = plan.weight_by_dt
|
|
306
|
+
producer_by_idx: Dict[int, Producer] = plan.producers
|
|
307
|
+
|
|
308
|
+
# Per-dataset weights are applied in every reduction (sum and mean) so the
|
|
309
|
+
# force Gram, diffusion average, and parametric Gram pool datasets the same
|
|
310
|
+
# way. Within-dataset weighting (per-dt vs per-point) is the caller's
|
|
311
|
+
# ``weight_by_dt``; the per-dataset multiplier is orthogonal.
|
|
312
|
+
use_weight = True # per-dataset weights applied in all reductions (unit weights => no-op)
|
|
313
|
+
|
|
314
|
+
acc = None
|
|
315
|
+
Teff_total = jnp.asarray(0.0, dtype=jnp.float32)
|
|
316
|
+
|
|
317
|
+
for chunk in plan.chunks:
|
|
318
|
+
producer = producer_by_idx[chunk.dataset_index]
|
|
319
|
+
t_block = chunk.t_block # (K,)
|
|
320
|
+
valid_block = chunk.valid_block # (K,)
|
|
321
|
+
weight = chunk.weight
|
|
322
|
+
|
|
323
|
+
def row_masked(t, is_valid, theta_):
|
|
324
|
+
row = producer(t)
|
|
325
|
+
y_w, dteff = _row_kernel(
|
|
326
|
+
row_eval,
|
|
327
|
+
row,
|
|
328
|
+
theta_,
|
|
329
|
+
reduce_over_particles=reduce_over_particles,
|
|
330
|
+
weight_by_dt=weight_by_dt,
|
|
331
|
+
)
|
|
332
|
+
assert y_w is not None
|
|
333
|
+
maskf = is_valid.astype(y_w.dtype)
|
|
334
|
+
if use_weight:
|
|
335
|
+
return (
|
|
336
|
+
y_w * maskf * weight,
|
|
337
|
+
dteff * is_valid.astype(dteff.dtype) * weight,
|
|
338
|
+
)
|
|
339
|
+
else:
|
|
340
|
+
return (
|
|
341
|
+
y_w * maskf,
|
|
342
|
+
dteff * is_valid.astype(dteff.dtype),
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
Ys, Dteffs = jax.vmap(row_masked, in_axes=(0, 0, None))(t_block, valid_block, theta)
|
|
346
|
+
y_sum = jnp.sum(Ys, axis=0)
|
|
347
|
+
dteff_sum = jnp.sum(Dteffs, axis=0)
|
|
348
|
+
|
|
349
|
+
Teff_total = Teff_total + dteff_sum
|
|
350
|
+
acc = y_sum if acc is None else (acc + y_sum)
|
|
351
|
+
|
|
352
|
+
return acc, Teff_total
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
# ---------------------------------------------------------------------------
|
|
356
|
+
# Batched runner: batch gather + batch statefunc + vmapped einsum
|
|
357
|
+
# ---------------------------------------------------------------------------
|
|
358
|
+
|
|
359
|
+
BatchRowEval = Callable[[Row, Any], jnp.ndarray]
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def _run_plan_batched(
|
|
363
|
+
plan: IntegrationPlan,
|
|
364
|
+
batch_row_eval: BatchRowEval,
|
|
365
|
+
theta: Any,
|
|
366
|
+
) -> Tuple[jnp.ndarray, jnp.ndarray]:
|
|
367
|
+
"""
|
|
368
|
+
Batched runtime: gathers K rows at once and evaluates in batch.
|
|
369
|
+
|
|
370
|
+
Instead of vmapping over individual time indices (which nests vmaps
|
|
371
|
+
inside the state-expression leaves), this path:
|
|
372
|
+
|
|
373
|
+
1. Gathers K rows with a single batch producer (one XLA gather),
|
|
374
|
+
2. Passes ``(K, N, d)`` tensors to state expressions, which handle
|
|
375
|
+
the leading batch dimensions in a single fused vmap,
|
|
376
|
+
3. vmaps the einsum contractions over the K axis.
|
|
377
|
+
|
|
378
|
+
The result shapes are ``(K, ...)`` where ``...`` is the per-row result
|
|
379
|
+
shape. Particle and time reductions happen outside the batch evaluator.
|
|
380
|
+
|
|
381
|
+
Returns ``(acc, Teff_total)`` with the same semantics as
|
|
382
|
+
:func:`_run_plan_core`.
|
|
383
|
+
"""
|
|
384
|
+
if not plan.chunks:
|
|
385
|
+
zero = jnp.asarray(0.0, dtype=jnp.float32)
|
|
386
|
+
return zero, zero
|
|
387
|
+
|
|
388
|
+
reduce_over_particles = plan.reduce_over_particles
|
|
389
|
+
batch_producer_by_idx: Dict[int, BatchProducer] = plan.batch_producers
|
|
390
|
+
|
|
391
|
+
use_weight = True # per-dataset weights applied in all reductions (unit weights => no-op)
|
|
392
|
+
|
|
393
|
+
acc = None
|
|
394
|
+
Teff_total = jnp.asarray(0.0, dtype=jnp.float32)
|
|
395
|
+
|
|
396
|
+
for chunk in plan.chunks:
|
|
397
|
+
batch_producer = batch_producer_by_idx[chunk.dataset_index]
|
|
398
|
+
t_block = chunk.t_block # (K,)
|
|
399
|
+
valid_block = chunk.valid_block # (K,), bool
|
|
400
|
+
weight = chunk.weight
|
|
401
|
+
|
|
402
|
+
# 1) Batch gather — one XLA gather per stream
|
|
403
|
+
batch_row = batch_producer(t_block)
|
|
404
|
+
# {X: (K,N,d), dX: (K,N,d), dt: (K,), mask_out: (K,N), ...}
|
|
405
|
+
|
|
406
|
+
# 2) Batch evaluate — statefunc sees (K,N,d), einsum vmapped over K
|
|
407
|
+
y = batch_row_eval(batch_row, theta)
|
|
408
|
+
# y shape: (K, N, ..., F) if particle axis is present,
|
|
409
|
+
# (K, ..., F) if the einsum already contracted particles.
|
|
410
|
+
|
|
411
|
+
# 3) Particle reduction over axis 1
|
|
412
|
+
if reduce_over_particles:
|
|
413
|
+
if y.ndim < 2:
|
|
414
|
+
raise ValueError(
|
|
415
|
+
"reduce_over_particles=True but batch_row_eval returned an array with fewer than 2 dimensions."
|
|
416
|
+
)
|
|
417
|
+
m = batch_row.get("mask_out", None) # (K, N)
|
|
418
|
+
if m is not None:
|
|
419
|
+
# Expand mask to broadcast: (K, N, 1, ..., 1)
|
|
420
|
+
n_trail = y.ndim - 2 # dims after particle axis
|
|
421
|
+
mexp = m.reshape(m.shape + (1,) * n_trail)
|
|
422
|
+
y = jnp.where(mexp, y, 0.0)
|
|
423
|
+
y = jnp.sum(y, axis=1) # (K, ..., F)
|
|
424
|
+
|
|
425
|
+
# 4) dt weighting
|
|
426
|
+
dt = batch_row["dt"] # (K,)
|
|
427
|
+
if plan.weight_by_dt:
|
|
428
|
+
dteff = dt * batch_row["N_active"] # (K,)
|
|
429
|
+
dt_exp = dt.reshape((dt.shape[0],) + (1,) * (y.ndim - 1))
|
|
430
|
+
y_w = y * dt_exp # (K, ..., F)
|
|
431
|
+
else:
|
|
432
|
+
dteff = batch_row["N_active"] # (K,)
|
|
433
|
+
y_w = y
|
|
434
|
+
|
|
435
|
+
# 5) Valid masking
|
|
436
|
+
valid_f = valid_block.astype(y_w.dtype)
|
|
437
|
+
valid_exp = valid_f.reshape((valid_f.shape[0],) + (1,) * (y_w.ndim - 1))
|
|
438
|
+
y_w = y_w * valid_exp
|
|
439
|
+
dteff = dteff * valid_block.astype(dteff.dtype)
|
|
440
|
+
|
|
441
|
+
if use_weight:
|
|
442
|
+
y_w = y_w * weight
|
|
443
|
+
dteff = dteff * weight
|
|
444
|
+
|
|
445
|
+
# 6) Sum over K
|
|
446
|
+
y_sum = jnp.sum(y_w, axis=0)
|
|
447
|
+
dteff_sum = jnp.sum(dteff, axis=0)
|
|
448
|
+
|
|
449
|
+
Teff_total = Teff_total + dteff_sum
|
|
450
|
+
acc = y_sum if acc is None else (acc + y_sum)
|
|
451
|
+
|
|
452
|
+
return acc, Teff_total
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
# ---------------------------------------------------------------------------
|
|
456
|
+
# Public API: integrate (non-parametric)
|
|
457
|
+
# ---------------------------------------------------------------------------
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def _has_time_varying_required_extras(collection, program) -> bool:
|
|
461
|
+
"""True when the program reads an extras key that is time-varying.
|
|
462
|
+
|
|
463
|
+
The batched runtime gathers extras once per chunk
|
|
464
|
+
(``make_batch_producer`` collects them at the chunk's first index),
|
|
465
|
+
which is only correct for static extras. Programs that read
|
|
466
|
+
:class:`TimeSeriesExtra` values or plain time-generator callables must
|
|
467
|
+
run on the per-``t`` core runtime, where ``build_extras(t)`` slices
|
|
468
|
+
them per frame.
|
|
469
|
+
"""
|
|
470
|
+
req = getattr(program, "required_extras", None)
|
|
471
|
+
keys: tuple = tuple(req() or ()) if callable(req) else ()
|
|
472
|
+
if not keys:
|
|
473
|
+
return False
|
|
474
|
+
# The reserved ``time`` extra (auto-injected per frame by
|
|
475
|
+
# ``build_extras``) is inherently time-varying, so any program that
|
|
476
|
+
# reads it — e.g. a :func:`~SFI.bases.time_fourier` dictionary — must
|
|
477
|
+
# run on the per-``t`` core runtime even though it is not stored as a
|
|
478
|
+
# TimeSeriesExtra on the dataset.
|
|
479
|
+
if "time" in keys:
|
|
480
|
+
return True
|
|
481
|
+
from SFI.trajectory.dataset import FunctionExtra, TimeSeriesExtra
|
|
482
|
+
|
|
483
|
+
for ds in collection.datasets:
|
|
484
|
+
for src in (ds.extras_global, ds.extras_local):
|
|
485
|
+
for k in keys:
|
|
486
|
+
v = (src or {}).get(k)
|
|
487
|
+
if isinstance(v, TimeSeriesExtra) or (callable(v) and not isinstance(v, FunctionExtra)):
|
|
488
|
+
return True
|
|
489
|
+
return False
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def integrate(
|
|
493
|
+
collection,
|
|
494
|
+
program,
|
|
495
|
+
*,
|
|
496
|
+
reduce: str = "sum", # {'sum','mean'}
|
|
497
|
+
reduce_over_particles: bool = True, # sum over leading i if present
|
|
498
|
+
weight_by_dt: bool = True,
|
|
499
|
+
subsampling: int = 1,
|
|
500
|
+
chunk_target_bytes: int = 512 * 1024**2,
|
|
501
|
+
context: Optional[str] = None,
|
|
502
|
+
batch: bool = True,
|
|
503
|
+
) -> jnp.ndarray:
|
|
504
|
+
"""
|
|
505
|
+
Integrate an instantaneous program over time and datasets.
|
|
506
|
+
|
|
507
|
+
Parameters
|
|
508
|
+
----------
|
|
509
|
+
collection
|
|
510
|
+
TrajectoryCollection exposing producers and time-index chunks.
|
|
511
|
+
program
|
|
512
|
+
Integrand object with `require`, `estimate_bytes_per_sample`, and `__call__`.
|
|
513
|
+
reduce : {'sum','mean'}
|
|
514
|
+
Dataset-and-time reduction. `'mean'` divides by the accumulated
|
|
515
|
+
effective exposure computed from the same `dt` used in the numerator.
|
|
516
|
+
reduce_over_particles : bool
|
|
517
|
+
If the program output has a leading particle axis, apply `mask_out`,
|
|
518
|
+
then sum that axis before the time reduction.
|
|
519
|
+
weight_by_dt : bool
|
|
520
|
+
If True (default), multiply each program output by ``dt`` before
|
|
521
|
+
accumulation. Set to False for programs whose output should be
|
|
522
|
+
summed without dt weighting (e.g. parametric Gram matrices).
|
|
523
|
+
subsampling : int
|
|
524
|
+
Keep indices with `t % subsampling == 0`.
|
|
525
|
+
chunk_target_bytes : int
|
|
526
|
+
Target working-set size for the vmapped kernel.
|
|
527
|
+
context : str, optional
|
|
528
|
+
Forwarded to dataset extras via producers.
|
|
529
|
+
|
|
530
|
+
Returns
|
|
531
|
+
-------
|
|
532
|
+
jax.Array
|
|
533
|
+
Reduced value with particle axis removed if requested. Shapes match the
|
|
534
|
+
program’s output after optional particle reduction.
|
|
535
|
+
"""
|
|
536
|
+
if reduce not in {"sum", "mean"}:
|
|
537
|
+
raise ValueError("reduce must be 'sum' or 'mean'")
|
|
538
|
+
|
|
539
|
+
# Time-varying extras must be sliced per frame: only the per-t core
|
|
540
|
+
# runtime does that (the batch producer gathers extras once per chunk).
|
|
541
|
+
if batch and _has_time_varying_required_extras(collection, program):
|
|
542
|
+
batch = False
|
|
543
|
+
|
|
544
|
+
plan = _build_plan(
|
|
545
|
+
collection,
|
|
546
|
+
program,
|
|
547
|
+
reduce=reduce,
|
|
548
|
+
reduce_over_particles=reduce_over_particles,
|
|
549
|
+
weight_by_dt=weight_by_dt,
|
|
550
|
+
subsampling=subsampling,
|
|
551
|
+
chunk_target_bytes=chunk_target_bytes,
|
|
552
|
+
context=context,
|
|
553
|
+
)
|
|
554
|
+
|
|
555
|
+
if batch:
|
|
556
|
+
batch_row_eval = _build_batch_row_eval(program, context=context, parametric=False)
|
|
557
|
+
acc, Teff_total = _run_plan_batched(plan, batch_row_eval, theta=None)
|
|
558
|
+
else:
|
|
559
|
+
# Original vmap-over-t path
|
|
560
|
+
def row_eval(row: Row, _theta: Any) -> jnp.ndarray:
|
|
561
|
+
return program(**row)
|
|
562
|
+
|
|
563
|
+
acc, Teff_total = _run_plan_core(plan, row_eval, theta=None)
|
|
564
|
+
|
|
565
|
+
if reduce == "sum":
|
|
566
|
+
return acc
|
|
567
|
+
|
|
568
|
+
# mean: check Teff_total on host for backwards-compatible error behaviour
|
|
569
|
+
Teff_val = float(Teff_total)
|
|
570
|
+
if Teff_val <= 0.0:
|
|
571
|
+
raise ValueError("Mean reduction requested but total exposure is non-positive.")
|
|
572
|
+
return acc / jnp.asarray(Teff_total, dtype=acc.dtype)
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
# ---------------------------------------------------------------------------
|
|
576
|
+
# Public API: parametric integrator
|
|
577
|
+
# ---------------------------------------------------------------------------
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
def make_parametric_integrator(
|
|
581
|
+
collection,
|
|
582
|
+
program,
|
|
583
|
+
*,
|
|
584
|
+
reduce: str = "sum",
|
|
585
|
+
reduce_over_particles: bool = True,
|
|
586
|
+
weight_by_dt: bool = True,
|
|
587
|
+
subsampling: int = 1,
|
|
588
|
+
chunk_target_bytes: int = 512 * 1024**2,
|
|
589
|
+
context: Optional[str] = None,
|
|
590
|
+
bytes_per_sample: Optional[int] = None,
|
|
591
|
+
batch: bool = True,
|
|
592
|
+
) -> Tuple[IntegrationPlan, Callable[[Any], jnp.ndarray]]:
|
|
593
|
+
"""
|
|
594
|
+
Build a reusable, jittable integrator for a parametric Integrand.
|
|
595
|
+
|
|
596
|
+
Parameters
|
|
597
|
+
----------
|
|
598
|
+
collection
|
|
599
|
+
TrajectoryCollection exposing producers and time-index chunks.
|
|
600
|
+
program
|
|
601
|
+
Integrand object with `require`, `estimate_bytes_per_sample`, and
|
|
602
|
+
a call signature ``program(**streams, params=theta)`` where `theta`
|
|
603
|
+
is a PyTree of parameters.
|
|
604
|
+
reduce, reduce_over_particles, weight_by_dt, subsampling, chunk_target_bytes, context
|
|
605
|
+
Same meaning as in :func:`integrate`.
|
|
606
|
+
bytes_per_sample : int, optional
|
|
607
|
+
Optional override for the per-sample memory estimate. If None, the
|
|
608
|
+
program's `estimate_bytes_per_sample` is used.
|
|
609
|
+
batch : bool
|
|
610
|
+
If True, use the batched integration path (see :func:`integrate`).
|
|
611
|
+
|
|
612
|
+
Returns
|
|
613
|
+
-------
|
|
614
|
+
plan : IntegrationPlan
|
|
615
|
+
Host-side plan describing the chunks and producers.
|
|
616
|
+
run : callable
|
|
617
|
+
JAX-jitted function ``run(theta) -> value`` that evaluates the
|
|
618
|
+
integration for a given set of parameters.
|
|
619
|
+
"""
|
|
620
|
+
if reduce not in {"sum", "mean"}:
|
|
621
|
+
raise ValueError("reduce must be 'sum' or 'mean'")
|
|
622
|
+
|
|
623
|
+
# Same correctness rule as `integrate`: time-varying extras require the
|
|
624
|
+
# per-t core runtime.
|
|
625
|
+
if batch and _has_time_varying_required_extras(collection, program):
|
|
626
|
+
batch = False
|
|
627
|
+
|
|
628
|
+
plan = _build_plan(
|
|
629
|
+
collection,
|
|
630
|
+
program,
|
|
631
|
+
reduce=reduce,
|
|
632
|
+
reduce_over_particles=reduce_over_particles,
|
|
633
|
+
weight_by_dt=weight_by_dt,
|
|
634
|
+
subsampling=subsampling,
|
|
635
|
+
chunk_target_bytes=chunk_target_bytes,
|
|
636
|
+
context=context,
|
|
637
|
+
bytes_per_sample=bytes_per_sample,
|
|
638
|
+
)
|
|
639
|
+
|
|
640
|
+
if not plan.chunks:
|
|
641
|
+
# Empty-plan edge case: always return 0.0
|
|
642
|
+
@jax.jit
|
|
643
|
+
def run_empty(theta):
|
|
644
|
+
del theta
|
|
645
|
+
return jnp.asarray(0.0, dtype=jnp.float32)
|
|
646
|
+
|
|
647
|
+
return plan, run_empty
|
|
648
|
+
|
|
649
|
+
if batch:
|
|
650
|
+
batch_row_eval = _build_batch_row_eval(program, context=context)
|
|
651
|
+
|
|
652
|
+
def run(theta):
|
|
653
|
+
acc, Teff_total = _run_plan_batched(plan, batch_row_eval, theta)
|
|
654
|
+
if reduce == "sum":
|
|
655
|
+
return acc
|
|
656
|
+
Teff_safe = jnp.where(Teff_total > 0, Teff_total, jnp.ones_like(Teff_total))
|
|
657
|
+
return acc / Teff_safe.astype(acc.dtype)
|
|
658
|
+
else:
|
|
659
|
+
# Original vmap-over-t path
|
|
660
|
+
def row_eval(row: Row, theta: Any) -> jnp.ndarray:
|
|
661
|
+
return program(params=theta, **row)
|
|
662
|
+
|
|
663
|
+
def run(theta):
|
|
664
|
+
acc, Teff_total = _run_plan_core(plan, row_eval, theta)
|
|
665
|
+
if reduce == "sum":
|
|
666
|
+
return acc
|
|
667
|
+
Teff_safe = jnp.where(Teff_total > 0, Teff_total, jnp.ones_like(Teff_total))
|
|
668
|
+
return acc / Teff_safe.astype(acc.dtype)
|
|
669
|
+
|
|
670
|
+
return plan, run
|
|
671
|
+
|
|
672
|
+
|
|
673
|
+
# ---------------------------------------------------------------------------
|
|
674
|
+
# Mini-batch parametric integrator
|
|
675
|
+
# ---------------------------------------------------------------------------
|
|
676
|
+
|
|
677
|
+
|
|
678
|
+
def _build_batch_row_eval(program, context=None, *, parametric: bool = True):
|
|
679
|
+
"""Build a JIT-compiled batch row evaluator.
|
|
680
|
+
|
|
681
|
+
Parameters
|
|
682
|
+
----------
|
|
683
|
+
program
|
|
684
|
+
Integrand or duck-typed program with ``require`` / ``__call__``.
|
|
685
|
+
context : str, optional
|
|
686
|
+
Forwarded to extras as a static constant.
|
|
687
|
+
parametric : bool
|
|
688
|
+
If True (default), forward ``theta`` as ``params=theta`` on every
|
|
689
|
+
call. Set False for non-parametric programs whose ``__call__``
|
|
690
|
+
does not accept a ``params`` keyword.
|
|
691
|
+
|
|
692
|
+
Returns
|
|
693
|
+
-------
|
|
694
|
+
batch_row_eval : callable
|
|
695
|
+
``batch_row_eval(row, theta) -> jnp.ndarray``.
|
|
696
|
+
"""
|
|
697
|
+
_base_static: Dict[str, Any] = {}
|
|
698
|
+
if context is not None:
|
|
699
|
+
_base_static["context"] = context
|
|
700
|
+
|
|
701
|
+
def _split_extras(row: Row):
|
|
702
|
+
ext = row.get("extras")
|
|
703
|
+
if ext is None or not isinstance(ext, dict):
|
|
704
|
+
return row, {}, ()
|
|
705
|
+
arr_ext: Dict[str, Any] = {}
|
|
706
|
+
fn_ext: Dict[str, Any] = {}
|
|
707
|
+
for k, v in ext.items():
|
|
708
|
+
if hasattr(v, "shape") and hasattr(v, "dtype"):
|
|
709
|
+
arr_ext[k] = v
|
|
710
|
+
elif callable(v):
|
|
711
|
+
fn_ext[k] = v
|
|
712
|
+
elif isinstance(v, (str, type(None), bool, int, float)):
|
|
713
|
+
fn_ext[k] = v
|
|
714
|
+
else:
|
|
715
|
+
arr_ext[k] = v
|
|
716
|
+
if not fn_ext:
|
|
717
|
+
return row, {}, ()
|
|
718
|
+
row = dict(row)
|
|
719
|
+
row["extras"] = arr_ext if arr_ext else {}
|
|
720
|
+
cache_key = tuple(sorted((k, id(v)) for k, v in fn_ext.items()))
|
|
721
|
+
return row, fn_ext, cache_key
|
|
722
|
+
|
|
723
|
+
_jit_cache: Dict[tuple, Any] = {}
|
|
724
|
+
|
|
725
|
+
def _make_jit_fn(fn_extras: Dict[str, Any]):
|
|
726
|
+
static = {**_base_static, **fn_extras}
|
|
727
|
+
if hasattr(program, "batch_call"):
|
|
728
|
+
|
|
729
|
+
@jax.jit
|
|
730
|
+
def jit_fn(row: Row, theta: Any) -> jnp.ndarray:
|
|
731
|
+
if static:
|
|
732
|
+
ext = dict(row.get("extras", {}))
|
|
733
|
+
ext.update(static)
|
|
734
|
+
row = {**row, "extras": ext}
|
|
735
|
+
if parametric:
|
|
736
|
+
return program.batch_call(params=theta, **row)
|
|
737
|
+
return program.batch_call(**row)
|
|
738
|
+
else:
|
|
739
|
+
|
|
740
|
+
@jax.jit
|
|
741
|
+
def jit_fn(row: Row, theta: Any) -> jnp.ndarray:
|
|
742
|
+
if static:
|
|
743
|
+
ext = dict(row.get("extras", {}))
|
|
744
|
+
ext.update(static)
|
|
745
|
+
row = {**row, "extras": ext}
|
|
746
|
+
batched = {k: v for k, v in row.items() if hasattr(v, "ndim") and v.ndim >= 1}
|
|
747
|
+
scalars = {k: v for k, v in row.items() if not hasattr(v, "ndim") or v.ndim < 1}
|
|
748
|
+
|
|
749
|
+
def _call(b):
|
|
750
|
+
if parametric:
|
|
751
|
+
return program(params=theta, **{**b, **scalars})
|
|
752
|
+
return program(**{**b, **scalars})
|
|
753
|
+
|
|
754
|
+
return jax.vmap(_call)(batched)
|
|
755
|
+
|
|
756
|
+
return jit_fn
|
|
757
|
+
|
|
758
|
+
_default_jit = _make_jit_fn({})
|
|
759
|
+
|
|
760
|
+
def batch_row_eval(row: Row, theta: Any) -> jnp.ndarray:
|
|
761
|
+
stripped, fn_extras, cache_key = _split_extras(row)
|
|
762
|
+
if not cache_key:
|
|
763
|
+
return _default_jit(stripped, theta)
|
|
764
|
+
if cache_key not in _jit_cache:
|
|
765
|
+
_jit_cache[cache_key] = _make_jit_fn(fn_extras)
|
|
766
|
+
return _jit_cache[cache_key](stripped, theta)
|
|
767
|
+
|
|
768
|
+
return batch_row_eval
|
|
769
|
+
|
|
770
|
+
|
|
771
|
+
# [new code — parametric update] minibatch infrastructure below
|
|
772
|
+
def _build_minibatch_runner(
|
|
773
|
+
plan: IntegrationPlan,
|
|
774
|
+
program,
|
|
775
|
+
*,
|
|
776
|
+
batch_size: int,
|
|
777
|
+
context: Optional[str] = None,
|
|
778
|
+
):
|
|
779
|
+
"""Build a stochastic mini-batch evaluator from an existing plan.
|
|
780
|
+
|
|
781
|
+
Parameters
|
|
782
|
+
----------
|
|
783
|
+
plan : IntegrationPlan
|
|
784
|
+
A plan already built by ``_build_plan`` / ``make_parametric_integrator``.
|
|
785
|
+
program : Integrand
|
|
786
|
+
The same program used to build *plan*.
|
|
787
|
+
batch_size : int
|
|
788
|
+
Number of time indices to sample per evaluation.
|
|
789
|
+
context : str, optional
|
|
790
|
+
Forwarded to extras.
|
|
791
|
+
|
|
792
|
+
Returns
|
|
793
|
+
-------
|
|
794
|
+
run_batch : callable
|
|
795
|
+
``run_batch(theta, rng_key) -> scalar``. An unbiased estimator
|
|
796
|
+
of the full-data loss (with ``reduce="sum"`` semantics).
|
|
797
|
+
"""
|
|
798
|
+
if not plan.chunks:
|
|
799
|
+
|
|
800
|
+
def run_batch_empty(theta, rng_key):
|
|
801
|
+
del theta, rng_key
|
|
802
|
+
return jnp.asarray(0.0, dtype=jnp.float32)
|
|
803
|
+
|
|
804
|
+
return run_batch_empty
|
|
805
|
+
|
|
806
|
+
reduce_over_particles = plan.reduce_over_particles
|
|
807
|
+
batch_row_eval = _build_batch_row_eval(program, context=context)
|
|
808
|
+
|
|
809
|
+
# Pool valid indices per dataset from the plan.
|
|
810
|
+
ds_indices: Dict[int, jnp.ndarray] = {}
|
|
811
|
+
for chunk in plan.chunks:
|
|
812
|
+
ds_idx = chunk.dataset_index
|
|
813
|
+
valid = chunk.t_block[chunk.valid_block]
|
|
814
|
+
if ds_idx in ds_indices:
|
|
815
|
+
ds_indices[ds_idx] = jnp.concatenate([ds_indices[ds_idx], valid])
|
|
816
|
+
else:
|
|
817
|
+
ds_indices[ds_idx] = valid
|
|
818
|
+
|
|
819
|
+
# Pre-compute per-dataset batch sizes (proportional allocation).
|
|
820
|
+
total_valid = sum(int(idx.shape[0]) for idx in ds_indices.values())
|
|
821
|
+
ds_batch_info = [] # list of (ds_idx, all_idx, n_batch)
|
|
822
|
+
for ds_idx, all_idx in ds_indices.items():
|
|
823
|
+
n_ds = int(all_idx.shape[0])
|
|
824
|
+
n_batch = max(1, min(n_ds, round(batch_size * n_ds / total_valid)))
|
|
825
|
+
ds_batch_info.append((ds_idx, all_idx, n_ds, n_batch))
|
|
826
|
+
|
|
827
|
+
def run_batch(theta, rng_key):
|
|
828
|
+
acc = jnp.asarray(0.0, dtype=jnp.float32)
|
|
829
|
+
|
|
830
|
+
for ds_idx, all_idx, n_ds, n_batch in ds_batch_info:
|
|
831
|
+
rng_key, subkey = jax.random.split(rng_key)
|
|
832
|
+
# Sample without replacement
|
|
833
|
+
perm = jax.random.permutation(subkey, n_ds)[:n_batch]
|
|
834
|
+
sampled = all_idx[perm] # (n_batch,)
|
|
835
|
+
|
|
836
|
+
batch_producer = plan.batch_producers[ds_idx]
|
|
837
|
+
batch_row = batch_producer(sampled)
|
|
838
|
+
|
|
839
|
+
y = batch_row_eval(batch_row, theta)
|
|
840
|
+
# y shape: (n_batch, N, ...) or (n_batch, ...)
|
|
841
|
+
|
|
842
|
+
if reduce_over_particles:
|
|
843
|
+
if y.ndim < 2:
|
|
844
|
+
raise ValueError("reduce_over_particles=True but batch_row_eval returned < 2 dimensions.")
|
|
845
|
+
m = batch_row.get("mask_out", None)
|
|
846
|
+
if m is not None:
|
|
847
|
+
n_trail = y.ndim - 2
|
|
848
|
+
mexp = m.reshape(m.shape + (1,) * n_trail)
|
|
849
|
+
y = jnp.where(mexp, y, 0.0)
|
|
850
|
+
y = jnp.sum(y, axis=1)
|
|
851
|
+
|
|
852
|
+
# dt weighting
|
|
853
|
+
if plan.weight_by_dt:
|
|
854
|
+
dt = batch_row["dt"]
|
|
855
|
+
dt_exp = dt.reshape((dt.shape[0],) + (1,) * (y.ndim - 1))
|
|
856
|
+
y_w = y * dt_exp
|
|
857
|
+
else:
|
|
858
|
+
y_w = y
|
|
859
|
+
|
|
860
|
+
# Sum over batch and scale to be unbiased
|
|
861
|
+
y_sum = jnp.sum(y_w, axis=0)
|
|
862
|
+
scale = jnp.asarray(n_ds / n_batch, dtype=y_sum.dtype)
|
|
863
|
+
acc = acc + y_sum * scale
|
|
864
|
+
|
|
865
|
+
return acc
|
|
866
|
+
|
|
867
|
+
return run_batch
|
|
868
|
+
|
|
869
|
+
|
|
870
|
+
def make_minibatch_parametric_integrator(
|
|
871
|
+
collection,
|
|
872
|
+
program,
|
|
873
|
+
*,
|
|
874
|
+
batch_size: int,
|
|
875
|
+
reduce: str = "sum",
|
|
876
|
+
reduce_over_particles: bool = True,
|
|
877
|
+
weight_by_dt: bool = True,
|
|
878
|
+
subsampling: int = 1,
|
|
879
|
+
chunk_target_bytes: int = 512 * 1024**2,
|
|
880
|
+
context: Optional[str] = None,
|
|
881
|
+
bytes_per_sample: Optional[int] = None,
|
|
882
|
+
batch: bool = True,
|
|
883
|
+
) -> Tuple[IntegrationPlan, Callable, Callable]:
|
|
884
|
+
"""Build a parametric integrator with both full and mini-batch runners.
|
|
885
|
+
|
|
886
|
+
Parameters
|
|
887
|
+
----------
|
|
888
|
+
collection, program, reduce, reduce_over_particles, weight_by_dt, subsampling, chunk_target_bytes, context, bytes_per_sample, batch
|
|
889
|
+
Same as :func:`make_parametric_integrator`.
|
|
890
|
+
batch_size : int
|
|
891
|
+
Number of time indices to sample per mini-batch evaluation.
|
|
892
|
+
|
|
893
|
+
Returns
|
|
894
|
+
-------
|
|
895
|
+
plan : IntegrationPlan
|
|
896
|
+
run_full : callable
|
|
897
|
+
``run_full(theta) -> scalar`` — full-data evaluator.
|
|
898
|
+
run_batch : callable
|
|
899
|
+
``run_batch(theta, rng_key) -> scalar`` — stochastic mini-batch
|
|
900
|
+
evaluator. Unbiased estimator of the full-data value.
|
|
901
|
+
"""
|
|
902
|
+
plan, run_full = make_parametric_integrator(
|
|
903
|
+
collection,
|
|
904
|
+
program,
|
|
905
|
+
reduce=reduce,
|
|
906
|
+
reduce_over_particles=reduce_over_particles,
|
|
907
|
+
weight_by_dt=weight_by_dt,
|
|
908
|
+
subsampling=subsampling,
|
|
909
|
+
chunk_target_bytes=chunk_target_bytes,
|
|
910
|
+
context=context,
|
|
911
|
+
bytes_per_sample=bytes_per_sample,
|
|
912
|
+
batch=batch,
|
|
913
|
+
)
|
|
914
|
+
run_batch = _build_minibatch_runner(
|
|
915
|
+
plan,
|
|
916
|
+
program,
|
|
917
|
+
batch_size=batch_size,
|
|
918
|
+
context=context,
|
|
919
|
+
)
|
|
920
|
+
return plan, run_full, run_batch
|