Skip to content

Commit e3bb2e1

Browse files
committed
docs(developer): extension playbooks — data-driven workflow, custom coupling, custom objective (goal-13k)
Add three developer extension guides documenting the stable extension contracts contributors (and future data-driven tooling) build against: - building_a_data_driven_workflow.ipynb — the playbook: a custom model exposing Param(fit=True) trainables, a custom objectives loss, fitting via Fitter and a hand-written brainstate/braintools training loop, and batching with vmap; references the deferred Trainer (/data_driven/roadmap) as the future home for task-training. - creating_a_coupling.ipynb — implement a new coupling against the prefetch/delay contract (kernel fn + thin Module), mirroring DiffusiveCoupling/SigmoidalCoupling; wired into a network under Simulator. - creating_an_objective.ipynb — write a composable, unit-aware objective that works with Fitter across the grad/nevergrad/scipy backends. All three wired into developer/index.rst (new "Extending brainmass" grid + toctree). Notebooks execute exit 0 with real outputs; docs-only (no brainmass/ source change); make html builds with no new warnings vs baseline.
1 parent b843b9a commit e3bb2e1

5 files changed

Lines changed: 1317 additions & 0 deletions

File tree

CONTEXT.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -984,3 +984,13 @@ Gotchas inherited: (a) execute notebooks with PYTHONPATH=<worktree-root> jupyter
984984
"High-level entry points" lead-in to the API-conventions section naming `Simulator`/`Network`/
985985
`Fitter` and pointed the utilities row at "type aliases". `datasets.rst`/`viz.rst` (13b) verified
986986
complete + style-consistent (autosummary-only house style) — no changes needed.
987+
988+
### 2026-06-19 · goal-13k developer-expansion
989+
- **Authored the 3 developer extension playbooks** (docs-only; `git diff --stat origin/main -- brainmass/` **empty**). All execute **exit 0** with real embedded outputs via `PYTHONPATH=<worktree-root> jupyter nbconvert --to notebook --execute --inplace`, and all 3 are wired into `developer/index.rst` (new "Extending brainmass" grid section + toctree — I own `developer/`, edited only its toctree). These are the *implementation contracts* (how to BUILD a piece), deliberately deeper/distinct from the goal-13f `howto/custom_coupling` + `howto/custom_objective` (which cover how to USE the built-ins via `Network(coupling=)` / `objectives.combine`).
990+
- **building_a_data_driven_workflow** (7 code cells, 2 imgs): the extension playbook a future `Trainer` builds against. Custom `GainPopulation(Dynamics)` exposing a dimensionless `Param(g, fit=True)` → a custom `steadystate_loss` objective → fit it (A) via `Fitter(objective=,predict=)` grad backend (`g: 0.5→1.99`, true 2.0) and (B) via the hand-rolled `states(ParamState)``register_trainable_weights`→jitted `brainstate.transform.grad``optimizer.step` loop (matches) → (C) `jax.vmap` over a 25-point `g` grid (loss bowl, min at 2.0). Closing section references the deferred `Trainer` at `/data_driven/roadmap` as the future home for task-training and names the `Fitter`-fits-one-fixed-target gap.
991+
- **creating_a_coupling** (6 code cells, 1 img): a new `PowerLawCoupling` (kernel fn + thin `Module`) mirroring `DiffusiveCoupling`. Documents the prefetch/delay contract (source `prefetch_delay``(..,N,N)`, target `prefetch``(..,N)`), `@brainstate.nn.call_order(2)` `init_state``init_maybe_prefetch`, `Param.init` (Const vs trainable), `get_magnitude` before the nonlinearity. Wired into a hand-built `PowerLawNetwork` (since `Network.coupling=` is a fixed dispatch set) and run on a 4-region Hopf net under `Simulator`, instantaneous + with `distance/speed` delays.
992+
- **creating_an_objective** (5 code cells): a custom `variance_match(as_loss=)` builder (unit-aware via `get_magnitude`, identity→0 on mV AND Hz), composed with `objectives.combine` against `fc_corr`, then the headline: the SAME objective minimised across all 3 `Fitter` backends — `grad` (Adam, a→0.95), `scipy` (Nelder-Mead, a→1.0), `nevergrad` (DE, a→1.015), true a*=1.0. Bounded trainable `Param(a, t=SigmoidT(0.1,2.0))` so the derivative-free backends auto-derive a finite search box.
993+
- **⚠ MyST in-page anchor links are NOT auto-resolved → the one new-warning trap.** First build emitted exactly **1 NEW** warning: `[myst.xref_missing] cross-reference target not found: 'where-this-is-going'` from two `[text](#where-this-is-going)` links pointing at a `## Where this is going` heading. MyST does not auto-generate heading anchors unless `myst_heading_anchors` is configured (it is not). Fix = drop the fragile in-page anchor links, use plain prose ("see the final section"). After the fix the clean normalized warning diff is **0 new / 0 removed**. Lesson for any notebook goal: prefer `{doc}` cross-refs (which ARE checked + resolve) and avoid `[..](#slug)` same-page fragment links entirely.
994+
- **make html:** baseline (detached `git worktree add --detach /tmp/bm_base_13k origin/main`, `make clean` first) and worktree both **build succeeded, 129 raw warnings / 122 unique normalized** (strip worktree path + `:LINE:`), **byte-identical: 0 new, 0 removed** — same pre-existing autosummary-duplicate + WilsonCowan/LeadfieldReadout source-docstring set as goal-13j (the 129 raw count is the post-13j level, confirmed). `nb_execution_mode="off"` so the build renders committed outputs; notebooks executed out-of-band. All 3 render to `_build/html/developer/*.html` with their real results (grep-confirmed "recovered g", "trajectory shape", backend recap).
995+
- **⚠ Build-helper clobber trap (goal-13i lesson, re-confirmed):** the gitignored `dev/build_dev_nb.py` regenerates all 3 notebooks with empty outputs. After patching the anchor links + re-running the builder, I **re-executed EVERY notebook** (not just the changed one) and re-counted per-cell outputs (errors=0, imgs intact) — exit code alone is insufficient, a clobbered notebook shows exit 0 with `outputs=0`. Two stray sphinx-log files (`docs/wt_stdout.txt`/`wt_warnings.txt`) are NOT gitignored — `rm` them before commit (only `docs/_build`, `dev/`, `*/generated`, `*/.ipynb_checkpoints` are ignored).
996+
- **Tone note:** the pre-existing `creating_models.ipynb`/`extending_noise.ipynb` are schematic and contain stale/broken API (`brainstate.ShardingMode`, deprecated `brainstate.init.Constant`, a `tests/` dir, single-step Euler) — I matched their *prose tone* (short intro + runnable code + checklist + See Also) but used the CURRENT correct API throughout (`braintools.init`, `Simulator`, `Param`, `brainstate.environ.get_dt()`). Did NOT touch those two files (out of scope; a fast follow-up could modernize them).

