daftar 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.
- daftar/__init__.py +36 -0
- daftar/__version__.py +1 -0
- daftar/adapters/__init__.py +37 -0
- daftar/adapters/base.py +91 -0
- daftar/adapters/cpm_adapter.py +244 -0
- daftar/adapters/jaxley_adapter.py +176 -0
- daftar/adapters/meltingpot_adapter.py +235 -0
- daftar/capture.py +257 -0
- daftar/cli.py +183 -0
- daftar/diff.py +269 -0
- daftar/manifest.py +168 -0
- daftar/run.py +317 -0
- daftar/store.py +202 -0
- daftar/sweep.py +370 -0
- daftar-0.1.0.dist-info/METADATA +289 -0
- daftar-0.1.0.dist-info/RECORD +20 -0
- daftar-0.1.0.dist-info/WHEEL +5 -0
- daftar-0.1.0.dist-info/entry_points.txt +2 -0
- daftar-0.1.0.dist-info/licenses/LICENSE +202 -0
- daftar-0.1.0.dist-info/top_level.txt +1 -0
daftar/__init__.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""daftar -- record what produced each computational result.
|
|
2
|
+
|
|
3
|
+
import daftar
|
|
4
|
+
|
|
5
|
+
with daftar.track("celegans", params={"dt": 0.025}, seed=42) as run:
|
|
6
|
+
run.add_input("data/connectome.csv")
|
|
7
|
+
v = simulate(dt=0.025)
|
|
8
|
+
run.log_result("mean_rate_hz", float(v.mean()))
|
|
9
|
+
|
|
10
|
+
Then, from a shell::
|
|
11
|
+
|
|
12
|
+
daftar list
|
|
13
|
+
daftar diff r-4f21ab r-88c07e
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from .__version__ import __version__
|
|
17
|
+
from .diff import Diff, compare_many, diff_manifests, render_diff, render_manifest
|
|
18
|
+
from .manifest import Manifest
|
|
19
|
+
from .run import Run, track, tracked
|
|
20
|
+
from .store import RunStore
|
|
21
|
+
from .sweep import (
|
|
22
|
+
ReplayPlan, SweepResult, export_bundle, grid, load_bundle, plan_replay, sweep,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"__version__",
|
|
27
|
+
"track", "tracked", "Run",
|
|
28
|
+
"Manifest", "RunStore",
|
|
29
|
+
"diff_manifests", "render_diff", "render_manifest", "Diff", "compare_many",
|
|
30
|
+
"sweep", "grid", "SweepResult",
|
|
31
|
+
"plan_replay", "ReplayPlan",
|
|
32
|
+
"export_bundle", "load_bundle",
|
|
33
|
+
"adapters",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
from . import adapters # noqa: E402 (needs the names above)
|
daftar/__version__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Framework adapters.
|
|
2
|
+
|
|
3
|
+
Import is lazy on purpose: someone with Jaxley but not MeltingPot installed must
|
|
4
|
+
still be able to ``import daftar``. Nothing here imports a target framework
|
|
5
|
+
at module load.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from . import cpm_adapter, jaxley_adapter, meltingpot_adapter
|
|
11
|
+
from .base import Adapter, AdapterRegistry, record_optional, safe
|
|
12
|
+
|
|
13
|
+
registry = AdapterRegistry()
|
|
14
|
+
registry.register("jaxley", jaxley_adapter)
|
|
15
|
+
registry.register("cpm", cpm_adapter)
|
|
16
|
+
registry.register("meltingpot", meltingpot_adapter)
|
|
17
|
+
|
|
18
|
+
jaxley = jaxley_adapter
|
|
19
|
+
cpm = cpm_adapter
|
|
20
|
+
meltingpot = meltingpot_adapter
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def available() -> list[str]:
|
|
24
|
+
"""Adapters whose target framework is importable right now."""
|
|
25
|
+
return registry.available()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def get(name: str):
|
|
29
|
+
return registry.get(name)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
__all__ = [
|
|
33
|
+
"registry", "available", "get",
|
|
34
|
+
"jaxley", "cpm", "meltingpot",
|
|
35
|
+
"jaxley_adapter", "cpm_adapter", "meltingpot_adapter",
|
|
36
|
+
"Adapter", "AdapterRegistry", "safe", "record_optional",
|
|
37
|
+
]
|
daftar/adapters/base.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""What an adapter is, and what makes one worth writing.
|
|
2
|
+
|
|
3
|
+
The core package can already track any Python function. An adapter earns its
|
|
4
|
+
existence only by knowing something domain-specific that a generic tracker
|
|
5
|
+
cannot infer:
|
|
6
|
+
|
|
7
|
+
* which arguments are *scientifically* meaningful parameters, as opposed to
|
|
8
|
+
file paths and verbosity flags;
|
|
9
|
+
* where the framework hides state that silently changes results -- a solver
|
|
10
|
+
tolerance, an estimator choice, a substrate revision;
|
|
11
|
+
* what counts as a comparable scalar result in that field.
|
|
12
|
+
|
|
13
|
+
An adapter that just calls ``log_params(kwargs)`` is not worth the import. The
|
|
14
|
+
test is whether it records something the researcher would have forgotten.
|
|
15
|
+
|
|
16
|
+
Adapters must never import their framework at module import time. Someone with
|
|
17
|
+
Jaxley installed but not MeltingPot has to be able to ``import daftar``.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from typing import Any, Protocol, runtime_checkable
|
|
23
|
+
|
|
24
|
+
from ..run import Run
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@runtime_checkable
|
|
28
|
+
class Adapter(Protocol):
|
|
29
|
+
"""Structural protocol. Adapters are modules, not classes."""
|
|
30
|
+
|
|
31
|
+
name: str
|
|
32
|
+
|
|
33
|
+
def is_available(self) -> bool:
|
|
34
|
+
"""True if the target framework is importable."""
|
|
35
|
+
|
|
36
|
+
def describe(self, obj: Any, run: Run, prefix: str = "") -> None:
|
|
37
|
+
"""Record everything provenance-relevant about ``obj`` into ``run``."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class AdapterRegistry:
|
|
41
|
+
def __init__(self) -> None:
|
|
42
|
+
self._adapters: dict[str, Any] = {}
|
|
43
|
+
|
|
44
|
+
def register(self, name: str, module: Any) -> None:
|
|
45
|
+
self._adapters[name] = module
|
|
46
|
+
|
|
47
|
+
def get(self, name: str) -> Any:
|
|
48
|
+
if name not in self._adapters:
|
|
49
|
+
raise KeyError(
|
|
50
|
+
f"no adapter named {name!r}; available: "
|
|
51
|
+
f"{', '.join(sorted(self._adapters)) or 'none'}"
|
|
52
|
+
)
|
|
53
|
+
return self._adapters[name]
|
|
54
|
+
|
|
55
|
+
def available(self) -> list[str]:
|
|
56
|
+
out = []
|
|
57
|
+
for name, mod in self._adapters.items():
|
|
58
|
+
try:
|
|
59
|
+
if mod.is_available():
|
|
60
|
+
out.append(name)
|
|
61
|
+
except Exception:
|
|
62
|
+
continue
|
|
63
|
+
return sorted(out)
|
|
64
|
+
|
|
65
|
+
def all(self) -> list[str]:
|
|
66
|
+
return sorted(self._adapters)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def safe(fn, default=None):
|
|
70
|
+
"""Run a probe that may fail against an unfamiliar framework version.
|
|
71
|
+
|
|
72
|
+
Adapters read private-ish attributes of fast-moving research code. A
|
|
73
|
+
provenance tool that crashes a four-hour simulation because a framework
|
|
74
|
+
renamed an attribute has done far more harm than the missing field is worth.
|
|
75
|
+
Every probe is best-effort; a missing field is recorded as missing.
|
|
76
|
+
"""
|
|
77
|
+
try:
|
|
78
|
+
return fn()
|
|
79
|
+
except Exception:
|
|
80
|
+
return default
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def record_optional(run: Run, key: str, fn, *, kind: str = "param") -> None:
|
|
84
|
+
"""Record ``fn()`` under ``key``, or note that it could not be read."""
|
|
85
|
+
value = safe(fn, default="<unavailable>")
|
|
86
|
+
if kind == "param":
|
|
87
|
+
run.log_param(key, value)
|
|
88
|
+
elif kind == "result":
|
|
89
|
+
run.log_result(key, value)
|
|
90
|
+
else:
|
|
91
|
+
run.manifest.set(f"{kind}.{key}", value)
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
"""cpm (Computational Psychiatry Modelling) adapter.
|
|
2
|
+
|
|
3
|
+
The cpm paper names the problem this package exists for: models are implemented
|
|
4
|
+
differently across labs with unstated assumptions, and undetected bugs propagate
|
|
5
|
+
as new researchers build on existing implementations. cpm solved model
|
|
6
|
+
*specification*. It did not solve knowing which fit produced which number.
|
|
7
|
+
|
|
8
|
+
What a generic tracker misses here:
|
|
9
|
+
|
|
10
|
+
* **Bounds and priors are the model.** Two fits with identical code and data but
|
|
11
|
+
a learning-rate bound of ``(0, 1)`` versus ``(0, 2)`` are different
|
|
12
|
+
experiments. cpm keeps these in ``Parameters``, not in the call arguments.
|
|
13
|
+
* **The estimator is a result-changing choice.** Fmin, FminBound, genetic,
|
|
14
|
+
BADS, and the hierarchical variants converge differently. Which one ran, with
|
|
15
|
+
which scipy method and tolerances, has to be in the record.
|
|
16
|
+
* **Fits have convergence status per participant.** A group-level parameter
|
|
17
|
+
computed from 60 fits of which 7 failed to converge is not the same number as
|
|
18
|
+
one where all 60 converged, and nothing in the output distinguishes them.
|
|
19
|
+
* **Participant count and identifiers.** Dropping two subjects changes every
|
|
20
|
+
group statistic. The count and a hash of the identifier list are recorded.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import hashlib
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
from ..run import Run
|
|
29
|
+
from .base import safe
|
|
30
|
+
|
|
31
|
+
name = "cpm"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def is_available() -> bool:
|
|
35
|
+
try:
|
|
36
|
+
import cpm # noqa: F401
|
|
37
|
+
return True
|
|
38
|
+
except Exception:
|
|
39
|
+
return False
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# --------------------------------------------------------------------------
|
|
43
|
+
# parameters
|
|
44
|
+
# --------------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
def describe_parameters(parameters: Any, run: Run, prefix: str = "model") -> None:
|
|
47
|
+
"""Record every free parameter's value, bounds, and prior."""
|
|
48
|
+
names = safe(lambda: list(parameters.keys()), [])
|
|
49
|
+
run.log_param(f"{prefix}.parameter_names", sorted(names))
|
|
50
|
+
run.log_param(f"{prefix}.n_parameters", len(names))
|
|
51
|
+
|
|
52
|
+
free = safe(lambda: list(parameters.free()), None)
|
|
53
|
+
if free is not None:
|
|
54
|
+
run.log_param(f"{prefix}.n_free_parameters", len(free))
|
|
55
|
+
|
|
56
|
+
# bounds() returns [[lowers], [uppers]] in cpm.
|
|
57
|
+
bounds = safe(lambda: parameters.bounds())
|
|
58
|
+
if bounds is not None:
|
|
59
|
+
try:
|
|
60
|
+
lowers, uppers = bounds[0], bounds[1]
|
|
61
|
+
for i, nm in enumerate(safe(lambda: list(parameters.free()), names)):
|
|
62
|
+
run.log_param(f"{prefix}.bounds.{nm}", f"[{lowers[i]}, {uppers[i]}]")
|
|
63
|
+
except Exception:
|
|
64
|
+
run.log_param(f"{prefix}.bounds", str(bounds))
|
|
65
|
+
|
|
66
|
+
for nm in names:
|
|
67
|
+
value = safe(lambda n=nm: getattr(parameters, n))
|
|
68
|
+
if value is None:
|
|
69
|
+
continue
|
|
70
|
+
run.log_param(f"{prefix}.value.{nm}", safe(lambda v=value: float(v), str(value)))
|
|
71
|
+
prior = safe(lambda v=value: getattr(v, "prior", None))
|
|
72
|
+
if prior is not None:
|
|
73
|
+
run.log_param(
|
|
74
|
+
f"{prefix}.prior.{nm}",
|
|
75
|
+
safe(lambda p=prior: getattr(p, "dist", type(p).__name__), str(prior)),
|
|
76
|
+
)
|
|
77
|
+
args = safe(lambda v=value: getattr(v, "args", None))
|
|
78
|
+
if args:
|
|
79
|
+
run.log_param(f"{prefix}.prior_args.{nm}", args)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# --------------------------------------------------------------------------
|
|
83
|
+
# data
|
|
84
|
+
# --------------------------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
def describe_data(data: Any, run: Run, prefix: str = "data") -> None:
|
|
87
|
+
"""Record shape and a stable hash of participant identifiers.
|
|
88
|
+
|
|
89
|
+
Hashing the identifier list rather than storing it keeps subject IDs out of
|
|
90
|
+
the manifest -- these are behavioural studies and manifests get committed to
|
|
91
|
+
public repositories -- while still detecting a changed or reordered cohort.
|
|
92
|
+
"""
|
|
93
|
+
run.log_param(f"{prefix}.type", type(data).__name__)
|
|
94
|
+
|
|
95
|
+
n = safe(lambda: len(data))
|
|
96
|
+
if n is not None:
|
|
97
|
+
run.log_param(f"{prefix}.n_records", n)
|
|
98
|
+
|
|
99
|
+
columns = safe(lambda: sorted(map(str, data.columns)))
|
|
100
|
+
if columns:
|
|
101
|
+
run.log_param(f"{prefix}.columns", columns)
|
|
102
|
+
|
|
103
|
+
for id_col in ("ppt", "participant", "subject", "id"):
|
|
104
|
+
ids = safe(lambda c=id_col: sorted(map(str, data[c].unique())))
|
|
105
|
+
if ids:
|
|
106
|
+
run.log_param(f"{prefix}.n_participants", len(ids))
|
|
107
|
+
digest = hashlib.sha256("\n".join(ids).encode()).hexdigest()[:12]
|
|
108
|
+
run.log_param(f"{prefix}.participant_id_sha256", digest)
|
|
109
|
+
break
|
|
110
|
+
|
|
111
|
+
if safe(lambda: "observed" in data) or safe(lambda: "observed" in data.columns):
|
|
112
|
+
run.log_param(f"{prefix}.has_observed", True)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
# --------------------------------------------------------------------------
|
|
116
|
+
# fitting
|
|
117
|
+
# --------------------------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
def describe_optimiser(optimiser: Any, run: Run, prefix: str = "fit") -> None:
|
|
120
|
+
"""Record which estimator ran and how it was configured."""
|
|
121
|
+
run.log_param(f"{prefix}.estimator", type(optimiser).__name__)
|
|
122
|
+
run.log_param(
|
|
123
|
+
f"{prefix}.loss",
|
|
124
|
+
safe(lambda: getattr(optimiser.loss, "__name__", str(optimiser.loss)), "unknown"),
|
|
125
|
+
)
|
|
126
|
+
run.log_param(f"{prefix}.uses_prior", bool(safe(lambda: optimiser.prior, False)))
|
|
127
|
+
|
|
128
|
+
kwargs = safe(lambda: dict(optimiser.kwargs or {}), {})
|
|
129
|
+
for k, v in sorted(kwargs.items()):
|
|
130
|
+
run.log_param(f"{prefix}.kwargs.{k}", v)
|
|
131
|
+
|
|
132
|
+
# The scipy method and tolerance hide inside kwargs and change the answer.
|
|
133
|
+
for key in ("method", "tol", "maxiter", "options"):
|
|
134
|
+
if key in kwargs:
|
|
135
|
+
run.log_param(f"{prefix}.{key}", kwargs[key])
|
|
136
|
+
|
|
137
|
+
model = safe(lambda: optimiser.model)
|
|
138
|
+
if model is not None:
|
|
139
|
+
params = safe(lambda: model.parameters)
|
|
140
|
+
if params is not None:
|
|
141
|
+
describe_parameters(params, run, prefix="model")
|
|
142
|
+
data = safe(lambda: model.data)
|
|
143
|
+
if data is not None:
|
|
144
|
+
describe_data(data, run, prefix="data")
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def describe_fit_results(optimiser: Any, run: Run, prefix: str = "fit") -> None:
|
|
148
|
+
"""Record convergence and group-level outcomes after ``optimise()``.
|
|
149
|
+
|
|
150
|
+
Convergence counts matter more than they look. A group mean over 60
|
|
151
|
+
participants of whom 7 hit the iteration limit is a different number from
|
|
152
|
+
one where all converged, and the output alone does not say which you have.
|
|
153
|
+
"""
|
|
154
|
+
fits = safe(lambda: list(optimiser.fit), [])
|
|
155
|
+
run.log_result(f"{prefix}.n_fits", len(fits))
|
|
156
|
+
if not fits:
|
|
157
|
+
return
|
|
158
|
+
|
|
159
|
+
converged = 0
|
|
160
|
+
unknown = 0
|
|
161
|
+
for f in fits:
|
|
162
|
+
status = None
|
|
163
|
+
for key in ("success", "converged", "status"):
|
|
164
|
+
if isinstance(f, dict) and key in f:
|
|
165
|
+
status = f[key]
|
|
166
|
+
break
|
|
167
|
+
if status is None:
|
|
168
|
+
unknown += 1
|
|
169
|
+
elif bool(status) is True or status == 0:
|
|
170
|
+
converged += 1
|
|
171
|
+
run.log_result(f"{prefix}.n_converged", converged)
|
|
172
|
+
if unknown:
|
|
173
|
+
run.log_result(f"{prefix}.n_convergence_unknown", unknown)
|
|
174
|
+
|
|
175
|
+
# Group-level central tendency of each fitted parameter.
|
|
176
|
+
def _summaries():
|
|
177
|
+
import numpy as np
|
|
178
|
+
params = safe(lambda: list(optimiser.parameters), [])
|
|
179
|
+
if not params:
|
|
180
|
+
return {}
|
|
181
|
+
keys = set()
|
|
182
|
+
for p in params:
|
|
183
|
+
if isinstance(p, dict):
|
|
184
|
+
keys |= set(p)
|
|
185
|
+
out = {}
|
|
186
|
+
for k in sorted(keys):
|
|
187
|
+
vals = [p[k] for p in params if isinstance(p, dict) and k in p]
|
|
188
|
+
numeric = [float(v) for v in vals if isinstance(v, (int, float))]
|
|
189
|
+
if numeric:
|
|
190
|
+
out[f"group_mean.{k}"] = round(float(np.mean(numeric)), 6)
|
|
191
|
+
out[f"group_sd.{k}"] = round(float(np.std(numeric)), 6)
|
|
192
|
+
return out
|
|
193
|
+
|
|
194
|
+
for k, v in safe(_summaries, {}).items():
|
|
195
|
+
run.log_result(f"{prefix}.{k}", v)
|
|
196
|
+
|
|
197
|
+
def _loss():
|
|
198
|
+
import numpy as np
|
|
199
|
+
vals = []
|
|
200
|
+
for f in fits:
|
|
201
|
+
if isinstance(f, dict):
|
|
202
|
+
for key in ("fun", "loss", "nll", "value"):
|
|
203
|
+
if key in f and isinstance(f[key], (int, float)):
|
|
204
|
+
vals.append(float(f[key]))
|
|
205
|
+
break
|
|
206
|
+
return round(float(np.mean(vals)), 6) if vals else None
|
|
207
|
+
|
|
208
|
+
mean_loss = safe(_loss)
|
|
209
|
+
if mean_loss is not None:
|
|
210
|
+
run.log_result(f"{prefix}.mean_loss", mean_loss)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def optimise(optimiser: Any, run: Run, **kwargs: Any):
|
|
214
|
+
"""Run ``optimiser.optimise()`` with configuration and outcome recorded.
|
|
215
|
+
|
|
216
|
+
::
|
|
217
|
+
|
|
218
|
+
with daftar.track("bandit-fit", seed=7) as run:
|
|
219
|
+
cpm_adapter.optimise(fmin, run)
|
|
220
|
+
"""
|
|
221
|
+
import cpm
|
|
222
|
+
|
|
223
|
+
run.log_param("fit.cpm_version", safe(lambda: cpm.__version__, "unknown"))
|
|
224
|
+
describe_optimiser(optimiser, run)
|
|
225
|
+
result = optimiser.optimise(**kwargs)
|
|
226
|
+
describe_fit_results(optimiser, run)
|
|
227
|
+
return result
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def describe(obj: Any, run: Run, prefix: str = "model") -> None:
|
|
231
|
+
"""Dispatch on whatever cpm object is handed in."""
|
|
232
|
+
cls = type(obj).__name__
|
|
233
|
+
if cls in ("Parameters",):
|
|
234
|
+
describe_parameters(obj, run, prefix)
|
|
235
|
+
elif cls in ("Wrapper", "Simulator"):
|
|
236
|
+
params = safe(lambda: obj.parameters)
|
|
237
|
+
if params is not None:
|
|
238
|
+
describe_parameters(params, run, prefix)
|
|
239
|
+
data = safe(lambda: obj.data)
|
|
240
|
+
if data is not None:
|
|
241
|
+
describe_data(data, run)
|
|
242
|
+
run.log_param(f"{prefix}.generator", cls)
|
|
243
|
+
else:
|
|
244
|
+
describe_optimiser(obj, run)
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""Jaxley adapter.
|
|
2
|
+
|
|
3
|
+
What a generic tracker misses about a Jaxley run, and what this records instead:
|
|
4
|
+
|
|
5
|
+
* ``jx.integrate`` takes ``solver`` and ``voltage_solver`` with *defaults*
|
|
6
|
+
(``bwd_euler``, ``jaxley.dhs``). Someone who never passes them has no record
|
|
7
|
+
of them, and Jaxley changing a default between versions silently moves every
|
|
8
|
+
result. Defaults are recorded explicitly.
|
|
9
|
+
* ``delta_t`` defaults to 0.025 ms and is the single most common cause of a
|
|
10
|
+
result moving. Recorded whether or not it was passed.
|
|
11
|
+
* The morphology is provenance. A ``Cell`` built from an SWC file, its branch
|
|
12
|
+
and compartment counts, and which channels are inserted where, all determine
|
|
13
|
+
the answer and none of it appears in the call arguments.
|
|
14
|
+
* Jaxley is JAX, so x64 mode matters. A run made with
|
|
15
|
+
``jax_enable_x64=True`` and one without are different experiments that look
|
|
16
|
+
identical in any generic log.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import sys
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
from ..run import Run
|
|
25
|
+
from .base import safe
|
|
26
|
+
|
|
27
|
+
name = "jaxley"
|
|
28
|
+
|
|
29
|
+
#: jx.integrate defaults, as of Jaxley 0.6.x. Recorded when not passed
|
|
30
|
+
#: explicitly, so that a future change of default is visible as a diff.
|
|
31
|
+
INTEGRATE_DEFAULTS = {
|
|
32
|
+
"delta_t": 0.025,
|
|
33
|
+
"solver": "bwd_euler",
|
|
34
|
+
"voltage_solver": "jaxley.dhs",
|
|
35
|
+
"t_max": None,
|
|
36
|
+
"checkpoint_lengths": None,
|
|
37
|
+
"return_states": False,
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def is_available() -> bool:
|
|
42
|
+
try:
|
|
43
|
+
import jaxley # noqa: F401
|
|
44
|
+
return True
|
|
45
|
+
except Exception:
|
|
46
|
+
return False
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _jax_config(run: Run) -> None:
|
|
50
|
+
jax = sys.modules.get("jax")
|
|
51
|
+
if jax is None:
|
|
52
|
+
return
|
|
53
|
+
cfg = getattr(jax, "config", None)
|
|
54
|
+
if cfg is None:
|
|
55
|
+
return
|
|
56
|
+
# x64 silently changes numerical results; platform changes them too.
|
|
57
|
+
run.manifest.set("env.jax_enable_x64", safe(lambda: cfg.jax_enable_x64, "unknown"))
|
|
58
|
+
run.manifest.set(
|
|
59
|
+
"env.jax_platform",
|
|
60
|
+
safe(lambda: jax.default_backend(), "unknown"),
|
|
61
|
+
)
|
|
62
|
+
run.manifest.set(
|
|
63
|
+
"env.jax_devices",
|
|
64
|
+
safe(lambda: ",".join(sorted({d.platform for d in jax.devices()})), "unknown"),
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def describe_module(module: Any, run: Run, prefix: str = "morphology") -> None:
|
|
69
|
+
"""Record the structure of a ``jx.Module`` (Cell, Network, Branch, Compartment)."""
|
|
70
|
+
run.log_param(f"{prefix}.type", type(module).__name__)
|
|
71
|
+
|
|
72
|
+
# Compartment/branch/cell counts live in the nodes DataFrame.
|
|
73
|
+
nodes = safe(lambda: module.nodes)
|
|
74
|
+
if nodes is not None:
|
|
75
|
+
run.log_param(f"{prefix}.n_compartments", safe(lambda: len(nodes), "unknown"))
|
|
76
|
+
for col, label in (
|
|
77
|
+
("global_branch_index", "n_branches"),
|
|
78
|
+
("global_cell_index", "n_cells"),
|
|
79
|
+
):
|
|
80
|
+
run.log_param(
|
|
81
|
+
f"{prefix}.{label}",
|
|
82
|
+
safe(lambda c=col: int(nodes[c].nunique()), "unknown"),
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
# Which channels are inserted, and how many compartments carry each.
|
|
86
|
+
channels = safe(lambda: [c._name for c in module.channels], [])
|
|
87
|
+
if channels:
|
|
88
|
+
run.log_param(f"{prefix}.channels", sorted(channels))
|
|
89
|
+
for ch in sorted(set(channels)):
|
|
90
|
+
run.log_param(
|
|
91
|
+
f"{prefix}.channel_compartments.{ch}",
|
|
92
|
+
safe(lambda c=ch: int(nodes[c].sum()) if c in nodes else 0, "unknown"),
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
edges = safe(lambda: module.edges)
|
|
96
|
+
if edges is not None and len(edges):
|
|
97
|
+
run.log_param(f"{prefix}.n_synapses", len(edges))
|
|
98
|
+
run.log_param(
|
|
99
|
+
f"{prefix}.synapse_types",
|
|
100
|
+
safe(lambda: sorted(set(edges["type"].tolist())), "unknown"),
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
# Trainable parameters change what a gradient step does.
|
|
104
|
+
run.log_param(
|
|
105
|
+
f"{prefix}.n_trainable_params",
|
|
106
|
+
safe(lambda: int(module.num_trainable_params), 0),
|
|
107
|
+
)
|
|
108
|
+
# Externals: stimuli and clamps are inputs, not incidental.
|
|
109
|
+
externals = safe(lambda: module.externals, {})
|
|
110
|
+
if externals:
|
|
111
|
+
run.log_param(f"{prefix}.externals", sorted(externals.keys()))
|
|
112
|
+
for k, v in sorted(externals.items()):
|
|
113
|
+
run.log_param(
|
|
114
|
+
f"{prefix}.external.{k}.shape",
|
|
115
|
+
safe(lambda vv=v: str(tuple(vv.shape)), "unknown"),
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def describe_integration(run: Run, **kwargs: Any) -> dict[str, Any]:
|
|
120
|
+
"""Record integrator settings, filling in Jaxley's defaults explicitly.
|
|
121
|
+
|
|
122
|
+
Returns the fully-resolved kwargs so the caller can pass them straight to
|
|
123
|
+
``jx.integrate`` -- there is then no way for what was recorded and what was
|
|
124
|
+
run to drift apart.
|
|
125
|
+
"""
|
|
126
|
+
resolved = dict(INTEGRATE_DEFAULTS)
|
|
127
|
+
resolved.update({k: v for k, v in kwargs.items() if v is not None or k in resolved})
|
|
128
|
+
|
|
129
|
+
for key, value in resolved.items():
|
|
130
|
+
run.log_param(f"integrate.{key}", value)
|
|
131
|
+
if key in INTEGRATE_DEFAULTS and key not in kwargs:
|
|
132
|
+
run.log_param(f"integrate.{key}.was_default", True)
|
|
133
|
+
|
|
134
|
+
_jax_config(run)
|
|
135
|
+
return resolved
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def integrate(module: Any, run: Run, **kwargs: Any):
|
|
139
|
+
"""``jx.integrate`` with the module and settings recorded first.
|
|
140
|
+
|
|
141
|
+
::
|
|
142
|
+
|
|
143
|
+
with daftar.track("celegans", seed=0) as run:
|
|
144
|
+
v = jaxley_adapter.integrate(cell, run, t_max=10.0, delta_t=0.025)
|
|
145
|
+
"""
|
|
146
|
+
import jaxley as jx
|
|
147
|
+
|
|
148
|
+
describe_module(module, run)
|
|
149
|
+
resolved = describe_integration(run, **kwargs)
|
|
150
|
+
run.log_param("integrate.jaxley_version", safe(lambda: jx.__version__, "unknown"))
|
|
151
|
+
|
|
152
|
+
call_kwargs = {k: v for k, v in resolved.items() if v is not None}
|
|
153
|
+
result = jx.integrate(module, **call_kwargs)
|
|
154
|
+
|
|
155
|
+
# Summary statistics, not the trace. A recorded array would make every diff
|
|
156
|
+
# unreadable; these four numbers are what you actually compare.
|
|
157
|
+
def _stats():
|
|
158
|
+
import numpy as np
|
|
159
|
+
arr = np.asarray(result)
|
|
160
|
+
return {
|
|
161
|
+
"shape": str(arr.shape),
|
|
162
|
+
"v_mean": float(arr.mean()),
|
|
163
|
+
"v_min": float(arr.min()),
|
|
164
|
+
"v_max": float(arr.max()),
|
|
165
|
+
"v_final_mean": float(arr[..., -1].mean()),
|
|
166
|
+
"n_nonfinite": int((~np.isfinite(arr)).sum()),
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
stats = safe(_stats, {})
|
|
170
|
+
for k, v in stats.items():
|
|
171
|
+
run.log_result(f"voltage.{k}", v)
|
|
172
|
+
return result
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def describe(obj: Any, run: Run, prefix: str = "morphology") -> None:
|
|
176
|
+
describe_module(obj, run, prefix)
|