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.
- pyfly_lightning/__init__.py +29 -0
- pyfly_lightning/__main__.py +5 -0
- pyfly_lightning/api.py +322 -0
- pyfly_lightning/auto.py +128 -0
- pyfly_lightning/cli.py +107 -0
- pyfly_lightning/data/__init__.py +7 -0
- pyfly_lightning/data/_checksums.json +14 -0
- pyfly_lightning/data/download.py +98 -0
- pyfly_lightning/data/graph.py +433 -0
- pyfly_lightning/data/registry.py +127 -0
- pyfly_lightning/data/schema.py +212 -0
- pyfly_lightning/envs.py +82 -0
- pyfly_lightning/freeze.py +42 -0
- pyfly_lightning/gates.py +148 -0
- pyfly_lightning/model/__init__.py +8 -0
- pyfly_lightning/model/nulls.py +70 -0
- pyfly_lightning/model/routing.py +384 -0
- pyfly_lightning/model/substrate.py +293 -0
- pyfly_lightning/prune.py +202 -0
- pyfly_lightning/prune_evo.py +278 -0
- pyfly_lightning/reward.py +338 -0
- pyfly_lightning/sim/__init__.py +3 -0
- pyfly_lightning/sim/bench.py +113 -0
- pyfly_lightning/sim/lif.py +195 -0
- pyfly_lightning/sim/probe.py +135 -0
- pyfly_lightning/train/__init__.py +16 -0
- pyfly_lightning/train/controls.py +73 -0
- pyfly_lightning/train/es.py +154 -0
- pyfly_lightning/train/module.py +306 -0
- pyfly_lightning/train/outer.py +167 -0
- pyfly_lightning/train/pipeline.py +255 -0
- pyfly_lightning/train/rl.py +370 -0
- pyfly_lightning/train/tasks.py +73 -0
- pyfly_lightning-0.1.0.dist-info/METADATA +428 -0
- pyfly_lightning-0.1.0.dist-info/RECORD +39 -0
- pyfly_lightning-0.1.0.dist-info/WHEEL +5 -0
- pyfly_lightning-0.1.0.dist-info/entry_points.txt +2 -0
- pyfly_lightning-0.1.0.dist-info/licenses/LICENSE +21 -0
- 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"]
|
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
|
+
)
|
pyfly_lightning/auto.py
ADDED
|
@@ -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
|
+
}
|