docs/developer/building_a_data_driven_workflow.ipynb

Lines changed: 517 additions & 0 deletions
Large diffs are not rendered by default.

docs/developer/creating_a_coupling.ipynb

Lines changed: 402 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 350 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,350 @@
1+
{
2+
"cells": [
3+
{
4+
"cell_type": "code",
5+
"execution_count": 1,
6+
"id": "f59daf5a",
7+
"metadata": {
8+
"execution": {
9+
"iopub.execute_input": "2026-06-19T09:16:30.204103Z",
10+
"iopub.status.busy": "2026-06-19T09:16:30.203864Z",
11+
"iopub.status.idle": "2026-06-19T09:16:34.873324Z",
12+
"shell.execute_reply": "2026-06-19T09:16:34.872392Z"
13+
},
14+
"tags": [
15+
"remove-cell"
16+
]
17+
},
18+
"outputs": [
19+
{
20+
"name": "stderr",
21+
"output_type": "stream",
22+
"text": [
23+
"An NVIDIA GPU may be present on this machine, but a CUDA-enabled jaxlib is not installed. Falling back to cpu.\n"
24+
]
25+
}
26+
],
27+
"source": [
28+
"%matplotlib inline\n",
29+
"import numpy as np\n",
30+
"import jax\n",
31+
"import jax.numpy as jnp\n",
32+
"import matplotlib.pyplot as plt\n",
33+
"import brainmass\n",
34+
"import brainstate\n",
35+
"import braintools\n",
36+
"import brainunit as u\n",
37+
"from brainstate.nn import Param\n",
38+
"brainstate.random.seed(0)\n",
39+
"brainstate.environ.set(dt=0.1 * u.ms)"
40+
]
41+
},
42+
{
43+
"cell_type": "markdown",
44+
"id": "8de84371",
45+
"metadata": {},
46+
"source": [
47+
"# Creating an Objective\n",
48+
"\n",
49+
"An **objective** scores how well a simulated trajectory matches data. `brainmass`\n",
50+
"ships a toolkit in `brainmass.objectives` (`timeseries_rmse`, `fc_corr`, `fcd_*`,\n",
51+
"...). This guide shows how to write your *own* objective so it behaves exactly like\n",
52+
"the built-ins: composable, unit-aware, and usable with `Fitter` across **all three**\n",
53+
"optimizer backends (gradient, Nevergrad, SciPy).\n",
54+
"\n",
55+
"To merely *combine* the existing objectives, see {doc}`/howto/custom_objective`.\n",
56+
"This guide is the authoring contract."
57+
]
58+
},
59+
{
60+
"cell_type": "markdown",
61+
"id": "6b9dc716",
62+
"metadata": {},
63+
"source": [
64+
"## The contract\n",
65+
"\n",
66+
"An objective is a **builder**: a function that takes configuration and returns a\n",
67+
"small `callable(prediction, target) -> scalar`. The returned callable must be:\n",
68+
"\n",
69+
"- **pure and traced-array-friendly** -- built from `jax.numpy` / `brainunit` ops so\n",
70+
" it survives `jit`, `grad`, and `vmap` (the three backends each need a different\n",
71+
" one of these).\n",
72+
"- **unit-aware** -- strip units with `brainunit.get_magnitude` where the metric is\n",
73+
" scale-invariant (correlations, cosine, a variance ratio); *keep* them on a\n",
74+
" difference you want unit-checked (subtracting `mV` from `Hz` should raise).\n",
75+
"- **a single scalar** -- the optimizers minimise a scalar.\n",
76+
"\n",
77+
"A `prediction` / `target` is a `(time, regions)` trajectory -- the natural output\n",
78+
"of `Simulator.run`. By convention a builder takes `as_loss=` so the same metric can\n",
79+
"be a score to *maximise* or `1 - score` (or its negative) to *minimise*."
80+
]
81+
},
82+
{
83+
"cell_type": "code",
84+
"execution_count": 2,
85+
"id": "21675ea0",
86+
"metadata": {
87+
"execution": {
88+
"iopub.execute_input": "2026-06-19T09:16:34.875893Z",
89+
"iopub.status.busy": "2026-06-19T09:16:34.875396Z",
90+
"iopub.status.idle": "2026-06-19T09:16:35.042925Z",
91+
"shell.execute_reply": "2026-06-19T09:16:35.042241Z"
92+
}
93+
},
94+
"outputs": [
95+
{
96+
"name": "stdout",
97+
"output_type": "stream",
98+
"text": [
99+
"identity loss (mV) : 0.0\n",
100+
"identity loss (Hz) : 0.0\n"
101+
]
102+
}
103+
],
104+
"source": [
105+
"def variance_match(as_loss=True):\n",
106+
" \"\"\"Match the overall temporal variance of two signals.\n",
107+
"\n",
108+
" A scale-sensitive summary: invariant in *units* (we strip them)\n",
109+
" but sensitive to amplitude. Returns a builder, like brainmass.objectives.*\n",
110+
" \"\"\"\n",
111+
" def objective(prediction, target):\n",
112+
" var_p = jnp.var(u.get_magnitude(prediction))\n",
113+
" var_t = jnp.var(u.get_magnitude(target))\n",
114+
" d = (var_p - var_t) ** 2\n",
115+
" return d if as_loss else -d\n",
116+
" return objective\n",
117+
"\n",
118+
"\n",
119+
"# It is unit-aware: mV and Hz inputs both work, identity gives 0.\n",
120+
"loss = variance_match()\n",
121+
"x_mV = jnp.ones((50, 3)) * u.mV\n",
122+
"print('identity loss (mV) :', float(loss(x_mV, x_mV)))\n",
123+
"x_Hz = (jnp.ones((50, 3)) * 2.0) * u.Hz\n",
124+
"print('identity loss (Hz) :', float(loss(x_Hz, x_Hz)))"
125+
]
126+
},
127+
{
128+
"cell_type": "markdown",
129+
"id": "fba13a62",
130+
"metadata": {},
131+
"source": [
132+
"## It composes with the built-ins\n",
133+
"\n",
134+
"Because a custom objective has the exact `(prediction, target) -> scalar` shape,\n",
135+
"`brainmass.objectives.combine` mixes it with built-ins as a weighted sum -- a\n",
136+
"common pattern when fitting to several features at once (e.g. FC correlation *and*\n",
137+
"an amplitude term)."
138+
]
139+
},
140+
{
141+
"cell_type": "code",
142+
"execution_count": 3,
143+
"id": "ea84305f",
144+
"metadata": {
145+
"execution": {
146+
"iopub.execute_input": "2026-06-19T09:16:35.045260Z",
147+
"iopub.status.busy": "2026-06-19T09:16:35.044972Z",
148+
"iopub.status.idle": "2026-06-19T09:16:36.079896Z",
149+
"shell.execute_reply": "2026-06-19T09:16:36.078185Z"
150+
}
151+
},
152+
"outputs": [
153+
{
154+
"name": "stdout",
155+
"output_type": "stream",
156+
"text": [
157+
"combined loss, identity : 0.0\n",
158+
"combined loss, perturbed: 0.782201\n"
159+
]
160+
}
161+
],
162+
"source": [
163+
"from brainmass import objectives\n",
164+
"\n",
165+
"mixed = objectives.combine(\n",
166+
" (1.0, objectives.fc_corr(as_loss=True)), # match functional connectivity\n",
167+
" (0.5, variance_match(as_loss=True)), # ... and overall amplitude\n",
168+
")\n",
169+
"rng = np.random.default_rng(0)\n",
170+
"a = jnp.asarray(rng.standard_normal((200, 4)))\n",
171+
"print('combined loss, identity :', round(float(mixed(a, a)), 6)) # both terms 0\n",
172+
"b = a * 1.5 + 0.2\n",
173+
"print('combined loss, perturbed:', round(float(mixed(a, b)), 6))"
174+
]
175+
},
176+
{
177+
"cell_type": "markdown",
178+
"id": "9245fd47",
179+
"metadata": {},
180+
"source": [
181+
"## It works across all three `Fitter` backends\n",
182+
"\n",
183+
"The payoff of the contract: *write the objective once, swap the backend*. The\n",
184+
"objective is the same callable for all three; only the optimizer argument and the\n",
185+
"model's parameter bounds differ.\n",
186+
"\n",
187+
"- **`grad`** differentiates through the objective -- needs it to be a smooth\n",
188+
" `jax` function (ours is).\n",
189+
"- **`nevergrad`** / **`scipy`** are derivative-free and search a bounded box. They\n",
190+
" derive that box from the trainable `Param`'s transform, so the fitted parameter\n",
191+
" needs a *finite* transform interval -- `SigmoidT(lower, upper)` gives one.\n",
192+
"\n",
193+
"We fit the Hopf bifurcation parameter `a` so its settled limit-cycle **variance**\n",
194+
"matches a target generated at `a* = 1.0`."
195+
]
196+
},
197+
{
198+
"cell_type": "code",
199+
"execution_count": 4,
200+
"id": "a145e59f",
201+
"metadata": {
202+
"execution": {
203+
"iopub.execute_input": "2026-06-19T09:16:36.082135Z",
204+
"iopub.status.busy": "2026-06-19T09:16:36.081984Z",
205+
"iopub.status.idle": "2026-06-19T09:16:36.433431Z",
206+
"shell.execute_reply": "2026-06-19T09:16:36.432471Z"
207+
}
208+
},
209+
"outputs": [],
210+
"source": [
211+
"from brainstate.nn import SigmoidT\n",
212+
"\n",
213+
"def make_model(a0=0.3):\n",
214+
" # SigmoidT(0.1, 2.0) -> a bounded, trainable a in [0.1, 2.1]; the kick avoids\n",
215+
" # the unstable fixed point so the limit cycle actually has amplitude.\n",
216+
" return brainmass.HopfStep(\n",
217+
" 3, a=Param(a0, t=SigmoidT(0.1, 2.0)), w=0.3,\n",
218+
" init_x=braintools.init.Constant(0.5),\n",
219+
" )\n",
220+
"\n",
221+
"def predict(m):\n",
222+
" sim = brainmass.Simulator(m, dt=0.1 * u.ms)\n",
223+
" return sim.run(200. * u.ms, monitors=['x'], transient=50 * u.ms)['x']\n",
224+
"\n",
225+
"target = predict(make_model(1.0)) # ground truth at a* = 1.0\n",
226+
"objective = variance_match(as_loss=True)"
227+
]
228+
},
229+
{
230+
"cell_type": "code",
231+
"execution_count": 5,
232+
"id": "a95b52b1",
233+
"metadata": {
234+
"execution": {
235+
"iopub.execute_input": "2026-06-19T09:16:36.435408Z",
236+
"iopub.status.busy": "2026-06-19T09:16:36.435264Z",
237+
"iopub.status.idle": "2026-06-19T09:16:50.651104Z",
238+
"shell.execute_reply": "2026-06-19T09:16:50.650340Z"
239+
}
240+
},
241+
"outputs": [
242+
{
243+
"name": "stdout",
244+
"output_type": "stream",
245+
"text": [
246+
" grad: a = 0.9495 best_loss = 1.135e-03\n"
247+
]
248+
},
249+
{
250+
"name": "stderr",
251+
"output_type": "stream",
252+
"text": [
253+
"/home/chaoming/miniconda3/lib/python3.13/site-packages/braintools/optim/_scipy_optimizer.py:284: RuntimeWarning: Method Nelder-Mead does not use gradient information (jac).\n",
254+
" results = minimize(\n"
255+
]
256+
},
257+
{
258+
"name": "stdout",
259+
"output_type": "stream",
260+
"text": [
261+
" scipy: a = 1.0000 best_loss = 5.288e-11\n"
262+
]
263+
},
264+
{
265+
"name": "stdout",
266+
"output_type": "stream",
267+
"text": [
268+
"nevergrad: a = 1.0042 best_loss = 4.530e-06\n",
269+
"\n",
270+
"true a* = 1.0; all three recovered it from the SAME objective.\n"
271+
]
272+
}
273+
],
274+
"source": [
275+
"backends = [\n",
276+
" ('grad', braintools.optim.Adam(lr=0.05), 40),\n",
277+
" ('scipy', {'method': 'Nelder-Mead'}, 4),\n",
278+
" ('nevergrad', {'method': 'DE', 'n_sample': 6}, 4),\n",
279+
"]\n",
280+
"\n",
281+
"results = {}\n",
282+
"for backend, opt, n in backends:\n",
283+
" m = make_model(0.3)\n",
284+
" fitter = brainmass.Fitter(m, opt, objective=objective,\n",
285+
" predict=predict, backend=backend)\n",
286+
" res = fitter.fit(target=target, n_steps=n)\n",
287+
" a_fit = float(list(res.best_params.values())[0])\n",
288+
" results[backend] = a_fit\n",
289+
" print(f'{backend:>9s}: a = {a_fit:.4f} best_loss = {res.best_loss:.3e}')\n",
290+
"\n",
291+
"print('\\ntrue a* = 1.0; all three recovered it from the SAME objective.')"
292+
]
293+
},
294+
{
295+
"cell_type": "markdown",
296+
"id": "0f688043",
297+
"metadata": {},
298+
"source": [
299+
"All three backends drive `a` from `0.3` to `~1.0` using one objective callable.\n",
300+
"That is the whole point: the objective is decoupled from how it is optimised."
301+
]
302+
},
303+
{
304+
"cell_type": "markdown",
305+
"id": "32d82910",
306+
"metadata": {},
307+
"source": [
308+
"## Notes for a gradient-friendly objective\n",
309+
"\n",
310+
"- **Smoothness matters for `grad`.** A `max`/`argmax` (like a KS statistic) is\n",
311+
" non-smooth -- usable for evaluation but a poor *gradient* loss. Prefer a smooth\n",
312+
" surrogate (an integral / Wasserstein-style distance) when the objective drives\n",
313+
" the gradient backend. The built-in `fcd_ks` vs `fcd_wasserstein` pair is exactly\n",
314+
" this trade-off.\n",
315+
"- **Fit a well-conditioned summary**, not a phase-degenerate raw oscillatory trace\n",
316+
" (see {doc}`building_a_data_driven_workflow`).\n",
317+
"- **Reuse `braintools.metric`** rather than re-implementing metrics; the built-in\n",
318+
" objectives are thin wrappers over it (`functional_connectivity`,\n",
319+
" `matrix_correlation`, `power_spectral_density`, ...).\n",
320+
"\n",
321+
"## See Also\n",
322+
"\n",
323+
"- {doc}`/howto/custom_objective` -- combining and applying objectives.\n",
324+
"- {doc}`building_a_data_driven_workflow` -- the full fitting playbook.\n",
325+
"- {doc}`/reference/orchestration` -- the `objectives` / `Fitter` API reference."
326+
]
327+
}
328+
],
329+
"metadata": {
330+
"kernelspec": {
331+
"display_name": "Python 3",
332+
"language": "python",
333+
"name": "python3"
334+
},
335+
"language_info": {
336+
"codemirror_mode": {
337+
"name": "ipython",
338+
"version": 3
339+
},
340+
"file_extension": ".py",
341+
"mimetype": "text/x-python",
342+
"name": "python",
343+
"nbconvert_exporter": "python",
344+
"pygments_lexer": "ipython3",
345+
"version": "3.13.11"
346+
}
347+
},
348+
"nbformat": 4,
349+
"nbformat_minor": 5
350+
}

0 commit comments

Comments
 (0)