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,129 @@
|
|
|
1
|
+
"""Framework-owned extras and the single resolver that materialises them.
|
|
2
|
+
|
|
3
|
+
A force or diffusion expression reads two kinds of data through its ``extras``
|
|
4
|
+
mapping: **user** values attached to the trajectory (drive protocols,
|
|
5
|
+
per-particle properties, geometry) and **reserved** values supplied by the
|
|
6
|
+
framework. The reserved keys are defined once here, in a small registry, and
|
|
7
|
+
assembled together with the user values by :func:`resolve_extras` — the single
|
|
8
|
+
entry point used by simulation, inference, and diagnostics.
|
|
9
|
+
|
|
10
|
+
Reserved keys:
|
|
11
|
+
|
|
12
|
+
``time``
|
|
13
|
+
Absolute time at each resolved frame — lets time-dependent bases (e.g.
|
|
14
|
+
:func:`~SFI.bases.time_fourier`) read the clock.
|
|
15
|
+
``duration``
|
|
16
|
+
Total trajectory span.
|
|
17
|
+
``dataset_index``
|
|
18
|
+
Dense index of the dataset within its collection, for pooled
|
|
19
|
+
multi-experiment models (:func:`~SFI.bases.per_dataset_scalar`,
|
|
20
|
+
:func:`~SFI.bases.dataset_indicator`).
|
|
21
|
+
``particle_index``
|
|
22
|
+
Per-particle integer ids, gathered per edge by interaction dispatchers.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
from dataclasses import dataclass
|
|
28
|
+
from typing import Any, Callable, Dict, Mapping, Optional
|
|
29
|
+
|
|
30
|
+
import jax.numpy as jnp
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class ExtrasContext:
|
|
35
|
+
"""Everything a reserved-key resolver needs for a set of frames.
|
|
36
|
+
|
|
37
|
+
Assembled by whoever drives the evaluation (the trajectory producer, the
|
|
38
|
+
diagnostics residual builder, or the simulator) and passed to
|
|
39
|
+
:func:`resolve_extras`.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
n_particles: int
|
|
43
|
+
dataset_index: int
|
|
44
|
+
frame_times: Any
|
|
45
|
+
duration: Any
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True)
|
|
49
|
+
class ReservedKey:
|
|
50
|
+
"""A framework-owned extras key and how it is materialised."""
|
|
51
|
+
|
|
52
|
+
name: str
|
|
53
|
+
resolve: Callable[[ExtrasContext], Any]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
_REGISTRY: Dict[str, ReservedKey] = {}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def register(key: ReservedKey) -> None:
|
|
60
|
+
"""Add a reserved key to the registry."""
|
|
61
|
+
_REGISTRY[key.name] = key
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
register(ReservedKey("time", lambda ctx: ctx.frame_times))
|
|
65
|
+
register(ReservedKey("duration", lambda ctx: ctx.duration))
|
|
66
|
+
register(ReservedKey("dataset_index", lambda ctx: jnp.asarray(ctx.dataset_index, dtype=jnp.int32)))
|
|
67
|
+
register(ReservedKey("particle_index", lambda ctx: jnp.arange(int(ctx.n_particles), dtype=jnp.int32)))
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
#: The set of reserved key names; user extras may not use these.
|
|
71
|
+
RESERVED_NAMES = frozenset(_REGISTRY)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def is_reserved(name: str) -> bool:
|
|
75
|
+
"""True when ``name`` is a framework-owned reserved key."""
|
|
76
|
+
return name in _REGISTRY
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def resolve_reserved(ctx: ExtrasContext) -> Dict[str, Any]:
|
|
80
|
+
"""Materialise every reserved key for ``ctx``."""
|
|
81
|
+
return {name: key.resolve(ctx) for name, key in _REGISTRY.items()}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def resolve_extras(user_extras: Mapping[str, Any], ctx: ExtrasContext) -> Dict[str, Any]:
|
|
85
|
+
"""Full per-frame extras: user values plus the resolved reserved keys.
|
|
86
|
+
|
|
87
|
+
Reserved names are framework-owned; a user entry colliding with one is
|
|
88
|
+
rejected so the meaning of a reserved key is never ambiguous.
|
|
89
|
+
"""
|
|
90
|
+
out = dict(user_extras)
|
|
91
|
+
clash = RESERVED_NAMES.intersection(out)
|
|
92
|
+
if clash:
|
|
93
|
+
raise ValueError(f"extras keys {sorted(clash)} are reserved; rename the user entries.")
|
|
94
|
+
out.update(resolve_reserved(ctx))
|
|
95
|
+
return out
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def slice_frame_extras(
|
|
99
|
+
extras_global: Optional[Mapping[str, Any]],
|
|
100
|
+
extras_local: Optional[Mapping[str, Any]],
|
|
101
|
+
*,
|
|
102
|
+
frame_idx: Any,
|
|
103
|
+
context: Optional[str] = None,
|
|
104
|
+
) -> Dict[str, Any]:
|
|
105
|
+
"""Materialise user extras at ``frame_idx``.
|
|
106
|
+
|
|
107
|
+
* :class:`~SFI.trajectory.dataset.TimeSeriesExtra` → sliced ``value.data[frame_idx]``;
|
|
108
|
+
* :class:`~SFI.trajectory.dataset.FunctionExtra` → its callable, forwarded;
|
|
109
|
+
* plain callable → invoked as ``value(frame_idx, context=context)``;
|
|
110
|
+
* anything else → forwarded unchanged.
|
|
111
|
+
|
|
112
|
+
``extras_local`` overrides ``extras_global`` on key conflicts.
|
|
113
|
+
"""
|
|
114
|
+
from SFI.trajectory.dataset import FunctionExtra, TimeSeriesExtra
|
|
115
|
+
|
|
116
|
+
def _materialise(value: Any) -> Any:
|
|
117
|
+
if isinstance(value, FunctionExtra):
|
|
118
|
+
return value.func
|
|
119
|
+
if isinstance(value, TimeSeriesExtra):
|
|
120
|
+
return jnp.asarray(value.data)[frame_idx]
|
|
121
|
+
if callable(value):
|
|
122
|
+
return value(frame_idx, context=context)
|
|
123
|
+
return value
|
|
124
|
+
|
|
125
|
+
out: Dict[str, Any] = {}
|
|
126
|
+
for source in (extras_global or {}, extras_local or {}):
|
|
127
|
+
for key, value in source.items():
|
|
128
|
+
out[key] = _materialise(value)
|
|
129
|
+
return out
|
SFI/utils/__init__.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""
|
|
2
|
+
SFI.utils — Mathematical, formatting, and plotting utilities.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from .formatting import model_summary, print_model_comparison
|
|
6
|
+
from .maths import as_default_float, default_float_dtype, fd_velocity, solve_or_pinv, sqrtm_psd, stable_pinv
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"stable_pinv",
|
|
10
|
+
"sqrtm_psd",
|
|
11
|
+
"solve_or_pinv",
|
|
12
|
+
"fd_velocity",
|
|
13
|
+
"model_summary",
|
|
14
|
+
"print_model_comparison",
|
|
15
|
+
"default_float_dtype",
|
|
16
|
+
"as_default_float",
|
|
17
|
+
]
|
SFI/utils/formatting.py
ADDED
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
# SFI/utils/formatting.py
|
|
2
|
+
"""Pretty-printing utilities for inferred models."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from typing import Optional, Sequence
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
|
|
10
|
+
# ANSI escape codes for terminal highlighting
|
|
11
|
+
_BOLD = "\033[1m"
|
|
12
|
+
_DIM = "\033[2m"
|
|
13
|
+
_RESET = "\033[0m"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def model_summary(
|
|
17
|
+
labels: Sequence[str],
|
|
18
|
+
coefficients: np.ndarray,
|
|
19
|
+
*,
|
|
20
|
+
stderr: Optional[np.ndarray] = None,
|
|
21
|
+
support: Optional[np.ndarray] = None,
|
|
22
|
+
coeffs_true: Optional[np.ndarray] = None,
|
|
23
|
+
support_true: Optional[np.ndarray] = None,
|
|
24
|
+
title: str = "Coefficient Table",
|
|
25
|
+
max_rows: int = 60,
|
|
26
|
+
significance_thresholds: tuple = (2.0, 10.0, 100.0),
|
|
27
|
+
auto_labels: bool = False,
|
|
28
|
+
) -> str:
|
|
29
|
+
"""
|
|
30
|
+
Build a human-readable coefficient table with SNR and significance.
|
|
31
|
+
|
|
32
|
+
Only active (support) coefficients are shown in the table body. Zeroed
|
|
33
|
+
basis functions are listed separately below, unless labels are
|
|
34
|
+
auto-generated.
|
|
35
|
+
|
|
36
|
+
Parameters
|
|
37
|
+
----------
|
|
38
|
+
labels : sequence of str
|
|
39
|
+
One label per basis function.
|
|
40
|
+
coefficients : 1-D array
|
|
41
|
+
Coefficient vector (length must match *labels* or *support*).
|
|
42
|
+
stderr : 1-D array or None
|
|
43
|
+
Standard errors (same length as *coefficients*). These reflect
|
|
44
|
+
sampling error only; discretization (finite-time-step) bias is
|
|
45
|
+
not included.
|
|
46
|
+
support : 1-D int array or None
|
|
47
|
+
Indices into *labels* that *coefficients* correspond to.
|
|
48
|
+
If ``None``, full support is assumed (all basis functions
|
|
49
|
+
have non-zero coefficients).
|
|
50
|
+
title : str
|
|
51
|
+
Section header printed above the table.
|
|
52
|
+
max_rows : int
|
|
53
|
+
If the table exceeds this many rows, truncate the middle.
|
|
54
|
+
significance_thresholds : tuple of float
|
|
55
|
+
Three SNR thresholds (in multiples of stderr) for the ``*``, ``**``,
|
|
56
|
+
and ``***`` significance levels. Defaults ``(2.0, 10.0, 100.0)``.
|
|
57
|
+
auto_labels : bool
|
|
58
|
+
If ``True``, labels were auto-generated (e.g. ``b0``, ``b1``, …)
|
|
59
|
+
and the list of zeroed functions is suppressed.
|
|
60
|
+
|
|
61
|
+
Returns
|
|
62
|
+
-------
|
|
63
|
+
str
|
|
64
|
+
Ready-to-print multi-line table. Terms are marked *, **, or ***
|
|
65
|
+
according to *significance_thresholds*; *** terms are **bold**,
|
|
66
|
+
* and ** terms are normal weight, non-significant active terms are dimmed.
|
|
67
|
+
"""
|
|
68
|
+
coefficients = np.asarray(coefficients).ravel()
|
|
69
|
+
n_labels = len(labels)
|
|
70
|
+
|
|
71
|
+
# Expand coefficients to full length when support is given
|
|
72
|
+
if support is not None:
|
|
73
|
+
support = np.asarray(support).ravel().astype(int)
|
|
74
|
+
if len(coefficients) == len(support):
|
|
75
|
+
# coefficients is sparse; expand to full length
|
|
76
|
+
full = np.zeros(n_labels)
|
|
77
|
+
full[support] = coefficients
|
|
78
|
+
coefficients = full
|
|
79
|
+
elif len(coefficients) != n_labels:
|
|
80
|
+
raise ValueError(
|
|
81
|
+
f"len(coefficients)={len(coefficients)} matches neither "
|
|
82
|
+
f"len(support)={len(support)} nor n_labels={n_labels}"
|
|
83
|
+
)
|
|
84
|
+
active = set(int(i) for i in support)
|
|
85
|
+
else:
|
|
86
|
+
active = None # all active
|
|
87
|
+
|
|
88
|
+
has_stderr = stderr is not None
|
|
89
|
+
if has_stderr:
|
|
90
|
+
stderr = np.asarray(stderr).ravel()
|
|
91
|
+
if stderr.shape[0] != n_labels:
|
|
92
|
+
# Might be sparse; try to expand
|
|
93
|
+
if support is not None and stderr.shape[0] == len(support):
|
|
94
|
+
se_full = np.zeros(n_labels)
|
|
95
|
+
se_full[support] = stderr
|
|
96
|
+
stderr = se_full
|
|
97
|
+
else:
|
|
98
|
+
raise ValueError(
|
|
99
|
+
f"len(stderr)={stderr.shape[0]} matches neither "
|
|
100
|
+
f"n_labels={n_labels} nor len(support)={len(support) if support is not None else 'N/A'}"
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
# Expand ground-truth coefficients to full length (paired "True" column)
|
|
104
|
+
has_true = coeffs_true is not None
|
|
105
|
+
true_full = None
|
|
106
|
+
if has_true:
|
|
107
|
+
ct = np.asarray(coeffs_true).ravel()
|
|
108
|
+
true_full = np.zeros(n_labels)
|
|
109
|
+
if support_true is not None:
|
|
110
|
+
st = np.asarray(support_true).ravel().astype(int)
|
|
111
|
+
true_full[st] = ct
|
|
112
|
+
elif ct.shape[0] == n_labels:
|
|
113
|
+
true_full = ct
|
|
114
|
+
elif support is not None and ct.shape[0] == len(support):
|
|
115
|
+
true_full[support] = ct
|
|
116
|
+
else:
|
|
117
|
+
true_full[: ct.shape[0]] = ct
|
|
118
|
+
|
|
119
|
+
# ── Build rows ──
|
|
120
|
+
# (index, label, coefficient, stderr_or_None, is_active, snr_or_None, true_or_None)
|
|
121
|
+
rows: list = []
|
|
122
|
+
for i, lab in enumerate(labels):
|
|
123
|
+
c = float(coefficients[i])
|
|
124
|
+
se = float(stderr[i]) if (stderr is not None and i < len(stderr)) else None
|
|
125
|
+
is_active = (active is None) or (i in active)
|
|
126
|
+
snr = abs(c / se) if (se is not None and se > 0) else None
|
|
127
|
+
tv = float(true_full[i]) if has_true else None
|
|
128
|
+
rows.append((i, lab, c, se, is_active, snr, tv))
|
|
129
|
+
|
|
130
|
+
# ── Column widths ──
|
|
131
|
+
idx_w = max(3, len(str(n_labels - 1)))
|
|
132
|
+
lab_w = max(5, max(len(r[1]) for r in rows))
|
|
133
|
+
coeff_fmt = "{:>12.5e}"
|
|
134
|
+
se_fmt = "{:>12.5e}"
|
|
135
|
+
snr_fmt = "{:>6.1f}"
|
|
136
|
+
|
|
137
|
+
# Determine whether SNR column should be shown
|
|
138
|
+
has_snr = has_stderr and any(r[5] is not None for r in rows)
|
|
139
|
+
|
|
140
|
+
# ── Header ──
|
|
141
|
+
sep_w = idx_w + lab_w + 30
|
|
142
|
+
if has_stderr:
|
|
143
|
+
sep_w += 15
|
|
144
|
+
if has_snr:
|
|
145
|
+
sep_w += 9 # " SNR "
|
|
146
|
+
if has_true:
|
|
147
|
+
sep_w += 15
|
|
148
|
+
sep = "─" * sep_w
|
|
149
|
+
|
|
150
|
+
lines: list[str] = []
|
|
151
|
+
lines.append(f" {title}")
|
|
152
|
+
lines.append(f" {sep}")
|
|
153
|
+
|
|
154
|
+
hdr = f" {'#':<{idx_w}} {'Label':<{lab_w}} {'Coefficient':>12}"
|
|
155
|
+
if has_stderr:
|
|
156
|
+
hdr += f" {'Std.Err':>12}"
|
|
157
|
+
if has_snr:
|
|
158
|
+
hdr += f" {'SNR':>6}"
|
|
159
|
+
if has_true:
|
|
160
|
+
hdr += f" {'True':>12}"
|
|
161
|
+
hdr += " Sig"
|
|
162
|
+
lines.append(hdr)
|
|
163
|
+
lines.append(f" {sep}")
|
|
164
|
+
|
|
165
|
+
# ── Rows (with optional truncation) — only show active (support) entries ──
|
|
166
|
+
thr1, thr2, thr3 = significance_thresholds
|
|
167
|
+
|
|
168
|
+
def _sig_marker(snr):
|
|
169
|
+
"""Return (marker_str, is_bold) for a given SNR (or None)."""
|
|
170
|
+
if snr is None:
|
|
171
|
+
return "·", False
|
|
172
|
+
if snr >= thr3:
|
|
173
|
+
return "***", True
|
|
174
|
+
if snr >= thr2:
|
|
175
|
+
return "**", False
|
|
176
|
+
if snr >= thr1:
|
|
177
|
+
return "*", False
|
|
178
|
+
return "·", False
|
|
179
|
+
|
|
180
|
+
def _fmt_row(r):
|
|
181
|
+
i, lab, c, se, act, snr, tv = r
|
|
182
|
+
marker, bold = _sig_marker(snr if act else None)
|
|
183
|
+
|
|
184
|
+
# Start with index and label
|
|
185
|
+
s = f" {i:<{idx_w}} {lab:<{lab_w}} {coeff_fmt.format(c)}"
|
|
186
|
+
if has_stderr:
|
|
187
|
+
s += f" {se_fmt.format(se)}" if se is not None else f" {'---':>12}"
|
|
188
|
+
if has_snr:
|
|
189
|
+
s += f" {snr_fmt.format(snr)}" if snr is not None else f" {'---':>6}"
|
|
190
|
+
if has_true:
|
|
191
|
+
s += f" {coeff_fmt.format(tv)}" if tv is not None else f" {'---':>12}"
|
|
192
|
+
|
|
193
|
+
# Significance marker (padded to 3 chars for alignment)
|
|
194
|
+
s += f" {marker:<3}"
|
|
195
|
+
|
|
196
|
+
# Apply ANSI formatting: bold for ***, dim for non-significant active
|
|
197
|
+
if bold:
|
|
198
|
+
s = _BOLD + s + _RESET
|
|
199
|
+
elif marker == "·" and has_snr:
|
|
200
|
+
s = _DIM + s + _RESET
|
|
201
|
+
|
|
202
|
+
return s
|
|
203
|
+
|
|
204
|
+
active_rows = [r for r in rows if r[4]]
|
|
205
|
+
zero_rows = [r for r in rows if not r[4]]
|
|
206
|
+
|
|
207
|
+
if len(active_rows) <= max_rows:
|
|
208
|
+
for r in active_rows:
|
|
209
|
+
lines.append(_fmt_row(r))
|
|
210
|
+
else:
|
|
211
|
+
half = (max_rows - 1) // 2
|
|
212
|
+
for r in active_rows[:half]:
|
|
213
|
+
lines.append(_fmt_row(r))
|
|
214
|
+
lines.append(f" {'...':^{sep_w}}")
|
|
215
|
+
for r in active_rows[-half:]:
|
|
216
|
+
lines.append(_fmt_row(r))
|
|
217
|
+
|
|
218
|
+
lines.append(f" {sep}")
|
|
219
|
+
|
|
220
|
+
n_active = len(active_rows)
|
|
221
|
+
summary_line = f" {n_active}/{n_labels} basis functions in support"
|
|
222
|
+
if has_snr:
|
|
223
|
+
n1 = sum(1 for r in active_rows if r[5] is not None and r[5] >= thr1)
|
|
224
|
+
n2 = sum(1 for r in active_rows if r[5] is not None and r[5] >= thr2)
|
|
225
|
+
n3 = sum(1 for r in active_rows if r[5] is not None and r[5] >= thr3)
|
|
226
|
+
summary_line += f", sig: {n1}* / {n2}** / {n3}*** (|SNR| ≥ {thr1:.4g} / {thr2:.4g} / {thr3:.4g})"
|
|
227
|
+
lines.append(summary_line)
|
|
228
|
+
if has_stderr:
|
|
229
|
+
lines.append(" (Std.err. reflects sampling error only; discretization bias is not included.)")
|
|
230
|
+
|
|
231
|
+
# ── Zeroed basis functions ──
|
|
232
|
+
if zero_rows and not auto_labels:
|
|
233
|
+
zero_labels = ", ".join(r[1] for r in zero_rows)
|
|
234
|
+
lines.append(f" Zeroed ({len(zero_rows)}): {zero_labels}")
|
|
235
|
+
|
|
236
|
+
return "\n".join(lines)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def print_model_comparison(
|
|
240
|
+
inferences,
|
|
241
|
+
labels,
|
|
242
|
+
*,
|
|
243
|
+
extra_cols=None,
|
|
244
|
+
metrics=None,
|
|
245
|
+
title: str = "Model Comparison",
|
|
246
|
+
) -> str:
|
|
247
|
+
"""Build a multi-model comparison table from several inference objects.
|
|
248
|
+
|
|
249
|
+
Parameters
|
|
250
|
+
----------
|
|
251
|
+
inferences :
|
|
252
|
+
Sequence of fitted inference objects.
|
|
253
|
+
labels :
|
|
254
|
+
One name per inference (row labels).
|
|
255
|
+
metrics :
|
|
256
|
+
Attribute names to read from each inference (default
|
|
257
|
+
``["n_params", "NMSE_force", "force_predicted_MSE"]``). The special
|
|
258
|
+
``"n_params"`` counts ``force_coefficients_full``.
|
|
259
|
+
extra_cols :
|
|
260
|
+
Optional ``{column_name: {label: value}}`` of caller-supplied cells.
|
|
261
|
+
title :
|
|
262
|
+
Header line.
|
|
263
|
+
|
|
264
|
+
Returns
|
|
265
|
+
-------
|
|
266
|
+
str
|
|
267
|
+
Ready-to-print table.
|
|
268
|
+
"""
|
|
269
|
+
if metrics is None:
|
|
270
|
+
metrics = ["n_params", "NMSE_force", "force_predicted_MSE"]
|
|
271
|
+
extra_cols = extra_cols or {}
|
|
272
|
+
cols = ["Model"] + list(metrics) + list(extra_cols.keys())
|
|
273
|
+
|
|
274
|
+
def _metric(inf, name):
|
|
275
|
+
if name == "n_params":
|
|
276
|
+
cf = getattr(inf, "force_coefficients_full", None)
|
|
277
|
+
return int(np.asarray(cf).size) if cf is not None else None
|
|
278
|
+
v = getattr(inf, name, None)
|
|
279
|
+
return float(v) if v is not None else None
|
|
280
|
+
|
|
281
|
+
rows = []
|
|
282
|
+
for inf, lab in zip(inferences, labels):
|
|
283
|
+
row = {"Model": lab}
|
|
284
|
+
for name in metrics:
|
|
285
|
+
row[name] = _metric(inf, name)
|
|
286
|
+
for col, vals in extra_cols.items():
|
|
287
|
+
row[col] = vals.get(lab) if isinstance(vals, dict) else None
|
|
288
|
+
rows.append(row)
|
|
289
|
+
|
|
290
|
+
def _fmt(v):
|
|
291
|
+
if v is None:
|
|
292
|
+
return "—"
|
|
293
|
+
if isinstance(v, bool):
|
|
294
|
+
return str(v)
|
|
295
|
+
if isinstance(v, int):
|
|
296
|
+
return str(v)
|
|
297
|
+
if isinstance(v, float):
|
|
298
|
+
return f"{v:.4g}"
|
|
299
|
+
return str(v)
|
|
300
|
+
|
|
301
|
+
widths = {c: max(len(str(c)), *(len(_fmt(r.get(c))) for r in rows)) for c in cols}
|
|
302
|
+
lines = [f" {title}"]
|
|
303
|
+
header = " " + " ".join(f"{str(c):>{widths[c]}}" for c in cols)
|
|
304
|
+
lines.append(header)
|
|
305
|
+
lines.append(" " + "─" * (len(header) - 2))
|
|
306
|
+
for r in rows:
|
|
307
|
+
lines.append(" " + " ".join(f"{_fmt(r.get(c)):>{widths[c]}}" for c in cols))
|
|
308
|
+
return "\n".join(lines)
|
SFI/utils/maths.py
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import jax
|
|
2
|
+
import jax.numpy as jnp
|
|
3
|
+
import jax.scipy.linalg as jsp_la
|
|
4
|
+
from jax import dtypes as _jdtypes
|
|
5
|
+
from jax import jit
|
|
6
|
+
|
|
7
|
+
# ---------------------------------------------------------------------
|
|
8
|
+
# Mathematical utilities
|
|
9
|
+
# ---------------------------------------------------------------------
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def default_float_dtype():
|
|
13
|
+
"""Return JAX's currently-active default float dtype.
|
|
14
|
+
|
|
15
|
+
Returns ``float64`` when ``JAX_ENABLE_X64`` is set, ``float32`` otherwise.
|
|
16
|
+
Use this everywhere user-supplied arrays enter the simulation pipeline
|
|
17
|
+
so that all internal arithmetic shares a single dtype and ``lax.scan``
|
|
18
|
+
carry-in / carry-out types always agree.
|
|
19
|
+
"""
|
|
20
|
+
return _jdtypes.canonicalize_dtype(jnp.float64)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def as_default_float(x):
|
|
24
|
+
"""Return ``x`` cast to the JAX default float dtype.
|
|
25
|
+
|
|
26
|
+
Accepts any array-like; passes through ``None`` unchanged so callers
|
|
27
|
+
can use it on optional inputs (e.g. ``v0``).
|
|
28
|
+
"""
|
|
29
|
+
if x is None:
|
|
30
|
+
return None
|
|
31
|
+
return jnp.asarray(x, dtype=default_float_dtype())
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def fd_velocity(x, dt, *, scheme: str = "central"):
|
|
35
|
+
"""Finite-difference velocity along the leading (time) axis.
|
|
36
|
+
|
|
37
|
+
Reconstructs ``v(t) ≈ dx/dt`` from a position array using a
|
|
38
|
+
finite-difference stencil — the same secant-velocity convention the
|
|
39
|
+
underdamped inference engine uses internally. The output keeps the
|
|
40
|
+
same leading time dimension as ``x`` (boundary frames fall back to a
|
|
41
|
+
one-sided stencil).
|
|
42
|
+
|
|
43
|
+
Parameters
|
|
44
|
+
----------
|
|
45
|
+
x : array_like, shape ``(T, ...)``
|
|
46
|
+
Positions sampled in time along axis 0 (e.g. ``(T, d)`` or
|
|
47
|
+
``(T, N, d)``).
|
|
48
|
+
dt : float or array_like, shape ``(T - 1,)``
|
|
49
|
+
Time step. Scalar for uniform sampling, or per-interval spacings.
|
|
50
|
+
scheme : {"central", "forward", "backward"}
|
|
51
|
+
Interior stencil. ``"central"`` is second-order accurate and the
|
|
52
|
+
default; ``"forward"`` / ``"backward"`` are first-order.
|
|
53
|
+
|
|
54
|
+
Returns
|
|
55
|
+
-------
|
|
56
|
+
v : jax.Array, shape ``(T, ...)``
|
|
57
|
+
Velocity estimate, same shape as ``x``.
|
|
58
|
+
"""
|
|
59
|
+
x = jnp.asarray(x)
|
|
60
|
+
if x.shape[0] < 2:
|
|
61
|
+
raise ValueError("fd_velocity needs at least 2 time points.")
|
|
62
|
+
dt = jnp.asarray(dt)
|
|
63
|
+
n_extra = x.ndim - 1
|
|
64
|
+
|
|
65
|
+
def _bcast(a):
|
|
66
|
+
# reshape a per-step (T-1,) array to broadcast over the trailing axes
|
|
67
|
+
return a.reshape(a.shape + (1,) * n_extra)
|
|
68
|
+
|
|
69
|
+
# forward differences between consecutive frames, shape (T-1, ...)
|
|
70
|
+
fwd = (x[1:] - x[:-1]) / (dt if dt.ndim == 0 else _bcast(dt))
|
|
71
|
+
|
|
72
|
+
if scheme == "forward":
|
|
73
|
+
return jnp.concatenate([fwd, fwd[-1:]], axis=0)
|
|
74
|
+
if scheme == "backward":
|
|
75
|
+
return jnp.concatenate([fwd[:1], fwd], axis=0)
|
|
76
|
+
if scheme == "central":
|
|
77
|
+
denom = (2.0 * dt) if dt.ndim == 0 else _bcast(dt[:-1] + dt[1:])
|
|
78
|
+
central = (x[2:] - x[:-2]) / denom
|
|
79
|
+
return jnp.concatenate([fwd[:1], central, fwd[-1:]], axis=0)
|
|
80
|
+
raise ValueError(f"Unknown scheme {scheme!r}; expected central/forward/backward.")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def stable_pinv(G):
|
|
84
|
+
"""Numerically-stable pseudo-inverse of a Gram matrix.
|
|
85
|
+
|
|
86
|
+
Normalizes G by its diagonal before inversion so that all diagonal
|
|
87
|
+
entries of the rescaled matrix are 1. This avoids ill-conditioning
|
|
88
|
+
when basis functions have very different scales.
|
|
89
|
+
|
|
90
|
+
Algorithm: let d_i = sqrt(G_{ii}) (clamped to 1 when zero). Compute
|
|
91
|
+
pinv(G / outer(d, d)) and rescale by outer(1/d, 1/d) to recover the
|
|
92
|
+
pseudo-inverse of the original G.
|
|
93
|
+
"""
|
|
94
|
+
# Normalize the matrix before computing the pseudo-inverse, for numerical stability
|
|
95
|
+
G_norm = jnp.sqrt(jnp.diag(G)) # Extract diagonal elements as normalization factors
|
|
96
|
+
# Avoid division by zero: if G_norm[i] is zero, keep it zero in scaling
|
|
97
|
+
safe_G_norm = jnp.where(G_norm > 0, G_norm, 1.0) # Replace 0 with 1 to avoid NaN in division
|
|
98
|
+
return jnp.linalg.pinv(G / jnp.outer(safe_G_norm, safe_G_norm)) * jnp.outer(1 / safe_G_norm, 1 / safe_G_norm)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@jit
|
|
102
|
+
def sqrtm_psd(A: jax.Array, eps: float = 0.0) -> jax.Array:
|
|
103
|
+
"""
|
|
104
|
+
Symmetric PSD matrix square root, applied to the last two (matrix) axes.
|
|
105
|
+
- Symmetrizes input for stability.
|
|
106
|
+
- Clips negative eigenvalues to 0 (PSD) then takes sqrt.
|
|
107
|
+
- Re-symmetrizes the result to kill small asymmetries from numerics.
|
|
108
|
+
"""
|
|
109
|
+
A = 0.5 * (A + jnp.swapaxes(A, -1, -2))
|
|
110
|
+
w, V = jnp.linalg.eigh(A)
|
|
111
|
+
w = jnp.clip(w, min=0.0) # PSD safeguard
|
|
112
|
+
if eps:
|
|
113
|
+
w = w + eps # optional jitter if you like
|
|
114
|
+
S = (V * jnp.sqrt(w)[..., None, :]) @ jnp.swapaxes(V, -1, -2)
|
|
115
|
+
return 0.5 * (S + jnp.swapaxes(S, -1, -2))
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def solve_or_pinv(A: jax.Array, b: jax.Array, tol: float = 1e-15) -> jax.Array:
|
|
119
|
+
"""
|
|
120
|
+
Solve A ⋅ x = b for x, with a fallback to the Moore–Penrose pseudo-inverse
|
|
121
|
+
if A is singular or not square. To improve numerical stability, we first
|
|
122
|
+
normalize A by its diagonal: A_norm = D^{-1} A D^{-1}, b_norm = D^{-1} b,
|
|
123
|
+
solve A_norm ⋅ x_norm = b_norm, and then recover x = D^{-1} x_norm.
|
|
124
|
+
|
|
125
|
+
This ensures that the diagonal entries of A_norm are 1 (assuming A has
|
|
126
|
+
positive diagonal), which often makes the linear solve or pseudo-inverse
|
|
127
|
+
more robust when A has widely varying scales on its diagonal.
|
|
128
|
+
|
|
129
|
+
Parameters
|
|
130
|
+
----------
|
|
131
|
+
A : jax.Array, shape (k, k)
|
|
132
|
+
The matrix to solve against. We assume that A has nonnegative diagonal
|
|
133
|
+
entries; if any diagonal entry is zero, we clip it to a small floor
|
|
134
|
+
to avoid division by zero.
|
|
135
|
+
b : jax.Array, shape (k,)
|
|
136
|
+
The right-hand side vector.
|
|
137
|
+
tol : float, default=1e-15
|
|
138
|
+
The tolerance for the pseudo-inverse. If A_norm is effectively singular,
|
|
139
|
+
we compute x_norm = pinv(A_norm, rcond=tol) @ b_norm.
|
|
140
|
+
|
|
141
|
+
Returns
|
|
142
|
+
-------
|
|
143
|
+
x : jax.Array, shape (k,)
|
|
144
|
+
The solution vector to A ⋅ x = b, computed as follows:
|
|
145
|
+
1) d_i = sqrt(max(A_{ii}, tol))
|
|
146
|
+
(we floor each diagonal entry to tol > 0 to avoid zero divides)
|
|
147
|
+
2) A_norm = D_inv @ A @ D_inv where D_inv = diag(1 / d_i)
|
|
148
|
+
b_norm = b / d
|
|
149
|
+
3) Solve A_norm ⋅ x_norm = b_norm:
|
|
150
|
+
- if A_norm is non-singular, use a direct solver
|
|
151
|
+
- otherwise, fall back to x_norm = pinv(A_norm) @ b_norm
|
|
152
|
+
4) Recover x = x_norm / d
|
|
153
|
+
"""
|
|
154
|
+
# 1) Extract and “floor” the diagonal of A to avoid zeros or negatives.
|
|
155
|
+
# If A_{ii} is very small or negative (due to numerical noise), we floor it.
|
|
156
|
+
diag_A = jnp.diag(A) # shape (k,)
|
|
157
|
+
# Clip to tol to avoid sqrt of zero or negative
|
|
158
|
+
diag_clipped = jnp.clip(diag_A, min=tol) # shape (k,)
|
|
159
|
+
d = jnp.sqrt(diag_clipped) # shape (k,)
|
|
160
|
+
|
|
161
|
+
# 2) Build the inverse scaling matrix D_inv = diag(1 / d_i)
|
|
162
|
+
# We do this by dividing each row of A by d_i and each column by d_j.
|
|
163
|
+
# More precisely: A_norm[i,j] = A[i,j] / (d[i] * d[j]).
|
|
164
|
+
D_inv = 1.0 / d # shape (k,)
|
|
165
|
+
# Use broadcasting to normalize A: first divide each row by d,
|
|
166
|
+
# then each column by d.
|
|
167
|
+
A_norm = (A * D_inv[:, None]) * D_inv[None, :]
|
|
168
|
+
|
|
169
|
+
# 3) Normalize the RHS vector b as well: b_norm = b / d.
|
|
170
|
+
b_norm = b / d # shape (k,)
|
|
171
|
+
|
|
172
|
+
# 4) Attempt a direct solve of A_norm ⋅ x_norm = b_norm.
|
|
173
|
+
# If A_norm is non-singular, this will succeed.
|
|
174
|
+
try:
|
|
175
|
+
# We do not assume positive-definite here, so use 'gen' unless we detect
|
|
176
|
+
# symmetry and positive definiteness. For simplicity, assume 'gen'.
|
|
177
|
+
x_norm = jsp_la.solve(A_norm, b_norm, assume_a="gen")
|
|
178
|
+
except Exception: # broad catch: JAX/XLA doesn't expose a stable LinAlgError type
|
|
179
|
+
# 4a) Fallback: if A_norm is singular or not square, use pseudo-inverse.
|
|
180
|
+
x_norm = jnp.linalg.pinv(A_norm, rtol=tol) @ b_norm
|
|
181
|
+
|
|
182
|
+
# 5) Scale back to obtain the final solution: x_i = x_norm[i] / d[i].
|
|
183
|
+
x = x_norm / d # shape (k,)
|
|
184
|
+
|
|
185
|
+
return x
|