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.
Files changed (104) hide show
  1. SFI/__init__.py +64 -0
  2. SFI/bases/__init__.py +85 -0
  3. SFI/bases/constants.py +492 -0
  4. SFI/bases/linear.py +325 -0
  5. SFI/bases/monomials.py +218 -0
  6. SFI/bases/pairs.py +998 -0
  7. SFI/bases/spde.py +1537 -0
  8. SFI/diagnostics/__init__.py +60 -0
  9. SFI/diagnostics/assess.py +87 -0
  10. SFI/diagnostics/dynamics_order.py +621 -0
  11. SFI/diagnostics/plotting.py +226 -0
  12. SFI/diagnostics/report.py +238 -0
  13. SFI/diagnostics/residual_tests.py +395 -0
  14. SFI/diagnostics/residuals.py +688 -0
  15. SFI/inference/__init__.py +58 -0
  16. SFI/inference/base.py +1460 -0
  17. SFI/inference/optimizers.py +200 -0
  18. SFI/inference/overdamped.py +1214 -0
  19. SFI/inference/parametric_core/__init__.py +34 -0
  20. SFI/inference/parametric_core/covariance.py +232 -0
  21. SFI/inference/parametric_core/driver.py +272 -0
  22. SFI/inference/parametric_core/flow.py +149 -0
  23. SFI/inference/parametric_core/flow_multi.py +362 -0
  24. SFI/inference/parametric_core/flow_ud.py +168 -0
  25. SFI/inference/parametric_core/jacobians.py +540 -0
  26. SFI/inference/parametric_core/objective.py +286 -0
  27. SFI/inference/parametric_core/objective_ud.py +253 -0
  28. SFI/inference/parametric_core/precision.py +229 -0
  29. SFI/inference/parametric_core/solve.py +763 -0
  30. SFI/inference/result.py +362 -0
  31. SFI/inference/serialization.py +245 -0
  32. SFI/inference/sparse/__init__.py +67 -0
  33. SFI/inference/sparse/base.py +43 -0
  34. SFI/inference/sparse/beam.py +303 -0
  35. SFI/inference/sparse/greedy.py +151 -0
  36. SFI/inference/sparse/hillclimb.py +307 -0
  37. SFI/inference/sparse/lasso.py +178 -0
  38. SFI/inference/sparse/metrics.py +78 -0
  39. SFI/inference/sparse/result.py +278 -0
  40. SFI/inference/sparse/scorer.py +323 -0
  41. SFI/inference/sparse/stlsq.py +165 -0
  42. SFI/inference/sparsity.py +40 -0
  43. SFI/inference/underdamped.py +1355 -0
  44. SFI/integrate/__init__.py +38 -0
  45. SFI/integrate/api.py +920 -0
  46. SFI/integrate/integrand.py +402 -0
  47. SFI/integrate/rk4.py +156 -0
  48. SFI/integrate/timeops.py +174 -0
  49. SFI/langevin/__init__.py +29 -0
  50. SFI/langevin/base.py +863 -0
  51. SFI/langevin/chunked.py +225 -0
  52. SFI/langevin/noise.py +446 -0
  53. SFI/langevin/overdamped.py +560 -0
  54. SFI/langevin/underdamped.py +448 -0
  55. SFI/statefunc/__init__.py +49 -0
  56. SFI/statefunc/basis.py +87 -0
  57. SFI/statefunc/core/runtime.py +47 -0
  58. SFI/statefunc/factory.py +346 -0
  59. SFI/statefunc/interactor.py +90 -0
  60. SFI/statefunc/layout/__init__.py +29 -0
  61. SFI/statefunc/layout/_base.py +186 -0
  62. SFI/statefunc/layout/_eval_compiler.py +453 -0
  63. SFI/statefunc/layout/_fd_atoms.py +196 -0
  64. SFI/statefunc/layout/_grid.py +878 -0
  65. SFI/statefunc/layout/_sectors.py +166 -0
  66. SFI/statefunc/memhint.py +222 -0
  67. SFI/statefunc/nodes/__init__.py +56 -0
  68. SFI/statefunc/nodes/base.py +318 -0
  69. SFI/statefunc/nodes/contract.py +422 -0
  70. SFI/statefunc/nodes/interactions/__init__.py +23 -0
  71. SFI/statefunc/nodes/interactions/dispatcher.py +1365 -0
  72. SFI/statefunc/nodes/interactions/prepare.py +161 -0
  73. SFI/statefunc/nodes/interactions/specs.py +362 -0
  74. SFI/statefunc/nodes/interactions/stencils.py +718 -0
  75. SFI/statefunc/nodes/leaf.py +530 -0
  76. SFI/statefunc/nodes/ops/__init__.py +27 -0
  77. SFI/statefunc/nodes/ops/concat.py +28 -0
  78. SFI/statefunc/nodes/ops/derivative.py +447 -0
  79. SFI/statefunc/nodes/ops/einsum.py +120 -0
  80. SFI/statefunc/nodes/ops/linear.py +153 -0
  81. SFI/statefunc/nodes/ops/mapn.py +138 -0
  82. SFI/statefunc/nodes/ops/reshape_rank.py +158 -0
  83. SFI/statefunc/nodes/ops/slice.py +83 -0
  84. SFI/statefunc/params.py +309 -0
  85. SFI/statefunc/psf.py +112 -0
  86. SFI/statefunc/sf.py +104 -0
  87. SFI/statefunc/stateexpr.py +1243 -0
  88. SFI/statefunc/structexpr.py +1003 -0
  89. SFI/trajectory/__init__.py +31 -0
  90. SFI/trajectory/collection.py +1122 -0
  91. SFI/trajectory/dataset.py +1164 -0
  92. SFI/trajectory/degrade.py +1108 -0
  93. SFI/trajectory/io.py +1027 -0
  94. SFI/trajectory/reserved_extras.py +129 -0
  95. SFI/utils/__init__.py +17 -0
  96. SFI/utils/formatting.py +308 -0
  97. SFI/utils/maths.py +185 -0
  98. SFI/utils/neighbors.py +162 -0
  99. SFI/utils/plotting.py +1936 -0
  100. stochasticforceinference-2.0.0.dist-info/METADATA +203 -0
  101. stochasticforceinference-2.0.0.dist-info/RECORD +104 -0
  102. stochasticforceinference-2.0.0.dist-info/WHEEL +5 -0
  103. stochasticforceinference-2.0.0.dist-info/licenses/LICENSE +21 -0
  104. stochasticforceinference-2.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,395 @@
