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,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()