pyfly-lightning 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.
Files changed (39) hide show
  1. pyfly_lightning/__init__.py +29 -0
  2. pyfly_lightning/__main__.py +5 -0
  3. pyfly_lightning/api.py +322 -0
  4. pyfly_lightning/auto.py +128 -0
  5. pyfly_lightning/cli.py +107 -0
  6. pyfly_lightning/data/__init__.py +7 -0
  7. pyfly_lightning/data/_checksums.json +14 -0
  8. pyfly_lightning/data/download.py +98 -0
  9. pyfly_lightning/data/graph.py +433 -0
  10. pyfly_lightning/data/registry.py +127 -0
  11. pyfly_lightning/data/schema.py +212 -0
  12. pyfly_lightning/envs.py +82 -0
  13. pyfly_lightning/freeze.py +42 -0
  14. pyfly_lightning/gates.py +148 -0
  15. pyfly_lightning/model/__init__.py +8 -0
  16. pyfly_lightning/model/nulls.py +70 -0
  17. pyfly_lightning/model/routing.py +384 -0
  18. pyfly_lightning/model/substrate.py +293 -0
  19. pyfly_lightning/prune.py +202 -0
  20. pyfly_lightning/prune_evo.py +278 -0
  21. pyfly_lightning/reward.py +338 -0
  22. pyfly_lightning/sim/__init__.py +3 -0
  23. pyfly_lightning/sim/bench.py +113 -0
  24. pyfly_lightning/sim/lif.py +195 -0
  25. pyfly_lightning/sim/probe.py +135 -0
  26. pyfly_lightning/train/__init__.py +16 -0
  27. pyfly_lightning/train/controls.py +73 -0
  28. pyfly_lightning/train/es.py +154 -0
  29. pyfly_lightning/train/module.py +306 -0
  30. pyfly_lightning/train/outer.py +167 -0
  31. pyfly_lightning/train/pipeline.py +255 -0
  32. pyfly_lightning/train/rl.py +370 -0
  33. pyfly_lightning/train/tasks.py +73 -0
  34. pyfly_lightning-0.1.0.dist-info/METADATA +428 -0
  35. pyfly_lightning-0.1.0.dist-info/RECORD +39 -0
  36. pyfly_lightning-0.1.0.dist-info/WHEEL +5 -0
  37. pyfly_lightning-0.1.0.dist-info/entry_points.txt +2 -0
  38. pyfly_lightning-0.1.0.dist-info/licenses/LICENSE +21 -0
  39. pyfly_lightning-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,29 @@
