StochasticForceInference 2.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- SFI/__init__.py +64 -0
- SFI/bases/__init__.py +85 -0
- SFI/bases/constants.py +492 -0
- SFI/bases/linear.py +325 -0
- SFI/bases/monomials.py +218 -0
- SFI/bases/pairs.py +998 -0
- SFI/bases/spde.py +1537 -0
- SFI/diagnostics/__init__.py +60 -0
- SFI/diagnostics/assess.py +87 -0
- SFI/diagnostics/dynamics_order.py +621 -0
- SFI/diagnostics/plotting.py +226 -0
- SFI/diagnostics/report.py +238 -0
- SFI/diagnostics/residual_tests.py +395 -0
- SFI/diagnostics/residuals.py +688 -0
- SFI/inference/__init__.py +58 -0
- SFI/inference/base.py +1460 -0
- SFI/inference/optimizers.py +200 -0
- SFI/inference/overdamped.py +1214 -0
- SFI/inference/parametric_core/__init__.py +34 -0
- SFI/inference/parametric_core/covariance.py +232 -0
- SFI/inference/parametric_core/driver.py +272 -0
- SFI/inference/parametric_core/flow.py +149 -0
- SFI/inference/parametric_core/flow_multi.py +362 -0
- SFI/inference/parametric_core/flow_ud.py +168 -0
- SFI/inference/parametric_core/jacobians.py +540 -0
- SFI/inference/parametric_core/objective.py +286 -0
- SFI/inference/parametric_core/objective_ud.py +253 -0
- SFI/inference/parametric_core/precision.py +229 -0
- SFI/inference/parametric_core/solve.py +763 -0
- SFI/inference/result.py +362 -0
- SFI/inference/serialization.py +245 -0
- SFI/inference/sparse/__init__.py +67 -0
- SFI/inference/sparse/base.py +43 -0
- SFI/inference/sparse/beam.py +303 -0
- SFI/inference/sparse/greedy.py +151 -0
- SFI/inference/sparse/hillclimb.py +307 -0
- SFI/inference/sparse/lasso.py +178 -0
- SFI/inference/sparse/metrics.py +78 -0
- SFI/inference/sparse/result.py +278 -0
- SFI/inference/sparse/scorer.py +323 -0
- SFI/inference/sparse/stlsq.py +165 -0
- SFI/inference/sparsity.py +40 -0
- SFI/inference/underdamped.py +1355 -0
- SFI/integrate/__init__.py +38 -0
- SFI/integrate/api.py +920 -0
- SFI/integrate/integrand.py +402 -0
- SFI/integrate/rk4.py +156 -0
- SFI/integrate/timeops.py +174 -0
- SFI/langevin/__init__.py +29 -0
- SFI/langevin/base.py +863 -0
- SFI/langevin/chunked.py +225 -0
- SFI/langevin/noise.py +446 -0
- SFI/langevin/overdamped.py +560 -0
- SFI/langevin/underdamped.py +448 -0
- SFI/statefunc/__init__.py +49 -0
- SFI/statefunc/basis.py +87 -0
- SFI/statefunc/core/runtime.py +47 -0
- SFI/statefunc/factory.py +346 -0
- SFI/statefunc/interactor.py +90 -0
- SFI/statefunc/layout/__init__.py +29 -0
- SFI/statefunc/layout/_base.py +186 -0
- SFI/statefunc/layout/_eval_compiler.py +453 -0
- SFI/statefunc/layout/_fd_atoms.py +196 -0
- SFI/statefunc/layout/_grid.py +878 -0
- SFI/statefunc/layout/_sectors.py +166 -0
- SFI/statefunc/memhint.py +222 -0
- SFI/statefunc/nodes/__init__.py +56 -0
- SFI/statefunc/nodes/base.py +318 -0
- SFI/statefunc/nodes/contract.py +422 -0
- SFI/statefunc/nodes/interactions/__init__.py +23 -0
- SFI/statefunc/nodes/interactions/dispatcher.py +1365 -0
- SFI/statefunc/nodes/interactions/prepare.py +161 -0
- SFI/statefunc/nodes/interactions/specs.py +362 -0
- SFI/statefunc/nodes/interactions/stencils.py +718 -0
- SFI/statefunc/nodes/leaf.py +530 -0
- SFI/statefunc/nodes/ops/__init__.py +27 -0
- SFI/statefunc/nodes/ops/concat.py +28 -0
- SFI/statefunc/nodes/ops/derivative.py +447 -0
- SFI/statefunc/nodes/ops/einsum.py +120 -0
- SFI/statefunc/nodes/ops/linear.py +153 -0
- SFI/statefunc/nodes/ops/mapn.py +138 -0
- SFI/statefunc/nodes/ops/reshape_rank.py +158 -0
- SFI/statefunc/nodes/ops/slice.py +83 -0
- SFI/statefunc/params.py +309 -0
- SFI/statefunc/psf.py +112 -0
- SFI/statefunc/sf.py +104 -0
- SFI/statefunc/stateexpr.py +1243 -0
- SFI/statefunc/structexpr.py +1003 -0
- SFI/trajectory/__init__.py +31 -0
- SFI/trajectory/collection.py +1122 -0
- SFI/trajectory/dataset.py +1164 -0
- SFI/trajectory/degrade.py +1108 -0
- SFI/trajectory/io.py +1027 -0
- SFI/trajectory/reserved_extras.py +129 -0
- SFI/utils/__init__.py +17 -0
- SFI/utils/formatting.py +308 -0
- SFI/utils/maths.py +185 -0
- SFI/utils/neighbors.py +162 -0
- SFI/utils/plotting.py +1936 -0
- stochasticforceinference-2.0.0.dist-info/METADATA +203 -0
- stochasticforceinference-2.0.0.dist-info/RECORD +104 -0
- stochasticforceinference-2.0.0.dist-info/WHEEL +5 -0
- stochasticforceinference-2.0.0.dist-info/licenses/LICENSE +21 -0
- stochasticforceinference-2.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,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
|
+
}
|