ayna 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.
- ayna/__init__.py +28 -0
- ayna/adapters/__init__.py +1 -0
- ayna/adapters/ctd.py +138 -0
- ayna/bundle.py +249 -0
- ayna/cli.py +251 -0
- ayna/metrics.py +168 -0
- ayna/probes.py +319 -0
- ayna/provenance.py +48 -0
- ayna/report.py +177 -0
- ayna/scan.py +321 -0
- ayna/select.py +158 -0
- ayna/synth.py +275 -0
- ayna/validate.py +161 -0
- ayna-0.1.0.dist-info/METADATA +322 -0
- ayna-0.1.0.dist-info/RECORD +19 -0
- ayna-0.1.0.dist-info/WHEEL +5 -0
- ayna-0.1.0.dist-info/entry_points.txt +2 -0
- ayna-0.1.0.dist-info/licenses/LICENSE +202 -0
- ayna-0.1.0.dist-info/top_level.txt +1 -0
ayna/__init__.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""ayna -- know whether your inferred dynamics are real, before you interpret them.
|
|
2
|
+
|
|
3
|
+
ayna checks the outputs of a fitted data-driven dynamics model (LFADS, a
|
|
4
|
+
sequential autoencoder, a neural ODE, ...) for the three failure modes defined
|
|
5
|
+
in the Computation-through-Dynamics Toolkit: underfitting, feature invention
|
|
6
|
+
and dynamical misattribution. It runs on numpy, never trains anything, and
|
|
7
|
+
says what it could not check.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
__version__ = "0.1.0"
|
|
11
|
+
|
|
12
|
+
from .bundle import Bundle, BundleError, load_bundle # noqa: E402
|
|
13
|
+
from .report import Check, Finding, Report # noqa: E402
|
|
14
|
+
from .scan import Thresholds, scan # noqa: E402
|
|
15
|
+
from .select import select # noqa: E402
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"Bundle",
|
|
19
|
+
"BundleError",
|
|
20
|
+
"Check",
|
|
21
|
+
"Finding",
|
|
22
|
+
"Report",
|
|
23
|
+
"Thresholds",
|
|
24
|
+
"load_bundle",
|
|
25
|
+
"scan",
|
|
26
|
+
"select",
|
|
27
|
+
"__version__",
|
|
28
|
+
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Exporters from training frameworks to ayna bundles. Imported lazily."""
|
ayna/adapters/ctd.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""Export a Computation-through-Dynamics Toolkit run to an ayna bundle.
|
|
2
|
+
|
|
3
|
+
Run this inside the CtD environment (torch, Lightning, ...). ayna itself never
|
|
4
|
+
imports torch; this module only touches CtD objects through the getters the
|
|
5
|
+
toolkit's own ``Comparison.compute_metrics`` uses, so it duck-types and can
|
|
6
|
+
be tested without CtD installed.
|
|
7
|
+
|
|
8
|
+
Assumptions, all taken from CtD's own comparison code:
|
|
9
|
+
|
|
10
|
+
* ``get_model_outputs(phase)`` returns ``(rates, latents)``, rates in spikes/bin;
|
|
11
|
+
* ``get_spiking(phase)`` returns all neurons, held-in first, held-out last;
|
|
12
|
+
* the held-in count is the width of the spiking the model receives,
|
|
13
|
+
``get_model_inputs(phase)[0]``;
|
|
14
|
+
* the task-trained analysis returns trials in the same order as the
|
|
15
|
+
data-trained one for each phase.
|
|
16
|
+
|
|
17
|
+
Status: written against CtD 1.0.2 source; not yet run on a live CtD install
|
|
18
|
+
(the pretrained models are Git-LFS objects). Please report mismatches.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from typing import Optional
|
|
24
|
+
|
|
25
|
+
import numpy as np
|
|
26
|
+
|
|
27
|
+
from ..bundle import Bundle
|
|
28
|
+
|
|
29
|
+
PHASES = ("train", "val")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _np(x):
|
|
33
|
+
if x is None:
|
|
34
|
+
return None
|
|
35
|
+
if hasattr(x, "detach"):
|
|
36
|
+
x = x.detach().cpu().numpy()
|
|
37
|
+
return np.asarray(x)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _get(obj, method, phase):
|
|
41
|
+
fn = getattr(obj, method, None)
|
|
42
|
+
if fn is None:
|
|
43
|
+
return None
|
|
44
|
+
try:
|
|
45
|
+
return _np(fn(phase=phase))
|
|
46
|
+
except (NotImplementedError, AttributeError, TypeError):
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def from_ctd(dd, tt=None, name: Optional[str] = None, inputs_kind: Optional[str] = None,
|
|
51
|
+
model_type: Optional[str] = None) -> Bundle:
|
|
52
|
+
"""Build a :class:`~ayna.bundle.Bundle` from CtD analysis objects.
|
|
53
|
+
|
|
54
|
+
``dd`` is an ``Analysis_DD`` (SAE or LFADS); ``tt`` an optional
|
|
55
|
+
``Analysis_TT`` supplying ground-truth latents and inputs.
|
|
56
|
+
"""
|
|
57
|
+
model_type = model_type or getattr(dd, "model_type", "")
|
|
58
|
+
parts = {k: [] for k in ("spikes", "rates", "latents", "inputs", "true_rates",
|
|
59
|
+
"true_latents", "true_inputs", "trial_lens")}
|
|
60
|
+
n_heldin = None
|
|
61
|
+
n_val = 0
|
|
62
|
+
if inputs_kind is None:
|
|
63
|
+
inputs_kind = "inferred" if model_type == "LFADS" and hasattr(dd, "get_inferred_inputs") else "supplied"
|
|
64
|
+
|
|
65
|
+
for phase in PHASES:
|
|
66
|
+
rates, latents = dd.get_model_outputs(phase=phase)
|
|
67
|
+
rates, latents = _np(rates), _np(latents)
|
|
68
|
+
spikes = _get(dd, "get_spiking", phase)
|
|
69
|
+
model_in = dd.get_model_inputs(phase=phase)
|
|
70
|
+
heldin_spikes, supplied = _np(model_in[0]), _np(model_in[1])
|
|
71
|
+
n_heldin = heldin_spikes.shape[-1]
|
|
72
|
+
if inputs_kind == "inferred":
|
|
73
|
+
inputs = _get(dd, "get_inferred_inputs", phase)
|
|
74
|
+
else:
|
|
75
|
+
inputs = supplied
|
|
76
|
+
parts["spikes"].append(spikes)
|
|
77
|
+
parts["rates"].append(rates)
|
|
78
|
+
parts["latents"].append(latents)
|
|
79
|
+
parts["inputs"].append(inputs)
|
|
80
|
+
parts["true_rates"].append(_get(dd, "get_true_rates", phase))
|
|
81
|
+
lens = _get(dd, "get_trial_lens", phase)
|
|
82
|
+
parts["trial_lens"].append(None if lens is None else lens.reshape(-1).astype(int))
|
|
83
|
+
if tt is not None:
|
|
84
|
+
parts["true_latents"].append(_get(tt, "get_latents", phase))
|
|
85
|
+
parts["true_inputs"].append(_get(tt, "get_inputs", phase))
|
|
86
|
+
if phase == "val":
|
|
87
|
+
n_val = rates.shape[0]
|
|
88
|
+
|
|
89
|
+
def cat(key):
|
|
90
|
+
items = parts[key]
|
|
91
|
+
if not items or any(i is None for i in items):
|
|
92
|
+
return None
|
|
93
|
+
return np.concatenate(items, axis=0)
|
|
94
|
+
|
|
95
|
+
spikes = cat("spikes")
|
|
96
|
+
N = spikes.shape[-1]
|
|
97
|
+
heldout = np.arange(N) >= n_heldin if n_heldin is not None and n_heldin < N else None
|
|
98
|
+
R = spikes.shape[0]
|
|
99
|
+
val = np.zeros(R, bool)
|
|
100
|
+
val[R - n_val:] = True
|
|
101
|
+
trial_lens = cat("trial_lens")
|
|
102
|
+
if trial_lens is not None and np.all(trial_lens >= spikes.shape[1]):
|
|
103
|
+
trial_lens = None
|
|
104
|
+
inputs = cat("inputs")
|
|
105
|
+
meta = {
|
|
106
|
+
"name": name or getattr(dd, "run_name", "ctd_run"),
|
|
107
|
+
"model": model_type or "unknown",
|
|
108
|
+
"source": "ctd",
|
|
109
|
+
"exporter": "ayna.adapters.ctd",
|
|
110
|
+
}
|
|
111
|
+
if inputs is not None:
|
|
112
|
+
meta["inputs_kind"] = inputs_kind
|
|
113
|
+
if tt is not None:
|
|
114
|
+
meta["tt_run"] = getattr(tt, "run_name", "")
|
|
115
|
+
return Bundle(spikes=spikes, rates=cat("rates"), latents=cat("latents"), heldout=heldout, val=val,
|
|
116
|
+
trial_lens=trial_lens, inputs=inputs, true_rates=cat("true_rates"),
|
|
117
|
+
true_latents=cat("true_latents"), true_inputs=cat("true_inputs"), meta=meta)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def export_run(dd_dir: str, out_path: str, model_type: str, tt_dir: Optional[str] = None,
|
|
121
|
+
inputs_kind: Optional[str] = None) -> str:
|
|
122
|
+
"""Load a saved CtD run from disk (needs CtD importable) and write a bundle."""
|
|
123
|
+
try:
|
|
124
|
+
from ctd.comparison.analysis.dd.dd import Analysis_DD
|
|
125
|
+
from ctd.comparison.analysis.tt.tt import Analysis_TT
|
|
126
|
+
except ImportError as exc: # pragma: no cover - depends on the user's env
|
|
127
|
+
raise ImportError(
|
|
128
|
+
"export-ctd must run in an environment where the Computation-through-Dynamics "
|
|
129
|
+
"Toolkit is installed (`pip install -e .` in its repository)."
|
|
130
|
+
) from exc
|
|
131
|
+
dd_dir = dd_dir.rstrip("/") + "/"
|
|
132
|
+
dd = Analysis_DD.create(run_name=dd_dir.rstrip("/").split("/")[-1], filepath=dd_dir, model_type=model_type)
|
|
133
|
+
tt = None
|
|
134
|
+
if tt_dir:
|
|
135
|
+
tt_dir = tt_dir.rstrip("/") + "/"
|
|
136
|
+
tt = Analysis_TT(run_name=tt_dir.rstrip("/").split("/")[-1], filepath=tt_dir)
|
|
137
|
+
bundle = from_ctd(dd, tt, inputs_kind=inputs_kind, model_type=model_type)
|
|
138
|
+
return str(bundle.save(out_path))
|
ayna/bundle.py
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
"""The ayna bundle: everything a fitted dynamics model produced, as plain arrays.
|
|
2
|
+
|
|
3
|
+
A bundle is one ``.npz`` file. ayna never imports the framework that trained
|
|
4
|
+
the model; whoever trained it exports these arrays once, and every check runs
|
|
5
|
+
on numpy from there.
|
|
6
|
+
|
|
7
|
+
Shapes use R trials, T time bins, N neurons, D inferred latent dimensions and
|
|
8
|
+
U input dimensions. Rates are expected counts **per bin**, the same unit as
|
|
9
|
+
the spikes.
|
|
10
|
+
|
|
11
|
+
Required
|
|
12
|
+
spikes (R, T, N) observed spike counts
|
|
13
|
+
rates (R, T, N) model-predicted rates (held-in and held-out neurons)
|
|
14
|
+
latents (R, T, D) model-inferred latent states
|
|
15
|
+
|
|
16
|
+
Optional
|
|
17
|
+
heldout (N,) bool neurons the model never saw as input (enables co-BPS)
|
|
18
|
+
val (R,) bool validation trials (default: the last 20%)
|
|
19
|
+
trial_lens (R,) int valid bins per trial, for padded data
|
|
20
|
+
inputs (R, T, U) external inputs; ``meta["inputs_kind"]`` says
|
|
21
|
+
whether they were ``supplied`` to the model or
|
|
22
|
+
``inferred`` by it
|
|
23
|
+
true_latents (R, T, Dt) ground truth (synthetic data only) -- tier 2
|
|
24
|
+
true_rates (R, T, N)
|
|
25
|
+
true_inputs (R, T, Ut)
|
|
26
|
+
meta JSON string: name, model, bin_ms, inputs_kind, labels, ...
|
|
27
|
+
|
|
28
|
+
A ``dynamics`` callable ``f(z, u) -> z_next`` (arrays ``(M, D)``, ``(M, U)``)
|
|
29
|
+
can be attached in Python; it cannot be stored in an ``.npz`` and unlocks the
|
|
30
|
+
tier-1 probes.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
import json
|
|
36
|
+
from dataclasses import dataclass, field
|
|
37
|
+
from pathlib import Path
|
|
38
|
+
from typing import Callable, Optional
|
|
39
|
+
|
|
40
|
+
import numpy as np
|
|
41
|
+
|
|
42
|
+
ARRAY_KEYS = (
|
|
43
|
+
"spikes",
|
|
44
|
+
"rates",
|
|
45
|
+
"latents",
|
|
46
|
+
"heldout",
|
|
47
|
+
"val",
|
|
48
|
+
"trial_lens",
|
|
49
|
+
"inputs",
|
|
50
|
+
"true_latents",
|
|
51
|
+
"true_rates",
|
|
52
|
+
"true_inputs",
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
RATE_FLOOR = 1e-9
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class BundleError(ValueError):
|
|
59
|
+
"""The bundle cannot be checked as given."""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass
|
|
63
|
+
class Bundle:
|
|
64
|
+
spikes: np.ndarray
|
|
65
|
+
rates: np.ndarray
|
|
66
|
+
latents: np.ndarray
|
|
67
|
+
heldout: Optional[np.ndarray] = None
|
|
68
|
+
val: Optional[np.ndarray] = None
|
|
69
|
+
trial_lens: Optional[np.ndarray] = None
|
|
70
|
+
inputs: Optional[np.ndarray] = None
|
|
71
|
+
true_latents: Optional[np.ndarray] = None
|
|
72
|
+
true_rates: Optional[np.ndarray] = None
|
|
73
|
+
true_inputs: Optional[np.ndarray] = None
|
|
74
|
+
meta: dict = field(default_factory=dict)
|
|
75
|
+
dynamics: Optional[Callable[[np.ndarray, np.ndarray], np.ndarray]] = None
|
|
76
|
+
source: str = ""
|
|
77
|
+
notes: list = field(default_factory=list)
|
|
78
|
+
|
|
79
|
+
def __post_init__(self):
|
|
80
|
+
for key in ARRAY_KEYS:
|
|
81
|
+
value = getattr(self, key)
|
|
82
|
+
if value is not None:
|
|
83
|
+
setattr(self, key, np.asarray(value))
|
|
84
|
+
self._validate()
|
|
85
|
+
|
|
86
|
+
# ------------------------------------------------------------------ shape
|
|
87
|
+
@property
|
|
88
|
+
def n_trials(self) -> int:
|
|
89
|
+
return self.spikes.shape[0]
|
|
90
|
+
|
|
91
|
+
@property
|
|
92
|
+
def n_bins(self) -> int:
|
|
93
|
+
return self.spikes.shape[1]
|
|
94
|
+
|
|
95
|
+
@property
|
|
96
|
+
def n_neurons(self) -> int:
|
|
97
|
+
return self.spikes.shape[2]
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def n_latents(self) -> int:
|
|
101
|
+
return self.latents.shape[2]
|
|
102
|
+
|
|
103
|
+
@property
|
|
104
|
+
def inputs_kind(self) -> Optional[str]:
|
|
105
|
+
if self.inputs is None:
|
|
106
|
+
return None
|
|
107
|
+
return self.meta.get("inputs_kind", "inferred")
|
|
108
|
+
|
|
109
|
+
@property
|
|
110
|
+
def has_ground_truth(self) -> bool:
|
|
111
|
+
return self.true_latents is not None or self.true_rates is not None
|
|
112
|
+
|
|
113
|
+
@property
|
|
114
|
+
def name(self) -> str:
|
|
115
|
+
return self.meta.get("name") or Path(self.source).stem or "bundle"
|
|
116
|
+
|
|
117
|
+
# ------------------------------------------------------------- validation
|
|
118
|
+
def _validate(self):
|
|
119
|
+
for key in ("spikes", "rates", "latents"):
|
|
120
|
+
arr = getattr(self, key)
|
|
121
|
+
if arr is None:
|
|
122
|
+
raise BundleError(f"'{key}' is required")
|
|
123
|
+
if arr.ndim != 3:
|
|
124
|
+
raise BundleError(
|
|
125
|
+
f"'{key}' must be (trials, bins, features); got shape {arr.shape}"
|
|
126
|
+
)
|
|
127
|
+
R, T, N = self.spikes.shape
|
|
128
|
+
if self.rates.shape != (R, T, N):
|
|
129
|
+
raise BundleError(
|
|
130
|
+
f"'rates' must match 'spikes' {self.spikes.shape}; got {self.rates.shape}"
|
|
131
|
+
)
|
|
132
|
+
for key in ("latents", "inputs", "true_latents", "true_inputs"):
|
|
133
|
+
arr = getattr(self, key)
|
|
134
|
+
if arr is None:
|
|
135
|
+
continue
|
|
136
|
+
if arr.ndim != 3 or arr.shape[:2] != (R, T):
|
|
137
|
+
raise BundleError(
|
|
138
|
+
f"'{key}' must be ({R}, {T}, features); got {arr.shape}"
|
|
139
|
+
)
|
|
140
|
+
if self.true_rates is not None and self.true_rates.shape != (R, T, N):
|
|
141
|
+
raise BundleError(f"'true_rates' must be {(R, T, N)}; got {self.true_rates.shape}")
|
|
142
|
+
|
|
143
|
+
if np.any(self.spikes < 0):
|
|
144
|
+
raise BundleError("'spikes' contains negative counts")
|
|
145
|
+
if not np.all(np.isfinite(self.rates)):
|
|
146
|
+
raise BundleError("'rates' contains NaN or inf")
|
|
147
|
+
if np.any(self.rates <= 0):
|
|
148
|
+
n_bad = int(np.sum(self.rates <= 0))
|
|
149
|
+
self.rates = np.maximum(self.rates, RATE_FLOOR)
|
|
150
|
+
self.notes.append(
|
|
151
|
+
f"{n_bad} non-positive predicted rates were floored at {RATE_FLOOR:g}"
|
|
152
|
+
)
|
|
153
|
+
if not np.all(np.isfinite(self.latents)):
|
|
154
|
+
raise BundleError("'latents' contains NaN or inf")
|
|
155
|
+
|
|
156
|
+
if self.heldout is not None:
|
|
157
|
+
self.heldout = self.heldout.astype(bool).reshape(-1)
|
|
158
|
+
if self.heldout.shape != (N,):
|
|
159
|
+
raise BundleError(f"'heldout' must have {N} entries")
|
|
160
|
+
if self.heldout.all():
|
|
161
|
+
raise BundleError("every neuron is marked held-out")
|
|
162
|
+
if self.val is None:
|
|
163
|
+
n_val = max(1, int(round(0.2 * R)))
|
|
164
|
+
self.val = np.zeros(R, bool)
|
|
165
|
+
self.val[R - n_val:] = True
|
|
166
|
+
self.notes.append(f"no 'val' split given; used the last {n_val} of {R} trials")
|
|
167
|
+
else:
|
|
168
|
+
self.val = self.val.astype(bool).reshape(-1)
|
|
169
|
+
if self.val.shape != (R,):
|
|
170
|
+
raise BundleError(f"'val' must have {R} entries")
|
|
171
|
+
if self.val.all() or not self.val.any():
|
|
172
|
+
raise BundleError("'val' must mark some, but not all, trials")
|
|
173
|
+
if self.trial_lens is not None:
|
|
174
|
+
self.trial_lens = self.trial_lens.astype(int).reshape(-1)
|
|
175
|
+
if self.trial_lens.shape != (R,) or self.trial_lens.max() > T or self.trial_lens.min() < 1:
|
|
176
|
+
raise BundleError("'trial_lens' must be R integers in [1, T]")
|
|
177
|
+
kind = self.meta.get("inputs_kind")
|
|
178
|
+
if kind is not None and kind not in ("supplied", "inferred"):
|
|
179
|
+
raise BundleError("meta['inputs_kind'] must be 'supplied' or 'inferred'")
|
|
180
|
+
|
|
181
|
+
# ------------------------------------------------------------- selection
|
|
182
|
+
def trials(self, split: str) -> np.ndarray:
|
|
183
|
+
if split == "train":
|
|
184
|
+
return ~self.val
|
|
185
|
+
if split == "val":
|
|
186
|
+
return self.val.copy()
|
|
187
|
+
if split == "all":
|
|
188
|
+
return np.ones(self.n_trials, bool)
|
|
189
|
+
raise ValueError(split)
|
|
190
|
+
|
|
191
|
+
def bin_mask(self, split: str = "all") -> np.ndarray:
|
|
192
|
+
"""(R, T) boolean mask of valid bins in the chosen trials."""
|
|
193
|
+
mask = np.zeros((self.n_trials, self.n_bins), bool)
|
|
194
|
+
mask[self.trials(split)] = True
|
|
195
|
+
if self.trial_lens is not None:
|
|
196
|
+
mask &= np.arange(self.n_bins)[None, :] < self.trial_lens[:, None]
|
|
197
|
+
return mask
|
|
198
|
+
|
|
199
|
+
def flat(self, key_or_array, split: str = "all", neurons=None) -> np.ndarray:
|
|
200
|
+
"""Flatten (R, T, F) to (samples, F) over valid bins of a split."""
|
|
201
|
+
arr = getattr(self, key_or_array) if isinstance(key_or_array, str) else key_or_array
|
|
202
|
+
if arr is None:
|
|
203
|
+
return None
|
|
204
|
+
arr = arr[self.bin_mask(split)]
|
|
205
|
+
if neurons is not None:
|
|
206
|
+
arr = arr[:, neurons]
|
|
207
|
+
return arr
|
|
208
|
+
|
|
209
|
+
@property
|
|
210
|
+
def heldin(self) -> Optional[np.ndarray]:
|
|
211
|
+
return None if self.heldout is None else ~self.heldout
|
|
212
|
+
|
|
213
|
+
# ------------------------------------------------------------------- I/O
|
|
214
|
+
def save(self, path) -> Path:
|
|
215
|
+
path = Path(path)
|
|
216
|
+
payload = {k: getattr(self, k) for k in ARRAY_KEYS if getattr(self, k) is not None}
|
|
217
|
+
payload["meta"] = np.array(json.dumps(self.meta, sort_keys=True))
|
|
218
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
219
|
+
np.savez_compressed(path, **payload)
|
|
220
|
+
return path if path.suffix == ".npz" else path.with_suffix(path.suffix + ".npz")
|
|
221
|
+
|
|
222
|
+
def summary(self) -> str:
|
|
223
|
+
parts = [
|
|
224
|
+
f"{self.n_trials} trials x {self.n_bins} bins",
|
|
225
|
+
f"{self.n_neurons} neurons"
|
|
226
|
+
+ (f" ({int(self.heldout.sum())} held-out)" if self.heldout is not None else ""),
|
|
227
|
+
f"{self.n_latents} latents",
|
|
228
|
+
]
|
|
229
|
+
if self.inputs is not None:
|
|
230
|
+
parts.append(f"{self.inputs.shape[2]} {self.inputs_kind} inputs")
|
|
231
|
+
if self.has_ground_truth:
|
|
232
|
+
parts.append("ground truth")
|
|
233
|
+
if self.dynamics is not None:
|
|
234
|
+
parts.append("dynamics callable")
|
|
235
|
+
return ", ".join(parts)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def load_bundle(path, dynamics=None) -> Bundle:
|
|
239
|
+
"""Read a bundle written by :meth:`Bundle.save` or by any exporter."""
|
|
240
|
+
path = Path(path)
|
|
241
|
+
if not path.exists():
|
|
242
|
+
raise BundleError(f"no such file: {path}")
|
|
243
|
+
with np.load(path, allow_pickle=False) as data:
|
|
244
|
+
arrays = {k: data[k] for k in ARRAY_KEYS if k in data.files}
|
|
245
|
+
meta = json.loads(str(data["meta"])) if "meta" in data.files else {}
|
|
246
|
+
missing = [k for k in ("spikes", "rates", "latents") if k not in arrays]
|
|
247
|
+
if missing:
|
|
248
|
+
raise BundleError(f"{path.name} lacks required array(s): {', '.join(missing)}")
|
|
249
|
+
return Bundle(**arrays, meta=meta, dynamics=dynamics, source=str(path))
|
ayna/cli.py
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
"""ayna command line.
|
|
2
|
+
|
|
3
|
+
ayna synth NAME [--faults underfit=0.5 feature_invention ...] [--sweep]
|
|
4
|
+
ayna scan BUNDLE [--out DIR] [--json] [--set name=value ...] [--daftar]
|
|
5
|
+
ayna select BUNDLE... | DIR [--out DIR] [--plot]
|
|
6
|
+
ayna validate [DIR] [--n 6] [--out DIR]
|
|
7
|
+
ayna info BUNDLE
|
|
8
|
+
ayna export-ctd DD_RUN_DIR OUT.npz --model-type SAE|LFADS [--tt TT_RUN_DIR]
|
|
9
|
+
ayna doctor
|
|
10
|
+
|
|
11
|
+
``scan`` exits 1 when an error-level finding is raised from the model's
|
|
12
|
+
outputs, so it can gate a pipeline or CI job.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import argparse
|
|
18
|
+
import json
|
|
19
|
+
import sys
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
from . import __version__
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _bundles_from(paths):
|
|
26
|
+
from .bundle import load_bundle
|
|
27
|
+
|
|
28
|
+
files = []
|
|
29
|
+
for p in paths:
|
|
30
|
+
p = Path(p)
|
|
31
|
+
files += sorted(p.glob("*.npz")) if p.is_dir() else [p]
|
|
32
|
+
if not files:
|
|
33
|
+
raise SystemExit("no .npz bundles found")
|
|
34
|
+
return [load_bundle(f) for f in files]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _overrides(items):
|
|
38
|
+
out = {}
|
|
39
|
+
for item in items or []:
|
|
40
|
+
key, sep, value = item.partition("=")
|
|
41
|
+
if not sep:
|
|
42
|
+
raise SystemExit(f"--set expects name=value, got '{item}'")
|
|
43
|
+
out[key.strip()] = value
|
|
44
|
+
return out
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def cmd_synth(args):
|
|
48
|
+
from .synth import synth, synth_sweep
|
|
49
|
+
|
|
50
|
+
out = Path(args.out)
|
|
51
|
+
if args.sweep:
|
|
52
|
+
target = out / args.name
|
|
53
|
+
bundles = synth_sweep(n_trials=args.trials, n_neurons=args.neurons, truth_seed=args.seed)
|
|
54
|
+
for b in bundles:
|
|
55
|
+
b.save(target / f"{b.name}.npz")
|
|
56
|
+
print(f"wrote {len(bundles)} bundles (latent sizes 2-64, simulated) to {target}/")
|
|
57
|
+
return 0
|
|
58
|
+
b = synth(args.name, args.faults, latent_dim=args.latent_dim,
|
|
59
|
+
inputs_kind=None if args.inputs == "auto" else args.inputs,
|
|
60
|
+
n_trials=args.trials, n_neurons=args.neurons, seed=args.seed)
|
|
61
|
+
path = b.save(out / f"{args.name}.npz")
|
|
62
|
+
faults = b.meta["synth"]["faults"] or "none"
|
|
63
|
+
print(f"wrote {path} ({b.summary()}; injected faults: {faults})")
|
|
64
|
+
print("note: a .npz cannot hold the dynamics callable, so tier-1 probes need the Python API")
|
|
65
|
+
return 0
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def cmd_scan(args):
|
|
69
|
+
from .bundle import load_bundle
|
|
70
|
+
from .scan import Thresholds, scan
|
|
71
|
+
from . import provenance
|
|
72
|
+
|
|
73
|
+
b = load_bundle(args.bundle)
|
|
74
|
+
th = Thresholds().update(_overrides(args.set))
|
|
75
|
+
if args.daftar and not provenance.available():
|
|
76
|
+
print("daftar is not installed; run `pip install \"ayna[provenance]\"`", file=sys.stderr)
|
|
77
|
+
with provenance.tracked("ayna scan", params={"bundle": str(args.bundle), **th.__dict__},
|
|
78
|
+
inputs=[args.bundle] if args.daftar else []) if args.daftar else _null() as run:
|
|
79
|
+
report = scan(b, th, nonlinear_fallback=not args.no_nonlinear)
|
|
80
|
+
if args.out:
|
|
81
|
+
report.save(args.out)
|
|
82
|
+
if run is not None:
|
|
83
|
+
provenance.log_report(run, report, args.out)
|
|
84
|
+
if args.json:
|
|
85
|
+
from .report import _jsonable
|
|
86
|
+
|
|
87
|
+
print(json.dumps(report.to_dict(), indent=2, default=_jsonable))
|
|
88
|
+
else:
|
|
89
|
+
print(report.render_text(), end="")
|
|
90
|
+
if args.out:
|
|
91
|
+
print(f"\nreport written to {args.out}/report.{{txt,md,json}}")
|
|
92
|
+
return 1 if report.has_errors else 0
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class _null:
|
|
96
|
+
def __enter__(self):
|
|
97
|
+
return None
|
|
98
|
+
|
|
99
|
+
def __exit__(self, *exc):
|
|
100
|
+
return False
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def cmd_select(args):
|
|
104
|
+
from .select import select
|
|
105
|
+
|
|
106
|
+
rep = select(_bundles_from(args.bundles))
|
|
107
|
+
print(rep.render_text(), end="")
|
|
108
|
+
if args.out:
|
|
109
|
+
rep.save(args.out)
|
|
110
|
+
if args.plot:
|
|
111
|
+
try:
|
|
112
|
+
rep.plot(Path(args.out) / "select.png")
|
|
113
|
+
except ImportError:
|
|
114
|
+
print("plot needs matplotlib: pip install \"ayna[plot]\"", file=sys.stderr)
|
|
115
|
+
print(f"\nwritten to {args.out}/")
|
|
116
|
+
return 0
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def cmd_validate(args):
|
|
120
|
+
from .scan import Thresholds
|
|
121
|
+
from .validate import validate_bundles, validate_synthetic
|
|
122
|
+
|
|
123
|
+
th = Thresholds().update(_overrides(args.set))
|
|
124
|
+
|
|
125
|
+
def progress(i, n, name):
|
|
126
|
+
print(f"\r [{i}/{n}] {name:<40}", end="", file=sys.stderr, flush=True)
|
|
127
|
+
|
|
128
|
+
if args.bundles:
|
|
129
|
+
val = validate_bundles(_bundles_from(args.bundles), th, progress)
|
|
130
|
+
if not val.cases:
|
|
131
|
+
raise SystemExit("none of these bundles carries ground truth; nothing to validate against")
|
|
132
|
+
else:
|
|
133
|
+
val = validate_synthetic(args.n, args.seed, th, progress)
|
|
134
|
+
print("\r" + " " * 60 + "\r", end="", file=sys.stderr)
|
|
135
|
+
print(val.render_text(), end="")
|
|
136
|
+
if not args.bundles:
|
|
137
|
+
print("\ncaveat: these faults were injected by ayna's own simulator, so this measures whether the "
|
|
138
|
+
"detectors work as designed -- not whether they transfer to trained models or recordings.")
|
|
139
|
+
if args.out:
|
|
140
|
+
val.save(args.out)
|
|
141
|
+
return 0
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def cmd_info(args):
|
|
145
|
+
from .bundle import load_bundle
|
|
146
|
+
|
|
147
|
+
b = load_bundle(args.bundle)
|
|
148
|
+
print(f"{args.bundle}: {b.summary()}")
|
|
149
|
+
for k, v in sorted(b.meta.items()):
|
|
150
|
+
print(f" {k}: {v}")
|
|
151
|
+
for n in b.notes:
|
|
152
|
+
print(f" note: {n}")
|
|
153
|
+
return 0
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def cmd_export_ctd(args):
|
|
157
|
+
from .adapters.ctd import export_run
|
|
158
|
+
|
|
159
|
+
path = export_run(args.dd_dir, args.out, args.model_type, args.tt, args.inputs)
|
|
160
|
+
print(f"wrote {path}")
|
|
161
|
+
return 0
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def cmd_doctor(args):
|
|
165
|
+
import platform
|
|
166
|
+
|
|
167
|
+
print(f"ayna {__version__} on Python {platform.python_version()}")
|
|
168
|
+
for mod, why in (("numpy", "core"), ("scipy", "core"), ("sklearn", "core"),
|
|
169
|
+
("matplotlib", "extra [plot]: select --plot"),
|
|
170
|
+
("daftar", "extra [provenance]: scan --daftar"),
|
|
171
|
+
("ctd", "export-ctd (install the CtD repository itself)"),
|
|
172
|
+
("torch", "needed by CtD, never by ayna")):
|
|
173
|
+
try:
|
|
174
|
+
m = __import__(mod)
|
|
175
|
+
print(f" ok {mod:<11} {getattr(m, '__version__', '')} ({why})")
|
|
176
|
+
except ImportError:
|
|
177
|
+
print(f" missing {mod:<11} ({why})")
|
|
178
|
+
return 0
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def build_parser():
|
|
182
|
+
p = argparse.ArgumentParser(prog="ayna", description="Know whether your inferred dynamics are real, before you interpret them.")
|
|
183
|
+
p.add_argument("--version", action="version", version=f"ayna {__version__}")
|
|
184
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
185
|
+
|
|
186
|
+
s = sub.add_parser("synth", help="write a synthetic bundle with injected faults")
|
|
187
|
+
s.add_argument("name")
|
|
188
|
+
s.add_argument("--faults", nargs="*", default=[], metavar="FAULT[=SEVERITY]",
|
|
189
|
+
help="underfit, feature_invention, misattribution, input_driven, nonlinear_readout")
|
|
190
|
+
s.add_argument("--sweep", action="store_true", help="write a simulated latent-size sweep instead")
|
|
191
|
+
s.add_argument("--latent-dim", type=int, default=8)
|
|
192
|
+
s.add_argument("--inputs", choices=["auto", "inferred", "supplied", "none"], default="auto")
|
|
193
|
+
s.add_argument("--trials", type=int, default=200)
|
|
194
|
+
s.add_argument("--neurons", type=int, default=60)
|
|
195
|
+
s.add_argument("--seed", type=int, default=0)
|
|
196
|
+
s.add_argument("--out", default="datasets")
|
|
197
|
+
s.set_defaults(fn=cmd_synth)
|
|
198
|
+
|
|
199
|
+
s = sub.add_parser("scan", help="diagnose one fitted model")
|
|
200
|
+
s.add_argument("bundle")
|
|
201
|
+
s.add_argument("--out", help="write report.{txt,md,json} here")
|
|
202
|
+
s.add_argument("--json", action="store_true")
|
|
203
|
+
s.add_argument("--set", nargs="*", metavar="NAME=VALUE", help="override thresholds")
|
|
204
|
+
s.add_argument("--no-nonlinear", action="store_true", help="skip the nonlinear cycle-consistency fallback")
|
|
205
|
+
s.add_argument("--daftar", action="store_true", help="record the scan with daftar")
|
|
206
|
+
s.set_defaults(fn=cmd_scan)
|
|
207
|
+
|
|
208
|
+
s = sub.add_parser("select", help="compare a sweep of models fitted to the same data")
|
|
209
|
+
s.add_argument("bundles", nargs="+")
|
|
210
|
+
s.add_argument("--out")
|
|
211
|
+
s.add_argument("--plot", action="store_true")
|
|
212
|
+
s.set_defaults(fn=cmd_select)
|
|
213
|
+
|
|
214
|
+
s = sub.add_parser("validate", help="check detectors against ground truth")
|
|
215
|
+
s.add_argument("bundles", nargs="*", help="bundles or directories with ground truth (default: synthetic corpus)")
|
|
216
|
+
s.add_argument("--n", type=int, default=6, help="synthetic cases per fault type")
|
|
217
|
+
s.add_argument("--seed", type=int, default=0)
|
|
218
|
+
s.add_argument("--set", nargs="*", metavar="NAME=VALUE")
|
|
219
|
+
s.add_argument("--out")
|
|
220
|
+
s.set_defaults(fn=cmd_validate)
|
|
221
|
+
|
|
222
|
+
s = sub.add_parser("info", help="describe a bundle")
|
|
223
|
+
s.add_argument("bundle")
|
|
224
|
+
s.set_defaults(fn=cmd_info)
|
|
225
|
+
|
|
226
|
+
s = sub.add_parser("export-ctd", help="export a CtD run to a bundle (run inside the CtD environment)")
|
|
227
|
+
s.add_argument("dd_dir")
|
|
228
|
+
s.add_argument("out")
|
|
229
|
+
s.add_argument("--model-type", required=True, choices=["SAE", "LFADS"])
|
|
230
|
+
s.add_argument("--tt", help="task-trained run directory, for ground truth")
|
|
231
|
+
s.add_argument("--inputs", choices=["supplied", "inferred"])
|
|
232
|
+
s.set_defaults(fn=cmd_export_ctd)
|
|
233
|
+
|
|
234
|
+
s = sub.add_parser("doctor", help="report versions and optional extras")
|
|
235
|
+
s.set_defaults(fn=cmd_doctor)
|
|
236
|
+
return p
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def main(argv=None):
|
|
240
|
+
args = build_parser().parse_args(argv)
|
|
241
|
+
from .bundle import BundleError
|
|
242
|
+
|
|
243
|
+
try:
|
|
244
|
+
return args.fn(args)
|
|
245
|
+
except BundleError as exc:
|
|
246
|
+
print(f"ayna: {exc}", file=sys.stderr)
|
|
247
|
+
return 2
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
if __name__ == "__main__": # pragma: no cover
|
|
251
|
+
sys.exit(main())
|