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,34 @@
1
+ # SFI/inference/parametric_core/ — stripped-to-the-bone parametric estimator.
2
+ """
3
+ Parallel, minimal reimplementation of the parametric force/diffusion
4
+ estimator (RK4-flow residuals + banded residual covariance + windowed
5
+ precision + AD-minimised windowed NLL).
6
+
7
+ Goal: a single, paper-ready, one-size-fits-all objective with a handful
8
+ of parameters, integrating like ``infer_*_linear`` (Basis/PSF in →
9
+ ``InferenceResultSF`` out, multi-particle aware) and reusing the
10
+ ``SFI.integrate`` engine for chunking/masking/JIT.
11
+
12
+ This package is intentionally independent of ``SFI.inference.parametric``
13
+ (the existing implementation, kept as the benchmark baseline).
14
+
15
+ Design note: ``docs/source/inference/parametric_core.rst`` (to be written);
16
+ working draft in the session plan.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from .solve import (
22
+ DiffusionSolveResult,
23
+ ForceSolveResult,
24
+ solve_diffusion_od,
25
+ solve_diffusion_ud,
26
+ solve_force_od,
27
+ solve_force_ud,
28
+ )
29
+
30
+ __all__ = [
31
+ "solve_force_od", "solve_force_ud",
32
+ "solve_diffusion_od", "solve_diffusion_ud",
33
+ "ForceSolveResult", "DiffusionSolveResult",
34
+ ]
@@ -0,0 +1,232 @@
1
+ # SFI/inference/parametric_core/covariance.py
2
+ """
3
+ Banded residual covariance for the parametric core.
4
+
5
+ The flow residuals are correlated only over a finite lag set by the SDE
6
+ order, so their covariance is **block-banded** with bandwidth ``B``:
7
+
8
+ overdamped (order 1) → B = 1 (tridiagonal)
9
+ underdamped (order 2) → B = 2 (pentadiagonal)
10
+ order-3 SDE → B = 3 (heptadiagonal) [future]
11
+
12
+ A single container :class:`CovarianceBlocks` (diagonal blocks + a tuple
13
+ of off-diagonal lag blocks) represents any bandwidth, so adding a
14
+ higher-order builder is a *localized* addition — the precision engine and
15
+ assembler below are bandwidth-generic and need no change.
16
+
17
+ ``D`` and ``Λ`` are full ``(d, d)`` matrices throughout (scalars / per-
18
+ axis vectors are accepted and promoted).
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from typing import NamedTuple
24
+
25
+ import jax.numpy as jnp
26
+
27
+ __all__ = ["CovarianceBlocks", "build_od_blocks", "build_ud_blocks", "assemble_dense"]
28
+
29
+
30
+ class CovarianceBlocks(NamedTuple):
31
+ """Block-banded covariance.
32
+
33
+ Parameters
34
+ ----------
35
+ A : array ``(n_res, d, d)``
36
+ Diagonal blocks ``Cov(r_n, r_n)``.
37
+ offdiag : tuple of arrays
38
+ ``offdiag[lag-1]`` has shape ``(n_res-lag, d, d)`` and holds
39
+ ``Cov(r_n, r_{n+lag})``.
40
+ bandwidth : int
41
+ Number of off-diagonal lags (``len(offdiag)``).
42
+ """
43
+
44
+ A: jnp.ndarray
45
+ offdiag: tuple
46
+ bandwidth: int
47
+
48
+
49
+ def _rel_jitter(A_raw, jitter, I_d):
50
+ r"""Diagonal regulariser **scaled to the block magnitude**.
51
+
52
+ ``jitter`` is a *relative* coefficient: the added term is
53
+ ``jitter · ⟨diag(A_raw)⟩ · I`` where the scale is the mean diagonal
54
+ entry of the (pre-jitter) blocks. An absolute floor would mask the
55
+ ``Δt^k``-small process variance at small ``Δt`` (e.g. the underdamped
56
+ ``(4/3)Δt³D ~ 1e-9``), making ``D`` unidentifiable; a relative floor
57
+ is always a tiny fraction of the residual variance, at any ``Δt``.
58
+ """
59
+ d = A_raw.shape[-1]
60
+ scale = jnp.mean(jnp.einsum("nii->n", A_raw)) / d
61
+ scale = jnp.maximum(scale, 1e-30)
62
+ return jitter * scale * I_d[None]
63
+
64
+
65
+ def _as_dd(x, d, dtype):
66
+ """Promote scalar / ``(d,)`` / ``(d, d)`` to a ``(d, d)`` matrix."""
67
+ x = jnp.asarray(x, dtype=dtype)
68
+ if x.ndim == 0:
69
+ return x * jnp.eye(d, dtype=dtype)
70
+ if x.ndim == 1:
71
+ return jnp.diag(x)
72
+ return x
73
+
74
+
75
+ def build_od_blocks(J, D, Lambda, dt, *, jitter=0.0, valid_mask=None):
76
+ r"""Overdamped (bandwidth-1) residual covariance blocks.
77
+
78
+ For the 2-point residual ``r_k = Y_{k+1} − Y_k − Φ_k(Y_k)`` the
79
+ leading-order covariance combines process noise (diffusion propagated
80
+ through the linearised flow) and measurement noise (shared between
81
+ neighbouring residuals):
82
+
83
+ A_k = Δt(J_k D J_kᵀ + D) + J_k Λ J_kᵀ + Λ + jitter·⟨diag A⟩·I
84
+ C_k = Cov(r_k, r_{k+1}) = −Λ J_{k+1}ᵀ
85
+
86
+ Parameters
87
+ ----------
88
+ J : array ``(n_res, d, d)``
89
+ Flow Jacobians at the residual base points.
90
+ D, Lambda : ``(d, d)`` (or scalar / ``(d,)``)
91
+ Diffusion and measurement-noise covariance matrices.
92
+ dt : float
93
+ jitter : float
94
+ *Relative* diagonal regulariser (a fraction of the mean diagonal
95
+ block magnitude — see :func:`_rel_jitter`), so it never masks the
96
+ ``Δt``-small process variance.
97
+ valid_mask : ``(n_res,)`` bool, optional
98
+ Where ``False``, the diagonal block is replaced by a neutral
99
+ ``(1+jitter)·I`` and adjacent off-diagonal couplings are zeroed,
100
+ decoupling masked residuals.
101
+
102
+ Returns
103
+ -------
104
+ CovarianceBlocks with ``bandwidth=1``.
105
+ """
106
+ J = jnp.asarray(J)
107
+ d = J.shape[-1]
108
+ dtype = J.dtype
109
+ I_d = jnp.eye(d, dtype=dtype)
110
+ D = jnp.asarray(D, dtype=dtype)
111
+ Lambda = _as_dd(Lambda, d, dtype)
112
+
113
+ if D.ndim == 3: # per-step (state-dependent) D, shape (n_res, d, d)
114
+ JDJ = jnp.einsum("kij,kjl,kml->kim", J, D, J)
115
+ D_diag = D
116
+ else:
117
+ D = _as_dd(D, d, dtype)
118
+ JDJ = jnp.einsum("kij,jl,kml->kim", J, D, J)
119
+ D_diag = D[None]
120
+ JSJ = jnp.einsum("kij,jl,kml->kim", J, Lambda, J)
121
+ A_raw = dt * (JDJ + D_diag) + JSJ + Lambda[None]
122
+ A = A_raw + _rel_jitter(A_raw, jitter, I_d)
123
+
124
+ # lag-1: C_k = −Λ J_{k+1}ᵀ
125
+ C = -jnp.einsum("ij,klj->kil", Lambda, J)[1:] # (n_res-1, d, d)
126
+
127
+ if valid_mask is not None:
128
+ m = valid_mask[:, None, None]
129
+ A = jnp.where(m, A, (1.0 + jitter) * I_d[None])
130
+ pair = (valid_mask[:-1] & valid_mask[1:])[:, None, None]
131
+ C = jnp.where(pair, C, jnp.zeros_like(C))
132
+
133
+ return CovarianceBlocks(A=A, offdiag=(C,), bandwidth=1)
134
+
135
+
136
+ def build_ud_blocks(alpha_plus, alpha_zero, alpha_minus, D, Lambda, dt,
137
+ *, jitter=0.0, valid_mask=None):
138
+ r"""Underdamped (bandwidth-2) residual covariance blocks.
139
+
140
+ The 3-point shooting residual ``r_n = α₋ ε_{n-1} + α₀ ε_n + α₊ ε_{n+1}
141
+ + ξ`` (measurement + process) has a pentadiagonal covariance:
142
+
143
+ A_n = (4/3)Δt³ D_sym + Σ_k α_k Λ α_kᵀ + jitter·⟨diag A⟩·I
144
+ C_n = (1/3)Δt³ D_sym + α₊ Λ α₀ᵀ|_{n,n+1} + α₀ Λ α₋ᵀ|_{n,n+1}
145
+ E_n = α₊ Λ α₋ᵀ|_{n,n+2}
146
+
147
+ The ``jitter`` regulariser is *relative* to the block magnitude
148
+ (:func:`_rel_jitter`): an absolute floor would dominate the ``Δt³``-small
149
+ process variance at small ``Δt`` and make ``D`` unidentifiable.
150
+
151
+ where ``D_sym = (D + Dᵀ)/2`` and the leading-order process noise scales
152
+ as ``Δt³`` (the velocity is integrated once more than the overdamped
153
+ increment).
154
+
155
+ Parameters
156
+ ----------
157
+ alpha_plus, alpha_zero, alpha_minus : ``(n_res, d, d)``
158
+ Measurement-noise propagators (``α₊ = I`` by construction).
159
+ D, Lambda : ``(d, d)`` (or scalar / ``(d,)``).
160
+ dt, jitter : float
161
+ valid_mask : ``(n_res,)`` bool, optional.
162
+
163
+ Returns
164
+ -------
165
+ CovarianceBlocks with ``bandwidth=2``.
166
+ """
167
+ ap = jnp.asarray(alpha_plus)
168
+ a0 = jnp.asarray(alpha_zero)
169
+ am = jnp.asarray(alpha_minus)
170
+ d = ap.shape[-1]
171
+ dtype = ap.dtype
172
+ I_d = jnp.eye(d, dtype=dtype)
173
+ D = jnp.asarray(D, dtype=dtype)
174
+ Lambda = _as_dd(Lambda, d, dtype)
175
+
176
+ if D.ndim == 3: # per-step (state/velocity-dependent) D, shape (n_res, d, d)
177
+ D_sym = 0.5 * (D + jnp.swapaxes(D, -1, -2))
178
+ V_xi = (4.0 / 3.0) * dt**3 * D_sym # (n_res, d, d)
179
+ C_proc = (1.0 / 3.0) * dt**3 * D_sym
180
+ V_xi_diag = V_xi
181
+ C_proc_lag1 = 0.5 * (C_proc[:-1] + C_proc[1:])
182
+ else:
183
+ D = _as_dd(D, d, dtype)
184
+ D_sym = 0.5 * (D + D.T)
185
+ V_xi_diag = ((4.0 / 3.0) * dt**3 * D_sym)[None]
186
+ C_proc_lag1 = ((1.0 / 3.0) * dt**3 * D_sym)[None]
187
+
188
+ se_pp = jnp.einsum("nij,jk,nlk->nil", ap, Lambda, ap)
189
+ se_00 = jnp.einsum("nij,jk,nlk->nil", a0, Lambda, a0)
190
+ se_mm = jnp.einsum("nij,jk,nlk->nil", am, Lambda, am)
191
+ A_raw = V_xi_diag + se_pp + se_00 + se_mm
192
+ A = A_raw + _rel_jitter(A_raw, jitter, I_d)
193
+
194
+ cross1a = jnp.einsum("nij,jk,nlk->nil", ap[:-1], Lambda, a0[1:])
195
+ cross1b = jnp.einsum("nij,jk,nlk->nil", a0[:-1], Lambda, am[1:])
196
+ C = C_proc_lag1 + cross1a + cross1b
197
+ E = jnp.einsum("nij,jk,nlk->nil", ap[:-2], Lambda, am[2:])
198
+
199
+ if valid_mask is not None:
200
+ m = valid_mask[:, None, None]
201
+ A = jnp.where(m, A, (1.0 + jitter) * I_d[None])
202
+ pair = (valid_mask[:-1] & valid_mask[1:])[:, None, None]
203
+ C = jnp.where(pair, C, jnp.zeros_like(C))
204
+ triple = (valid_mask[:-2] & valid_mask[1:-1] & valid_mask[2:])[:, None, None]
205
+ E = jnp.where(triple, E, jnp.zeros_like(E))
206
+
207
+ return CovarianceBlocks(A=A, offdiag=(C, E), bandwidth=2)
208
+
209
+
210
+ def assemble_dense(blocks: CovarianceBlocks):
211
+ """Assemble a :class:`CovarianceBlocks` into a dense symmetric matrix.
212
+
213
+ Returns
214
+ -------
215
+ Sigma : array ``(n_res·d, n_res·d)``
216
+ """
217
+ A = blocks.A
218
+ n_res, d, _ = A.shape
219
+ Nd = n_res * d
220
+ S = jnp.zeros((Nd, Nd), dtype=A.dtype)
221
+
222
+ for i in range(n_res):
223
+ S = S.at[i * d:(i + 1) * d, i * d:(i + 1) * d].set(A[i])
224
+
225
+ for lag_idx, C_lag in enumerate(blocks.offdiag):
226
+ lag = lag_idx + 1
227
+ for i in range(n_res - lag):
228
+ r0, c0 = i * d, (i + lag) * d
229
+ S = S.at[r0:r0 + d, c0:c0 + d].set(C_lag[i])
230
+ S = S.at[c0:c0 + d, r0:r0 + d].set(C_lag[i].T)
231
+
232
+ return S
@@ -0,0 +1,272 @@
1
+ # SFI/inference/parametric_core/driver.py
2
+ """
3
+ IRLS driver for the parametric core.
4
+
5
+ Minimises the windowed objective by alternating:
6
+
7
+ 1. **freeze** the precision ``P`` at the current ``θ`` (and the current
8
+ ``D, Λ``) and minimise the resulting quadratic
9
+ ``Q(θ) = ½ Σ rₙ(θ)ᵀ P rₙ(θ)`` over ``θ`` with L-BFGS — the inner solve;
10
+ 2. **reprofile** ``D, Λ`` from the residuals at the updated ``θ``.
11
+
12
+ For linear-in-θ models this converges in ~1 outer iteration (it is the
13
+ GLS fixed point); for nonlinear-in-θ it is ordinary IRLS. The inner
14
+ objective and its gradient are supplied by the caller (built from the
15
+ integrate-engine loss program), so the driver is agnostic to dynamics and
16
+ bandwidth.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import logging
22
+
23
+ import jax
24
+ import jax.numpy as jnp
25
+ import numpy as np
26
+ from scipy.optimize import minimize as _sp_minimize
27
+
28
+ from SFI.utils.maths import default_float_dtype
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+ __all__ = ["irls_minimize", "gn_minimize", "condition_cap_ridge"]
33
+
34
+
35
+ def condition_cap_ridge(G, gram_cond_max=1e10):
36
+ r"""Tikhonov ridge that caps the condition number of the Gram ``G``.
37
+
38
+ Mirrors the existing Gauss–Newton driver: if ``cond(G) > gram_cond_max``
39
+ return ``λ = σ_max / gram_cond_max`` (so the regularised matrix
40
+ ``G + λI`` has condition number ``≤ gram_cond_max``), else ``0``. Adding
41
+ ``½λ‖θ‖²`` to the inner objective then bounds ``θ`` along the
42
+ ill-conditioned / unconstrained directions instead of letting L-BFGS
43
+ drive them to ``±∞`` — graceful degradation under high measurement noise.
44
+
45
+ Returns a finite, non-negative scalar (``0`` for a non-finite / zero
46
+ Gram, where no meaningful curvature scale exists).
47
+ """
48
+ G = jnp.asarray(G)
49
+ svals = jnp.linalg.svdvals(G)
50
+ s_max = svals[0]
51
+ s_min = svals[-1]
52
+ finite = jnp.isfinite(s_max) & (s_max > 0)
53
+ over = s_max > gram_cond_max * s_min
54
+ lam = jnp.where(finite & over, s_max / gram_cond_max, 0.0)
55
+ return lam
56
+
57
+
58
+ def irls_minimize(
59
+ theta0,
60
+ build_inner,
61
+ D0,
62
+ Lambda0,
63
+ *,
64
+ profile_fn=None,
65
+ max_outer=5,
66
+ reprofile_iters=None,
67
+ inner_maxiter=80,
68
+ inner_ftol=1e-12,
69
+ tol=1e-7,
70
+ label="",
71
+ ):
72
+ r"""Run the IRLS outer loop.
73
+
74
+ Parameters
75
+ ----------
76
+ theta0 : ``(n_params,)``
77
+ build_inner : callable
78
+ ``build_inner(theta_frozen, D, Lambda) -> inner(theta_live) ->
79
+ (value, grad)``. Built once per outer iteration so the frozen
80
+ precision is hoisted out of the inner L-BFGS sweep.
81
+ D0, Lambda0 : ``(d, d)`` initial noise matrices.
82
+ profile_fn : callable or None
83
+ ``profile_fn(theta, D, Lambda) -> (D, Lambda)``; called after
84
+ the inner solve at the first ``reprofile_iters`` outer iterations
85
+ (warm-started from the current noise). ``None`` holds ``(D, Λ)``
86
+ fixed.
87
+ max_outer, reprofile_iters, inner_maxiter, inner_ftol, tol : see module docstring.
88
+
89
+ Returns
90
+ -------
91
+ theta_best, D_best, Lambda_best, info
92
+ """
93
+ dtype = default_float_dtype()
94
+ theta = jnp.asarray(theta0, dtype=dtype)
95
+ D = jnp.asarray(D0, dtype=dtype)
96
+ Lambda = jnp.asarray(Lambda0, dtype=dtype)
97
+ if reprofile_iters is None:
98
+ reprofile_iters = max_outer
99
+
100
+ best_loss = float("inf")
101
+ best = (theta, D, Lambda)
102
+ converged = False
103
+ outer = 0
104
+
105
+ for outer in range(max_outer):
106
+ theta_prev = theta
107
+ inner = build_inner(jax.lax.stop_gradient(theta), D, Lambda)
108
+
109
+ def _obj(x):
110
+ v, g = inner(jnp.asarray(x, dtype=dtype))
111
+ return float(v), np.asarray(g, dtype=np.float64)
112
+
113
+ res = _sp_minimize(
114
+ _obj, np.asarray(theta, dtype=np.float64), jac=True, method="L-BFGS-B",
115
+ options={"maxiter": inner_maxiter, "ftol": inner_ftol, "gtol": inner_ftol},
116
+ )
117
+ theta = jnp.asarray(res.x, dtype=dtype)
118
+ loss = float(res.fun)
119
+
120
+ if profile_fn is not None and outer < reprofile_iters:
121
+ D, Lambda = profile_fn(theta, D, Lambda)
122
+
123
+ if loss < best_loss and bool(jnp.all(jnp.isfinite(theta))):
124
+ best_loss = loss
125
+ best = (theta, D, Lambda)
126
+
127
+ rel = float(jnp.linalg.norm(theta - theta_prev) / (jnp.linalg.norm(theta_prev) + 1e-30))
128
+ logger.info("[core-irls] %s outer %d: loss=%.6g rel=%.3g", label, outer, loss, rel)
129
+ if rel < tol:
130
+ converged = True
131
+ break
132
+
133
+ info = {"outer_iterations": outer + 1, "converged": converged, "loss": best_loss}
134
+ return best[0], best[1], best[2], info
135
+
136
+
137
+ def gn_minimize(
138
+ theta0,
139
+ gram_fn,
140
+ D0,
141
+ Lambda0,
142
+ *,
143
+ profile_fn=None,
144
+ nll_fn=None,
145
+ max_iter=8,
146
+ reprofile_iters=None,
147
+ gram_cond_max=1e10,
148
+ line_search_alphas=(1.0, 0.5, 0.25, 0.1),
149
+ tol=1e-7,
150
+ label="",
151
+ merit="nll",
152
+ ):
153
+ r"""Direct Gauss–Newton outer loop on the windowed Gram.
154
+
155
+ The linear-in-θ fast path: the windowed estimator already returns the GN
156
+ normal-equation pieces ``(G, f, nll)`` — with ``G = ψᵀPψ`` the GN Hessian,
157
+ ``f = ψᵀPr`` the score (``ψ = ∂r/∂θ``, so ``f`` is the **+gradient** of
158
+ ``½ rᵀP r``) — so the step is the closed-form ``δθ = −(G+λI+μ·diagG)⁻¹ f``.
159
+ No nested ``value_and_grad`` through the flow, so for a linear-in-θ force it
160
+ reaches the GLS fixed point in ~1–2 iterations instead of ~80 L-BFGS
161
+ objective evaluations.
162
+
163
+ Mirrors the legacy ``parametric_gn_iterate`` driver: Tikhonov condition cap
164
+ (:func:`condition_cap_ridge`), Levenberg–Marquardt damping with line-search
165
+ retry, ``(D, Λ)`` reprofiling at early iterations, and best-iterate
166
+ tracking.
167
+
168
+ Parameters
169
+ ----------
170
+ theta0 : ``(n_params,)``
171
+ gram_fn : callable ``(theta, D, Lambda) -> (G, f, nll)``.
172
+ D0, Lambda0 : ``(d, d)`` initial noise matrices.
173
+ profile_fn : callable or None ``(theta, D, Λ) -> (D, Λ)``; called after
174
+ iter 0 for the first ``reprofile_iters`` iterations.
175
+ nll_fn : callable or None scalar objective for the line search; falls back
176
+ to the Gram's own ``nll`` when ``None``.
177
+ merit : {"nll", "phi"} line-search / best-iterate merit. ``"nll"`` (the
178
+ symmetric MLE) descends the windowed NLL. ``"phi"`` is the
179
+ estimating-equation residual ``‖f‖ = ‖⟨ψ_left P r⟩‖`` — the correct
180
+ merit when ``G`` is asymmetric (the EIV instrument path), where the IV
181
+ root does **not** minimise the NLL. The condition guard is taken on the
182
+ symmetric part ``½(G+Gᵀ)`` so it stays valid for asymmetric ``G``.
183
+ max_iter, gram_cond_max, line_search_alphas, tol : GN budget / guards.
184
+
185
+ Returns
186
+ -------
187
+ theta_best, D_best, Lambda_best, info (same contract as
188
+ :func:`irls_minimize`).
189
+ """
190
+ dtype = default_float_dtype()
191
+ theta = jnp.asarray(theta0, dtype=dtype)
192
+ D = jnp.asarray(D0, dtype=dtype)
193
+ Lambda = jnp.asarray(Lambda0, dtype=dtype)
194
+ n_params = int(theta.shape[0])
195
+ I_n = jnp.eye(n_params, dtype=dtype)
196
+ if reprofile_iters is None:
197
+ reprofile_iters = max_iter
198
+
199
+ def _merit(th, D_, Se_):
200
+ if merit == "phi":
201
+ return float(jnp.linalg.norm(gram_fn(th, D_, Se_)[1]))
202
+ if nll_fn is not None:
203
+ return float(nll_fn(th, D_, Se_))
204
+ return float(gram_fn(th, D_, Se_)[2])
205
+
206
+ best_nll = float("inf")
207
+ best = (theta, D, Lambda)
208
+ converged = False
209
+ lm = 0.0
210
+ outer = 0
211
+
212
+ for outer in range(max_iter):
213
+ if profile_fn is not None and 0 < outer < reprofile_iters + 1:
214
+ D, Lambda = profile_fn(theta, D, Lambda)
215
+
216
+ G, f, nll = gram_fn(theta, D, Lambda)
217
+ # The acceptance bar must be the SAME objective as the line-search trial
218
+ # (_merit): when nll_fn is given, the Gram program's own nll carries an
219
+ # arbitrary offset against it (different window/conditioning
220
+ # normalisation), and comparing trial-merit against gram-nll can reject
221
+ # every step — freezing θ at the init and reporting rel=0 "convergence".
222
+ if merit == "phi":
223
+ nll_val = float(jnp.linalg.norm(f))
224
+ elif nll_fn is not None:
225
+ nll_val = float(nll_fn(theta, D, Lambda))
226
+ else:
227
+ nll_val = float(nll)
228
+ if nll_val < best_nll and bool(jnp.all(jnp.isfinite(theta))):
229
+ best_nll = nll_val
230
+ best = (theta, D, Lambda)
231
+
232
+ # Tikhonov condition cap on the symmetric part (valid for asymmetric G).
233
+ lam = float(condition_cap_ridge(0.5 * (G + G.T), gram_cond_max))
234
+ G_reg = G + lam * I_n
235
+
236
+ theta_prev = theta
237
+ accepted = False
238
+ retries = 0
239
+ while not accepted and retries <= 8:
240
+ G_damped = G_reg + lm * jnp.diag(jnp.diag(G_reg)) if lm > 0 else G_reg
241
+ delta = -jnp.linalg.solve(G_damped, f)
242
+ if not bool(jnp.all(jnp.isfinite(delta))):
243
+ retries += 1
244
+ lm = max(lm * 4.0, 1e-3)
245
+ continue
246
+ for alpha in line_search_alphas:
247
+ theta_trial = theta + alpha * delta
248
+ if not bool(jnp.all(jnp.isfinite(theta_trial))):
249
+ continue
250
+ nll_trial = _merit(theta_trial, D, Lambda)
251
+ if nll_trial < nll_val + 1e-8:
252
+ theta = theta_trial
253
+ accepted = True
254
+ if nll_trial < best_nll:
255
+ best_nll = nll_trial
256
+ best = (theta, D, Lambda)
257
+ break
258
+ if not accepted:
259
+ retries += 1
260
+ lm = max(lm * 4.0, 1e-3)
261
+
262
+ if accepted:
263
+ lm *= 0.25
264
+ rel = float(jnp.linalg.norm(theta - theta_prev) / (jnp.linalg.norm(theta_prev) + 1e-30))
265
+ logger.info("[core-gn] %s iter %d: nll=%.6g rel=%.3g lam=%.2e accepted=%s",
266
+ label, outer, nll_val, rel, lam, accepted)
267
+ if not accepted or rel < tol:
268
+ converged = rel < tol
269
+ break
270
+
271
+ info = {"outer_iterations": outer + 1, "converged": converged, "loss": best_nll}
272
+ return best[0], best[1], best[2], info
@@ -0,0 +1,149 @@
1
+ # SFI/inference/parametric_core/flow.py
2
+ """
3
+ Deterministic flow, Jacobian, and residuals for the parametric core.
4
+
5
+ The estimator linearises the SDE around the *deterministic* flow of the
6
+ drift field. For a single observation interval Δt the displacement and
7
+ its sensitivity to the start point are
8
+
9
+ Φ(y;θ) = ψ_Δt(y;θ) − y, J(y;θ) = I + ∂Φ/∂y = ∂ψ_Δt/∂y,
10
+
11
+ where ``ψ_Δt`` is the RK4 (or single-step Euler) flow of ``ẋ = F(x;θ)``.
12
+ Choosing ``integrator="euler", n_substeps=1`` recovers the bare Euler
13
+ predictor ``Φ = F(y;θ)·Δt`` — useful for *independently* assessing the
14
+ effect of flow propagation versus the covariance/precision model.
15
+
16
+ These are pure, JAX-traceable functions (parameters are closed over in
17
+ ``drift_fn``); batching over windows/particles is done by the caller via
18
+ ``vmap`` or the ``SFI.integrate`` engine.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import jax
24
+ import jax.numpy as jnp
25
+
26
+ from SFI.integrate.rk4 import select_flow
27
+
28
+ __all__ = ["flow_step", "flow_displacement", "od_window_residuals", "od_instrument"]
29
+
30
+
31
+ def flow_displacement(drift_fn, y, dt, n_substeps, integrator="rk4"):
32
+ """Deterministic displacement ``Φ(y) = flow(y) − y`` over one interval Δt.
33
+
34
+ Parameters
35
+ ----------
36
+ drift_fn : callable ``(d,) → (d,)``
37
+ Autonomous vector field with parameters already closed over.
38
+ y : array ``(d,)``
39
+ dt : float
40
+ n_substeps : int (static)
41
+ integrator : {"rk4", "euler"}
42
+
43
+ Returns
44
+ -------
45
+ Phi : array ``(d,)``
46
+ """
47
+ flow = select_flow(integrator)
48
+ return flow(drift_fn, y, dt, n_substeps) - y
49
+
50
+
51
+ def flow_step(drift_fn, y, dt, n_substeps, integrator="rk4"):
52
+ """Displacement ``Φ`` and flow Jacobian ``J = I + ∂Φ/∂y`` at a point.
53
+
54
+ Parameters
55
+ ----------
56
+ drift_fn : callable ``(d,) → (d,)``
57
+ y : array ``(d,)``
58
+ dt : float
59
+ n_substeps : int (static)
60
+ integrator : {"rk4", "euler"}
61
+
62
+ Returns
63
+ -------
64
+ Phi : array ``(d,)``
65
+ J : array ``(d, d)``
66
+ ``J = ∂(flow)/∂y`` — the linear tangent map of the flow.
67
+ """
68
+ d = y.shape[-1]
69
+
70
+ def disp(z):
71
+ return flow_displacement(drift_fn, z, dt, n_substeps, integrator)
72
+
73
+ Phi = disp(y)
74
+ J = jax.jacfwd(disp)(y) + jnp.eye(d, dtype=y.dtype)
75
+ return Phi, J
76
+
77
+
78
+ def od_window_residuals(drift_fn, X_w, dt, n_substeps, integrator="rk4"):
79
+ """Overdamped 2-point residuals and flow Jacobians over a position window.
80
+
81
+ For a window ``X_w`` of ``W`` consecutive positions, returns the
82
+ ``W-1`` residuals ``r_k = X_{k+1} − X_k − Φ_k(X_k)`` together with the
83
+ flow Jacobians ``J_k`` at each window base point.
84
+
85
+ Parameters
86
+ ----------
87
+ drift_fn : callable ``(d,) → (d,)``
88
+ X_w : array ``(W, d)``
89
+ dt : float
90
+ n_substeps : int (static)
91
+ integrator : {"rk4", "euler"}
92
+
93
+ Returns
94
+ -------
95
+ r : array ``(W-1, d)``
96
+ J : array ``(W-1, d, d)``
97
+ """
98
+ def _step(y):
99
+ return flow_step(drift_fn, y, dt, n_substeps, integrator)
100
+
101
+ Phi, J = jax.vmap(_step)(X_w[:-1])
102
+ r = X_w[1:] - X_w[:-1] - Phi
103
+ return r, J
104
+
105
+
106
+ def od_instrument(drift_of_theta, theta, x_base, dt, n_substeps, integrator="rk4"):
107
+ r"""η-clean instrument (left test function) for one overdamped residual.
108
+
109
+ The residual ``r_c = X_{c+1} − X_c − Φ(X_c;θ)`` has regressor
110
+ ``ψ_right = ∂r_c/∂θ = −∂Φ/∂θ|_{X_c}``, which is correlated with the
111
+ measurement noise ``η_c`` sitting in ``X_c`` (and in ``r_c``) — the
112
+ errors-in-variables bias. The instrument replaces that left factor with
113
+ the same sensitivity built from a base point ``x_base`` whose noise is
114
+ *independent* of the residual's (the core uses the lagged ``X_{c-1}``),
115
+ flow-propagated forward one interval so it stays geometrically aligned
116
+ with the center:
117
+
118
+ .. math::
119
+
120
+ ψ_{\mathrm{inst}} = -\tfrac12\!\left(
121
+ \left.\frac{∂Φ}{∂θ}\right|_{x_{\mathrm{base}}}
122
+ + \left.\frac{∂Φ}{∂θ}\right|_{x_{\mathrm{base}}+Φ(x_{\mathrm{base}})}
123
+ \right).
124
+
125
+ The trapezoidal pair (base point and its deterministic forward image) is
126
+ the parametric analogue of the legacy lag-1 *shift* instrument that SFI
127
+ auto-selects under measurement noise. The forward point is evaluated at
128
+ the current ``θ`` (stop-gradient), so the θ-derivative is taken of the
129
+ sensitivity *at fixed points* — a valid test function for ``∂r/∂θ``.
130
+
131
+ Parameters
132
+ ----------
133
+ drift_of_theta : callable ``θ_flat → (drift_fn : (d,) → (d,))``
134
+ theta : array ``(n_params,)``
135
+ x_base : array ``(d,)``
136
+ η-clean base point (independent of the center residual's noise).
137
+ dt, n_substeps, integrator : flow controls (as elsewhere).
138
+
139
+ Returns
140
+ -------
141
+ psi_inst : array ``(d, n_params)``
142
+ """
143
+ def disp_theta_jac(y):
144
+ return jax.jacfwd(
145
+ lambda th: flow_displacement(drift_of_theta(th), y, dt, n_substeps, integrator))(theta)
146
+
147
+ Phi0 = flow_displacement(drift_of_theta(theta), x_base, dt, n_substeps, integrator)
148
+ x_fwd = jax.lax.stop_gradient(x_base + Phi0)
149
+ return -0.5 * (disp_theta_jac(x_base) + disp_theta_jac(x_fwd))