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 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
+ ]
@@ -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)