1
+ """Statistical tests on standardised residuals.
2
+
3
+ A well-specified fit produces whitened residuals that are an
4
+ independent, identically distributed standard-normal sample. The four
5
+ checks here probe that claim from complementary angles:
6
+
7
+ * :func:`residual_moments` — mean, variance, skewness and kurtosis
8
+ (pooled and per spatial component) plus the per-component covariance,
9
+ which should equal the identity when the diffusion is correct;
10
+ * :func:`autocorrelation_tests` — Ljung--Box test on the residuals and
11
+ on their squares, measured strictly along time;
12
+ * :func:`normality_test` — Kolmogorov--Smirnov test against ``N(0, 1)``
13
+ with Q--Q data for plotting;
14
+ * :func:`mse_consistency` — predicted vs realised normalised mean
15
+ squared error of the inferred force.
16
+
17
+ References
18
+ ----------
19
+ Ljung, G. M., & Box, G. E. P. (1978). On a measure of lack of fit in
20
+ time series models. Biometrika, 65(2), 297--303.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import numpy as np
26
+ from scipy import stats
27
+
28
+ from .residuals import ResidualBundle
29
+
30
+
31
+ # --------------------------------------------------------------------- #
32
+ # Moments
33
+ # --------------------------------------------------------------------- #
34
+ def residual_moments(bundle: ResidualBundle) -> dict:
35
+ """Pooled and per-component moments of the standardised residuals.
36
+
37
+ The per-component covariance should be the identity if the inferred
38
+ diffusion correctly describes the noise; deviations are summarised by
39
+ the Frobenius distance from ``I`` and the worst off-diagonal entry.
40
+ """
41
+ z = np.asarray(bundle.z, dtype=np.float64)
42
+ z_c = np.asarray(bundle.z_components, dtype=np.float64)
43
+ n = int(z.size)
44
+ if n == 0:
45
+ return {
46
+ "n": 0,
47
+ "mean": float("nan"),
48
+ "std": float("nan"),
49
+ "skew": float("nan"),
50
+ "excess_kurt": float("nan"),
51
+ }
52
+
53
+ mean = float(np.mean(z))
54
+ std = float(np.std(z, ddof=1)) if n > 1 else float("nan")
55
+ if std and std > 0:
56
+ z0 = (z - mean) / std
57
+ skew = float(np.mean(z0**3))
58
+ excess_kurt = float(np.mean(z0**4) - 3.0)
59
+ else:
60
+ skew = float("nan")
61
+ excess_kurt = float("nan")
62
+
63
+ # Per-component
64
+ if z_c.shape[0] > 1:
65
+ m_c = z_c.mean(axis=0)
66
+ s_c = z_c.std(axis=0, ddof=1)
67
+ scale = np.where(s_c > 0, s_c, 1.0) # guard against zero std
68
+ zc0 = (z_c - m_c) / scale
69
+ skew_c = np.mean(zc0**3, axis=0)
70
+ kurt_c = np.mean(zc0**4, axis=0) - 3.0
71
+ cov = np.cov(z_c, rowvar=False)
72
+ if cov.ndim == 0:
73
+ cov = np.array([[float(cov)]])
74
+ d = cov.shape[0]
75
+ diff = cov - np.eye(d)
76
+ cov_frob = float(np.linalg.norm(diff, ord="fro"))
77
+ offdiag = diff - np.diag(np.diag(diff))
78
+ offdiag_max = float(np.max(np.abs(offdiag))) if d > 1 else 0.0
79
+ else:
80
+ d = z_c.shape[1]
81
+ m_c = np.full(d, np.nan)
82
+ s_c = np.full(d, np.nan)
83
+ skew_c = np.full(d, np.nan)
84
+ kurt_c = np.full(d, np.nan)
85
+ cov = np.full((d, d), np.nan)
86
+ cov_frob = float("nan")
87
+ offdiag_max = float("nan")
88
+
89
+ return {
90
+ "n": n,
91
+ "mean": mean,
92
+ "std": std,
93
+ "skew": skew,
94
+ "excess_kurt": excess_kurt,
95
+ "per_component": {
96
+ "mean": m_c,
97
+ "std": s_c,
98
+ "skew": skew_c,
99
+ "excess_kurt": kurt_c,
100
+ "covariance": cov,
101
+ "cov_minus_I_frob": cov_frob,
102
+ "cov_offdiag_max": offdiag_max,
103
+ },
104
+ }
105
+
106
+
107
+ # --------------------------------------------------------------------- #
108
+ # Autocorrelation (Ljung--Box)
109
+ # --------------------------------------------------------------------- #
110
+ def _unit_series(bundle: ResidualBundle) -> list[np.ndarray]:
111
+ """Time-ordered residual series, one per (dataset, particle, component).
112
+
113
+ Masked-out steps are dropped, so each series holds the valid samples
114
+ of a single scalar channel in time order. Keeping autocorrelation
115
+ within a single channel avoids mixing particles or spatial
116
+ components at short lags — the pitfall of a flattened pooled stream.
117
+ """
118
+ series: list[np.ndarray] = []
119
+ for z_full, mask in bundle.whitened:
120
+ z_full = np.asarray(z_full) # (K, N, d)
121
+ mask = np.asarray(mask) # (K, N)
122
+ _, N, d = z_full.shape
123
+ for n in range(N):
124
+ m = mask[:, n]
125
+ if int(m.sum()) < 3:
126
+ continue
127
+ zc = z_full[m, n, :] # (Kv, d), time-ordered
128
+ for c in range(d):
129
+ series.append(zc[:, c])
130
+ return series
131
+
132
+
133
+ def _pooled_acf(series: list[np.ndarray], n_lags: int) -> tuple[np.ndarray, int]:
134
+ """Autocorrelation at lags ``1..n_lags`` pooled across many series.
135
+
136
+ Centres by the global mean and normalises by the global variance,
137
+ accumulating lagged products *within* each series only. For a single
138
+ gap-free series this reduces to the textbook biased ACF estimator.
139
+ Returns ``(acf, n_total)``.
140
+ """
141
+ if not series:
142
+ return np.full(n_lags, np.nan), 0
143
+ allv = np.concatenate(series)
144
+ n_total = int(allv.size)
145
+ if n_total < 2:
146
+ return np.full(n_lags, np.nan), n_total
147
+ mu = float(allv.mean())
148
+ var = float(np.mean((allv - mu) ** 2))
149
+ if var <= 0.0:
150
+ return np.full(n_lags, np.nan), n_total
151
+
152
+ num = np.zeros(n_lags, dtype=np.float64)
153
+ for s in series:
154
+ sc = np.asarray(s, dtype=np.float64) - mu
155
+ m = sc.size
156
+ kmax = min(n_lags, m - 1)
157
+ for k in range(1, kmax + 1):
158
+ num[k - 1] += float(np.dot(sc[: m - k], sc[k:]))
159
+ acf = num / (n_total * var)
160
+ return acf, n_total
161
+
162
+
163
+ def _ljung_box(acf: np.ndarray, n: int) -> dict:
164
+ """Ljung--Box statistic from a pooled ACF and effective sample size."""
165
+ n_lags = acf.size
166
+ ks = np.arange(1, n_lags + 1)
167
+ finite = np.isfinite(acf) & (n - ks > 0)
168
+ if not finite.any():
169
+ return {"statistic": float("nan"), "pvalue": float("nan"), "n_lags": 0}
170
+ ks_f = ks[finite]
171
+ rk = acf[finite]
172
+ Q = float(n * (n + 2) * np.sum(rk**2 / (n - ks_f)))
173
+ df = int(finite.sum())
174
+ p = float(stats.chi2.sf(Q, df=df))
175
+ return {"statistic": Q, "pvalue": p, "n_lags": df}
176
+
177
+
178
+ def autocorrelation_tests(bundle: ResidualBundle, n_lags: int | None = None) -> dict:
179
+ """Ljung--Box tests on the residuals and their squares.
180
+
181
+ Autocorrelation of ``z`` flags missing dynamics or a too-small
182
+ basis; autocorrelation of ``z**2`` flags state-dependent noise that
183
+ a constant diffusion misses. Both are measured strictly along time
184
+ (see :func:`_unit_series`).
185
+
186
+ Returns keys ``"acf"``, ``"acf_squared"``, ``"ljung_box"``,
187
+ ``"ljung_box_squared"`` and ``"n_eff"``. The default lag count is
188
+ ``min(20, n_eff // 5)``.
189
+ """
190
+ series = _unit_series(bundle)
191
+ n_eff = int(sum(int(s.size) for s in series))
192
+ if n_eff < 6 or not series:
193
+ nan = {"statistic": float("nan"), "pvalue": float("nan"), "n_lags": 0}
194
+ return {
195
+ "acf": np.array([]),
196
+ "acf_squared": np.array([]),
197
+ "ljung_box": nan,
198
+ "ljung_box_squared": nan,
199
+ "n_eff": n_eff,
200
+ }
201
+
202
+ if n_lags is None:
203
+ n_lags = int(min(20, max(1, n_eff // 5)))
204
+
205
+ acf, _ = _pooled_acf(series, n_lags)
206
+ acf2, _ = _pooled_acf([s**2 for s in series], n_lags)
207
+
208
+ return {
209
+ "acf": acf,
210
+ "acf_squared": acf2,
211
+ "ljung_box": _ljung_box(acf, n_eff),
212
+ "ljung_box_squared": _ljung_box(acf2, n_eff),
213
+ "n_eff": n_eff,
214
+ }
215
+
216
+
217
+ # --------------------------------------------------------------------- #
218
+ # Normality
219
+ # --------------------------------------------------------------------- #
220
+ def normality_test(bundle: ResidualBundle) -> dict:
221
+ """Kolmogorov--Smirnov test of the pooled residuals against ``N(0, 1)``.
222
+
223
+ Returns ``{"ks": {statistic, pvalue}, "qq": {sample_quantiles,
224
+ theoretical_quantiles}}``. The residuals are whitened to known mean
225
+ 0 and variance 1 by construction, so the fully-specified ``N(0, 1)``
226
+ reference is appropriate (no parameters are estimated from ``z``).
227
+ """
228
+ z = np.asarray(bundle.z, dtype=np.float64)
229
+ n = z.size
230
+ if n < 8:
231
+ nan = {"statistic": float("nan"), "pvalue": float("nan")}
232
+ return {"ks": nan, "qq": {"sample_quantiles": z, "theoretical_quantiles": z * 0}}
233
+
234
+ ks_stat, ks_p = stats.kstest(z, "norm")
235
+ z_sorted = np.sort(z)
236
+ probs = (np.arange(1, n + 1) - 0.5) / n
237
+ theo = stats.norm.ppf(probs)
238
+ return {
239
+ "ks": {"statistic": float(ks_stat), "pvalue": float(ks_p)},
240
+ "qq": {"sample_quantiles": z_sorted, "theoretical_quantiles": theo},
241
+ }
242
+
243
+
244
+ # --------------------------------------------------------------------- #
245
+ # Predicted vs realised MSE
246
+ # --------------------------------------------------------------------- #
247
+ def mse_consistency(inferer, bundle: ResidualBundle) -> dict:
248
+ """Compare predicted vs realised Itô NMSE for the inferred force.
249
+
250
+ The whitened residuals satisfy ``r^T A^{-1} r / dt = ||z||^2``. Under
251
+ a correctly specified model each ``||z_t||^2`` follows a
252
+ :math:`\\chi^2_d` law (mean ``d``, variance ``2d``), so the sample
253
+ mean of the squared norms has standard error ``sqrt(2d / n)``. We
254
+ report a z-score for the excess ``(mean - d)``; ``|z| > 5`` signals
255
+ bias or a misspecified diffusion. The realised NMSE divides the
256
+ systematic part of that excess by the mean force magnitude
257
+ ``<F^T A^{-1} F>`` and is compared to ``force_predicted_MSE``.
258
+ """
259
+ sqn = np.asarray(bundle.z_squared_norms, dtype=np.float64)
260
+ F2 = np.asarray(
261
+ getattr(bundle, "force_quadratic_form", np.zeros(0)),
262
+ dtype=np.float64,
263
+ )
264
+ n_obs = int(bundle.n_obs)
265
+ d = int(bundle.d)
266
+
267
+ out: dict = {}
268
+ pred = getattr(inferer, "force_predicted_MSE", None)
269
+ out["predicted_NMSE"] = float(pred) if isinstance(pred, (int, float)) and pred == pred else None
270
+
271
+ if n_obs == 0:
272
+ out["realised_NMSE"] = float("nan")
273
+ out["ratio"] = float("nan")
274
+ return out
275
+
276
+ mean_mahal = float(np.mean(sqn))
277
+ excess = mean_mahal - d
278
+ excess_stderr = (2.0 * d / max(n_obs, 1)) ** 0.5
279
+ excess_z = float(excess / excess_stderr) if excess_stderr > 0 else float("nan")
280
+
281
+ mean_dt = float(getattr(bundle, "mean_dt", float("nan")))
282
+ # Convert the chi-square excess to a mean-square force error. For the
283
+ # overdamped increment residual the factor is 1; for the underdamped
284
+ # acceleration residual it is KAPPA_UD (the residual carries a 2/3 noise
285
+ # factor, so the same excess maps to a 2/3-smaller force error).
286
+ factor = float(getattr(bundle, "nmse_excess_factor", 1.0))
287
+ SSE = factor * max(excess, 0.0) / mean_dt if (mean_dt > 0 and np.isfinite(mean_dt)) else 0.0
288
+ denom = float(np.mean(F2)) if F2.size > 0 else float("nan")
289
+ realised = SSE / denom if (denom and denom > 0 and np.isfinite(denom)) else float("nan")
290
+
291
+ out["realised_NMSE"] = float(realised)
292
+ out["mean_mahalanobis_norm"] = float(mean_mahal)
293
+ out["force_quadratic_form"] = float(denom)
294
+ out["excess_z"] = excess_z
295
+
296
+ pred_nmse = out.get("predicted_NMSE")
297
+ if pred_nmse is not None and pred_nmse > 0 and realised == realised:
298
+ out["ratio"] = float(realised / pred_nmse)
299
+ else:
300
+ out["ratio"] = float("nan")
301
+ return out
302
+
303
+
304
+ # --------------------------------------------------------------------- #
305
+ # Four-point cross-covariance (parametric flow residuals)
306
+ # --------------------------------------------------------------------- #
307
+ def parametric_four_point_diagnostic(data, drift_fn, dt, n_substeps=4):
308
+ r"""Four-point diagnostic: test that Cov[r_i, r_{i+2}] = 0.
309
+
310
+ Under correct model specification and i.i.d. measurement noise,
311
+ midpoint flow residuals separated by two steps share no measurement
312
+ noise source, so their cross-covariance should vanish. Deviations
313
+ indicate model misspecification, correlated measurement noise,
314
+ or higher-order effects.
315
+
316
+ Parameters
317
+ ----------
318
+ data : TrajectoryCollection or TrajectoryDataset
319
+ Observed trajectory data.
320
+ drift_fn : callable (d,) → (d,)
321
+ Drift function with parameters already closed over.
322
+ dt : float
323
+ Observation time step.
324
+ n_substeps : int
325
+ RK4 micro-steps per interval.
326
+
327
+ Returns
328
+ -------
329
+ result : dict
330
+ ``C_02`` : (d, d) empirical cross-covariance of r_0 and r_2.
331
+ ``frobenius_norm`` : ||C_02||_F.
332
+ ``n_quadruplets`` : number of quadruplets used.
333
+ """
334
+ import jax.numpy as jnp
335
+
336
+ from SFI.integrate.rk4 import ode_flow
337
+ from SFI.trajectory import TrajectoryCollection, TrajectoryDataset
338
+
339
+ if isinstance(data, TrajectoryDataset):
340
+ datasets = [data]
341
+ elif isinstance(data, TrajectoryCollection):
342
+ datasets = data.datasets
343
+ else:
344
+ raise TypeError(type(data))
345
+
346
+ def disp_fn(z):
347
+ return ode_flow(drift_fn, z, dt, n_substeps) - z
348
+
349
+ C02_sum = None
350
+ n_total = 0
351
+
352
+ for ds in datasets:
353
+ X = np.asarray(ds.X)
354
+ if X.ndim == 2:
355
+ X = X[:, None, :]
356
+ T, N_part, d = X.shape
357
+
358
+ for p in range(N_part):
359
+ Y = X[:, p, :] # (T, d)
360
+ n_quads = T - 3
361
+ if n_quads < 1:
362
+ continue
363
+
364
+ # Midpoint residuals for all steps
365
+ residuals = []
366
+ for t in range(T - 1):
367
+ y_mid = 0.5 * (Y[t] + Y[t + 1])
368
+ dy = Y[t + 1] - Y[t]
369
+ phi = np.asarray(disp_fn(jnp.asarray(y_mid)))
370
+ residuals.append(dy - phi)
371
+ residuals = np.array(residuals) # (T-1, d)
372
+
373
+ # Cross-covariance of r_t and r_{t+2}
374
+ r0 = residuals[:-2] # (T-3, d)
375
+ r2 = residuals[2:] # (T-3, d)
376
+ C02 = np.einsum("ni,nj->ij", r0, r2) / r0.shape[0]
377
+
378
+ if C02_sum is None:
379
+ C02_sum = C02
380
+ else:
381
+ C02_sum = C02_sum + C02
382
+ n_total += 1
383
+
384
+ if n_total == 0:
385
+ raise ValueError("Not enough data for 4-point diagnostic.")
386
+
387
+ assert C02_sum is not None
388
+ C02_avg = C02_sum / n_total
389
+ frob = float(np.linalg.norm(C02_avg))
390
+
391
+ return {
392
+ "C_02": C02_avg,
393
+ "frobenius_norm": frob,
394
+ "n_quadruplets": n_total,
395
+ }