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,226 @@
|
|
|
1
|
+
"""Diagnostic plotting helpers.
|
|
2
|
+
|
|
3
|
+
Three panels covering the common visual checks of a fitted inference
|
|
4
|
+
object:
|
|
5
|
+
|
|
6
|
+
* :func:`plot_qq` — normal Q--Q plot of pooled whitened residuals;
|
|
7
|
+
* :func:`plot_residual_histogram` — histogram of pooled residuals
|
|
8
|
+
overlaid with the standard normal density;
|
|
9
|
+
* :func:`plot_residual_acf` — autocorrelation of the residuals (and of
|
|
10
|
+
their squares) with the ``±1.96/√n`` band;
|
|
11
|
+
* :func:`plot_summary` — a 1×3 figure combining the three panels.
|
|
12
|
+
|
|
13
|
+
All helpers accept either a fitted inference object (calling
|
|
14
|
+
:func:`~SFI.diagnostics.assess.assess` internally) or a pre-computed
|
|
15
|
+
:class:`~SFI.diagnostics.report.DiagnosticsReport`.
|
|
16
|
+
|
|
17
|
+
Plots use the SFI palette (:data:`SFI.utils.plotting.SFI_COLORS`) but do
|
|
18
|
+
*not* call ``apply_style()`` — applying the gallery style is the caller's
|
|
19
|
+
responsibility (see ``GALLERY_STYLE_GUIDE.md``).
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
import numpy as np
|
|
27
|
+
|
|
28
|
+
from SFI.utils.plotting import SFI_COLORS
|
|
29
|
+
|
|
30
|
+
from .assess import assess
|
|
31
|
+
from .report import DiagnosticsReport
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _coerce_report(obj: Any, *, level: str = "standard") -> DiagnosticsReport:
|
|
35
|
+
"""Return a :class:`DiagnosticsReport` from either a report or inferer."""
|
|
36
|
+
if isinstance(obj, DiagnosticsReport):
|
|
37
|
+
return obj
|
|
38
|
+
return assess(obj, level=level)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _get_residuals(report: DiagnosticsReport) -> np.ndarray:
|
|
42
|
+
"""Return pooled standardised residuals as a 1-D array."""
|
|
43
|
+
qq = report.residuals.get("normality", {}).get("qq", {})
|
|
44
|
+
sample = qq.get("sample_quantiles")
|
|
45
|
+
if sample is not None:
|
|
46
|
+
return np.asarray(sample).ravel()
|
|
47
|
+
raise ValueError(
|
|
48
|
+
"Diagnostics report does not contain raw residual samples. "
|
|
49
|
+
"Run assess() with level='standard' (the default) before plotting."
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def plot_qq(report_or_inferer, *, ax=None, level: str = "standard"):
|
|
54
|
+
"""Normal Q--Q plot of pooled whitened residuals."""
|
|
55
|
+
import matplotlib.pyplot as plt
|
|
56
|
+
from scipy.stats import norm
|
|
57
|
+
|
|
58
|
+
rep = _coerce_report(report_or_inferer, level=level)
|
|
59
|
+
sample = np.sort(_get_residuals(rep))
|
|
60
|
+
n = sample.size
|
|
61
|
+
if n == 0:
|
|
62
|
+
raise ValueError("No residuals to plot.")
|
|
63
|
+
probs = (np.arange(1, n + 1) - 0.5) / n
|
|
64
|
+
theo = norm.ppf(probs)
|
|
65
|
+
|
|
66
|
+
if ax is None:
|
|
67
|
+
_, ax = plt.subplots(figsize=(4.5, 4.5))
|
|
68
|
+
|
|
69
|
+
lo = float(min(theo.min(), sample.min()))
|
|
70
|
+
hi = float(max(theo.max(), sample.max()))
|
|
71
|
+
ax.plot([lo, hi], [lo, hi], "--", color=SFI_COLORS["exact"], lw=1.2, label="$y = x$")
|
|
72
|
+
|
|
73
|
+
if n > 5000: # subsample to keep the figure light
|
|
74
|
+
idx = np.linspace(0, n - 1, 5000).astype(int)
|
|
75
|
+
ax.scatter(theo[idx], sample[idx], s=6, alpha=0.6, color=SFI_COLORS["data"], label=f"residuals (n={n})")
|
|
76
|
+
else:
|
|
77
|
+
ax.scatter(theo, sample, s=8, alpha=0.7, color=SFI_COLORS["data"], label=f"residuals (n={n})")
|
|
78
|
+
|
|
79
|
+
ax.set_xlabel("theoretical quantile (N(0,1))")
|
|
80
|
+
ax.set_ylabel("sample quantile")
|
|
81
|
+
ax.set_title("Q--Q plot")
|
|
82
|
+
ax.legend(frameon=False, loc="best", fontsize=8)
|
|
83
|
+
return ax
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def plot_residual_histogram(report_or_inferer, *, ax=None, bins: int = 60, level: str = "standard"):
|
|
87
|
+
"""Histogram of pooled residuals overlaid with N(0,1) density."""
|
|
88
|
+
import matplotlib.pyplot as plt
|
|
89
|
+
from scipy.stats import norm
|
|
90
|
+
|
|
91
|
+
rep = _coerce_report(report_or_inferer, level=level)
|
|
92
|
+
sample = _get_residuals(rep)
|
|
93
|
+
if ax is None:
|
|
94
|
+
_, ax = plt.subplots(figsize=(4.5, 3.5))
|
|
95
|
+
|
|
96
|
+
ax.hist(sample, bins=bins, density=True, alpha=0.65, color=SFI_COLORS["data"], label=f"residuals (n={sample.size})")
|
|
97
|
+
grid = np.linspace(sample.min(), sample.max(), 256)
|
|
98
|
+
ax.plot(grid, norm.pdf(grid), "--", color=SFI_COLORS["exact"], lw=1.4, label="N(0,1)")
|
|
99
|
+
ax.set_xlabel("standardised residual $z$")
|
|
100
|
+
ax.set_ylabel("density")
|
|
101
|
+
ax.set_title("Residual histogram")
|
|
102
|
+
ax.legend(frameon=False, loc="best", fontsize=8)
|
|
103
|
+
return ax
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def plot_residual_acf(report_or_inferer, *, ax=None, level: str = "standard"):
|
|
107
|
+
"""Autocorrelation of the residuals (and of $z^2$).
|
|
108
|
+
|
|
109
|
+
Reads the ACF computed by
|
|
110
|
+
:func:`~SFI.diagnostics.residual_tests.autocorrelation_tests`
|
|
111
|
+
(stored on the report) rather than recomputing it.
|
|
112
|
+
"""
|
|
113
|
+
import matplotlib.pyplot as plt
|
|
114
|
+
|
|
115
|
+
rep = _coerce_report(report_or_inferer, level=level)
|
|
116
|
+
ac = rep.residuals.get("autocorr", {})
|
|
117
|
+
acf = np.asarray(ac.get("acf", []), dtype=np.float64)
|
|
118
|
+
acf2 = np.asarray(ac.get("acf_squared", []), dtype=np.float64)
|
|
119
|
+
n_eff = int(ac.get("n_eff", 0))
|
|
120
|
+
if acf.size == 0:
|
|
121
|
+
raise ValueError("Report has no autocorrelation data; run assess(level='standard').")
|
|
122
|
+
|
|
123
|
+
if ax is None:
|
|
124
|
+
_, ax = plt.subplots(figsize=(4.5, 3.5))
|
|
125
|
+
|
|
126
|
+
lags = np.arange(1, acf.size + 1)
|
|
127
|
+
band = 1.96 / np.sqrt(max(n_eff, 1))
|
|
128
|
+
ax.axhline(0.0, color="0.5", lw=0.8)
|
|
129
|
+
ax.fill_between(
|
|
130
|
+
np.arange(0, acf.size + 1), -band, band,
|
|
131
|
+
color=SFI_COLORS["exact"], alpha=0.15, label="95% band",
|
|
132
|
+
)
|
|
133
|
+
ax.vlines(lags, 0.0, acf, color=SFI_COLORS["data"], lw=1.5, label="ACF($z$)")
|
|
134
|
+
if acf2.size == acf.size:
|
|
135
|
+
ax.plot(lags, acf2, "o-", color=SFI_COLORS["highlight"], ms=3, lw=1.0, label="ACF($z^2$)")
|
|
136
|
+
ax.set_xlabel("lag")
|
|
137
|
+
ax.set_ylabel("autocorrelation")
|
|
138
|
+
ax.set_title("Residual ACF")
|
|
139
|
+
ax.legend(frameon=False, loc="best", fontsize=8)
|
|
140
|
+
return ax
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def plot_summary(report_or_inferer, *, level: str = "standard", figsize=(13.0, 4.0)):
|
|
144
|
+
"""1×3 summary figure: Q--Q plot, residual histogram, and ACF.
|
|
145
|
+
|
|
146
|
+
Returns the matplotlib :class:`Figure`.
|
|
147
|
+
"""
|
|
148
|
+
import matplotlib.pyplot as plt
|
|
149
|
+
|
|
150
|
+
rep = _coerce_report(report_or_inferer, level=level)
|
|
151
|
+
fig, axes = plt.subplots(1, 3, figsize=figsize)
|
|
152
|
+
plot_qq(rep, ax=axes[0])
|
|
153
|
+
plot_residual_histogram(rep, ax=axes[1])
|
|
154
|
+
plot_residual_acf(rep, ax=axes[2])
|
|
155
|
+
fig.set_layout_engine("tight")
|
|
156
|
+
return fig
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def plot_dynamics_order(report, *, axes=None):
|
|
160
|
+
"""Visualise an OD-vs-UD classification (:func:`classify_dynamics`).
|
|
161
|
+
|
|
162
|
+
Two panels versus the sampling step ``dt``:
|
|
163
|
+
|
|
164
|
+
* **lag-2 persistence** ``rho2 = C2/(C0+2C1)`` (noise-immune): tends to 0
|
|
165
|
+
for overdamped data, to a positive plateau for inertia;
|
|
166
|
+
* **apparent kinetic energy** ``K = (C0+2C1)/dt^2`` on log-log axes, with
|
|
167
|
+
reference slopes ``-1`` (overdamped, ``K ~ 2D/dt``) and ``0``
|
|
168
|
+
(underdamped); the fitted log-log slope ``beta`` is annotated.
|
|
169
|
+
|
|
170
|
+
The parametric-fit prediction is overlaid on both panels. Accepts a
|
|
171
|
+
:class:`~SFI.diagnostics.dynamics_order.DynamicsOrderReport`.
|
|
172
|
+
"""
|
|
173
|
+
import matplotlib.pyplot as plt
|
|
174
|
+
|
|
175
|
+
from .dynamics_order import _dynamics_model
|
|
176
|
+
|
|
177
|
+
dt = np.asarray(report.scan["dt"], dtype=float)
|
|
178
|
+
rho2 = np.asarray(report.scaling["rho2"], dtype=float)
|
|
179
|
+
K = np.asarray(report.scaling["K"], dtype=float)
|
|
180
|
+
beta = float(report.scaling.get("beta", float("nan")))
|
|
181
|
+
p = report.fit.get("params", {})
|
|
182
|
+
verdict = report.verdict
|
|
183
|
+
|
|
184
|
+
hg = np.geomspace(dt.min(), dt.max(), 100)
|
|
185
|
+
m0, m1, m2 = _dynamics_model(hg, p.get("sigma2", 0.0), p.get("D", 0.0), p.get("V", 0.0), p.get("gamma", 1.0))
|
|
186
|
+
nf = m0 + 2.0 * m1
|
|
187
|
+
with np.errstate(divide="ignore", invalid="ignore"):
|
|
188
|
+
rho2_fit = np.where(np.abs(nf) > 0, m2 / nf, np.nan)
|
|
189
|
+
K_fit = nf / hg**2
|
|
190
|
+
|
|
191
|
+
if axes is None:
|
|
192
|
+
fig, axes = plt.subplots(1, 2, figsize=(10.0, 4.0))
|
|
193
|
+
else:
|
|
194
|
+
fig = np.ravel(axes)[0].figure
|
|
195
|
+
ax_rho, ax_k = np.ravel(axes)[:2]
|
|
196
|
+
|
|
197
|
+
ax_rho.semilogx(dt, rho2, "o", color=SFI_COLORS["data"], label="data")
|
|
198
|
+
ax_rho.semilogx(hg, rho2_fit, "-", color=SFI_COLORS["inferred"], label="fit")
|
|
199
|
+
ax_rho.axhline(0.0, color="0.5", lw=0.8)
|
|
200
|
+
ax_rho.set_xlabel(r"sampling step $\Delta t$")
|
|
201
|
+
ax_rho.set_ylabel(r"$\rho_2 = C_2 / (C_0 + 2 C_1)$")
|
|
202
|
+
ax_rho.set_title(f"lag-2 persistence — verdict: {verdict}")
|
|
203
|
+
ax_rho.legend(frameon=False, loc="best", fontsize=8)
|
|
204
|
+
|
|
205
|
+
good = K > 0
|
|
206
|
+
anchor = float(K[good][0]) if good.any() else 1.0
|
|
207
|
+
ax_k.loglog(dt[good], K[good], "o", color=SFI_COLORS["data"], label="data")
|
|
208
|
+
ax_k.loglog(hg, K_fit, "-", color=SFI_COLORS["inferred"], label="fit")
|
|
209
|
+
ax_k.loglog(hg, anchor * (dt[0] / hg), ":", color=SFI_COLORS["exact"], lw=1.0, label="OD ref (slope $-1$)")
|
|
210
|
+
ax_k.loglog(hg, np.full_like(hg, anchor), "--", color=SFI_COLORS["highlight"], lw=1.0, label="UD ref (slope $0$)")
|
|
211
|
+
ax_k.set_xlabel(r"sampling step $\Delta t$")
|
|
212
|
+
ax_k.set_ylabel(r"apparent KE $\tilde K = (C_0 + 2 C_1)/\Delta t^2$")
|
|
213
|
+
ax_k.set_title(rf"scaling slope $\beta = {beta:.2f}$ (OD$\to-1$, UD$\to0$)")
|
|
214
|
+
ax_k.legend(frameon=False, loc="best", fontsize=8)
|
|
215
|
+
|
|
216
|
+
fig.set_layout_engine("tight")
|
|
217
|
+
return fig
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
__all__ = [
|
|
221
|
+
"plot_qq",
|
|
222
|
+
"plot_residual_histogram",
|
|
223
|
+
"plot_residual_acf",
|
|
224
|
+
"plot_summary",
|
|
225
|
+
"plot_dynamics_order",
|
|
226
|
+
]
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
"""DiagnosticsReport dataclass — structured output of `assess()`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from dataclasses import asdict, dataclass, field
|
|
7
|
+
from typing import Any, Mapping
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
|
|
11
|
+
#: One-line action hint per flag, appended to `flag_issues` messages.
|
|
12
|
+
#: Keys are ``(section, test)``; ``(section, "")`` is the section fallback.
|
|
13
|
+
#: Wording mirrors docs/source/diagnostics.rst ("Interpreting flags" and
|
|
14
|
+
#: "When a flag points beyond the linear estimators") — keep in lockstep.
|
|
15
|
+
_FLAG_HINTS: dict[tuple[str, str], str] = {
|
|
16
|
+
("autocorr", "ljung_box"): (
|
|
17
|
+
"missing time-correlated feature — widen the basis; if it persists, "
|
|
18
|
+
"suspect coarse sampling: the parametric estimator (infer_force) "
|
|
19
|
+
"extends the usable Δt"
|
|
20
|
+
),
|
|
21
|
+
("autocorr", "ljung_box_squared"): (
|
|
22
|
+
"diffusion mis-estimated or state-dependent — try the other "
|
|
23
|
+
"compute_diffusion_constant method or a state-dependent diffusion "
|
|
24
|
+
"basis; the parametric estimators profile (D, Λ)"
|
|
25
|
+
),
|
|
26
|
+
("normality", ""): (
|
|
27
|
+
"non-Gaussian residuals — rare events not captured by the basis, "
|
|
28
|
+
"or a non-Gaussian noise structure"
|
|
29
|
+
),
|
|
30
|
+
("moments", "mean"): (
|
|
31
|
+
"non-zero residual mean — systematic drift bias; widen the basis"
|
|
32
|
+
),
|
|
33
|
+
("moments", "std"): (
|
|
34
|
+
"whitened std far from 1 — D̄ likely wrong: try both "
|
|
35
|
+
"compute_diffusion_constant methods, then the parametric estimator"
|
|
36
|
+
),
|
|
37
|
+
("mse_consistency", ""): (
|
|
38
|
+
"realised error above predicted — model bias; on experimental data "
|
|
39
|
+
"usually measurement noise: consider the parametric estimator "
|
|
40
|
+
"(infer_force)"
|
|
41
|
+
),
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _hint(section: str, test: str = "") -> str:
|
|
46
|
+
return _FLAG_HINTS.get((section, test)) or _FLAG_HINTS.get((section, ""), "")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _to_jsonable(obj: Any) -> Any:
|
|
50
|
+
"""Recursively convert numpy / jax arrays to plain Python objects."""
|
|
51
|
+
if isinstance(obj, Mapping):
|
|
52
|
+
return {k: _to_jsonable(v) for k, v in obj.items()}
|
|
53
|
+
if isinstance(obj, (list, tuple)):
|
|
54
|
+
return [_to_jsonable(v) for v in obj]
|
|
55
|
+
if hasattr(obj, "tolist"):
|
|
56
|
+
try:
|
|
57
|
+
return obj.tolist()
|
|
58
|
+
except Exception:
|
|
59
|
+
pass
|
|
60
|
+
if isinstance(obj, (np.floating, np.integer)):
|
|
61
|
+
return obj.item()
|
|
62
|
+
return obj
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass
|
|
66
|
+
class DiagnosticsReport:
|
|
67
|
+
"""Container for residual-consistency test results.
|
|
68
|
+
|
|
69
|
+
Attributes
|
|
70
|
+
----------
|
|
71
|
+
residuals : dict
|
|
72
|
+
Test results. Always holds ``"moments"``; at ``level="standard"``
|
|
73
|
+
also ``"autocorr"``, ``"normality"`` and ``"mse_consistency"``.
|
|
74
|
+
meta : dict
|
|
75
|
+
Backend tag, regime, ``n_obs``, ``n_particles``, ``d``, level.
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
residuals: dict = field(default_factory=dict)
|
|
79
|
+
meta: dict = field(default_factory=dict)
|
|
80
|
+
|
|
81
|
+
# ------------------------------------------------------------------ #
|
|
82
|
+
# Serialisation
|
|
83
|
+
# ------------------------------------------------------------------ #
|
|
84
|
+
def to_dict(self) -> dict:
|
|
85
|
+
"""Return a JSON-serialisable representation of the report."""
|
|
86
|
+
return _to_jsonable(asdict(self))
|
|
87
|
+
|
|
88
|
+
def to_json(self, indent: int = 2) -> str:
|
|
89
|
+
"""Serialise the report to a JSON string."""
|
|
90
|
+
return json.dumps(self.to_dict(), indent=indent, default=str)
|
|
91
|
+
|
|
92
|
+
# ------------------------------------------------------------------ #
|
|
93
|
+
# Issue flagging
|
|
94
|
+
# ------------------------------------------------------------------ #
|
|
95
|
+
def flag_issues(self, alpha: float = 0.01, *, hints: bool = True) -> list[str]:
|
|
96
|
+
"""List human-readable warnings.
|
|
97
|
+
|
|
98
|
+
Returns one line per test whose p-value is below ``alpha`` or
|
|
99
|
+
whose statistic crosses a sane threshold (residual mean off zero,
|
|
100
|
+
std far from one, MSE-consistency ``|z| > 5``).
|
|
101
|
+
|
|
102
|
+
Parameters
|
|
103
|
+
----------
|
|
104
|
+
alpha : float
|
|
105
|
+
Significance level for the p-value tests.
|
|
106
|
+
hints : bool
|
|
107
|
+
When True (default), each message carries a one-line action
|
|
108
|
+
hint (" — <what to do>"); set False for bare statistics
|
|
109
|
+
(machine parsing).
|
|
110
|
+
"""
|
|
111
|
+
msgs: list[str] = []
|
|
112
|
+
|
|
113
|
+
def _emit(base: str, section: str, test: str = "") -> None:
|
|
114
|
+
h = _hint(section, test) if hints else ""
|
|
115
|
+
msgs.append(f"{base} — {h}" if h else base)
|
|
116
|
+
|
|
117
|
+
# Normality / autocorrelation p-values
|
|
118
|
+
for section_name, section in (
|
|
119
|
+
("normality", self.residuals.get("normality", {})),
|
|
120
|
+
("autocorr", self.residuals.get("autocorr", {})),
|
|
121
|
+
):
|
|
122
|
+
for test_name, payload in section.items():
|
|
123
|
+
if not isinstance(payload, Mapping):
|
|
124
|
+
continue
|
|
125
|
+
p = payload.get("pvalue")
|
|
126
|
+
if p is not None and p < alpha:
|
|
127
|
+
_emit(
|
|
128
|
+
f"[{section_name}/{test_name}] p={p:.2e} < {alpha}",
|
|
129
|
+
section_name,
|
|
130
|
+
test_name,
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
# Residual moments
|
|
134
|
+
moments = self.residuals.get("moments", {})
|
|
135
|
+
m = moments.get("mean")
|
|
136
|
+
s = moments.get("std")
|
|
137
|
+
n = float(moments.get("n", 1))
|
|
138
|
+
band = 4.0 / max(n**0.5, 1.0)
|
|
139
|
+
if m is not None and s is not None and abs(m) > band:
|
|
140
|
+
_emit(
|
|
141
|
+
f"[moments/mean] |mean|={abs(m):.3g} (expected ~0, 4σ band={band:.3g})",
|
|
142
|
+
"moments",
|
|
143
|
+
"mean",
|
|
144
|
+
)
|
|
145
|
+
if s is not None and (s < 0.5 or s > 2.0):
|
|
146
|
+
_emit(f"[moments/std] std={s:.3g}", "moments", "std")
|
|
147
|
+
|
|
148
|
+
# Predicted vs realised MSE — flag on the chi^2 z-score of the
|
|
149
|
+
# residual excess, which is sampling-noise-aware (the raw ratio is
|
|
150
|
+
# too noisy at modest n_obs). A decisive z (|z|>5) flags on its
|
|
151
|
+
# own; a moderately significant z (|z|>2) flags when the realised/
|
|
152
|
+
# predicted ratio is also an order of magnitude off — a large,
|
|
153
|
+
# consistently-signed excess at modest n_obs (e.g. a structurally
|
|
154
|
+
# misspecified force family) would otherwise stay silent.
|
|
155
|
+
consistency = self.residuals.get("mse_consistency", {})
|
|
156
|
+
excess_z = consistency.get("excess_z")
|
|
157
|
+
ratio = consistency.get("ratio")
|
|
158
|
+
if excess_z is not None and (
|
|
159
|
+
abs(excess_z) > 5.0
|
|
160
|
+
or (abs(excess_z) > 2.0 and ratio is not None and ratio > 10.0)
|
|
161
|
+
):
|
|
162
|
+
_emit(
|
|
163
|
+
f"[mse_consistency] residual chi^2 z-score = {excess_z:+.2f}, "
|
|
164
|
+
f"realised/predicted NMSE = {ratio:.2g}",
|
|
165
|
+
"mse_consistency",
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
return msgs
|
|
169
|
+
|
|
170
|
+
# ------------------------------------------------------------------ #
|
|
171
|
+
# Pretty-printing
|
|
172
|
+
# ------------------------------------------------------------------ #
|
|
173
|
+
def print_summary(self, alpha: float = 0.01, *, hints: bool = True) -> None:
|
|
174
|
+
"""Print a human-readable summary of the diagnostic report.
|
|
175
|
+
|
|
176
|
+
Each flagged issue carries a one-line action hint unless
|
|
177
|
+
``hints=False``.
|
|
178
|
+
"""
|
|
179
|
+
print("\n=== SFI diagnostics report ===")
|
|
180
|
+
meta = self.meta or {}
|
|
181
|
+
if meta:
|
|
182
|
+
print(f"backend : {meta.get('backend', '?')}")
|
|
183
|
+
print(f"regime : {meta.get('regime', '?')}")
|
|
184
|
+
print(
|
|
185
|
+
f"n_obs : {meta.get('n_obs', '?')} "
|
|
186
|
+
f"n_particles: {meta.get('n_particles', '?')} "
|
|
187
|
+
f"d: {meta.get('d', '?')}"
|
|
188
|
+
)
|
|
189
|
+
print(f"level : {meta.get('level', '?')}")
|
|
190
|
+
|
|
191
|
+
res = self.residuals or {}
|
|
192
|
+
if res:
|
|
193
|
+
print("\n-- Residuals --")
|
|
194
|
+
mom = res.get("moments", {})
|
|
195
|
+
if mom:
|
|
196
|
+
print(
|
|
197
|
+
f" mean = {mom.get('mean', float('nan')):+.4f} "
|
|
198
|
+
f"std = {mom.get('std', float('nan')):.4f} "
|
|
199
|
+
f"skew = {mom.get('skew', float('nan')):+.3f} "
|
|
200
|
+
f"kurt-3 = {mom.get('excess_kurt', float('nan')):+.3f} "
|
|
201
|
+
f"(n={mom.get('n', 0)})"
|
|
202
|
+
)
|
|
203
|
+
for sect_name, sect in (
|
|
204
|
+
("normality", res.get("normality", {})),
|
|
205
|
+
("autocorr", res.get("autocorr", {})),
|
|
206
|
+
):
|
|
207
|
+
if not sect:
|
|
208
|
+
continue
|
|
209
|
+
for test_name, payload in sect.items():
|
|
210
|
+
if not isinstance(payload, Mapping):
|
|
211
|
+
continue
|
|
212
|
+
stat = payload.get("statistic")
|
|
213
|
+
p = payload.get("pvalue")
|
|
214
|
+
if stat is None and p is None:
|
|
215
|
+
continue
|
|
216
|
+
flag = "✗" if (p is not None and p < alpha) else "✓"
|
|
217
|
+
s_str = f"stat={stat:.3g}" if stat is not None else ""
|
|
218
|
+
p_str = f"p={p:.3g}" if p is not None else ""
|
|
219
|
+
print(f" {flag} {sect_name:9s} {test_name:18s} {s_str:14s} {p_str}")
|
|
220
|
+
|
|
221
|
+
mc = res.get("mse_consistency", {})
|
|
222
|
+
if mc:
|
|
223
|
+
pred = mc.get("predicted_NMSE")
|
|
224
|
+
real = mc.get("realised_NMSE")
|
|
225
|
+
excess_z = mc.get("excess_z")
|
|
226
|
+
pred_str = f"{pred:.3g}" if isinstance(pred, (int, float)) else str(pred)
|
|
227
|
+
real_str = f"{real:.3g}" if isinstance(real, (int, float)) else str(real)
|
|
228
|
+
z_str = f"{excess_z:+.2f}" if isinstance(excess_z, (int, float)) else str(excess_z)
|
|
229
|
+
print(f" predicted NMSE = {pred_str} realised NMSE = {real_str} χ² z = {z_str} (|z|>5 ⇒ bias)")
|
|
230
|
+
|
|
231
|
+
issues = self.flag_issues(alpha=alpha, hints=hints)
|
|
232
|
+
print("\n-- Flags --")
|
|
233
|
+
if not issues:
|
|
234
|
+
print(" (no issues at α = {:.2g})".format(alpha))
|
|
235
|
+
else:
|
|
236
|
+
for msg in issues:
|
|
237
|
+
print(f" ! {msg}")
|
|
238
|
+
print()
|