flybrain 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.
flybrain/__init__.py ADDED
@@ -0,0 +1,18 @@
1
+ """flybrain: the complete fruit fly nervous system (MaleCNS v1.0 connectome, 166,700 neurons)
2
+ as a spiking network you can stimulate, read out and train readouts on.
3
+
4
+ from flybrain import FlyBrain
5
+ brain = FlyBrain(device="auto") # downloads the brain files on first use
6
+ brain.stimulate(brain.cells(["LC4", "LPLC2"], side="L"), 0.8)
7
+ fired = brain.step() # indices of the neurons that spiked this 20 ms step
8
+ """
9
+ from .brain import FlyBrain, cuda_available
10
+ from .data import DATA, download, ensure_data, has_data
11
+ from .eyes import ENCODER, Blob, Eyes, FeatureDetectors, blob_for
12
+ from .reservoir import Readout, Trace, auc, bases_for, fit_logistic, fit_ridge, folds, project, run
13
+
14
+ __version__ = "0.1.0"
15
+
16
+ __all__ = ["FlyBrain", "cuda_available", "DATA", "download", "ensure_data", "has_data",
17
+ "ENCODER", "Blob", "Eyes", "FeatureDetectors", "blob_for",
18
+ "Readout", "Trace", "auc", "bases_for", "fit_logistic", "fit_ridge", "folds", "project", "run"]
flybrain/__main__.py ADDED
@@ -0,0 +1,51 @@
1
+ """Command line: `flybrain download`, `flybrain build`, `flybrain info` (or `python -m flybrain ...`)."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ from pathlib import Path
6
+
7
+ from . import __version__
8
+ from .data import DATA, FILES, RELEASE_URL, download, has_data
9
+
10
+
11
+ def main(argv: list[str] | None = None) -> None:
12
+ parser = argparse.ArgumentParser(prog="flybrain", description="The MaleCNS fruit fly connectome as a spiking network.")
13
+ parser.add_argument("--version", action="version", version=f"flybrain {__version__}")
14
+ sub = parser.add_subparsers(dest="command", required=True)
15
+
16
+ fetch = sub.add_parser("download", help="fetch the prebuilt brain files (~260 MB)")
17
+ fetch.add_argument("--data", type=Path, default=DATA, help=f"where to put them (default {DATA}, or $FLY_DATA)")
18
+ fetch.add_argument("--url", default=RELEASE_URL, help="base URL of the files (or $FLYBRAIN_DATA_URL)")
19
+ fetch.add_argument("--force", action="store_true", help="download again even if the files are there")
20
+
21
+ build = sub.add_parser("build", help="download MaleCNS v1.0 (~1.1 GB) and build the brain files from it")
22
+ build.add_argument("--data", type=Path, default=DATA, help=f"data folder (default {DATA}, or $FLY_DATA)")
23
+
24
+ info = sub.add_parser("info", help="show the data folder and whether a GPU is usable")
25
+ info.add_argument("--data", type=Path, default=DATA)
26
+
27
+ args = parser.parse_args(argv)
28
+ if args.command == "download":
29
+ download(args.data, args.url, force=args.force)
30
+ print(f"brain files in {args.data}")
31
+ elif args.command == "build":
32
+ try:
33
+ from .build import build as build_brain
34
+ except ImportError as e:
35
+ raise SystemExit(f"building needs pandas and pyarrow ({e}): pip install \"flybrain[build]\"")
36
+ build_brain(args.data)
37
+ else:
38
+ from .brain import cuda_available
39
+ print(f"flybrain {__version__}")
40
+ print(f"data folder: {args.data}")
41
+ for name in FILES:
42
+ path = args.data / name
43
+ print(f" {name}: {f'{path.stat().st_size / 1e6:,.0f} MB' if path.exists() else 'missing'}")
44
+ if not has_data(args.data):
45
+ print(" run `flybrain download` (or just create a FlyBrain) to fetch them")
46
+ gpu = "available" if cuda_available() else 'not available (pip install "flybrain[gpu]")'
47
+ print(f"cuda: {gpu}")
48
+
49
+
50
+ if __name__ == "__main__":
51
+ main()
flybrain/brain.py ADDED
@@ -0,0 +1,201 @@
1
+ """Leaky integrate-and-fire simulation of the MaleCNS connectome.
2
+
3
+ Dynamics follow ornata/fly (fly64/model.py) so results are comparable:
4
+ v <- exp(-dt/tau) v + gain * W @ spikes + tonic + noise + eye input
5
+ v >= 1 -> spike, reset to 0
6
+ tonic/gain/noise are hand-calibrated, not measured. fly64 used tonic 0.18,
7
+ gain 1.5, which parks every neuron at threshold (0.18 / (1 - 0.82) = 1.0) so
8
+ the network ticks on its own. inject.py showed tonic 0.14, gain 3.0 keeps
9
+ descending neurons quiet at rest (~1 Hz) while LC4/LPLC2 -> DNp01 and
10
+ LC10a -> DNa02 signals still get through, ipsilaterally.
11
+
12
+ batch > 1 runs that many independent flies (same wiring, own voltages and
13
+ noise) in lock-step; on a GPU one sparse multiply serves them all, so 8 flies
14
+ cost about as much as 1-2.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import os
19
+ from pathlib import Path
20
+
21
+ import numba
22
+ import numpy as np
23
+ from scipy import sparse
24
+
25
+ from .data import DATA, ensure_data
26
+
27
+
28
+ @numba.njit(nogil=True, parallel=True)
29
+ def _propagate(indptr, indices, weights, fired, n):
30
+ """Sum the outgoing weights (CSC columns) of every neuron that spiked.
31
+ Each thread scatters into its own buffer; buffers are summed at the end."""
32
+ threads = numba.get_num_threads()
33
+ partial = np.zeros((threads, n), np.float32)
34
+ chunk = (len(fired) + threads - 1) // threads
35
+ for t in numba.prange(threads):
36
+ acc = partial[t]
37
+ for k in range(t * chunk, min(len(fired), (t + 1) * chunk)):
38
+ j = fired[k]
39
+ for e in range(indptr[j], indptr[j + 1]):
40
+ acc[indices[e]] += weights[e]
41
+ current = np.zeros(n, np.float32)
42
+ for i in numba.prange(n):
43
+ s = np.float32(0.0)
44
+ for t in range(threads):
45
+ s += partial[t, i]
46
+ current[i] = s
47
+ return current
48
+
49
+
50
+ def cuda_available() -> bool:
51
+ try:
52
+ import cupy
53
+ return cupy.cuda.runtime.getDeviceCount() > 0
54
+ except Exception:
55
+ return False
56
+
57
+
58
+ class FlyBrain:
59
+ """device: "cpu" (numba), "cuda" (CuPy, NVIDIA GPU) or "auto"; defaults to
60
+ $FLY_DEVICE, else "cpu". Both run the same model; the noise streams differ,
61
+ so individual spikes differ between devices but statistics match.
62
+
63
+ batch: number of independent flies. Voltages are (n, batch). With batch 1,
64
+ step() returns the fired neuron indices; with batch > 1, a list of them,
65
+ one array per fly. Inputs broadcast: an amount can be a number (same for
66
+ every fly) or an array of length batch (one per fly)."""
67
+ dt = 0.020
68
+ tau = 0.100
69
+ gain = 3.0
70
+ tonic = 0.14 # calibrated at dt = 0.020; rescaled for other steps (see __init__)
71
+ noise_hz = 1.2
72
+ noise_amp = 0.22
73
+ eye_gain = 0.62
74
+
75
+ def __init__(self, data: Path | str | None = None, seed: int = 64, device: str | None = None, batch: int = 1,
76
+ dt: float | None = None, sensory_input: bool = True, refractory: float = 0.0):
77
+ """data: folder with brain.npz and weights.npz (default $FLY_DATA, else ~/fly-data).
78
+ If they aren't there, the prebuilt brain is downloaded into it first (~260 MB, once).
79
+
80
+ dt: step length in seconds (default 0.020). tonic is rescaled so a silent
81
+ neuron settles at the same voltage as in the calibrated 20 ms model.
82
+
83
+ sensory_input: False removes every synapse onto sensory neurons (any superclass
84
+ containing "sensory"), so they fire only from noise and what you inject. With
85
+ True (the original model), olfactory receptor neurons excite each other into a
86
+ runaway loop and sit near maximum rate at rest, so odours add nothing.
87
+
88
+ refractory: seconds a neuron is held at 0 after it spikes (0 = none; at 20 ms
89
+ steps the step itself already caps rates at 50 Hz)."""
90
+ device = device or os.environ.get("FLY_DEVICE", "cpu")
91
+ if device == "auto":
92
+ device = "cuda" if cuda_available() else "cpu"
93
+ if device not in ("cpu", "cuda"):
94
+ raise ValueError(f"device must be cpu, cuda or auto, not {device!r}")
95
+ self.device = device
96
+ self.batch = int(batch)
97
+ if dt is not None:
98
+ self.dt = float(dt)
99
+ self.tonic = type(self).tonic * (1 - np.exp(-self.dt / self.tau)) / (1 - np.exp(-0.020 / self.tau))
100
+ self.refractory_steps = int(round(refractory / self.dt))
101
+ self.sensory_input = sensory_input
102
+ data = ensure_data(data)
103
+ meta = np.load(data / "brain.npz")
104
+ W = sparse.load_npz(data / "weights.npz")
105
+ if not sensory_input:
106
+ if "superclass" not in meta.files:
107
+ raise RuntimeError("brain.npz has no superclass; run `flybrain build`")
108
+ sensory = np.char.find(meta["superclass"].astype(str), "sensory") >= 0
109
+ W = sparse.diags((~sensory).astype(np.float32)) @ W.tocsr() # rows = postsynaptic
110
+ if device == "cuda":
111
+ import cupy
112
+ from cupyx.scipy import sparse as cusparse
113
+ self.xp = cupy
114
+ self._W = cusparse.csr_matrix(W.tocsr().astype(np.float32)) # rows = postsynaptic
115
+ else:
116
+ self.xp = np
117
+ W = W.tocsc()
118
+ self.n = W.shape[0]
119
+ self.indptr, self.indices, self.weights = W.indptr, W.indices, W.data
120
+ self.visual = meta["visual"]
121
+ self.azimuth = meta["azimuth"] # -1 far left ... +1 far right
122
+ self.cell_type = meta["cell_type"]
123
+ self.side = meta["side"]
124
+ self.positions = meta["positions"] if "positions" in meta.files else None
125
+ self.superclass = meta["superclass"] if "superclass" in meta.files else None
126
+ self.groups = {k.removeprefix("group_"): meta[k] for k in meta.files if k.startswith("group_")}
127
+ self._visual = self.xp.asarray(self.visual)
128
+ self.decay = np.float32(np.exp(-self.dt / self.tau))
129
+ self.reset(seed)
130
+
131
+ def reset(self, seed: int | None = None) -> None:
132
+ """Silence the network (all voltages 0, no spikes) and restart the noise."""
133
+ xp = self.xp
134
+ self.rng = xp.random.default_rng(seed)
135
+ self.v = xp.zeros((self.n, self.batch), xp.float32)
136
+ self.fired = xp.empty(0, xp.int64) # flat indices into v
137
+ self.steps = 0
138
+ # step of each neuron's last spike, for the refractory period
139
+ self.last_spike = xp.full((self.n, self.batch), -10**6, xp.int32) if self.refractory_steps else None
140
+
141
+ def cells(self, types: list[str], side: str | None = None) -> np.ndarray:
142
+ """Neurons whose cell type is in `types`. A superclass name
143
+ ("descending_neuron", "visual_projection", ...) selects the whole class."""
144
+ mask = np.isin(self.cell_type, types)
145
+ if self.superclass is not None:
146
+ mask |= np.isin(self.superclass, types)
147
+ if side:
148
+ mask &= self.side == side
149
+ return np.flatnonzero(mask)
150
+
151
+ def _amount(self, amount):
152
+ """A number, or one value per fly, shaped to broadcast over v[idx]."""
153
+ a = self.xp.asarray(amount, dtype=self.xp.float32)
154
+ return a if a.ndim == 0 else a.reshape(1, -1)
155
+
156
+ def stimulate(self, idx: np.ndarray, amount) -> None:
157
+ """Add voltage to these neurons right now (before the next step)."""
158
+ self.v[self.xp.asarray(idx)] += self._amount(amount)
159
+
160
+ def synaptic_input(self, fired):
161
+ """Input current (n, batch) from the flat spike indices of the last step."""
162
+ xp, B = self.xp, self.batch
163
+ if self.device == "cuda":
164
+ spikes = xp.zeros((self.n, B), xp.float32)
165
+ spikes.ravel()[fired] = 1.0
166
+ if B == 1:
167
+ return (self._W @ spikes[:, 0])[:, None]
168
+ return self._W @ spikes
169
+ rows, cols = np.divmod(fired, B)
170
+ return np.column_stack([_propagate(self.indptr, self.indices, self.weights, rows[cols == b], self.n)
171
+ for b in range(B)])
172
+
173
+ def step(self, eye_drive: np.ndarray | None = None, inject=()):
174
+ """Advance one step (dt, 20 ms by default). eye_drive: 0..1 per photoreceptor (len(self.visual)), or
175
+ (len(self.visual), batch); inject: (neuron indices, extra voltage) pairs
176
+ added this step. Returns the indices of the neurons that fired (NumPy):
177
+ one array with batch 1, else a list with one array per fly."""
178
+ xp, B = self.xp, self.batch
179
+ current = self.synaptic_input(self.fired) * self.gain
180
+ self.v *= self.decay
181
+ self.v += current + self.tonic
182
+ self.v += (self.rng.random((self.n, B)) < self.noise_hz * self.dt) * np.float32(self.noise_amp)
183
+ if eye_drive is not None:
184
+ drive = xp.asarray(eye_drive, dtype=xp.float32)
185
+ self.v[self._visual] += (drive[:, None] if drive.ndim == 1 else drive) * self.eye_gain
186
+ for idx, amount in inject:
187
+ self.v[xp.asarray(idx)] += self._amount(amount)
188
+ if self.refractory_steps:
189
+ self.v[(self.steps - self.last_spike) <= self.refractory_steps] = 0.0
190
+ fired = xp.flatnonzero(self.v >= 1.0)
191
+ self.v.ravel()[fired] = 0.0
192
+ if self.refractory_steps:
193
+ self.last_spike.ravel()[fired] = self.steps
194
+ self.fired = fired
195
+ self.steps += 1
196
+ flat = fired if xp is np else fired.get()
197
+ if B == 1:
198
+ return flat
199
+ rows, cols = np.divmod(flat, B)
200
+ order = np.argsort(cols, kind="stable")
201
+ return np.split(rows[order], np.cumsum(np.bincount(cols, minlength=B))[:-1])
flybrain/build.py ADDED
@@ -0,0 +1,204 @@
1
+ """Build a simulatable network from the MaleCNS v1.0 connectome.
2
+
3
+ Same recipe as ornata/fly (fly64/data.py): all superclass-annotated neurons,
4
+ synapse-count weights, sign from predicted neurotransmitter, per-neuron input
5
+ normalization. Also records where each photoreceptor sits in the eye so a
6
+ 1-D "scene" (opponent to the left/right, near/far) can be projected onto it.
7
+
8
+ Output: <DATA>/brain.npz (weights as CSR, neuron groups, eye azimuths)
9
+
10
+ flybrain build [--data DIR] (needs pip install "flybrain[build]")
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import re
16
+ import urllib.request
17
+ import zipfile
18
+ import xml.etree.ElementTree as ET
19
+ from pathlib import Path
20
+
21
+ import numpy as np
22
+ import pyarrow.feather as feather
23
+ from scipy import sparse
24
+
25
+ from .data import DATA
26
+
27
+ BUCKET = "https://storage.googleapis.com/flyem-male-cns/v1.0/connectome-data/flat-connectome"
28
+ SOURCES = {
29
+ "body-annotations-male-cns-v1.0-minconf-0.5.feather": f"{BUCKET}/body-annotations-male-cns-v1.0-minconf-0.5.feather",
30
+ "body-neurotransmitters-male-cns-v1.0.feather": f"{BUCKET}/body-neurotransmitters-male-cns-v1.0.feather",
31
+ "connectome-weights-male-cns-v1.0-minconf-0.5.feather": f"{BUCKET}/connectome-weights-male-cns-v1.0-minconf-0.5.feather",
32
+ "optic-columns.xlsx": "https://raw.githubusercontent.com/flyconnectome/2025malecns/"
33
+ "67767d2233657983993ff6c2be48e836a935863c/supplemental_data/optic-column-type-assignments-v1.0.xlsx",
34
+ }
35
+
36
+
37
+ def download_all(raw: Path) -> None:
38
+ """Fetch the MaleCNS v1.0 tables (~1.1 GB) into `raw` unless already present."""
39
+ raw.mkdir(parents=True, exist_ok=True)
40
+ for name, url in SOURCES.items():
41
+ target = raw / name
42
+ if target.exists():
43
+ continue
44
+ partial = target.with_suffix(target.suffix + ".part")
45
+ print(f"downloading {name}")
46
+
47
+ def progress(blocks, block_size, total):
48
+ if total > 0:
49
+ print(f"\r {min(blocks * block_size, total) / 1e6:,.0f} / {total / 1e6:,.0f} MB", end="")
50
+
51
+ urllib.request.urlretrieve(url, partial, progress)
52
+ print()
53
+ partial.replace(target)
54
+ INHIBITORY = "gaba|glutamate|histamine"
55
+
56
+ # Descending neurons (brain -> body commands) used as motor read-outs.
57
+ # Types follow the literature: DNa02 steering, DNp01 giant-fiber escape,
58
+ # DNg100 forward walking (as in fly64), MDN = moonwalker (backward walking).
59
+ # punch/kick have no fly equivalent; these picks are arbitrary: DNg11 (a
60
+ # descending neuron with no known fighting role) and pIP10 (the male
61
+ # courtship-song command neuron). Nothing we tested drives either of them.
62
+ MOTOR_TYPES = {
63
+ "forward": ["DNg100"],
64
+ "steer": ["DNa02"],
65
+ "escape": ["DNp01"],
66
+ "backward": ["MDN"],
67
+ "punch": ["DNg11"],
68
+ "kick": ["pIP10"],
69
+ }
70
+
71
+
72
+ def optic_columns(path: Path) -> dict[int, tuple[str, int, int]]:
73
+ """body id -> (eye side, h1, h2) hex column coordinates, from the xlsx."""
74
+ ns = {"s": "http://schemas.openxmlformats.org/spreadsheetml/2006/main"}
75
+ result = {}
76
+ with zipfile.ZipFile(path) as archive:
77
+ strings = ["".join(e.itertext()) for e in ET.fromstring(archive.read("xl/sharedStrings.xml"))]
78
+ for sheet in (1, 2):
79
+ root = ET.fromstring(archive.read(f"xl/worksheets/sheet{sheet}.xml"))
80
+ for row in root.findall("s:sheetData/s:row", ns)[1:]:
81
+ cells = {}
82
+ for c in row:
83
+ v = c.find("s:v", ns)
84
+ if v is not None:
85
+ cells[re.sub(r"\d", "", c.attrib["r"])] = strings[int(v.text)] if c.get("t") == "s" else v.text
86
+ match = re.fullmatch(r"ME_([LR])_col_(\d+)_(\d+)", cells.get("A", ""))
87
+ if not match:
88
+ continue
89
+ side, h1, h2 = match.groups()
90
+ for col in ("B", "C", "E"):
91
+ try:
92
+ body = int(cells.get(col, -1))
93
+ except ValueError:
94
+ continue
95
+ if body > 0:
96
+ result[body] = (side, int(h1), int(h2))
97
+ return result
98
+
99
+
100
+ def build(data: Path | str = DATA) -> None:
101
+ """Download MaleCNS v1.0 into <data>/raw and write weights.npz, brain.npz and brain.json to `data`."""
102
+ data = Path(data)
103
+ raw = data / "raw"
104
+ download_all(raw)
105
+ ann = feather.read_table(raw / "body-annotations-male-cns-v1.0-minconf-0.5.feather").to_pandas()
106
+ nt = feather.read_table(raw / "body-neurotransmitters-male-cns-v1.0.feather",
107
+ columns=["body", "consensus_nt"]).to_pandas()
108
+
109
+ ann = ann.loc[ann["superclass"].notna() & ann["superclass"].ne("")]
110
+ ann = ann.drop_duplicates("bodyId").sort_values("bodyId").set_index("bodyId")
111
+ ids = ann.index.to_numpy(np.int64)
112
+ n = len(ids)
113
+ print(f"{n:,} neurons")
114
+
115
+ labels = nt.drop_duplicates("body").set_index("body").reindex(ids)["consensus_nt"]
116
+ sign = np.where(labels.fillna("unclear").str.lower().str.contains(INHIBITORY), -1.0, 1.0).astype(np.float32)
117
+
118
+ edges = feather.read_table(raw /"connectome-weights-male-cns-v1.0-minconf-0.5.feather",
119
+ columns=["body_pre", "body_post", "weight"], memory_map=True)
120
+ pre_parts, post_parts, w_parts = [], [], []
121
+ for i, batch in enumerate(edges.to_batches(max_chunksize=4_000_000), 1):
122
+ pre_id = batch.column(0).to_numpy(zero_copy_only=False)
123
+ post_id = batch.column(1).to_numpy(zero_copy_only=False)
124
+ pre = np.minimum(np.searchsorted(ids, pre_id), n - 1)
125
+ post = np.minimum(np.searchsorted(ids, post_id), n - 1)
126
+ ok = (ids[pre] == pre_id) & (ids[post] == post_id)
127
+ pre_parts.append(pre[ok].astype(np.int32))
128
+ post_parts.append(post[ok].astype(np.int32))
129
+ w_parts.append(batch.column(2).to_numpy(zero_copy_only=False)[ok].astype(np.float32))
130
+ print(f"\r scanned {min(i * 4_000_000, edges.num_rows):,} / {edges.num_rows:,} edge rows", end="")
131
+ print()
132
+ del edges
133
+ pre, post, w = (np.concatenate(p) for p in (pre_parts, post_parts, w_parts))
134
+ del pre_parts, post_parts, w_parts
135
+ w *= sign[pre]
136
+ incoming = np.bincount(post, weights=np.abs(w), minlength=n).astype(np.float32)
137
+ w /= np.maximum(incoming[post], 1.0)
138
+ W = sparse.csr_matrix((w, (post, pre)), shape=(n, n), dtype=np.float32)
139
+ print(f"{W.nnz:,} connections")
140
+
141
+ cell_type = ann["flywireType"].fillna(ann["type"]).fillna("").astype(str)
142
+ side = ann["somaSide"].fillna(ann["rootSide"]).fillna("").astype(str).str.upper()
143
+ instance = ann["instance"].fillna("").astype(str).str.upper()
144
+
145
+ # Soma (or soma-tract) position of each neuron, in EM voxels; NaN if unknown.
146
+ positions = np.full((n, 3), np.nan, np.float32)
147
+ for i, (soma, to_soma) in enumerate(zip(ann["somaLocation"], ann["tosomaLocation"])):
148
+ loc = soma if isinstance(soma, (list, np.ndarray)) and len(soma) == 3 else to_soma
149
+ if isinstance(loc, (list, np.ndarray)) and len(loc) == 3:
150
+ positions[i] = loc
151
+
152
+ def pick(types, want_side=None):
153
+ mask = cell_type.isin(types)
154
+ if want_side:
155
+ mask &= (side == want_side) | instance.str.contains(f"_{want_side}")
156
+ return np.flatnonzero(mask.to_numpy()).astype(np.int32)
157
+
158
+ groups = {}
159
+ for name, types in MOTOR_TYPES.items():
160
+ groups[f"{name}_L"] = pick(types, "L")
161
+ groups[f"{name}_R"] = pick(types, "R")
162
+
163
+ # Photoreceptors and their azimuth in the eye (-1 = far left ... +1 = far right).
164
+ visual = pick(["R1-6", "R7", "R8"])
165
+ columns = optic_columns(raw / "optic-columns.xlsx")
166
+ known = np.array([i for i, b in enumerate(ids) if int(b) in columns], np.int32)
167
+ to_known = abs(W[known][:, visual]).tocsc() # R1-6 -> strongest column-assigned partner
168
+ h1_max = max(h1 for _, h1, _ in columns.values())
169
+ azimuth = np.full(len(visual), np.nan, np.float32)
170
+ eye = np.array([side.iat[v] for v in visual])
171
+ for k, neuron in enumerate(visual):
172
+ loc = columns.get(int(ids[neuron]))
173
+ if loc is None:
174
+ a, b = to_known.indptr[k:k + 2]
175
+ if b > a:
176
+ loc = columns[int(ids[known[to_known.indices[a + np.argmax(to_known.data[a:b])]]])]
177
+ if loc is not None:
178
+ eye_side, h1, _ = loc
179
+ eye[k] = eye_side
180
+ # h1 runs front->back within an eye (approximate, like fly64).
181
+ frac = (h1 - 1) / max(h1_max - 1, 1)
182
+ azimuth[k] = -(0.06 + 0.94 * frac) if eye_side == "L" else (0.06 + 0.94 * frac)
183
+ missing = np.isnan(azimuth)
184
+ azimuth[missing] = np.where(eye[missing] == "L", -0.5, 0.5) # unplaced: mid-eye guess
185
+ print(f"{len(visual):,} photoreceptors ({missing.sum()} without a column estimate)")
186
+
187
+ for name, idx in groups.items():
188
+ print(f" {name:11s} {len(idx):3d} neurons {sorted(set(cell_type.iloc[idx]))}")
189
+ if any(len(g) == 0 for g in groups.values()):
190
+ raise RuntimeError("a motor group resolved to zero neurons; check MOTOR_TYPES")
191
+
192
+ sparse.save_npz(data / "weights.npz", W, compressed=False)
193
+ np.savez(data / "brain.npz", ids=ids, visual=visual, azimuth=azimuth,
194
+ cell_type=cell_type.to_numpy().astype(str), side=side.to_numpy().astype(str),
195
+ positions=positions, superclass=ann["superclass"].to_numpy().astype(str),
196
+ **{f"group_{k}": v for k, v in groups.items()})
197
+ (data / "brain.json").write_text(json.dumps(
198
+ {"neurons": n, "connections": int(W.nnz), "photoreceptors": int(len(visual)),
199
+ "groups": {k: int(len(v)) for k, v in groups.items()}}, indent=2))
200
+ print(f"saved to {data}")
201
+
202
+
203
+ if __name__ == "__main__":
204
+ build()
flybrain/data.py ADDED
@@ -0,0 +1,80 @@
1
+ """Where the brain files live, and fetching them.
2
+
3
+ FlyBrain needs two files built from the MaleCNS v1.0 connectome: weights.npz (the signed,
4
+ normalized connection matrix) and brain.npz (cell types, sides, positions, readout groups,
5
+ eye layout). They are too big for the package, so the first FlyBrain() downloads a prebuilt
6
+ copy (~260 MB) into $FLY_DATA (default ~/fly-data). `flybrain build` makes the same files
7
+ from the original MaleCNS release instead.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import hashlib
12
+ import os
13
+ import sys
14
+ import urllib.error
15
+ import urllib.request
16
+ from pathlib import Path
17
+
18
+ DATA = Path(os.environ.get("FLY_DATA", Path.home() / "fly-data"))
19
+
20
+ RELEASE_URL = os.environ.get("FLYBRAIN_DATA_URL",
21
+ "https://github.com/alextitonis/fly.ai/releases/download/brain-v1")
22
+
23
+ # sha256 of the prebuilt files (166,700 neurons, 25,582,938 connections)
24
+ FILES = {
25
+ "brain.npz": "cc9bd1ecd00bd703a6fa648bc6ad145c93c7c1ee53debdcc9ce0d1f4305e6aca",
26
+ "weights.npz": "c29919aa44069a271b1ee978abe05fa9bf6e45e4ba3e436e92b624ef1b5be40c",
27
+ }
28
+
29
+
30
+ def has_data(data: Path | str = DATA) -> bool:
31
+ """True if both brain files are in `data`."""
32
+ return all((Path(data) / name).exists() for name in FILES)
33
+
34
+
35
+ def download(data: Path | str = DATA, url: str = RELEASE_URL, force: bool = False) -> Path:
36
+ """Fetch the prebuilt brain files into `data`, checking their sha256. Files already
37
+ there are kept unless force=True."""
38
+ data = Path(data)
39
+ data.mkdir(parents=True, exist_ok=True)
40
+ for name, expected in FILES.items():
41
+ target = data / name
42
+ if target.exists() and not force:
43
+ continue
44
+ partial = target.with_suffix(target.suffix + ".part")
45
+ digest = hashlib.sha256()
46
+ print(f"downloading {name}", file=sys.stderr)
47
+ try:
48
+ with urllib.request.urlopen(f"{url.rstrip('/')}/{name}") as response, open(partial, "wb") as out:
49
+ total = int(response.headers.get("Content-Length") or 0)
50
+ done = 0
51
+ while chunk := response.read(1 << 20):
52
+ out.write(chunk)
53
+ digest.update(chunk)
54
+ done += len(chunk)
55
+ if total:
56
+ print(f"\r {done / 1e6:,.0f} / {total / 1e6:,.0f} MB", end="", file=sys.stderr)
57
+ print(file=sys.stderr)
58
+ except (urllib.error.URLError, OSError) as e:
59
+ partial.unlink(missing_ok=True)
60
+ raise RuntimeError(f"could not download {name} from {url}: {e}") from e
61
+ if digest.hexdigest() != expected:
62
+ partial.unlink(missing_ok=True)
63
+ raise RuntimeError(f"{name} from {url} has the wrong checksum; not using it")
64
+ partial.replace(target)
65
+ return data
66
+
67
+
68
+ def ensure_data(data: Path | str | None = None) -> Path:
69
+ """The data folder, downloading the prebuilt brain first if it isn't there yet."""
70
+ data = DATA if data is None else Path(data)
71
+ if has_data(data):
72
+ return data
73
+ print(f"flybrain: no brain files in {data}; downloading the prebuilt brain (~260 MB, once)", file=sys.stderr)
74
+ try:
75
+ download(data)
76
+ except RuntimeError as e:
77
+ raise FileNotFoundError(
78
+ f"no brain files in {data} and the download failed ({e}). Build them from the MaleCNS "
79
+ f"release instead: pip install \"flybrain[build]\" && flybrain build --data \"{data}\"") from e
80
+ return data
flybrain/eyes.py ADDED
@@ -0,0 +1,124 @@
1
+ """What the fly "sees".
2
+
3
+ Two routes into the brain:
4
+ * Eyes: a 1-D panorama projected onto the 6,006 photoreceptors. Each has an
5
+ azimuth (-1 = far left, +1 = far right) estimated from the MaleCNS
6
+ optic-column tables. Kept for completeness: in a spiking model this signal
7
+ fades at the lamina (see sweep.py).
8
+ * FeatureDetectors: drive the fly's own visual projection neuron types
9
+ directly, on the side where things are. This is the route that works.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ from dataclasses import dataclass
14
+
15
+ import numpy as np
16
+
17
+ BACKGROUND = 0.9
18
+
19
+
20
+ @dataclass
21
+ class Blob:
22
+ center: float # azimuth, -1..1
23
+ half_width: float # azimuth units
24
+ darkness: float # 0 = invisible, 1 = black
25
+
26
+
27
+ def render(azimuth: np.ndarray, blobs: list[Blob]) -> np.ndarray:
28
+ lum = np.full(len(azimuth), BACKGROUND, np.float32)
29
+ for b in blobs:
30
+ inside = np.abs(azimuth - b.center) <= b.half_width
31
+ lum[inside] = np.minimum(lum[inside], BACKGROUND * (1 - b.darkness))
32
+ return lum
33
+
34
+
35
+ class Eyes:
36
+ def __init__(self, azimuth: np.ndarray):
37
+ self.azimuth = azimuth
38
+ self.previous: np.ndarray | None = None
39
+
40
+ def drive(self, blobs: list[Blob]) -> np.ndarray:
41
+ lum = render(self.azimuth, blobs)
42
+ change = np.zeros_like(lum) if self.previous is None else np.abs(lum - self.previous)
43
+ self.previous = lum
44
+ return np.clip(0.45 * lum + 1.6 * change, 0, 1)
45
+
46
+
47
+ # Encoder parameters (the hand-set defaults). Each can also be an array with one value per
48
+ # fly for a batched brain, so several encoders run side by side (sshfighter/replay.py).
49
+ ENCODER = {
50
+ "loom_gain": 10.0, # angular growth per game frame -> LPLC2 voltage
51
+ "loom_size": 0.0, # angular size -> LPLC2 voltage (looming neurons also respond to size)
52
+ "chase_base": 0.6, # LC10a voltage whenever the opponent is visible...
53
+ "chase_gain": 0.2, # ...plus this much per unit of angular size
54
+ "threat_max": 0.8, # LC4 voltage when the opponent attacks at close range
55
+ "shot_gain": 10.0, # projectile angular growth -> LPLC1 voltage
56
+ "cap": 0.8, # most voltage any channel adds per step
57
+ }
58
+
59
+ # Which identified neuron types each channel drives. The picks are ours, guided by
60
+ # the literature and checked by stimulating each type (all four reach descending
61
+ # neurons): LPLC2 = looming, LC4 = fast looming and escape (strongly drives
62
+ # DNp01/02/04), LPLC1 = small approaching objects, LC10a = the target a male chases.
63
+ CHANNELS = {"loom": ["LPLC2"], "threat": ["LC4"], "shot": ["LPLC1"], "chase": ["LC10a"]}
64
+
65
+
66
+ class FeatureDetectors:
67
+ """Shortcut past the lamina, which a spiking model can't relay (its neurons
68
+ are graded in real flies). As in Eon's embodied fly, this visual front end
69
+ is a model; everything downstream of these neurons is the connectome.
70
+ """
71
+
72
+ def __init__(self, brain, **encoder):
73
+ unknown = set(encoder) - set(ENCODER)
74
+ if unknown:
75
+ raise ValueError(f"unknown encoder parameters: {sorted(unknown)}")
76
+ self.p = {k: np.asarray(encoder.get(k, v), np.float32) for k, v in ENCODER.items()}
77
+ self.cells = {ch: {s: brain.cells(types, s) for s in "LR"} for ch, types in CHANNELS.items()}
78
+ self.previous: dict = {}
79
+ self.last = {f"{ch}{s}": 0.0 for ch in CHANNELS for s in "LR"} # for display: mean over flies
80
+
81
+ @property
82
+ def loom(self):
83
+ return self.cells["loom"]
84
+
85
+ @property
86
+ def chase(self):
87
+ return self.cells["chase"]
88
+
89
+ def inject(self, opp=None, shots=(), threat: float = 0.0) -> list:
90
+ """opp: (dx, size) of the opponent, or None; shots: hostile projectiles as
91
+ (stable key, dx, size); threat: 0..1, how hard the opponent is attacking
92
+ right now. dx is in screen units from the fly. Call once per game frame.
93
+ Amounts are numbers, or one per fly when encoder parameters are arrays."""
94
+ p = self.p
95
+ drive = {key: np.float32(0.0) for key in self.last}
96
+ seen = {}
97
+
98
+ def angle_and_growth(key, dx, size):
99
+ angle = size / max(abs(dx), 8.0)
100
+ seen[key] = angle
101
+ return angle, max(0.0, angle - self.previous.get(key, angle))
102
+
103
+ if opp is not None:
104
+ dx, size = opp
105
+ s = "L" if dx < 0 else "R"
106
+ angle, growth = angle_and_growth("opp", dx, size)
107
+ drive[f"loom{s}"] = np.clip(growth * p["loom_gain"] + angle * p["loom_size"], 0, p["cap"])
108
+ drive[f"chase{s}"] = np.clip(p["chase_base"] + p["chase_gain"] * angle, 0, p["cap"])
109
+ drive[f"threat{s}"] = p["threat_max"] * np.float32(np.clip(threat, 0, 1))
110
+ for key, dx, size in shots:
111
+ s = "L" if dx < 0 else "R"
112
+ _, growth = angle_and_growth(key, dx, size)
113
+ drive[f"shot{s}"] = np.maximum(drive[f"shot{s}"], np.clip(growth * p["shot_gain"], 0, p["cap"]))
114
+ self.previous = seen
115
+ self.last = {key: float(np.mean(amount)) for key, amount in drive.items()}
116
+ return [(self.cells[key[:-1]][key[-1]], amount) for key, amount in drive.items() if np.any(amount > 0)]
117
+
118
+
119
+ def blob_for(dx: float, size: float, darkness: float) -> Blob:
120
+ """An object `dx` world units to the side (screen coordinates) of the fly."""
121
+ distance = max(abs(dx), 8.0)
122
+ return Blob(center=float(np.clip(dx / 110.0, -1, 1)),
123
+ half_width=float(np.clip(size / distance * 0.5, 0.03, 0.7)),
124
+ darkness=darkness)
flybrain/reservoir.py ADDED
@@ -0,0 +1,275 @@
1
+ """Generic reservoir computing on the fly connectome.
2
+
3
+ input -> encoder -> fly brain (frozen) -> trace -> trained readout -> output
4
+
5
+ `flybrain.FlyBrain` is never trained: its weights come straight from the connectome.
6
+ This module is everything task-agnostic around it:
7
+
8
+ * `Trace` turns the neurons that fire each step into a decaying feature vector,
9
+ for any neuron population you name (a cell type, a `brain.groups[...]` set,
10
+ or your own index array).
11
+ * `run` steps the brain over a sequence of inputs and collects a `Trace`,
12
+ so the whole loop is one call from a notebook.
13
+ * `Readout` fits a linear (ridge) or logistic PCA readout from that activity to
14
+ your own labels, model-selected by cross-validation, and predicts on new
15
+ activity.
16
+
17
+ Nothing here knows about SSH Fighter, or any other task. `sshfighter/reservoir.py`
18
+ is a worked example of using this module for one game; `flyreservoir_example.py`
19
+ at the repo root is a minimal one with synthetic data, runnable without any game
20
+ or recordings.
21
+
22
+ Encoders (turning task input into neuron drive) are necessarily task-specific --
23
+ you write them by picking `brain.cells([...types], side=...)` and passing
24
+ `(idx, amount)` pairs to `brain.step(inject=...)`. `flybrain/eyes.py` is a worked
25
+ example of a visual encoder for SSH Fighter.
26
+ """
27
+ from __future__ import annotations
28
+
29
+ from pathlib import Path
30
+
31
+ import numpy as np
32
+ from scipy.optimize import minimize
33
+ from scipy.special import expit
34
+
35
+ __all__ = ["Trace", "run", "bases_for", "project", "fit_logistic", "fit_ridge", "auc", "folds", "Readout"]
36
+
37
+
38
+ # ---- trace: turns spikes into features ---------------------------------------------------
39
+
40
+ class Trace:
41
+ """Exponentially decaying spike trace of a neuron population.
42
+
43
+ Pick the population by `types` (a list of MaleCNS cell types, via `brain.cells`),
44
+ `group` (a name from `brain.groups`), or your own `idx` array. `side` restricts
45
+ `types` to "L" or "R" (see `FlyBrain.cells`).
46
+
47
+ With a batched brain (`batch > 1`, several flies stepped together), `aggregate`
48
+ controls what `features()` returns: "mean" (default) is one vector, the flies'
49
+ average trace -- the natural choice when the flies vote on one shared decision;
50
+ "batch" keeps one column per fly, shape (n_neurons, batch), for independent
51
+ per-fly readouts.
52
+ """
53
+
54
+ def __init__(self, brain, types: list[str] | None = None, group: str | None = None,
55
+ idx: np.ndarray | None = None, side: str | None = None, tau: float = 0.1,
56
+ aggregate: str = "mean"):
57
+ if sum(x is not None for x in (types, group, idx)) != 1:
58
+ raise ValueError("pass exactly one of types, group, idx")
59
+ if idx is None:
60
+ idx = brain.groups[group] if group is not None else brain.cells(types, side=side)
61
+ if aggregate not in ("mean", "batch"):
62
+ raise ValueError('aggregate must be "mean" or "batch"')
63
+ self.idx = np.asarray(idx)
64
+ self.slot = np.full(brain.n, -1, np.int64)
65
+ self.slot[self.idx] = np.arange(len(self.idx))
66
+ self.aggregate = aggregate
67
+ self.batch = brain.batch
68
+ width = len(self.idx)
69
+ self.trace = np.zeros(width if aggregate == "mean" else (width, self.batch), np.float32)
70
+ self.decay = np.float32(np.exp(-brain.dt / tau))
71
+
72
+ def observe(self, fired) -> np.ndarray:
73
+ """`fired`: whatever `FlyBrain.step()` returned -- one array of spike indices
74
+ (batch 1), or a list of them, one per fly. Returns `features()`."""
75
+ self.trace *= self.decay
76
+ flies = fired if isinstance(fired, list) else [fired]
77
+ if self.aggregate == "mean":
78
+ for f in flies:
79
+ slots = self.slot[f]
80
+ self.trace[slots[slots >= 0]] += 1.0 / len(flies)
81
+ else:
82
+ for b, f in enumerate(flies):
83
+ slots = self.slot[f]
84
+ self.trace[slots[slots >= 0], b] += 1.0
85
+ return self.features()
86
+
87
+ def features(self) -> np.ndarray:
88
+ return self.trace.copy()
89
+
90
+ def reset(self) -> None:
91
+ self.trace[...] = 0
92
+
93
+
94
+ def run(brain, steps: int, encode=None, trace: Trace | None = None, eye_drive=None) -> np.ndarray:
95
+ """Step the brain `steps` times and collect activity, so a whole task can run
96
+ from one call: `activity = flybrain.run(brain, len(inputs), encode=...)`.
97
+
98
+ `encode(t)`, if given, returns the `inject` list for step `t` (see
99
+ `FlyBrain.step`) -- typically `encoder.inject(...)` for some task-specific
100
+ encoder built on `brain.cells(...)`. `eye_drive`, if given, is either an
101
+ array (same every step) or `eye_drive(t)`. `trace` defaults to a `Trace`
102
+ over every neuron; pass your own to watch a specific population.
103
+
104
+ Returns activity stacked over time: `(steps, n_features)`, or
105
+ `(steps, n_features, batch)` if `trace.aggregate == "batch"`.
106
+ """
107
+ if trace is None:
108
+ trace = Trace(brain, idx=np.arange(brain.n))
109
+ out = []
110
+ for t in range(steps):
111
+ inject = encode(t) if encode is not None else ()
112
+ drive = eye_drive(t) if callable(eye_drive) else eye_drive
113
+ fired = brain.step(eye_drive=drive, inject=inject)
114
+ out.append(trace.observe(fired))
115
+ return np.stack(out)
116
+
117
+
118
+ # ---- PCA basis shared by training and inference ------------------------------------------
119
+
120
+ def bases_for(X: np.ndarray, ks) -> dict:
121
+ """For each k in `ks`: (mean, projection, scale) of the top-k principal
122
+ components of X's columns. k=None just standardises every column (no PCA)."""
123
+ mu = X.mean(0)
124
+ out, vt = {}, None
125
+ for k in ks:
126
+ if k is None:
127
+ P = np.eye(X.shape[1], dtype=np.float32)
128
+ else:
129
+ if vt is None:
130
+ vt = np.linalg.svd(X - mu, full_matrices=False)[2]
131
+ P = vt[:min(k, len(vt))].T.astype(np.float32)
132
+ sd = ((X - mu) @ P).std(0) + 1e-6
133
+ out[k] = (mu, P, sd)
134
+ return out
135
+
136
+
137
+ def project(basis, X: np.ndarray) -> np.ndarray:
138
+ mu, P, sd = basis
139
+ return ((X - mu) @ P) / sd
140
+
141
+
142
+ # ---- fitting: linear (ridge) and logistic readouts ----------------------------------------
143
+
144
+ def fit_logistic(Z: np.ndarray, y: np.ndarray, lam: float):
145
+ """L2-regularised logistic regression, y in {0, 1}. Returns (weights, bias)."""
146
+ def objective(w):
147
+ z = Z @ w[:-1] + w[-1]
148
+ loss = np.mean(np.logaddexp(0, z) - y * z) + 0.5 * lam * w[:-1] @ w[:-1]
149
+ r = (expit(z) - y) / len(y)
150
+ return loss, np.append(Z.T @ r + lam * w[:-1], r.sum())
151
+
152
+ w = minimize(objective, np.zeros(Z.shape[1] + 1), jac=True, method="L-BFGS-B").x
153
+ return w[:-1], w[-1]
154
+
155
+
156
+ def fit_ridge(Z: np.ndarray, y: np.ndarray, lam: float):
157
+ """Ridge regression. y is (n,) for one output or (n, outputs) for several
158
+ (e.g. one column per class, fit jointly). Returns (weights, bias): weights
159
+ is (features,) or (outputs, features) to match y; bias matches in the same way."""
160
+ single = y.ndim == 1
161
+ y2 = y[:, None] if single else y
162
+ zm, ym = Z.mean(0), y2.mean(0)
163
+ Zc = Z - zm
164
+ W = np.linalg.solve(Zc.T @ Zc + lam * len(y2) * np.eye(Z.shape[1]), Zc.T @ (y2 - ym))
165
+ b = ym - zm @ W
166
+ return (W[:, 0], float(b[0])) if single else (W.T, b)
167
+
168
+
169
+ def auc(y: np.ndarray, s: np.ndarray) -> float:
170
+ """Area under the ROC curve. nan if y has only one class."""
171
+ pos, neg = y == 1, y == 0
172
+ if pos.sum() == 0 or neg.sum() == 0:
173
+ return float("nan")
174
+ ranks = np.empty(len(s))
175
+ ranks[np.argsort(s, kind="stable")] = np.arange(1, len(s) + 1)
176
+ return (ranks[pos].sum() - pos.sum() * (pos.sum() + 1) / 2) / (pos.sum() * neg.sum())
177
+
178
+
179
+ def folds(n: int, groups: np.ndarray | None = None, k: int = 5):
180
+ """Cross-validation splits over n samples, as (train_idx, test_idx) pairs.
181
+ With `groups` (one label per sample, e.g. a match or recording id), this is
182
+ leave-one-group-out. Without it, plain k-fold."""
183
+ if groups is not None:
184
+ groups = np.asarray(groups)
185
+ for g in np.unique(groups):
186
+ test = np.flatnonzero(groups == g)
187
+ train = np.flatnonzero(groups != g)
188
+ if len(train):
189
+ yield train, test
190
+ else:
191
+ idx = np.arange(n)
192
+ for part in np.array_split(idx, min(k, n)):
193
+ if len(part) == 0 or len(part) == n:
194
+ continue
195
+ test = part
196
+ train = np.setdiff1d(idx, test, assume_unique=True)
197
+ yield train, test
198
+
199
+
200
+ class Readout:
201
+ """A PCA + linear readout: activity -> label, with the PCA rank and L2
202
+ strength picked by cross-validation.
203
+
204
+ `kind="ridge"` regresses onto a continuous or multi-column target (mean
205
+ squared error). `kind="logistic"` classifies a binary target in {0, 1}
206
+ (held-out AUC). Both compress activity to its top principal components
207
+ first (see `bases_for`) -- this is what makes it a *reservoir* readout: the
208
+ brain does the nonlinear mixing, the readout only has to be linear on that.
209
+ """
210
+
211
+ def __init__(self, kind: str, basis, w, b, cv_score: float, components: int | None, lam: float):
212
+ self.kind, self.basis, self.w, self.b = kind, basis, w, b
213
+ self.cv_score, self.components, self.lam = cv_score, components, lam
214
+
215
+ @classmethod
216
+ def fit(cls, X: np.ndarray, y: np.ndarray, kind: str = "ridge", groups: np.ndarray | None = None,
217
+ components=(5, 20, 60), lambdas=(1e-2, 1e-1, 1.0, 10.0), verbose: bool = False) -> "Readout":
218
+ """X: (n_samples, n_features) activity, e.g. from `Trace.features()` stacked
219
+ over time. y: (n_samples,) labels -- 0/1 for `kind="logistic"`, numeric
220
+ (or (n_samples, outputs)) for `kind="ridge"`. `groups`, if given, makes
221
+ cross-validation leave-one-group-out (e.g. one group per recorded episode)
222
+ instead of plain k-fold, so the score isn't inflated by correlated samples
223
+ from the same episode landing in both train and test.
224
+
225
+ Every (k, lambda) in the grid is scored by cross-validated held-out AUC
226
+ (logistic) or negative MSE (ridge); the best is refit on all of X, y.
227
+ """
228
+ if kind not in ("ridge", "logistic"):
229
+ raise ValueError('kind must be "ridge" or "logistic"')
230
+ fit_fn = fit_logistic if kind == "logistic" else fit_ridge
231
+ grid = [(k, lam) for k in components for lam in lambdas]
232
+ scores = {g: [] for g in grid}
233
+ for train, test in folds(len(X), groups):
234
+ Xtr, ytr, Xte, yte = X[train], y[train], X[test], y[test]
235
+ if kind == "logistic" and (ytr.min() == ytr.max() or yte.min() == yte.max()):
236
+ continue
237
+ bases = bases_for(Xtr, components)
238
+ for k, lam in grid:
239
+ w, b = fit_fn(project(bases[k], Xtr), ytr, lam)
240
+ pred = project(bases[k], Xte) @ w.T + b if w.ndim > 1 else project(bases[k], Xte) @ w + b
241
+ score = auc(yte, expit(pred)) if kind == "logistic" else -np.mean((pred - yte) ** 2)
242
+ if not np.isnan(score):
243
+ scores[(k, lam)].append(score)
244
+ avg = {g: (float(np.mean(s)) if s else float("-inf")) for g, s in scores.items()}
245
+ k, lam = max(avg, key=avg.get)
246
+ basis = bases_for(X, [k])[k]
247
+ w, b = fit_fn(project(basis, X), y, lam)
248
+ if verbose:
249
+ print(f"readout: {k} components, lambda {lam:g}, "
250
+ f"cross-validated {'AUC' if kind == 'logistic' else 'neg-MSE'} {avg[(k, lam)]:.3f}")
251
+ return cls(kind, basis, w, b, avg[(k, lam)], k, lam)
252
+
253
+ def predict(self, x: np.ndarray) -> np.ndarray | float:
254
+ """x: one sample (n_features,) or a batch (n_samples, n_features). Returns
255
+ the raw ridge output, or the logistic probability, in the same shape."""
256
+ single = x.ndim == 1
257
+ z = project(self.basis, x[None] if single else x)
258
+ raw = z @ self.w.T + self.b if self.w.ndim > 1 else z @ self.w + self.b
259
+ out = expit(raw) if self.kind == "logistic" else raw
260
+ return out[0] if single else out
261
+
262
+ def save(self, path: str | Path) -> None:
263
+ np.savez(path, kind=self.kind, mu=self.basis[0], P=self.basis[1], sd=self.basis[2],
264
+ w=self.w, b=np.asarray(self.b), cv_score=self.cv_score,
265
+ components=self.components if self.components is not None else -1, lam=self.lam)
266
+
267
+ @classmethod
268
+ def load(cls, path: str | Path) -> "Readout":
269
+ d = np.load(path, allow_pickle=False)
270
+ basis = (d["mu"], d["P"], d["sd"])
271
+ b = d["b"]
272
+ b = float(b) if b.ndim == 0 else b
273
+ components = int(d["components"])
274
+ return cls(str(d["kind"]), basis, d["w"], b, float(d["cv_score"]),
275
+ None if components < 0 else components, float(d["lam"]))
@@ -0,0 +1,87 @@
1
+ Metadata-Version: 2.4
2
+ Name: flybrain
3
+ Version: 0.1.0
4
+ Summary: The complete fruit fly nervous system (MaleCNS connectome, 166,700 neurons) as a spiking network, on CPU or GPU.
5
+ Author: alextitonis
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://flyaiworld.com
8
+ Project-URL: Source, https://github.com/alextitonis/fly.ai
9
+ Keywords: connectome,drosophila,spiking neural network,neuroscience,reservoir computing
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Intended Audience :: Science/Research
13
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
14
+ Classifier: Topic :: Scientific/Engineering :: Bio-Informatics
15
+ Requires-Python: >=3.10
16
+ Description-Content-Type: text/markdown
17
+ License-File: LICENSE
18
+ Requires-Dist: numpy>=2.0
19
+ Requires-Dist: scipy>=1.13
20
+ Requires-Dist: numba>=0.61
21
+ Provides-Extra: gpu
22
+ Requires-Dist: cupy-cuda12x[ctk]>=13; extra == "gpu"
23
+ Provides-Extra: build
24
+ Requires-Dist: pandas>=2.2; extra == "build"
25
+ Requires-Dist: pyarrow>=15; extra == "build"
26
+ Dynamic: license-file
27
+
28
+ # flybrain
29
+
30
+ The complete central nervous system of an adult male fruit fly, *Drosophila melanogaster*, as a
31
+ spiking network you can run on your own computer: **166,700 neurons and 25.6 million
32
+ connections** from the [MaleCNS v1.0 connectome](https://male-cns.janelia.org), wired as
33
+ electron microscopy found them.
34
+
35
+ Nothing inside the brain is trained. You drive some of the fly's own neurons, step the network
36
+ forward, and read out what its descending neurons (the brain's commands to the body) do.
37
+
38
+ ```sh
39
+ pip install flybrain # CPU (numba)
40
+ pip install "flybrain[gpu]" # plus CuPy for an NVIDIA GPU (CUDA 12)
41
+ ```
42
+
43
+ ```python
44
+ from flybrain import FlyBrain
45
+
46
+ brain = FlyBrain(device="auto") # first run downloads the brain files (~260 MB) to ~/fly-data
47
+ left_loom = brain.cells(["LC4", "LPLC2"], side="L") # looming detectors, left eye
48
+ giant_fiber = brain.cells(["DNp01"], side="L") # the escape command neuron
49
+
50
+ for step in range(50): # one second at 20 ms per step
51
+ fired = brain.step(inject=[(left_loom, 0.8)])
52
+ if set(giant_fiber) & set(fired):
53
+ print(f"left giant fiber fired at {step * brain.dt:.2f} s")
54
+ ```
55
+
56
+ ## What's in it
57
+
58
+ * `FlyBrain`: leaky integrate-and-fire over the whole connectome. `device="cpu" | "cuda" | "auto"`,
59
+ `batch=8` runs 8 independent flies at once, plus `dt`, `sensory_input` and `refractory` options.
60
+ `brain.cells([...])` finds neurons by cell type or superclass (`"descending_neuron"`).
61
+ * `Trace`, `run`, `Readout`: reservoir computing. Collect a spike trace of any neuron population
62
+ over your task, then fit a cross-validated linear or logistic PCA readout to your labels.
63
+ * `Eyes`, `FeatureDetectors`: a visual encoder that drives the fly's visual projection neurons.
64
+
65
+ ## Data
66
+
67
+ The brain files live in `$FLY_DATA` (default `~/fly-data`). The first `FlyBrain()` downloads them;
68
+ you can also run it ahead of time:
69
+
70
+ ```sh
71
+ flybrain download # prebuilt files, sha256-checked
72
+ flybrain build # or build them from the MaleCNS release (~1.1 GB; pip install "flybrain[build]")
73
+ flybrain info # data folder and GPU status
74
+ ```
75
+
76
+ The first step on CPU is slow while numba compiles; later steps take about 12–15 ms on 24 threads.
77
+ On an RTX 4060 a step takes 1.4 ms.
78
+
79
+ ## Credits and license
80
+
81
+ Code: MIT. The connectome data is MaleCNS v1.0 by FlyEM (HHMI Janelia), the University of
82
+ Cambridge, the MRC Laboratory of Molecular Biology and Google Research, used under
83
+ [CC BY 4.0](https://male-cns.janelia.org/download/). If you use it, cite Berg, S. et al. (2026),
84
+ *Sexual dimorphism in the complete connectome of the Drosophila male central nervous system*, *Cell*.
85
+ The neuron model follows [Fly64](https://github.com/ornata/fly) by Jessica Paquette.
86
+
87
+ Source, experiments and results: [github.com/alextitonis/fly.ai](https://github.com/alextitonis/fly.ai)
@@ -0,0 +1,13 @@
1
+ flybrain/__init__.py,sha256=q1U72lEWZfnOJO8-jYNytGU4CuEpdckE6-XT5YVEkBA,1002
2
+ flybrain/__main__.py,sha256=eP9lEQhK5FyOHIlvu47TbaIo_Df53u1i6xSRws4HLx4,2426
3
+ flybrain/brain.py,sha256=FC7P_9XTLjBlWCnjSziwQCTkm-rmYr0HgnnpYQx4mww,9406
4
+ flybrain/build.py,sha256=NNcWdFN8fxIGuSyPIz3L07h405rd37t6iRa2LS9MHpU,9634
5
+ flybrain/data.py,sha256=ePvdQ2AUYvNhUOnoticZ6S57Qi7YFYHD96ipdUyzHYg,3447
6
+ flybrain/eyes.py,sha256=lfBgPXbuWDvaLysO70xcz2D6LHZi466qEGQoYR3KgX4,5455
7
+ flybrain/reservoir.py,sha256=48h7bM5Hhc530SkoM4NfAl_PWssZGzN-eeov2xxy1o0,12687
8
+ flybrain-0.1.0.dist-info/licenses/LICENSE,sha256=yOLVyKQYhZFHyoP0vyf7SG4_vvm__UglSFIQ5oeScRY,1068
9
+ flybrain-0.1.0.dist-info/METADATA,sha256=YIN3o-ql7ORfaERK_e0OV_f5kXQmMBanXxsp2e_6OiQ,4044
10
+ flybrain-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
11
+ flybrain-0.1.0.dist-info/entry_points.txt,sha256=EKDxAS8z8EdppS_-TrSIPR75w7nutTFVbEHFxrpxbY0,52
12
+ flybrain-0.1.0.dist-info/top_level.txt,sha256=ZDXUud3CZilW1jhzGIqBix0bKnpxLJx21KRbCLwZy9I,9
13
+ flybrain-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ flybrain = flybrain.__main__:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 alextitonis
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ flybrain