1
+ """pyfly-lightning: a frozen-connectome substrate with Lightning-style training.
2
+
3
+ The distribution is `pyfly-lightning` and the import is `pyfly_lightning`, following
4
+ the `pytorch-lightning` / `pytorch_lightning` convention. A bare `pyfly` import name
5
+ would collide with the unrelated `pyfly` package on PyPI (a load-testing framework),
6
+ and two distributions installing the same top-level directory get merged in
7
+ site-packages: one __init__.py wins and uninstalling either removes both.
8
+
9
+ import pyfly_lightning as pfl
10
+
11
+ The substrate is a real Drosophila connectome (MaleCNS v1.0 by default).
12
+ The only trainable parts are the encode/decode adapters around declared ports.
13
+ See PLAN.md for the design, and docs/census-wm-ports.md for why CX is the
14
+ default working-memory port.
15
+ """
16
+ from pyfly_lightning.api import FitResult, Fly, Port, fit, load
17
+ from pyfly_lightning.auto import Plan, plan
18
+ from pyfly_lightning.data.download import cache_dir, fetch
19
+ from pyfly_lightning.data.graph import SparseConnectome, load_malecns
20
+ from pyfly_lightning.data.registry import DATASETS, describe
21
+ from pyfly_lightning.data.schema import NeuronTable
22
+ from pyfly_lightning.envs import CueDelayChoice
23
+ from pyfly_lightning.freeze import FreezeState, FrozenError
24
+
25
+ __version__ = "0.1.0"
26
+ __all__ = ["DATASETS", "describe", "fetch", "cache_dir",
27
+ "NeuronTable", "SparseConnectome", "load_malecns",
28
+ "FreezeState", "FrozenError", "CueDelayChoice",
29
+ "Fly", "Port", "load", "fit", "FitResult", "Plan", "plan"]
@@ -0,0 +1,5 @@
1
+ """`python -m pyfly_lightning` runs the same entry point as the `pyfly-lightning` command."""
2
+ from pyfly_lightning.cli import main
3
+
4
+ if __name__ == "__main__":
5
+ raise SystemExit(main())
pyfly_lightning/api.py ADDED
@@ -0,0 +1,322 @@
1
+ """The documented user-facing surface (PLAN.md section 4).
2
+
3
+ import pyfly_lightning as pfl
4
+
5
+ fly = pfl.load("malecns10")
6
+ fly.io(inputs=["cb_sensory"], outputs=["vnc_motor"])
7
+ sub = fly.slice(budget=4096, include_pools=("CX",))
8
+ fly.port(sub, "working_memory", selector={"class": "CX"}, read=True, write=True)
9
+ fly.gate(sub, plastic=True)
10
+ bus = fly.reward_bus(sub, selector={"class": "DAN"})
11
+ model = fly.model(sub, in_dim=784, n_classes=10)
12
+
13
+ Thin wrappers over `Substrate`, `CreditChannel` and `FlyModule`. Kept deliberately
14
+ thin: the point is that the four primitives the design calls for -- declare the I/O
15
+ boundary, slice, port, gate -- are addressable by name, not that this layer does
16
+ anything the core cannot.
17
+ """
18
+ from __future__ import annotations
19
+
20
+ from dataclasses import dataclass, field
21
+
22
+ import numpy as np
23
+
24
+ from pyfly_lightning.data.graph import load_connectome
25
+ from pyfly_lightning.data.schema import NeuronTable
26
+ from pyfly_lightning.model.substrate import Substrate
27
+
28
+
29
+ @dataclass
30
+ class Port:
31
+ """A named read/write handle on a set of substrate nodes."""
32
+ name: str
33
+ positions: np.ndarray
34
+ body_ids: np.ndarray
35
+ read: bool = True
36
+ write: bool = True
37
+ protected: bool = True
38
+
39
+ def __len__(self) -> int:
40
+ return int(self.positions.size)
41
+
42
+ def __repr__(self) -> str:
43
+ mode = ("rw" if self.read and self.write else
44
+ "r" if self.read else "w" if self.write else "-")
45
+ return (f"<Port {self.name!r} n={len(self)} mode={mode} "
46
+ f"protected={self.protected}>")
47
+
48
+
49
+ @dataclass
50
+ class Fly:
51
+ """A dataset plus the primitives to build a model on it."""
52
+
53
+ dataset: str = "malecns10"
54
+ _table: object = None
55
+ _connectome: object = None
56
+ inputs: tuple = ()
57
+ outputs: tuple = ()
58
+ ports: dict = field(default_factory=dict)
59
+
60
+ # ---------- lazy data ----------
61
+ @property
62
+ def table(self) -> NeuronTable:
63
+ if self._table is None:
64
+ self._table = NeuronTable.load(dataset=self.dataset)
65
+ return self._table
66
+
67
+ @property
68
+ def connectome(self):
69
+ """Lazily load the graph for THIS dataset.
70
+
71
+ Hardcoding one loader here meant `load("flywire783")` silently returned the
72
+ MaleCNS graph, so a user could run a whole experiment on the wrong connectome
73
+ without a single warning.
74
+ """
75
+ if self._connectome is None:
76
+ self._connectome = load_connectome(self.dataset)
77
+ return self._connectome
78
+
79
+ def __repr__(self) -> str:
80
+ return (f"<Fly {self.dataset} ports={list(self.ports)} "
81
+ f"in={len(self.inputs)} out={len(self.outputs)}>")
82
+
83
+ # ---------- the four primitives ----------
84
+ def io(self, inputs=None, outputs=None) -> dict:
85
+ """Declare the I/O boundary from the release's own superclasses."""
86
+ if inputs is not None:
87
+ self.inputs = tuple(inputs)
88
+ if outputs is not None:
89
+ self.outputs = tuple(outputs)
90
+ in_super = {"ol_sensory", "cb_sensory", "vnc_sensory",
91
+ "sensory_ascending", "sensory_descending"}
92
+ out_super = {"vnc_motor", "cb_motor", "descending_neuron",
93
+ "vnc_efferent", "cb_endocrine"}
94
+ ask_in = set(self.inputs) | in_super
95
+ ask_out = set(self.outputs) | out_super
96
+ return {"inputs": self.table._mask_super(self.table.df, ask_in)["bodyId"].values,
97
+ "outputs": self.table._mask_super(self.table.df, ask_out)["bodyId"].values}
98
+
99
+ def slice(self, **kwargs) -> Substrate:
100
+ """Build a substrate slice. Extra inputs come from the declared boundary."""
101
+ kwargs.setdefault("n_inputs", 512)
102
+ kwargs.setdefault("n_outputs", 128)
103
+ kwargs.setdefault("budget", 4096)
104
+ return Substrate.build(self.connectome, self.table, **kwargs)
105
+
106
+ def _positions(self, substrate: Substrate, selector) -> tuple:
107
+ if isinstance(selector, str):
108
+ ids = self.table.pool(selector)["bodyId"].values
109
+ elif isinstance(selector, dict):
110
+ df = self.table.df
111
+ mask = np.ones(len(df), bool)
112
+ for k, v in selector.items():
113
+ if k == "cell_class":
114
+ k = "class"
115
+ mask &= (df[k] == v).to_numpy()
116
+ ids = df.loc[mask, "bodyId"].values
117
+ else:
118
+ ids = np.asarray(selector, dtype=np.int64)
119
+ pos = substrate.index().get_indexer(np.asarray(ids))
120
+ pos = pos[pos >= 0]
121
+ return pos, substrate.node_ids[pos]
122
+
123
+ def port(self, substrate: Substrate, name: str, selector=None, read=True,
124
+ write=True, protected=True, allow_empty: bool = False) -> Port:
125
+ """Expose a named read/write handle on real neurons.
126
+
127
+ This is the answer to "reserve neurons for working memory": you do not
128
+ reserve neurons, you tap an existing pool. See section 2.
129
+
130
+ Raises if the selector matches nothing INSIDE THE SLICE. A slice is grown
131
+ from the declared inputs, so a pool that is not reachable from them will
132
+ simply be absent -- and a silently empty port would look like a working
133
+ handle that does nothing. Pass `include_pools=` to `slice()` to force the
134
+ pool in.
135
+ """
136
+ if selector is None:
137
+ pos, ids = substrate.input_pos, substrate.node_ids[substrate.input_pos]
138
+ else:
139
+ pos, ids = self._positions(substrate, selector)
140
+ if pos.size == 0 and not allow_empty:
141
+ raise ValueError(
142
+ f"selector {selector!r} matched no neurons inside this slice "
143
+ f"({substrate.n:,} nodes). Slices are grown from the declared sensory "
144
+ f"inputs, so a pool that is not reachable from them is absent. Pass "
145
+ f"include_pools=... to Substrate.build/Fly.slice, or allow_empty=True.")
146
+ p = Port(name=name, positions=pos, body_ids=ids, read=read, write=write,
147
+ protected=protected)
148
+ self.ports[name] = p
149
+ return p
150
+
151
+ def gate(self, substrate: Substrate, positions=None, name=None, ablate=False,
152
+ plastic=False, module=None) -> dict:
153
+ """Change how a slice may be used. Two DISTINCT meanings, never conflated.
154
+
155
+ ablate structural: zero the in/out edges. Changes what the slice can do.
156
+ plastic parametric: permit this slice's weights to change. Changes how.
157
+
158
+ `ablate` on a substrate goes through `FlyModule.apply_topology_mask` when a
159
+ module is supplied, so the freeze precondition is enforced.
160
+ """
161
+ if positions is None and name is not None:
162
+ positions = self.ports[name].positions
163
+ out = {"ablate": bool(ablate), "plastic": bool(plastic)}
164
+ if ablate:
165
+ if module is None:
166
+ raise ValueError("ablate requires `module=` so the topology freeze "
167
+ "precondition is enforced")
168
+ out["dropped"] = module.apply_topology_mask(
169
+ np.asarray(positions, dtype=np.int64))
170
+ del substrate
171
+ return out
172
+
173
+ def reward_bus(self, substrate: Substrate, selector=None, policy=None,
174
+ **kwargs):
175
+ """Build a CreditChannel onto neuromodulatory targets.
176
+
177
+ `policy` defaults to NoPlasticity: stimulating and measuring is allowed,
178
+ changing weights is opt-in and is an assumption, not a property of the
179
+ connectome (section 3).
180
+ """
181
+ from pyfly_lightning.reward import CreditChannel
182
+ if selector is None:
183
+ selector = {"class": "DAN"}
184
+ pos, _ = self._positions(substrate, selector)
185
+ return CreditChannel(targets=pos, **kwargs)
186
+
187
+ def model(self, substrate: Substrate, **kwargs):
188
+ from pyfly_lightning.train import FlyModule
189
+ return FlyModule(substrate, **kwargs)
190
+
191
+ def plasticity(self, module, positions, policy=None, **kwargs):
192
+ from pyfly_lightning.reward import NoPlasticity, PlasticPool
193
+ return PlasticPool(module, np.asarray(positions, dtype=np.int64),
194
+ policy=policy or NoPlasticity(), **kwargs)
195
+
196
+
197
+ def load(dataset: str = "malecns10") -> Fly:
198
+ """Load a dataset handle. Data is fetched lazily on first use."""
199
+ return Fly(dataset=dataset)
200
+
201
+
202
+ @dataclass
203
+ class FitResult:
204
+ """Everything a run must report, including the controls."""
205
+ accuracy: float
206
+ chance: float
207
+ plan: dict
208
+ controls: dict
209
+ verdict: str
210
+ model: object = None
211
+ substrate: dict = field(default_factory=dict)
212
+
213
+ def __str__(self) -> str:
214
+ return (f"<FitResult acc={self.accuracy:.4f} chance={self.chance:.4f} "
215
+ f"verdict={self.verdict}>")
216
+
217
+ def as_dict(self) -> dict:
218
+ return {"accuracy": self.accuracy, "chance": self.chance, "plan": self.plan,
219
+ "controls": self.controls, "verdict": self.verdict,
220
+ "substrate": self.substrate}
221
+
222
+
223
+ def those_controls_are_required() -> str:
224
+ return ("RewiredNull, ShuffledWeightNull, PooledFeatureNull and NoPlasticityControl "
225
+ "are built in. A run whose claim does not beat all of them is reported "
226
+ "UNSUPPORTED.")
227
+
228
+
229
+ def fit(X, y, *, dataset: str = "malecns10", temporal: bool = False,
230
+ modulation: bool = False, plan=None, device: str = "cpu", seed: int = 0,
231
+ val_frac: float = 0.25, verbose: bool = False) -> FitResult:
232
+ """Fit the connectome substrate to a task. One call, no connectome knowledge needed.
233
+
234
+ Runs: plan -> substrate slice -> ES routing -> trained decoder -> optional
235
+ modulation channel -> mandatory controls -> verdict.
236
+
237
+ The verdict is `SUPPORTED` only if the fit beats every null. On every task
238
+ measured so far it does not, and the reason is documented in
239
+ docs/architecture.md sections 11 and 22: the connectome's specific wiring is
240
+ interchangeable with its own rewiring. The framework's value is that the
241
+ INTERFACE is optimised for you, not that the substrate is better.
242
+ """
243
+ import torch
244
+
245
+ from pyfly_lightning.auto import plan as _plan
246
+ from pyfly_lightning.model.nulls import rewired_substrate, shuffled_weight_substrate
247
+ from pyfly_lightning.train.pipeline import PipelineConfig, StagedPipeline
248
+
249
+ X = np.asarray(X, np.float32)
250
+ y = np.asarray(y, np.int64)
251
+ n, d = X.shape
252
+ C = int(y.max()) + 1
253
+ pl = plan or _plan(n, d, C, temporal=temporal, modulation=modulation)
254
+
255
+ rng = np.random.default_rng(seed)
256
+ perm = rng.permutation(n)
257
+ n_val = max(1, int(round(val_frac * n)))
258
+ itr, iva = perm[:n - n_val], perm[n - n_val:]
259
+
260
+ fly = load(dataset)
261
+ sub = fly.slice(budget=pl.budget, include_pools=pl.pools, n_inputs=pl.n_inputs,
262
+ n_outputs=pl.n_outputs, seed=seed)
263
+ dan_pos = None
264
+ if modulation:
265
+ pos, _ = fly._positions(sub, {"class": "DAN"})
266
+ dan_pos = pos if pos.size else None
267
+
268
+ Xt, yt = torch.as_tensor(X[itr], device=device), torch.as_tensor(y[itr], device=device)
269
+ Xv, yv = torch.as_tensor(X[iva], device=device), torch.as_tensor(y[iva], device=device)
270
+ chance = float(np.bincount(y[iva], minlength=C).max() / len(iva))
271
+
272
+ cfg = PipelineConfig(latent=pl.latent, T=pl.T, seed=seed,
273
+ es_iters=pl.es_iters, es_popsize=pl.es_popsize,
274
+ use_dan=modulation, train_dan_map=modulation)
275
+ model = StagedPipeline(sub, in_dim=d, n_classes=C, dan_pos=dan_pos,
276
+ config=cfg, device=device).to(device)
277
+ hist = model.stage1_es(Xt, yt)
278
+ model.stage2_decode(Xt, yt, Xv, yv)
279
+ if modulation and dan_pos is not None:
280
+ model.stage3_modulate(Xt, yt, Xv, yv)
281
+ acc = model.accuracy(Xv, yv)
282
+ if verbose:
283
+ print(f" plan: {pl}")
284
+ print(f" ES {hist[0]:.4f} -> {max(hist):.4f}; final acc {acc:.4f} (chance {chance:.4f})")
285
+
286
+ def eval_substrate(s):
287
+ m = StagedPipeline(s, in_dim=d, n_classes=C, dan_pos=dan_pos,
288
+ config=PipelineConfig(latent=pl.latent, T=pl.T, seed=seed,
289
+ es_iters=pl.es_iters,
290
+ es_popsize=pl.es_popsize,
291
+ use_dan=modulation,
292
+ train_dan_map=modulation),
293
+ device=device).to(device)
294
+ m.stage1_es(Xt, yt)
295
+ m.stage2_decode(Xt, yt, Xv, yv)
296
+ return m.accuracy(Xv, yv)
297
+
298
+ rewired = eval_substrate(rewired_substrate(sub, seed=seed))
299
+ shuffled = eval_substrate(shuffled_weight_substrate(sub, seed=seed))
300
+
301
+ # the pooled-feature null: ridge on the RAW features, same readout class
302
+ Ftr = torch.as_tensor(X[itr], device=device)
303
+ Fva = torch.as_tensor(X[iva], device=device)
304
+ Y = torch.zeros((len(itr), C), device=device)
305
+ Y[torch.arange(len(itr), device=device), yt.long()] = 1.0
306
+ A = Ftr.T @ Ftr + torch.eye(d, device=device)
307
+ Wr = torch.linalg.solve(A, Ftr.T @ Y)
308
+ pooled = float(((Fva @ Wr).argmax(1) == yv).float().mean())
309
+
310
+ controls = {"fly": round(acc, 4), "rewired": round(rewired, 4),
311
+ "shuffled_weight": round(shuffled, 4), "pooled_features": round(pooled, 4),
312
+ "no_plasticity": True, "chance": round(chance, 4)}
313
+ beats = all(acc > controls[k] for k in ("rewired", "shuffled_weight",
314
+ "pooled_features"))
315
+ return FitResult(
316
+ accuracy=round(acc, 4), chance=round(chance, 4), plan=pl.as_dict(),
317
+ controls=controls,
318
+ verdict="SUPPORTED" if beats else "UNSUPPORTED",
319
+ model=model,
320
+ substrate={"n": sub.n, "edges": sub.n_edges,
321
+ "n_groups": sub.input_surface.n_groups},
322
+ )
@@ -0,0 +1,128 @@
1
+ """Automatic configuration: the user brings a task, the system picks the substrate.
2
+
3
+ This is the deliverable behind "let users apply the connectome to a task without
4
+ researching its wiring, inputs or outputs". Every default here is derived from a
5
+ measurement in docs/architecture.md, not from taste, and `Plan.reason` records
6
+ which measurement drove which choice so a surprising result can be traced.
7
+
8
+ What the defaults encode
9
+ ------------------------
10
+ * CX is forced in (section 22). Without it, injected activity dies within 2 steps
11
+ (`late_echo` 0.0); with it, activity recirculates for 60+ steps. This is the one
12
+ measured contribution the connectome makes, and it is a property of the recurrent
13
+ pool, not of specific weights.
14
+ * The substrate is kept small. Measured: 4096 nodes / 119k edges already recirculate
15
+ (late_echo 30) while 16384 nodes / 1.45M edges cost 12x more for the same memory.
16
+ Larger is not better here and is 12x slower.
17
+ * Routing is grouped at `medium` granularity (~10 modality groups, ~330 parameters).
18
+ At `fine` granularity the router alone is 41,877 parameters, which no
19
+ full-covariance optimiser can fit.
20
+ * The ES budget scales with the data, capped so a fit on a small dataset finishes in
21
+ minutes. Section 22 shows the optimiser is worth +7.6 to +15.3 points over naive
22
+ input, so it is not optional -- but neither is it free.
23
+ """
24
+ from __future__ import annotations
25
+
26
+ from dataclasses import dataclass, field
27
+
28
+ import numpy as np
29
+
30
+
31
+ @dataclass
32
+ class Plan:
33
+ budget: int = 4096
34
+ n_inputs: int = 256
35
+ n_outputs: int = 64
36
+ pools: tuple = ("CX",)
37
+ latent: int = 16
38
+ T: int = 4
39
+ es_iters: int = 15
40
+ es_popsize: int = 16
41
+ readout: str = "ridge+head"
42
+ reason: dict = field(default_factory=dict)
43
+
44
+ @property
45
+ def es_evaluations(self) -> int:
46
+ return self.es_iters * self.es_popsize
47
+
48
+ def as_dict(self) -> dict:
49
+ d = {"budget": self.budget, "n_inputs": self.n_inputs,
50
+ "n_outputs": self.n_outputs, "pools": list(self.pools),
51
+ "latent": self.latent, "T": self.T,
52
+ "es_iters": self.es_iters, "es_popsize": self.es_popsize,
53
+ "es_evaluations": self.es_evaluations, "readout": self.readout}
54
+ d["reason"] = self.reason
55
+ return d
56
+
57
+ def __str__(self) -> str:
58
+ return (f"<Plan {self.budget} nodes, pools={list(self.pools)}, latent={self.latent}, "
59
+ f"T={self.T}, ES={self.es_evaluations} evals>")
60
+
61
+
62
+ # Measured anchors from docs/architecture.md.
63
+ _MIN_BUDGET_FOR_MEMORY = 4096 # late_echo 30 at 4096; 0.0 without CX (section 22)
64
+ _MAX_USEFUL_BUDGET = 8192 # 16384 costs 12x the edges for the same late_echo
65
+ _DAN_COVERAGE_OK = 8192 # DAN reaches 71% at 4096, 83% at 8192 (section 22)
66
+
67
+
68
+ def plan(n_samples: int, n_features: int, n_classes: int, *,
69
+ temporal: bool = False, modulation: bool = True,
70
+ es_budget_cap: int = 2000) -> Plan:
71
+ """Pick a substrate and an optimiser budget for a task of this shape."""
72
+ if n_samples < 2 or n_features < 1 or n_classes < 2:
73
+ raise ValueError("need at least 2 samples, 1 feature and 2 classes")
74
+
75
+ pools = ["CX"]
76
+ if modulation:
77
+ pools.append("DAN")
78
+
79
+ # Scale the substrate, and especially the READOUT dimension, with the data.
80
+ # Measured on iris: 64 readout features trained on 120 samples lost to 4 raw
81
+ # features (0.7133 vs 0.8267). The substrate was not the problem -- the feature
82
+ # count was. A small dataset needs a narrow interface, so the readout width is
83
+ # derived from n_samples first and the substrate is sized to match.
84
+ n_outputs = int(np.clip(n_samples // 16, 8, 64))
85
+ if n_samples < 1000:
86
+ budget = 2048 # 4096+ recirculates but is pointless on 150 rows
87
+ elif n_samples < 3000:
88
+ budget = _MIN_BUDGET_FOR_MEMORY
89
+ else:
90
+ budget = max(_MIN_BUDGET_FOR_MEMORY,
91
+ _DAN_COVERAGE_OK if modulation else 0)
92
+ budget = int(min(budget, _MAX_USEFUL_BUDGET))
93
+ if modulation and budget < _DAN_COVERAGE_OK:
94
+ pools = ["CX"] # no room for a channel with real targets
95
+
96
+ # Latent controls the ROUTER's dimensionality, which is what ES has to search.
97
+ latent = int(np.clip(2 ** int(np.ceil(np.log2(max(4, n_features)))), 8, 64))
98
+ # The sequence length must exceed the hop distance for the readout to see it.
99
+ T = 4 if not temporal else 8
100
+
101
+ # ES budget scales with the data but is capped: measured is better than assumed,
102
+ # and 600 evaluations already bought +7.6 to +15.3 points (section 22).
103
+ popsize = 16
104
+ iters = int(np.clip(round(n_samples / 40), 5, 60))
105
+ while iters * popsize > es_budget_cap and iters > 5:
106
+ iters -= 1
107
+
108
+ return Plan(
109
+ budget=budget, n_inputs=int(min(256, max(64, n_features * 8))),
110
+ n_outputs=n_outputs, pools=tuple(pools), latent=latent, T=T,
111
+ es_iters=iters, es_popsize=popsize,
112
+ reason={
113
+ "budget": (f"CX is forced in because without it late_echo = 0.0 and activity "
114
+ f"dies in 2 steps; capped at {_MAX_USEFUL_BUDGET} because 16384 "
115
+ f"costs 12x the edges for the same late_echo"),
116
+ "pools": ("DAN added because the modulation channel needs in-slice targets "
117
+ "-- it reaches 83% of the slice at 8192 nodes"
118
+ if modulation else
119
+ "CX only; modulation channel disabled by request"),
120
+ "latent": f"scaled from n_features={n_features} to bound the ES search space",
121
+ "n_outputs": (f"{n_outputs} readout features = n_samples/16, because 64 "
122
+ f"features on 120 samples lost to 4 raw features on iris"),
123
+ "readout_width": f"n_outputs={n_outputs} scaled from n_samples={n_samples}",
124
+ "T": ("8 for a temporal task so the sequence exceeds the hop distance"
125
+ if temporal else "4; the feed-forward readout needs one pass"),
126
+ "es": (f"{iters} x {popsize} = {iters * popsize} evaluations, scaled from "
127
+ f"n_samples={n_samples} and capped at {es_budget_cap}"),
128
+ })
pyfly_lightning/cli.py ADDED
@@ -0,0 +1,107 @@
1
+ """Console entry point.
2
+
3
+ pyfly describe list datasets and their caveats
4
+ pyfly get malecns10 download (sha256-verified, cached)
5
+ pyfly verify malecns10 check the cache
6
+ pyfly plan --n-samples 150 --n-features 4 --n-classes 3
7
+ pyfly fit --csv iris.csv --target species --out report.json
8
+ pyfly fit --npy X.npy y.npy --modulation
9
+
10
+ `plan` and `fit` are the point: a user brings a task and gets a fitted model with
11
+ the mandatory controls, without studying the connectome's wiring, inputs or outputs.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import sys
17
+
18
+
19
+ def _load_xy(args):
20
+ import numpy as np
21
+ if args.csv:
22
+ import pandas as pd
23
+ df = pd.read_csv(args.csv)
24
+ if args.target not in df.columns:
25
+ raise SystemExit(f"target column {args.target!r} not in {list(df.columns)}")
26
+ y = df[args.target]
27
+ X = df.drop(columns=[args.target]).select_dtypes("number")
28
+ if X.shape[1] == 0:
29
+ raise SystemExit("no numeric feature columns found")
30
+ codes, _ = pd.factorize(y)
31
+ return X.to_numpy(np.float32), codes.astype(np.int64)
32
+ if len(args.npy) != 2:
33
+ raise SystemExit("--npy takes exactly two paths: X.npy y.npy")
34
+ return (np.load(args.npy[0]).astype(np.float32),
35
+ np.load(args.npy[1]).astype(np.int64))
36
+
37
+
38
+ def cmd_plan(args) -> int:
39
+ from pyfly_lightning.auto import plan
40
+ p = plan(args.n_samples, args.n_features, args.n_classes,
41
+ modulation=not args.no_modulation)
42
+ print(json.dumps(p.as_dict(), indent=2))
43
+ print(f"\n{p}")
44
+ return 0
45
+
46
+
47
+ def cmd_fit(args) -> int:
48
+ import pyfly_lightning as pfl
49
+ X, y = _load_xy(args)
50
+ print(f"data: {X.shape[0]} samples, {X.shape[1]} features, "
51
+ f"{int(y.max()) + 1} classes")
52
+ res = pfl.fit(X, y, dataset=args.dataset, modulation=args.modulation,
53
+ device=args.device, seed=args.seed, verbose=True)
54
+ print()
55
+ print(json.dumps(res.as_dict(), indent=2))
56
+ print(f"\n{res}")
57
+ if res.verdict == "UNSUPPORTED":
58
+ print("\nverdict=UNSUPPORTED: the fit did not beat every null. The four scores "
59
+ "above are in the JSON; see the README's measurement table and "
60
+ "docs/architecture.md for the protocols behind them.")
61
+ if args.out:
62
+ with open(args.out, "w") as f:
63
+ json.dump(res.as_dict(), f, indent=2)
64
+ print(f"wrote {args.out}")
65
+ return 0
66
+
67
+
68
+ def main(argv: list[str] | None = None) -> int:
69
+ argv = list(sys.argv[1:] if argv is None else argv)
70
+ from pyfly_lightning.data.download import main as dl_main
71
+
72
+ if not argv or argv[0] in ("-h", "--help", "help"):
73
+ print(__doc__)
74
+ return 0
75
+
76
+ cmd = argv[0]
77
+ if cmd in ("describe", "get", "verify"):
78
+ return dl_main(argv)
79
+
80
+ import argparse
81
+ p = argparse.ArgumentParser(prog=f"pyfly {cmd}", description=__doc__)
82
+ if cmd == "plan":
83
+ p.add_argument("--n-samples", type=int, required=True)
84
+ p.add_argument("--n-features", type=int, required=True)
85
+ p.add_argument("--n-classes", type=int, default=2)
86
+ p.add_argument("--no-modulation", action="store_true")
87
+ a = p.parse_args(argv[1:])
88
+ return cmd_plan(a)
89
+ if cmd == "fit":
90
+ p.add_argument("--csv", default=None)
91
+ p.add_argument("--target", default=None)
92
+ p.add_argument("--npy", nargs="*", default=[])
93
+ p.add_argument("--dataset", default="malecns10")
94
+ p.add_argument("--modulation", action="store_true")
95
+ p.add_argument("--device", default="cpu")
96
+ p.add_argument("--seed", type=int, default=0)
97
+ p.add_argument("--out", default=None)
98
+ a = p.parse_args(argv[1:])
99
+ if not a.csv and len(a.npy) != 2:
100
+ raise SystemExit("pass either --csv FILE --target COL, or --npy X.npy y.npy")
101
+ return cmd_fit(a)
102
+ print(f"unknown command {cmd!r}", file=sys.stderr)
103
+ return 2
104
+
105
+
106
+ if __name__ == "__main__":
107
+ raise SystemExit(main())
@@ -0,0 +1,7 @@
1
+ from pyfly_lightning.data.download import cache_dir, fetch, fetch_dataset, verify
2
+ from pyfly_lightning.data.graph import SparseConnectome, load_malecns
3
+ from pyfly_lightning.data.registry import DATASETS, describe, files_for
4
+ from pyfly_lightning.data.schema import NeuronTable
5
+
6
+ __all__ = ["DATASETS", "describe", "files_for", "fetch", "fetch_dataset",
7
+ "cache_dir", "verify", "NeuronTable", "SparseConnectome", "load_malecns"]
@@ -0,0 +1,14 @@
1
+ {
2
+ "flywire783/Supplemental_file1_neuron_annotations.tsv": {
3
+ "bytes": 31718505,
4
+ "sha256": "9a4f8b2f843196074431ebd7cd883536afa1be86c8a4ce90970441e8be81d1be"
5
+ },
6
+ "malecns10/body-annotations.feather": {
7
+ "bytes": 14483314,
8
+ "sha256": "2177e246113e4cfbf1e7772ec37c6da1955ff22e8063d0b1f833101f99a9a3b2"
9
+ },
10
+ "malecns10/connectome-weights-significant-only.feather": {
11
+ "bytes": 502169298,
12
+ "sha256": "5c536423a62a688e59e7b441f9c04d6272c9a1f017e35814cf561f8c275d9e9e"
13
+ }
14
+ }