mihenk 0.1.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.
- mihenk/__init__.py +31 -0
- mihenk/adapters/__init__.py +1 -0
- mihenk/adapters/pysindy.py +146 -0
- mihenk/bundle.py +168 -0
- mihenk/cli.py +185 -0
- mihenk/model.py +302 -0
- mihenk/numerics.py +138 -0
- mihenk/provenance.py +48 -0
- mihenk/report.py +293 -0
- mihenk/scan.py +738 -0
- mihenk/synth.py +95 -0
- mihenk/validate.py +158 -0
- mihenk-0.1.0.dist-info/METADATA +167 -0
- mihenk-0.1.0.dist-info/RECORD +18 -0
- mihenk-0.1.0.dist-info/WHEEL +5 -0
- mihenk-0.1.0.dist-info/entry_points.txt +2 -0
- mihenk-0.1.0.dist-info/licenses/LICENSE +202 -0
- mihenk-0.1.0.dist-info/top_level.txt +1 -0
mihenk/__init__.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""mihenk: a touchstone for discovered equations.
|
|
2
|
+
|
|
3
|
+
Before you interpret the law that PySINDy (or any equation-discovery tool)
|
|
4
|
+
found, mihenk checks whether it could be an artifact of noise, of how
|
|
5
|
+
derivatives were estimated, of which states the data happened to visit, or of
|
|
6
|
+
analysis choices.
|
|
7
|
+
|
|
8
|
+
import mihenk
|
|
9
|
+
report = mihenk.scan(bundle, model) # tier 0: data + equation
|
|
10
|
+
report = mihenk.scan_pysindy(sindy, x, t) # tiers 0-1 with a fitted PySINDy model
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
__version__ = "0.1.0"
|
|
14
|
+
|
|
15
|
+
from .bundle import Bundle # noqa: E402
|
|
16
|
+
from .model import Model # noqa: E402
|
|
17
|
+
from .report import Check, Finding, Report # noqa: E402
|
|
18
|
+
from .scan import Thresholds, scan # noqa: E402
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def scan_pysindy(sindy_model, x, t, *, feature_names=None, name="pysindy", val=None,
|
|
22
|
+
thresholds=None, variants=True, seed=0, meta=None):
|
|
23
|
+
"""Scan a fitted PySINDy model on the data it was fitted to (tiers 0 and 1)."""
|
|
24
|
+
from .adapters import pysindy as _ps
|
|
25
|
+
|
|
26
|
+
return _ps.scan_pysindy(sindy_model, x, t, feature_names=feature_names, name=name, val=val,
|
|
27
|
+
thresholds=thresholds, variants=variants, seed=seed, meta=meta)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
__all__ = ["Bundle", "Model", "Report", "Finding", "Check", "Thresholds", "scan", "scan_pysindy",
|
|
31
|
+
"__version__"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Adapters for discovery tools. Each is optional and imports its tool lazily."""
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""PySINDy adapter (``pip install "mihenk[pysindy]"``), written against PySINDy 2.x.
|
|
2
|
+
|
|
3
|
+
Turns a fitted ``pysindy.SINDy`` into a :class:`~mihenk.model.Model`, and builds
|
|
4
|
+
the tier-1 callables: ``refit`` (the same configuration on resampled data) and
|
|
5
|
+
``variants`` (the same data under other defensible derivative methods and
|
|
6
|
+
sparsity thresholds).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import numpy as np
|
|
12
|
+
|
|
13
|
+
from ..bundle import Bundle
|
|
14
|
+
from ..model import Model, parse_term
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _require():
|
|
18
|
+
try:
|
|
19
|
+
import pysindy # noqa: F401
|
|
20
|
+
except ImportError as exc: # pragma: no cover
|
|
21
|
+
raise ImportError('this needs PySINDy: pip install "mihenk[pysindy]"') from exc
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def from_pysindy(sindy, variables=None, source="pysindy") -> Model:
|
|
25
|
+
"""Read variables, candidate library and coefficients from a fitted PySINDy model."""
|
|
26
|
+
_require()
|
|
27
|
+
if getattr(sindy, "n_control_features_", 0):
|
|
28
|
+
raise NotImplementedError("models with control inputs (u) are not supported in mihenk 0.1")
|
|
29
|
+
lib_name = type(sindy.feature_library).__name__
|
|
30
|
+
if "Weak" in lib_name or "PDE" in lib_name:
|
|
31
|
+
raise NotImplementedError(f"{lib_name} is not supported in mihenk 0.1 (ODE libraries only)")
|
|
32
|
+
variables = list(variables or sindy.feature_names)
|
|
33
|
+
names = sindy.get_feature_names()
|
|
34
|
+
try:
|
|
35
|
+
terms = [parse_term(n, variables) for n in names]
|
|
36
|
+
except Exception as exc:
|
|
37
|
+
raise ValueError(f"cannot read PySINDy feature names as expressions ({names[:5]}...): {exc}") from exc
|
|
38
|
+
coefs = np.asarray(sindy.coefficients(), dtype=float)
|
|
39
|
+
m = Model(variables, terms, coefs, library=list(terms), source=source)
|
|
40
|
+
m.meta = {"library": lib_name, "optimizer": type(sindy.optimizer).__name__,
|
|
41
|
+
"differentiation": type(sindy.differentiation_method).__name__}
|
|
42
|
+
return m
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _fit_clone(sindy, pairs, variables, **overrides):
|
|
46
|
+
from sklearn.base import clone
|
|
47
|
+
|
|
48
|
+
m = clone(sindy)
|
|
49
|
+
if overrides:
|
|
50
|
+
m.set_params(**overrides)
|
|
51
|
+
xs = [x for x, _ in pairs]
|
|
52
|
+
ts = [t for _, t in pairs]
|
|
53
|
+
import warnings
|
|
54
|
+
|
|
55
|
+
with warnings.catch_warnings():
|
|
56
|
+
warnings.simplefilter("ignore")
|
|
57
|
+
if len(xs) == 1:
|
|
58
|
+
m.fit(xs[0], t=ts[0], feature_names=list(variables))
|
|
59
|
+
else:
|
|
60
|
+
m.fit(xs, t=ts, feature_names=list(variables))
|
|
61
|
+
model = from_pysindy(m, variables)
|
|
62
|
+
# STLSQ can stop at max_iter with coefficients far below its own threshold still non-zero;
|
|
63
|
+
# in a refit those are optimizer leftovers, not structure, so they are not counted as terms.
|
|
64
|
+
thr = m.optimizer.get_params().get("threshold")
|
|
65
|
+
if thr:
|
|
66
|
+
small = (np.abs(model.coefficients) < thr) & (model.coefficients != 0)
|
|
67
|
+
model.meta["pruned_below_threshold"] = int(small.sum())
|
|
68
|
+
model.coefficients[small] = 0.0
|
|
69
|
+
return model
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def make_refit(sindy, variables=None):
|
|
73
|
+
variables = list(variables or sindy.feature_names)
|
|
74
|
+
return lambda pairs: _fit_clone(sindy, pairs, variables)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def make_variants(sindy, variables=None, threshold_factors=(0.5, 1.0, 1.5)) -> dict:
|
|
78
|
+
"""Other defensible analyses of the same data: derivative method x sparsity threshold."""
|
|
79
|
+
import pysindy as ps
|
|
80
|
+
|
|
81
|
+
variables = list(variables or sindy.feature_names)
|
|
82
|
+
diffs = {"FiniteDifference": ps.FiniteDifference, "SmoothedFiniteDifference": ps.SmoothedFiniteDifference}
|
|
83
|
+
current_diff = type(sindy.differentiation_method).__name__
|
|
84
|
+
params = sindy.optimizer.get_params()
|
|
85
|
+
th0 = params.get("threshold")
|
|
86
|
+
out = {}
|
|
87
|
+
for dname, dcls in diffs.items():
|
|
88
|
+
for f in threshold_factors if th0 is not None else (1.0,):
|
|
89
|
+
if dname == current_diff and f == 1.0:
|
|
90
|
+
continue # that is the original analysis
|
|
91
|
+
over = {"differentiation_method": dcls()}
|
|
92
|
+
label = dname
|
|
93
|
+
if th0 is not None:
|
|
94
|
+
over["optimizer__threshold"] = th0 * f
|
|
95
|
+
label += f", threshold {th0 * f:.3g}"
|
|
96
|
+
out[label] = (lambda pairs, over=over: _fit_clone(sindy, pairs, variables, **over))
|
|
97
|
+
return out
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def scan_pysindy(sindy, x, t, *, feature_names=None, name="pysindy", val=None, thresholds=None,
|
|
101
|
+
variants=True, seed=0, meta=None):
|
|
102
|
+
from ..scan import scan
|
|
103
|
+
|
|
104
|
+
variables = list(feature_names or sindy.feature_names)
|
|
105
|
+
xs = x if isinstance(x, (list, tuple)) else [x]
|
|
106
|
+
if isinstance(t, (int, float)):
|
|
107
|
+
ts = [np.arange(len(xi)) * float(t) for xi in xs]
|
|
108
|
+
else:
|
|
109
|
+
ts = t if isinstance(t, (list, tuple)) else [t]
|
|
110
|
+
b = Bundle(list(xs), list(ts), variables, val=val, meta={"name": name, **(meta or {})})
|
|
111
|
+
model = from_pysindy(sindy, variables)
|
|
112
|
+
return scan(b, model, refit=make_refit(sindy, variables),
|
|
113
|
+
variants=make_variants(sindy, variables) if variants else None,
|
|
114
|
+
thresholds=thresholds, seed=seed)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
# --------------------------------------------------------------- CLI helpers
|
|
118
|
+
DIFFS = {"fd": "FiniteDifference", "smoothed": "SmoothedFiniteDifference"}
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def build_sindy(degree: int = 2, threshold: float = 0.1, diff: str = "fd"):
|
|
122
|
+
"""A PySINDy model from a small, serializable configuration (used by ``mihenk fit``)."""
|
|
123
|
+
_require()
|
|
124
|
+
import pysindy as ps
|
|
125
|
+
|
|
126
|
+
if diff not in DIFFS:
|
|
127
|
+
raise ValueError(f"diff must be one of {', '.join(DIFFS)}")
|
|
128
|
+
return ps.SINDy(optimizer=ps.STLSQ(threshold=threshold),
|
|
129
|
+
feature_library=ps.PolynomialLibrary(degree=degree),
|
|
130
|
+
differentiation_method=getattr(ps, DIFFS[diff])())
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def fit_bundle(bundle: Bundle, degree: int = 2, threshold: float = 0.1, diff: str = "fd"):
|
|
134
|
+
"""Fit PySINDy to a bundle's trajectories; returns ``(sindy, model)``."""
|
|
135
|
+
import warnings
|
|
136
|
+
|
|
137
|
+
sindy = build_sindy(degree, threshold, diff)
|
|
138
|
+
with warnings.catch_warnings():
|
|
139
|
+
warnings.simplefilter("ignore")
|
|
140
|
+
if bundle.n_traj == 1:
|
|
141
|
+
sindy.fit(bundle.xs[0], t=bundle.ts[0], feature_names=bundle.variables)
|
|
142
|
+
else:
|
|
143
|
+
sindy.fit(bundle.xs, t=bundle.ts, feature_names=bundle.variables)
|
|
144
|
+
model = from_pysindy(sindy, bundle.variables, source="pysindy (mihenk fit)")
|
|
145
|
+
model.meta["pysindy_config"] = {"degree": degree, "threshold": threshold, "diff": diff}
|
|
146
|
+
return sindy, model
|
mihenk/bundle.py
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""The mihenk bundle: the observations an equation was discovered from, as plain arrays.
|
|
2
|
+
|
|
3
|
+
A bundle is one ``.npz`` file holding one or more trajectories. Shapes use T
|
|
4
|
+
time samples and n state variables; trajectories may differ in length.
|
|
5
|
+
|
|
6
|
+
Keys
|
|
7
|
+
x (T, n) one trajectory, or ``x_0, x_1, ...`` for several
|
|
8
|
+
t (T,) sample times (or ``t_0, t_1, ...``); a scalar ``dt`` also works
|
|
9
|
+
x_dot (T, n) optional measured derivatives (``x_dot_0, ...``)
|
|
10
|
+
val (K,) optional bool: trajectories held out from discovery.
|
|
11
|
+
With one trajectory, the last 20% is held out instead.
|
|
12
|
+
meta JSON string: variables, name, units, true_equations (synthetic only), ...
|
|
13
|
+
|
|
14
|
+
``true_equations`` in meta (``{"x": "-10*x + 10*y", ...}``) unlocks the tier-2
|
|
15
|
+
ground-truth checks; only synthetic data has it.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
import numpy as np
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class Bundle:
|
|
29
|
+
xs: list # list of (T_k, n) arrays
|
|
30
|
+
ts: list # list of (T_k,) arrays
|
|
31
|
+
variables: list
|
|
32
|
+
x_dots: list | None = None
|
|
33
|
+
val: np.ndarray | None = None # (K,) bool, held-out trajectories
|
|
34
|
+
meta: dict = field(default_factory=dict)
|
|
35
|
+
|
|
36
|
+
def __post_init__(self):
|
|
37
|
+
if isinstance(self.xs, np.ndarray):
|
|
38
|
+
self.xs = [self.xs]
|
|
39
|
+
if isinstance(self.ts, np.ndarray):
|
|
40
|
+
self.ts = [self.ts]
|
|
41
|
+
self.xs = [np.atleast_2d(np.asarray(x, float)) for x in self.xs]
|
|
42
|
+
self.ts = [np.asarray(t, float).ravel() for t in self.ts]
|
|
43
|
+
if len(self.xs) != len(self.ts):
|
|
44
|
+
raise ValueError(f"{len(self.xs)} trajectories but {len(self.ts)} time vectors")
|
|
45
|
+
n = self.xs[0].shape[1]
|
|
46
|
+
for k, (x, t) in enumerate(zip(self.xs, self.ts)):
|
|
47
|
+
if x.shape[1] != n:
|
|
48
|
+
raise ValueError(f"trajectory {k} has {x.shape[1]} variables, expected {n}")
|
|
49
|
+
if x.shape[0] != t.shape[0]:
|
|
50
|
+
raise ValueError(f"trajectory {k}: {x.shape[0]} samples but {t.shape[0]} times")
|
|
51
|
+
if x.shape[0] < 20:
|
|
52
|
+
raise ValueError(f"trajectory {k} has only {x.shape[0]} samples; mihenk needs at least 20")
|
|
53
|
+
if np.any(np.diff(t) <= 0):
|
|
54
|
+
raise ValueError(f"trajectory {k}: times must be strictly increasing")
|
|
55
|
+
if not np.all(np.isfinite(x)):
|
|
56
|
+
raise ValueError(f"trajectory {k} contains NaN or inf")
|
|
57
|
+
if len(self.variables) != n:
|
|
58
|
+
raise ValueError(f"{len(self.variables)} variable names for {n} columns")
|
|
59
|
+
self.variables = [str(v) for v in self.variables]
|
|
60
|
+
if self.x_dots is not None:
|
|
61
|
+
self.x_dots = [np.asarray(d, float) for d in self.x_dots]
|
|
62
|
+
if self.val is not None:
|
|
63
|
+
self.val = np.asarray(self.val, bool).ravel()
|
|
64
|
+
if self.val.shape[0] != len(self.xs):
|
|
65
|
+
raise ValueError("val must have one entry per trajectory")
|
|
66
|
+
if self.val.all():
|
|
67
|
+
raise ValueError("every trajectory is marked held-out; nothing is left for fitting")
|
|
68
|
+
|
|
69
|
+
# ------------------------------------------------------------ split
|
|
70
|
+
@property
|
|
71
|
+
def n_traj(self) -> int:
|
|
72
|
+
return len(self.xs)
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def n_vars(self) -> int:
|
|
76
|
+
return self.xs[0].shape[1]
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def name(self) -> str:
|
|
80
|
+
return self.meta.get("name", "bundle")
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def true_equations(self) -> dict | None:
|
|
84
|
+
return self.meta.get("true_equations")
|
|
85
|
+
|
|
86
|
+
def split(self, holdout_fraction: float = 0.2):
|
|
87
|
+
"""(train, test) lists of (x, t) pairs.
|
|
88
|
+
|
|
89
|
+
Held-out trajectories when ``val`` is given (or, with several
|
|
90
|
+
trajectories, the last 20% of them); otherwise the tail of the single
|
|
91
|
+
trajectory.
|
|
92
|
+
"""
|
|
93
|
+
pairs = list(zip(self.xs, self.ts))
|
|
94
|
+
if self.val is not None:
|
|
95
|
+
train = [p for p, v in zip(pairs, self.val) if not v]
|
|
96
|
+
test = [p for p, v in zip(pairs, self.val) if v]
|
|
97
|
+
return train, test, "held-out trajectories (val)"
|
|
98
|
+
if self.n_traj >= 3:
|
|
99
|
+
k = max(1, int(round(holdout_fraction * self.n_traj)))
|
|
100
|
+
return pairs[:-k], pairs[-k:], f"last {k} of {self.n_traj} trajectories"
|
|
101
|
+
train, test = [], []
|
|
102
|
+
for x, t in pairs:
|
|
103
|
+
cut = int(round((1 - holdout_fraction) * len(t)))
|
|
104
|
+
train.append((x[:cut], t[:cut]))
|
|
105
|
+
test.append((x[cut:], t[cut:]))
|
|
106
|
+
return train, test, f"last {int(holdout_fraction * 100)}% of each trajectory"
|
|
107
|
+
|
|
108
|
+
# ------------------------------------------------------------ io
|
|
109
|
+
def save(self, path) -> Path:
|
|
110
|
+
path = Path(path)
|
|
111
|
+
arrays = {}
|
|
112
|
+
for k, (x, t) in enumerate(zip(self.xs, self.ts)):
|
|
113
|
+
arrays[f"x_{k}"] = x
|
|
114
|
+
arrays[f"t_{k}"] = t
|
|
115
|
+
if self.x_dots is not None:
|
|
116
|
+
arrays[f"x_dot_{k}"] = self.x_dots[k]
|
|
117
|
+
if self.val is not None:
|
|
118
|
+
arrays["val"] = self.val
|
|
119
|
+
meta = dict(self.meta)
|
|
120
|
+
meta["variables"] = self.variables
|
|
121
|
+
arrays["meta"] = np.array(json.dumps(meta))
|
|
122
|
+
np.savez_compressed(path, **arrays)
|
|
123
|
+
return path
|
|
124
|
+
|
|
125
|
+
@classmethod
|
|
126
|
+
def load(cls, path) -> "Bundle":
|
|
127
|
+
path = Path(path)
|
|
128
|
+
if path.suffix == ".csv":
|
|
129
|
+
return cls.from_csv(path)
|
|
130
|
+
with np.load(path, allow_pickle=False) as z:
|
|
131
|
+
keys = set(z.files)
|
|
132
|
+
meta = json.loads(str(z["meta"])) if "meta" in keys else {}
|
|
133
|
+
if "x" in keys:
|
|
134
|
+
xs = [z["x"]]
|
|
135
|
+
ts = [_times(z, "t", xs[0].shape[0])]
|
|
136
|
+
dots = [z["x_dot"]] if "x_dot" in keys else None
|
|
137
|
+
else:
|
|
138
|
+
idx = sorted(int(k[2:]) for k in keys if k.startswith("x_") and k[2:].isdigit())
|
|
139
|
+
if not idx:
|
|
140
|
+
raise ValueError(f"{path}: no 'x' or 'x_0' array")
|
|
141
|
+
xs = [z[f"x_{k}"] for k in idx]
|
|
142
|
+
ts = [_times(z, f"t_{k}", xs[i].shape[0]) for i, k in enumerate(idx)]
|
|
143
|
+
dots = [z[f"x_dot_{k}"] for k in idx] if all(f"x_dot_{k}" in keys for k in idx) else None
|
|
144
|
+
val = z["val"] if "val" in keys else None
|
|
145
|
+
variables = meta.get("variables") or [f"x{i}" for i in range(xs[0].shape[1])]
|
|
146
|
+
meta.setdefault("name", path.stem)
|
|
147
|
+
return cls(xs, ts, variables, x_dots=dots, val=val, meta=meta)
|
|
148
|
+
|
|
149
|
+
@classmethod
|
|
150
|
+
def from_csv(cls, path, time_column: str = "t") -> "Bundle":
|
|
151
|
+
"""One trajectory per file; a header row names the columns, one of them the time."""
|
|
152
|
+
path = Path(path)
|
|
153
|
+
with open(path) as fh:
|
|
154
|
+
header = [h.strip() for h in fh.readline().split(",")]
|
|
155
|
+
data = np.loadtxt(path, delimiter=",", skiprows=1, ndmin=2)
|
|
156
|
+
if time_column not in header:
|
|
157
|
+
raise ValueError(f"{path}: no '{time_column}' column; columns are {header}")
|
|
158
|
+
ti = header.index(time_column)
|
|
159
|
+
cols = [i for i in range(len(header)) if i != ti]
|
|
160
|
+
return cls([data[:, cols]], [data[:, ti]], [header[i] for i in cols], meta={"name": path.stem})
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _times(z, key, n):
|
|
164
|
+
if key in z.files:
|
|
165
|
+
return z[key]
|
|
166
|
+
if "dt" in z.files:
|
|
167
|
+
return np.arange(n) * float(z["dt"])
|
|
168
|
+
raise ValueError(f"no '{key}' times and no 'dt'")
|
mihenk/cli.py
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""mihenk command line.
|
|
2
|
+
|
|
3
|
+
mihenk scan BUNDLE --model MODEL [--out DIR] [--json] [--set name=value ...] [--daftar]
|
|
4
|
+
mihenk fit BUNDLE -o MODEL.json [--degree D] [--threshold T] [--diff fd|smoothed]
|
|
5
|
+
mihenk synth SYSTEM -o BUNDLE.npz [--noise F] [--n-traj K] [--seed S]
|
|
6
|
+
mihenk validate [--quick]
|
|
7
|
+
mihenk info
|
|
8
|
+
|
|
9
|
+
``scan`` exits with status 1 when it finds errors, so it can gate a pipeline.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
import json
|
|
16
|
+
import sys
|
|
17
|
+
from contextlib import nullcontext
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from . import __version__, provenance
|
|
21
|
+
from .bundle import Bundle
|
|
22
|
+
from .model import Model
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _parse_sets(items):
|
|
26
|
+
out = {}
|
|
27
|
+
for item in items or []:
|
|
28
|
+
if "=" not in item:
|
|
29
|
+
raise SystemExit(f"--set expects name=value, got {item!r}")
|
|
30
|
+
k, v = item.split("=", 1)
|
|
31
|
+
out[k.strip()] = v.strip()
|
|
32
|
+
return out
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def cmd_scan(args) -> int:
|
|
36
|
+
from .scan import Thresholds, scan
|
|
37
|
+
|
|
38
|
+
bundle = Bundle.load(args.bundle)
|
|
39
|
+
model = Model.load(args.model)
|
|
40
|
+
th = Thresholds().update(_parse_sets(args.set))
|
|
41
|
+
refit = variants = None
|
|
42
|
+
cfg = model.meta.get("pysindy_config")
|
|
43
|
+
if cfg and not args.no_refit:
|
|
44
|
+
try:
|
|
45
|
+
from .adapters import pysindy as A
|
|
46
|
+
|
|
47
|
+
sindy, _ = A.fit_bundle(bundle, **cfg)
|
|
48
|
+
refit = A.make_refit(sindy, bundle.variables)
|
|
49
|
+
variants = A.make_variants(sindy, bundle.variables)
|
|
50
|
+
except ImportError:
|
|
51
|
+
print("note: PySINDy is not installed, so tier-1 checks are skipped "
|
|
52
|
+
'(pip install "mihenk[pysindy]")', file=sys.stderr)
|
|
53
|
+
if args.daftar and not provenance.available():
|
|
54
|
+
print('daftar is not installed; run `pip install "mihenk[provenance]"`', file=sys.stderr)
|
|
55
|
+
return 2
|
|
56
|
+
params = {"thresholds": _parse_sets(args.set), "seed": args.seed, "bundle": str(args.bundle),
|
|
57
|
+
"model": str(args.model)}
|
|
58
|
+
ctx = provenance.tracked("mihenk scan", params, inputs=[args.bundle, args.model], seed=args.seed) \
|
|
59
|
+
if args.daftar else nullcontext(None)
|
|
60
|
+
with ctx as run:
|
|
61
|
+
report = scan(bundle, model, refit=refit, variants=variants, thresholds=th, seed=args.seed)
|
|
62
|
+
out = None
|
|
63
|
+
if args.out:
|
|
64
|
+
out = report.save(args.out)
|
|
65
|
+
provenance.log_report(run, report, out)
|
|
66
|
+
if args.json:
|
|
67
|
+
print(json.dumps(report.to_dict(), indent=2, default=str))
|
|
68
|
+
else:
|
|
69
|
+
print(report.render_text(), end="")
|
|
70
|
+
if out:
|
|
71
|
+
print(f"\nreport written to {out}/report.html (also .json, .md, .txt)")
|
|
72
|
+
return 1 if report.has_errors else 0
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def cmd_fit(args) -> int:
|
|
76
|
+
from .adapters import pysindy as A
|
|
77
|
+
|
|
78
|
+
bundle = Bundle.load(args.bundle)
|
|
79
|
+
_, model = A.fit_bundle(bundle, degree=args.degree, threshold=args.threshold, diff=args.diff)
|
|
80
|
+
model.save(args.output)
|
|
81
|
+
print(model.pretty())
|
|
82
|
+
print(f"\nmodel written to {args.output}; next: mihenk scan {args.bundle} --model {args.output}")
|
|
83
|
+
return 0
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def cmd_synth(args) -> int:
|
|
87
|
+
from . import synth
|
|
88
|
+
|
|
89
|
+
if args.list or not args.system:
|
|
90
|
+
for name, s in synth.SYSTEMS.items():
|
|
91
|
+
print(f"{name:<24} {', '.join(s.variables):<10} {s.note}")
|
|
92
|
+
return 0
|
|
93
|
+
b = synth.simulate(args.system, noise=args.noise, n_traj=args.n_traj, dt=args.dt,
|
|
94
|
+
duration=args.duration, seed=args.seed)
|
|
95
|
+
out = args.output or f"{b.name}.npz"
|
|
96
|
+
b.save(out)
|
|
97
|
+
print(f"wrote {out}: {b.n_traj} trajectory(ies), {len(b.ts[0])} samples each, variables "
|
|
98
|
+
f"{', '.join(b.variables)}")
|
|
99
|
+
print("true law: " + "; ".join(f"{v}' = {e}" for v, e in b.true_equations.items()))
|
|
100
|
+
return 0
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def cmd_validate(args) -> int:
|
|
104
|
+
from . import validate
|
|
105
|
+
|
|
106
|
+
if args.seeds > 1:
|
|
107
|
+
text, ok = validate.sweep(range(args.seed, args.seed + args.seeds), quick=args.quick)
|
|
108
|
+
print(text)
|
|
109
|
+
return 0 if ok else 1
|
|
110
|
+
results = validate.run_all(quick=args.quick, seed=args.seed)
|
|
111
|
+
print(validate.render(results))
|
|
112
|
+
return 0 if all(r.passed for r in results) else 1
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def cmd_info(_args) -> int:
|
|
116
|
+
print(f"mihenk {__version__}")
|
|
117
|
+
for mod, why in (("pysindy", 'extra [pysindy]: fit, tier-1 refits'),
|
|
118
|
+
("daftar", "extra [provenance]: scan --daftar")):
|
|
119
|
+
try:
|
|
120
|
+
m = __import__(mod)
|
|
121
|
+
status = getattr(m, "__version__", "installed")
|
|
122
|
+
except ImportError:
|
|
123
|
+
status = "not installed"
|
|
124
|
+
print(f" {mod:<10} {status:<16} {why}")
|
|
125
|
+
return 0
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
129
|
+
p = argparse.ArgumentParser(prog="mihenk", description="A touchstone for discovered equations.")
|
|
130
|
+
p.add_argument("--version", action="version", version=f"mihenk {__version__}")
|
|
131
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
132
|
+
|
|
133
|
+
s = sub.add_parser("scan", help="check a discovered equation against its data")
|
|
134
|
+
s.add_argument("bundle", help="data: .npz bundle or .csv with a 't' column")
|
|
135
|
+
s.add_argument("--model", required=True, help="equation: .json from `mihenk fit`/Model.save, or a text "
|
|
136
|
+
"file with lines like x' = -10 x + 10 y")
|
|
137
|
+
s.add_argument("--out", help="directory for report.html/.json/.md/.txt")
|
|
138
|
+
s.add_argument("--json", action="store_true", help="print the report as JSON")
|
|
139
|
+
s.add_argument("--set", action="append", metavar="NAME=VALUE", help="override a threshold")
|
|
140
|
+
s.add_argument("--seed", type=int, default=0)
|
|
141
|
+
s.add_argument("--no-refit", action="store_true", help="skip tier-1 refits even if possible")
|
|
142
|
+
s.add_argument("--daftar", action="store_true", help="record the scan with daftar")
|
|
143
|
+
s.set_defaults(fn=cmd_scan)
|
|
144
|
+
|
|
145
|
+
f = sub.add_parser("fit", help="fit PySINDy (polynomial library, STLSQ) and save the equation")
|
|
146
|
+
f.add_argument("bundle")
|
|
147
|
+
f.add_argument("-o", "--output", default="model.json")
|
|
148
|
+
f.add_argument("--degree", type=int, default=2)
|
|
149
|
+
f.add_argument("--threshold", type=float, default=0.1)
|
|
150
|
+
f.add_argument("--diff", choices=["fd", "smoothed"], default="fd")
|
|
151
|
+
f.set_defaults(fn=cmd_fit)
|
|
152
|
+
|
|
153
|
+
y = sub.add_parser("synth", help="simulate a system with a known law")
|
|
154
|
+
y.add_argument("system", nargs="?")
|
|
155
|
+
y.add_argument("-o", "--output")
|
|
156
|
+
y.add_argument("--noise", type=float, default=0.0, help="noise SD as a fraction of each variable's SD")
|
|
157
|
+
y.add_argument("--n-traj", type=int, default=1)
|
|
158
|
+
y.add_argument("--dt", type=float)
|
|
159
|
+
y.add_argument("--duration", type=float)
|
|
160
|
+
y.add_argument("--seed", type=int, default=0)
|
|
161
|
+
y.add_argument("--list", action="store_true")
|
|
162
|
+
y.set_defaults(fn=cmd_synth)
|
|
163
|
+
|
|
164
|
+
v = sub.add_parser("validate", help="run mihenk's self-validation cases")
|
|
165
|
+
v.add_argument("--quick", action="store_true")
|
|
166
|
+
v.add_argument("--seed", type=int, default=0)
|
|
167
|
+
v.add_argument("--seeds", type=int, default=1, help="run over this many seeds and summarize rates")
|
|
168
|
+
v.set_defaults(fn=cmd_validate)
|
|
169
|
+
|
|
170
|
+
i = sub.add_parser("info", help="version and optional dependencies")
|
|
171
|
+
i.set_defaults(fn=cmd_info)
|
|
172
|
+
return p
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def main(argv=None) -> int:
|
|
176
|
+
args = build_parser().parse_args(argv)
|
|
177
|
+
try:
|
|
178
|
+
return args.fn(args)
|
|
179
|
+
except (ValueError, KeyError, FileNotFoundError, NotImplementedError) as exc:
|
|
180
|
+
print(f"mihenk: error: {exc}", file=sys.stderr)
|
|
181
|
+
return 2
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
if __name__ == "__main__": # pragma: no cover
|
|
185
|
+
sys.exit(main())
|