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