flyboost-connectome 0.1.0__tar.gz

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.
@@ -0,0 +1,145 @@
1
+ Metadata-Version: 2.4
2
+ Name: flyboost-connectome
3
+ Version: 0.1.0
4
+ Summary: Scikit-learn compatible boosting estimators built on a Drosophila connectome reservoir
5
+ Author: FlyBoost contributors
6
+ Requires-Python: >=3.10
7
+ Description-Content-Type: text/markdown
8
+ Requires-Dist: numpy>=1.26
9
+ Requires-Dist: scikit-learn>=1.6
10
+ Requires-Dist: torch>=2.4
11
+ Provides-Extra: dev
12
+ Requires-Dist: pytest>=8; extra == "dev"
13
+ Requires-Dist: build>=1.2; extra == "dev"
14
+
15
+ # FlyBoost
16
+
17
+ A small scikit-learn-compatible wrapper around the FlyBoost prototype from the
18
+ research notebook. The connectome tensor file is **not** bundled; point
19
+ `graph_path` at your existing `malecns_nomotor.pt`.
20
+
21
+ ## Install
22
+
23
+ From the project directory:
24
+
25
+ ```bash
26
+ pip install .
27
+ ```
28
+
29
+ Editable development install:
30
+
31
+ ```bash
32
+ pip install -e .
33
+ ```
34
+
35
+ You can also install the source archive directly:
36
+
37
+ ```bash
38
+ pip install flyboost-connectome-0.1.0.tar.gz
39
+ ```
40
+
41
+ ## Binary classification
42
+
43
+ ```python
44
+ from flyboost import FlyBoostClassifier
45
+
46
+ model = FlyBoostClassifier(
47
+ graph_path="data/malecns_nomotor.pt",
48
+ n_flies=8,
49
+ epochs_per_fly=8,
50
+ fly_steps=8,
51
+ batch_size=32,
52
+ fly_lr=2e-3,
53
+ fly_mode="real", # "real" | "shuffled" | "frozen"
54
+ random_state=42,
55
+ verbose=1,
56
+ )
57
+
58
+ model.fit(
59
+ X_train,
60
+ y_train,
61
+ eval_set=[(X_val, y_val)],
62
+ )
63
+
64
+ pred = model.predict(X_val)
65
+ proba = model.predict_proba(X_val)
66
+ print(model.score(X_val, y_val))
67
+ print(model.evals_result())
68
+ ```
69
+
70
+ Switching controls requires only one parameter:
71
+
72
+ ```python
73
+ real = FlyBoostClassifier(fly_mode="real", ...)
74
+ shuffled = FlyBoostClassifier(fly_mode="shuffled", ...)
75
+ frozen = FlyBoostClassifier(fly_mode="frozen", ...)
76
+ ```
77
+
78
+ All boosted shuffled flies share one shuffled topology for the same graph and
79
+ `shuffle_seed`; their sensory mappings still differ by stage seed.
80
+
81
+ ## Regression
82
+
83
+ ```python
84
+ from flyboost import FlyBoostRegressor
85
+
86
+ reg = FlyBoostRegressor(
87
+ graph_path="data/malecns_nomotor.pt",
88
+ n_flies=8,
89
+ epochs_per_fly=8,
90
+ fly_mode="real",
91
+ verbose=1,
92
+ )
93
+ reg.fit(X_train, y_train, eval_set=[(X_val, y_val)])
94
+ yhat = reg.predict(X_val)
95
+ print(reg.score(X_val, y_val)) # sklearn R^2
96
+ ```
97
+
98
+ ## sklearn ecosystem
99
+
100
+ Because the estimators inherit from `BaseEstimator` and the appropriate mixin,
101
+ normal sklearn parameter plumbing works:
102
+
103
+ ```python
104
+ from sklearn.base import clone
105
+ from sklearn.pipeline import make_pipeline
106
+ from sklearn.preprocessing import StandardScaler
107
+ from sklearn.model_selection import GridSearchCV
108
+
109
+ base = FlyBoostClassifier(graph_path="data/malecns_nomotor.pt")
110
+ clone(base)
111
+
112
+ pipe = make_pipeline(StandardScaler(), base)
113
+
114
+ search = GridSearchCV(
115
+ base,
116
+ {
117
+ "fly_mode": ["real", "shuffled", "frozen"],
118
+ "n_flies": [1, 8, 64],
119
+ "epochs_per_fly": [1, 8, 64],
120
+ },
121
+ cv=3,
122
+ )
123
+ ```
124
+
125
+ For the compute-matched control experiments, set only:
126
+
127
+ ```python
128
+ FlyBoostClassifier(n_flies=1, epochs_per_fly=64, ...)
129
+ FlyBoostClassifier(n_flies=8, epochs_per_fly=8, ...)
130
+ FlyBoostClassifier(n_flies=64, epochs_per_fly=1, ...)
131
+ ```
132
+
133
+ ## Verbosity
134
+
135
+ - `verbose=0`: no training prints
136
+ - `verbose=1`: stage metrics
137
+ - `verbose>=2`: per-fly epoch metrics plus control-construction diagnostics
138
+
139
+ ## Current scope
140
+
141
+ - classifier: binary targets
142
+ - regressor: single-output targets
143
+ - `eval_set=[(X_val, y_val), ...]` supported
144
+ - `evals_result()` stores stage-wise metrics
145
+ - graph file stays external to keep the package lightweight
@@ -0,0 +1,131 @@
1
+ # FlyBoost
2
+
3
+ A small scikit-learn-compatible wrapper around the FlyBoost prototype from the
4
+ research notebook. The connectome tensor file is **not** bundled; point
5
+ `graph_path` at your existing `malecns_nomotor.pt`.
6
+
7
+ ## Install
8
+
9
+ From the project directory:
10
+
11
+ ```bash
12
+ pip install .
13
+ ```
14
+
15
+ Editable development install:
16
+
17
+ ```bash
18
+ pip install -e .
19
+ ```
20
+
21
+ You can also install the source archive directly:
22
+
23
+ ```bash
24
+ pip install flyboost-connectome-0.1.0.tar.gz
25
+ ```
26
+
27
+ ## Binary classification
28
+
29
+ ```python
30
+ from flyboost import FlyBoostClassifier
31
+
32
+ model = FlyBoostClassifier(
33
+ graph_path="data/malecns_nomotor.pt",
34
+ n_flies=8,
35
+ epochs_per_fly=8,
36
+ fly_steps=8,
37
+ batch_size=32,
38
+ fly_lr=2e-3,
39
+ fly_mode="real", # "real" | "shuffled" | "frozen"
40
+ random_state=42,
41
+ verbose=1,
42
+ )
43
+
44
+ model.fit(
45
+ X_train,
46
+ y_train,
47
+ eval_set=[(X_val, y_val)],
48
+ )
49
+
50
+ pred = model.predict(X_val)
51
+ proba = model.predict_proba(X_val)
52
+ print(model.score(X_val, y_val))
53
+ print(model.evals_result())
54
+ ```
55
+
56
+ Switching controls requires only one parameter:
57
+
58
+ ```python
59
+ real = FlyBoostClassifier(fly_mode="real", ...)
60
+ shuffled = FlyBoostClassifier(fly_mode="shuffled", ...)
61
+ frozen = FlyBoostClassifier(fly_mode="frozen", ...)
62
+ ```
63
+
64
+ All boosted shuffled flies share one shuffled topology for the same graph and
65
+ `shuffle_seed`; their sensory mappings still differ by stage seed.
66
+
67
+ ## Regression
68
+
69
+ ```python
70
+ from flyboost import FlyBoostRegressor
71
+
72
+ reg = FlyBoostRegressor(
73
+ graph_path="data/malecns_nomotor.pt",
74
+ n_flies=8,
75
+ epochs_per_fly=8,
76
+ fly_mode="real",
77
+ verbose=1,
78
+ )
79
+ reg.fit(X_train, y_train, eval_set=[(X_val, y_val)])
80
+ yhat = reg.predict(X_val)
81
+ print(reg.score(X_val, y_val)) # sklearn R^2
82
+ ```
83
+
84
+ ## sklearn ecosystem
85
+
86
+ Because the estimators inherit from `BaseEstimator` and the appropriate mixin,
87
+ normal sklearn parameter plumbing works:
88
+
89
+ ```python
90
+ from sklearn.base import clone
91
+ from sklearn.pipeline import make_pipeline
92
+ from sklearn.preprocessing import StandardScaler
93
+ from sklearn.model_selection import GridSearchCV
94
+
95
+ base = FlyBoostClassifier(graph_path="data/malecns_nomotor.pt")
96
+ clone(base)
97
+
98
+ pipe = make_pipeline(StandardScaler(), base)
99
+
100
+ search = GridSearchCV(
101
+ base,
102
+ {
103
+ "fly_mode": ["real", "shuffled", "frozen"],
104
+ "n_flies": [1, 8, 64],
105
+ "epochs_per_fly": [1, 8, 64],
106
+ },
107
+ cv=3,
108
+ )
109
+ ```
110
+
111
+ For the compute-matched control experiments, set only:
112
+
113
+ ```python
114
+ FlyBoostClassifier(n_flies=1, epochs_per_fly=64, ...)
115
+ FlyBoostClassifier(n_flies=8, epochs_per_fly=8, ...)
116
+ FlyBoostClassifier(n_flies=64, epochs_per_fly=1, ...)
117
+ ```
118
+
119
+ ## Verbosity
120
+
121
+ - `verbose=0`: no training prints
122
+ - `verbose=1`: stage metrics
123
+ - `verbose>=2`: per-fly epoch metrics plus control-construction diagnostics
124
+
125
+ ## Current scope
126
+
127
+ - classifier: binary targets
128
+ - regressor: single-output targets
129
+ - `eval_set=[(X_val, y_val), ...]` supported
130
+ - `evals_result()` stores stage-wise metrics
131
+ - graph file stays external to keep the package lightweight
@@ -0,0 +1,25 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "flyboost-connectome"
7
+ version = "0.1.0"
8
+ description = "Scikit-learn compatible boosting estimators built on a Drosophila connectome reservoir"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ authors = [{name = "FlyBoost contributors"}]
12
+ dependencies = [
13
+ "numpy>=1.26",
14
+ "scikit-learn>=1.6",
15
+ "torch>=2.4",
16
+ ]
17
+
18
+ [project.optional-dependencies]
19
+ dev = ["pytest>=8", "build>=1.2"]
20
+
21
+ [tool.setuptools.packages.find]
22
+ where = ["src"]
23
+
24
+ [tool.pytest.ini_options]
25
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,14 @@
1
+ from .estimators import FlyBoostClassifier, FlyBoostRegressor
2
+ from .fly import FrozenTrainableFly, ShuffledTrainableFly, TrainableFly
3
+ from .graph import FlyGraph
4
+
5
+ __all__ = [
6
+ "FlyBoostClassifier",
7
+ "FlyBoostRegressor",
8
+ "FlyGraph",
9
+ "TrainableFly",
10
+ "ShuffledTrainableFly",
11
+ "FrozenTrainableFly",
12
+ ]
13
+
14
+ __version__ = "0.1.0"
@@ -0,0 +1,508 @@
1
+ from __future__ import annotations
2
+
3
+ import copy
4
+ import math
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+
8
+ import numpy as np
9
+ import torch
10
+ from sklearn.base import BaseEstimator, ClassifierMixin, RegressorMixin
11
+ from sklearn.utils.multiclass import check_classification_targets, type_of_target
12
+ from sklearn.utils.validation import check_is_fitted, validate_data
13
+
14
+ from .fly import FLY_MODES
15
+ from .graph import FlyGraph, load_graph_cached
16
+
17
+
18
+ @dataclass
19
+ class BoostStage:
20
+ state_dict: dict[str, torch.Tensor]
21
+
22
+
23
+ def _as_float_tensor(x: np.ndarray) -> torch.Tensor:
24
+ return torch.tensor(np.asarray(x, dtype=np.float32), dtype=torch.float32)
25
+
26
+
27
+ def _resolve_device(device: str) -> str:
28
+ if device != "auto":
29
+ return str(device)
30
+ return "cuda" if torch.cuda.is_available() else "cpu"
31
+
32
+
33
+ def _check_sample_weight(sample_weight, n_samples: int) -> torch.Tensor:
34
+ if sample_weight is None:
35
+ return torch.ones(n_samples, dtype=torch.float32)
36
+ sw = np.asarray(sample_weight, dtype=np.float32)
37
+ if sw.ndim != 1 or len(sw) != n_samples:
38
+ raise ValueError(
39
+ f"sample_weight must have shape ({n_samples},), got {sw.shape}"
40
+ )
41
+ if np.any(sw < 0):
42
+ raise ValueError("sample_weight cannot contain negative values")
43
+ if not np.any(sw > 0):
44
+ raise ValueError("sample_weight must contain at least one positive value")
45
+ return torch.from_numpy(sw)
46
+
47
+
48
+ class _FlyBoostBase(BaseEstimator):
49
+ """Shared sklearn-facing plumbing for FlyBoost estimators."""
50
+
51
+ def __init__(
52
+ self,
53
+ graph_path: str = "data/malecns_nomotor.pt",
54
+ n_flies: int = 8,
55
+ learning_rate: float = 0.2,
56
+ fly_steps: int = 8,
57
+ epochs_per_fly: int = 8,
58
+ batch_size: int = 32,
59
+ fly_lr: float = 2e-3,
60
+ fly_gain_lr: float = 0.0,
61
+ fly_mode: str = "real",
62
+ leak: float = 0.5,
63
+ max_log_gain: float = math.log(2.0),
64
+ shuffle_seed: int = 20260915,
65
+ device: str = "cpu",
66
+ random_state: int | None = 42,
67
+ verbose: int = 0,
68
+ ):
69
+ self.graph_path = graph_path
70
+ self.n_flies = n_flies
71
+ self.learning_rate = learning_rate
72
+ self.fly_steps = fly_steps
73
+ self.epochs_per_fly = epochs_per_fly
74
+ self.batch_size = batch_size
75
+ self.fly_lr = fly_lr
76
+ self.fly_gain_lr = fly_lr if fly_gain_lr == 0 else fly_gain_lr
77
+ self.fly_mode = fly_mode
78
+ self.leak = leak
79
+ self.max_log_gain = max_log_gain
80
+ self.shuffle_seed = shuffle_seed
81
+ self.device = device
82
+ self.random_state = random_state
83
+ self.verbose = verbose
84
+
85
+ def _validate_hyperparameters(self) -> None:
86
+ if self.fly_mode not in FLY_MODES:
87
+ raise ValueError(
88
+ f"fly_mode must be one of {sorted(FLY_MODES)}, got {self.fly_mode!r}"
89
+ )
90
+ if int(self.n_flies) < 1:
91
+ raise ValueError("n_flies must be >= 1")
92
+ if int(self.epochs_per_fly) < 1:
93
+ raise ValueError("epochs_per_fly must be >= 1")
94
+ if int(self.fly_steps) < 1:
95
+ raise ValueError("fly_steps must be >= 1")
96
+ if int(self.batch_size) < 1:
97
+ raise ValueError("batch_size must be >= 1")
98
+ if float(self.learning_rate) <= 0:
99
+ raise ValueError("learning_rate must be > 0")
100
+ if float(self.fly_lr) <= 0:
101
+ raise ValueError("fly_lr must be > 0")
102
+ if not (0 < float(self.leak) <= 1):
103
+ raise ValueError("leak must be in (0, 1]")
104
+
105
+ @property
106
+ def _seed(self) -> int:
107
+ return 0 if self.random_state is None else int(self.random_state)
108
+
109
+ def _graph(self) -> FlyGraph:
110
+ path = str(Path(self.graph_path).expanduser().resolve())
111
+ if not Path(path).exists():
112
+ raise FileNotFoundError(
113
+ f"FlyBoost graph not found: {path}. "
114
+ "Pass graph_path=... pointing to malecns_nomotor.pt."
115
+ )
116
+ return load_graph_cached(path)
117
+
118
+ def _new_fly(self, graph: FlyGraph, stage: int):
119
+ fly_cls = FLY_MODES[self.fly_mode]
120
+ kwargs = dict(
121
+ graph=graph,
122
+ n_features=self.n_features_in_,
123
+ steps=self.fly_steps,
124
+ leak=self.leak,
125
+ max_log_gain=self.max_log_gain,
126
+ seed=self._seed + int(stage),
127
+ device=self.device_,
128
+ verbose=self.verbose,
129
+ )
130
+ if self.fly_mode == "shuffled":
131
+ kwargs["shuffle_seed"] = self.shuffle_seed
132
+
133
+ # nn.Linear initializes from PyTorch's RNG. Fork/restore the RNG so
134
+ # random_state makes repeated fits deterministic without perturbing
135
+ # the caller's global RNG state.
136
+ init_seed = self._seed + 20000 + int(stage)
137
+ cuda_devices = []
138
+ if str(self.device_).startswith("cuda") and torch.cuda.is_available():
139
+ dev = torch.device(self.device_)
140
+ cuda_devices = [dev.index if dev.index is not None else torch.cuda.current_device()]
141
+ with torch.random.fork_rng(devices=cuda_devices):
142
+ torch.manual_seed(init_seed)
143
+ if cuda_devices:
144
+ torch.cuda.manual_seed_all(init_seed)
145
+ return fly_cls(**kwargs)
146
+
147
+ def _make_optimizer(self, fly) -> torch.optim.Optimizer:
148
+ # Only genuinely trainable tensors enter the optimizer. This makes
149
+ # frozen mode readout-only by construction.
150
+ params = [p for p in fly.parameters() if p.requires_grad]
151
+ return torch.optim.Adam([
152
+ {
153
+ "params": [
154
+ fly.pre_theta,
155
+ fly.post_theta,
156
+ fly.sensor_theta,
157
+ ],
158
+ "lr": self.fly_gain_lr,
159
+ },
160
+ {
161
+ "params": fly.readout.parameters(),
162
+ "lr": self.fly_lr,
163
+ },
164
+ ])
165
+
166
+ def _batched_predict(self, fly, x: torch.Tensor) -> torch.Tensor:
167
+ out = []
168
+ with torch.no_grad():
169
+ for start in range(0, len(x), int(self.batch_size)):
170
+ xb = x[start : start + int(self.batch_size)].to(self.device_)
171
+ out.append(fly(xb).cpu())
172
+ return torch.cat(out) if out else torch.empty(0, dtype=torch.float32)
173
+
174
+ def _fit_fly_to_target(
175
+ self,
176
+ fly,
177
+ x: torch.Tensor,
178
+ target: torch.Tensor,
179
+ weight: torch.Tensor,
180
+ stage: int,
181
+ ) -> None:
182
+ opt = self._make_optimizer(fly)
183
+ gen = torch.Generator(device="cpu")
184
+ gen.manual_seed(self._seed + 10000 + int(stage))
185
+
186
+ for epoch in range(int(self.epochs_per_fly)):
187
+ perm = torch.randperm(len(x), generator=gen)
188
+ weighted_loss_sum = 0.0
189
+ seen_weight = 0.0
190
+
191
+ for start in range(0, len(x), int(self.batch_size)):
192
+ idx = perm[start : start + int(self.batch_size)]
193
+ xb = x[idx].to(self.device_)
194
+ tb = target[idx].to(self.device_)
195
+ wb = weight[idx].to(self.device_)
196
+
197
+ opt.zero_grad(set_to_none=True)
198
+ pred = fly(xb)
199
+ denom = wb.sum().clamp_min(1e-8)
200
+ loss = (wb * (pred - tb).square()).sum() / denom
201
+ loss.backward()
202
+ torch.nn.utils.clip_grad_norm_(
203
+ [p for p in fly.parameters() if p.requires_grad], 5.0
204
+ )
205
+ opt.step()
206
+
207
+ batch_weight = float(wb.sum().detach().cpu())
208
+ weighted_loss_sum += float(loss.detach().cpu()) * batch_weight
209
+ seen_weight += batch_weight
210
+
211
+ if int(self.verbose) >= 2:
212
+ mean_loss = weighted_loss_sum / max(seen_weight, 1e-12)
213
+ print(
214
+ f" fly {stage + 1}/{self.n_flies} "
215
+ f"epoch {epoch + 1}/{self.epochs_per_fly} "
216
+ f"weighted_mse={mean_loss:.5f}"
217
+ )
218
+
219
+ @staticmethod
220
+ def _freeze_and_save(fly) -> BoostStage:
221
+ fly.eval()
222
+ for p in fly.parameters():
223
+ p.requires_grad_(False)
224
+ state = {
225
+ k: v.detach().cpu().clone()
226
+ for k, v in fly.state_dict().items()
227
+ }
228
+ return BoostStage(state)
229
+
230
+ def _predict_raw_tensor(self, X) -> torch.Tensor:
231
+ check_is_fitted(self, attributes=["stages_", "n_features_in_"])
232
+ Xv = validate_data(
233
+ self, X, reset=False, dtype=np.float32, ensure_2d=True
234
+ )
235
+ x = _as_float_tensor(Xv)
236
+ graph = self._graph()
237
+ raw = torch.full(
238
+ (len(x),), float(self.base_score_), dtype=torch.float32
239
+ )
240
+ for stage, saved in enumerate(self.stages_):
241
+ fly = self._new_fly(graph, stage)
242
+ fly.load_state_dict(saved.state_dict, strict=True)
243
+ fly.eval()
244
+ raw += float(self.learning_rate) * self._batched_predict(fly, x)
245
+ del fly
246
+ return raw
247
+
248
+ def evals_result(self) -> dict:
249
+ check_is_fitted(self, attributes=["evals_result_"])
250
+ return copy.deepcopy(self.evals_result_)
251
+
252
+
253
+ class FlyBoostClassifier(ClassifierMixin, _FlyBoostBase):
254
+ """
255
+ Binary classifier using stage-wise Fly connectome weak learners.
256
+
257
+ Parameters
258
+ ----------
259
+ fly_mode : {"real", "shuffled", "frozen"}, default="real"
260
+ Selects the biological-connectome model, degree-preserving shuffled
261
+ control, or frozen-connectome/readout-only control.
262
+
263
+ Notes
264
+ -----
265
+ This first packaged version intentionally supports binary classification.
266
+ """
267
+
268
+ def __sklearn_tags__(self):
269
+ tags = super().__sklearn_tags__()
270
+ if tags.classifier_tags is not None:
271
+ tags.classifier_tags.multi_class = False
272
+ tags.classifier_tags.poor_score = True
273
+ return tags
274
+
275
+ def fit(self, X, y, eval_set=None):
276
+ self._validate_hyperparameters()
277
+ Xv, yv = validate_data(
278
+ self,
279
+ X,
280
+ y,
281
+ reset=True,
282
+ dtype=np.float32,
283
+ ensure_2d=True,
284
+ )
285
+ check_classification_targets(yv)
286
+ y_type = type_of_target(yv, input_name="y", raise_unknown=True)
287
+ if y_type != "binary":
288
+ raise ValueError(
289
+ "Only binary classification is supported. "
290
+ f"The type of the target is {y_type}."
291
+ )
292
+ classes = np.unique(yv)
293
+ if len(classes) != 2:
294
+ raise ValueError(
295
+ "Only binary classification with exactly two classes is supported; "
296
+ f"got {len(classes)} class" + ("es" if len(classes) != 1 else "") + "."
297
+ )
298
+ self.classes_ = classes
299
+ y01 = (np.asarray(yv) == classes[1]).astype(np.float32)
300
+
301
+ self.device_ = _resolve_device(self.device)
302
+ x = _as_float_tensor(Xv)
303
+ y_t = torch.from_numpy(y01)
304
+ sw = torch.ones(len(x), dtype=torch.float32)
305
+
306
+ graph = self._graph()
307
+ self.stages_ = []
308
+ self.evals_result_ = {"train": {"logloss": [], "accuracy": []}}
309
+
310
+ weighted_pos = (sw * y_t).sum() / sw.sum().clamp_min(1e-8)
311
+ weighted_pos = weighted_pos.clamp(1e-4, 1 - 1e-4)
312
+ self.base_score_ = float(torch.log(weighted_pos / (1 - weighted_pos)))
313
+ ensemble = torch.full_like(y_t, self.base_score_)
314
+
315
+ eval_data = self._prepare_classifier_eval_set(eval_set)
316
+ eval_ensembles = [
317
+ torch.full((len(ex),), self.base_score_, dtype=torch.float32)
318
+ for ex, _ in eval_data
319
+ ]
320
+ for i in range(len(eval_data)):
321
+ self.evals_result_[f"validation_{i}"] = {
322
+ "logloss": [],
323
+ "accuracy": [],
324
+ }
325
+
326
+ for stage in range(int(self.n_flies)):
327
+ p = torch.sigmoid(ensemble)
328
+ g = p - y_t
329
+ h = (p * (1 - p)).clamp_min(1e-3)
330
+ z = (-g / h).clamp(-8.0, 8.0)
331
+ fit_weight = h * sw
332
+
333
+ fly = self._new_fly(graph, stage)
334
+ self._fit_fly_to_target(fly, x, z, fit_weight, stage)
335
+
336
+ ensemble += float(self.learning_rate) * self._batched_predict(fly, x)
337
+ train_logloss = float(
338
+ torch.nn.functional.binary_cross_entropy_with_logits(
339
+ ensemble, y_t, weight=sw, reduction="sum"
340
+ )
341
+ / sw.sum().clamp_min(1e-8)
342
+ )
343
+ train_acc = float(((ensemble > 0).float() == y_t).float().mean())
344
+ self.evals_result_["train"]["logloss"].append(train_logloss)
345
+ self.evals_result_["train"]["accuracy"].append(train_acc)
346
+
347
+ msg = (
348
+ f"[stage {stage + 1}] train_logloss={train_logloss:.5f} "
349
+ f"train_acc={train_acc:.3f}"
350
+ )
351
+
352
+ for i, (ex, ey) in enumerate(eval_data):
353
+ eval_ensembles[i] += float(self.learning_rate) * self._batched_predict(
354
+ fly, ex
355
+ )
356
+ logits = eval_ensembles[i]
357
+ val_logloss = float(
358
+ torch.nn.functional.binary_cross_entropy_with_logits(logits, ey)
359
+ )
360
+ val_acc = float(((logits > 0).float() == ey).float().mean())
361
+ key = f"validation_{i}"
362
+ self.evals_result_[key]["logloss"].append(val_logloss)
363
+ self.evals_result_[key]["accuracy"].append(val_acc)
364
+ msg += (
365
+ f" {key}_logloss={val_logloss:.5f} "
366
+ f"{key}_acc={val_acc:.3f}"
367
+ )
368
+
369
+ if int(self.verbose) >= 1:
370
+ print(msg)
371
+
372
+ self.stages_.append(self._freeze_and_save(fly))
373
+ del fly
374
+
375
+ return self
376
+
377
+ def _prepare_classifier_eval_set(self, eval_set):
378
+ if eval_set is None:
379
+ return []
380
+ out = []
381
+ for X_eval, y_eval in eval_set:
382
+ Xe = validate_data(
383
+ self, X_eval, reset=False, dtype=np.float32, ensure_2d=True
384
+ )
385
+ ye = np.asarray(y_eval)
386
+ unknown = np.setdiff1d(np.unique(ye), self.classes_)
387
+ if len(unknown):
388
+ raise ValueError(
389
+ f"eval_set contains labels not seen in fit(): {unknown.tolist()}"
390
+ )
391
+ y01 = (ye == self.classes_[1]).astype(np.float32)
392
+ if len(Xe) != len(y01):
393
+ raise ValueError("X_eval and y_eval have inconsistent lengths")
394
+ out.append((_as_float_tensor(Xe), torch.from_numpy(y01)))
395
+ return out
396
+
397
+ def decision_function(self, X) -> np.ndarray:
398
+ return self._predict_raw_tensor(X).numpy()
399
+
400
+ def predict_proba(self, X) -> np.ndarray:
401
+ logits = self._predict_raw_tensor(X)
402
+ p1 = torch.sigmoid(logits).numpy()
403
+ return np.column_stack([1.0 - p1, p1])
404
+
405
+ def predict(self, X) -> np.ndarray:
406
+ logits = self._predict_raw_tensor(X).numpy()
407
+ return self.classes_[(logits > 0).astype(np.int64)]
408
+
409
+
410
+ class FlyBoostRegressor(RegressorMixin, _FlyBoostBase):
411
+ """Single-output squared-error regressor using Fly connectome weak learners."""
412
+
413
+ def __sklearn_tags__(self):
414
+ tags = super().__sklearn_tags__()
415
+ if tags.regressor_tags is not None:
416
+ tags.regressor_tags.poor_score = True
417
+ return tags
418
+
419
+ def fit(self, X, y, eval_set=None):
420
+ self._validate_hyperparameters()
421
+ Xv, yv = validate_data(
422
+ self,
423
+ X,
424
+ y,
425
+ reset=True,
426
+ dtype=np.float32,
427
+ ensure_2d=True,
428
+ y_numeric=True,
429
+ )
430
+ y_arr = np.asarray(yv, dtype=np.float32)
431
+ if y_arr.ndim != 1:
432
+ raise ValueError("FlyBoostRegressor supports single-output regression only")
433
+
434
+ self.device_ = _resolve_device(self.device)
435
+ x = _as_float_tensor(Xv)
436
+ y_t = torch.from_numpy(y_arr)
437
+ sw = torch.ones(len(x), dtype=torch.float32)
438
+
439
+ graph = self._graph()
440
+ self.stages_ = []
441
+ self.evals_result_ = {"train": {"rmse": [], "mae": []}}
442
+
443
+ self.base_score_ = float((sw * y_t).sum() / sw.sum().clamp_min(1e-8))
444
+ ensemble = torch.full_like(y_t, self.base_score_)
445
+
446
+ eval_data = self._prepare_regression_eval_set(eval_set)
447
+ eval_ensembles = [
448
+ torch.full((len(ex),), self.base_score_, dtype=torch.float32)
449
+ for ex, _ in eval_data
450
+ ]
451
+ for i in range(len(eval_data)):
452
+ self.evals_result_[f"validation_{i}"] = {"rmse": [], "mae": []}
453
+
454
+ for stage in range(int(self.n_flies)):
455
+ residual = y_t - ensemble
456
+ fly = self._new_fly(graph, stage)
457
+ self._fit_fly_to_target(fly, x, residual, sw, stage)
458
+
459
+ ensemble += float(self.learning_rate) * self._batched_predict(fly, x)
460
+ err = ensemble - y_t
461
+ train_rmse = float(torch.sqrt((sw * err.square()).sum() / sw.sum()))
462
+ train_mae = float((sw * err.abs()).sum() / sw.sum())
463
+ self.evals_result_["train"]["rmse"].append(train_rmse)
464
+ self.evals_result_["train"]["mae"].append(train_mae)
465
+
466
+ msg = (
467
+ f"[stage {stage + 1}] train_rmse={train_rmse:.5f} "
468
+ f"train_mae={train_mae:.5f}"
469
+ )
470
+
471
+ for i, (ex, ey) in enumerate(eval_data):
472
+ eval_ensembles[i] += float(self.learning_rate) * self._batched_predict(
473
+ fly, ex
474
+ )
475
+ err_eval = eval_ensembles[i] - ey
476
+ rmse = float(torch.sqrt(torch.mean(err_eval.square())))
477
+ mae = float(torch.mean(err_eval.abs()))
478
+ key = f"validation_{i}"
479
+ self.evals_result_[key]["rmse"].append(rmse)
480
+ self.evals_result_[key]["mae"].append(mae)
481
+ msg += f" {key}_rmse={rmse:.5f} {key}_mae={mae:.5f}"
482
+
483
+ if int(self.verbose) >= 1:
484
+ print(msg)
485
+
486
+ self.stages_.append(self._freeze_and_save(fly))
487
+ del fly
488
+
489
+ return self
490
+
491
+ def _prepare_regression_eval_set(self, eval_set):
492
+ if eval_set is None:
493
+ return []
494
+ out = []
495
+ for X_eval, y_eval in eval_set:
496
+ Xe = validate_data(
497
+ self, X_eval, reset=False, dtype=np.float32, ensure_2d=True
498
+ )
499
+ ye = np.asarray(y_eval, dtype=np.float32)
500
+ if ye.ndim != 1:
501
+ raise ValueError("Each regression y_eval must be 1-dimensional")
502
+ if len(Xe) != len(ye):
503
+ raise ValueError("X_eval and y_eval have inconsistent lengths")
504
+ out.append((_as_float_tensor(Xe), torch.from_numpy(ye)))
505
+ return out
506
+
507
+ def predict(self, X) -> np.ndarray:
508
+ return self._predict_raw_tensor(X).numpy()
@@ -0,0 +1,225 @@
1
+ from __future__ import annotations
2
+
3
+ import math
4
+
5
+ import torch
6
+ from torch import nn
7
+
8
+ from .graph import FlyGraph
9
+
10
+
11
+ class TrainableFly(nn.Module):
12
+ def __init__(
13
+ self,
14
+ graph: FlyGraph,
15
+ n_features: int,
16
+ steps: int = 4,
17
+ leak: float = 0.5,
18
+ max_log_gain: float = math.log(2.0),
19
+ seed: int = 0,
20
+ device: str = "cpu",
21
+ verbose: int = 0,
22
+ ):
23
+ super().__init__()
24
+ self.n = graph.num_nodes
25
+ self.n_features = int(n_features)
26
+ self.steps = int(steps)
27
+ self.leak = float(leak)
28
+ self.max_log_gain = float(max_log_gain)
29
+ self.device_name = device
30
+ self.verbose = int(verbose)
31
+
32
+ W = torch.sparse_coo_tensor(
33
+ graph.edge_index.to(device),
34
+ graph.base_weight.to(device),
35
+ size=(self.n, self.n),
36
+ dtype=torch.float32,
37
+ device=device,
38
+ ).coalesce()
39
+ W.requires_grad_(False)
40
+
41
+ # Large fixed graph tensors are deliberately excluded from state_dict.
42
+ self.register_buffer("W", W, persistent=False)
43
+ self.register_buffer("sensory_idx", graph.sensory_idx.to(device), persistent=False)
44
+ self.register_buffer("readout_idx", graph.readout_idx.to(device), persistent=False)
45
+
46
+ gen = torch.Generator(device="cpu")
47
+ gen.manual_seed(int(seed))
48
+ perm = graph.sensory_idx[
49
+ torch.randperm(len(graph.sensory_idx), generator=gen)
50
+ ]
51
+ feat = torch.arange(len(perm), dtype=torch.long) % self.n_features
52
+ inv = torch.full((graph.num_nodes,), -1, dtype=torch.long)
53
+ inv[perm] = feat
54
+ sensory_feature = inv[graph.sensory_idx]
55
+ self.register_buffer(
56
+ "sensory_feature", sensory_feature.to(device), persistent=False
57
+ )
58
+
59
+ self.pre_theta = nn.Parameter(torch.zeros(self.n, device=device))
60
+ self.post_theta = nn.Parameter(torch.zeros(self.n, device=device))
61
+ self.sensor_theta = nn.Parameter(
62
+ torch.zeros(len(graph.sensory_idx), device=device)
63
+ )
64
+ self.readout = nn.Linear(
65
+ len(graph.readout_idx), 1, bias=True, device=device
66
+ )
67
+
68
+ def _gain(self, theta: torch.Tensor) -> torch.Tensor:
69
+ return torch.exp(self.max_log_gain * torch.tanh(theta))
70
+
71
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
72
+ x = x.to(self.pre_theta.device, dtype=torch.float32)
73
+ b = x.shape[0]
74
+ h = torch.zeros((b, self.n), dtype=torch.float32, device=x.device)
75
+
76
+ drive = torch.zeros_like(h)
77
+ drive[:, self.sensory_idx] = (
78
+ x[:, self.sensory_feature] * self._gain(self.sensor_theta)
79
+ )
80
+
81
+ pre_gain = self._gain(self.pre_theta)
82
+ post_gain = self._gain(self.post_theta)
83
+
84
+ for _ in range(self.steps):
85
+ h_pre = h * pre_gain
86
+ recurrent = torch.sparse.mm(self.W, h_pre.T).T
87
+ recurrent = recurrent * post_gain
88
+ proposal = torch.tanh(recurrent + drive)
89
+ h = (1.0 - self.leak) * h + self.leak * proposal
90
+
91
+ return self.readout(h[:, self.readout_idx]).squeeze(-1)
92
+
93
+
94
+ class ShuffledTrainableFly(TrainableFly):
95
+ """
96
+ Null-model fly.
97
+
98
+ Preserves the neuron populations, edge count, exact source out-degrees,
99
+ exact destination in-degrees, and source-associated signed weights while
100
+ destroying the biological pre -> post pairing.
101
+ """
102
+
103
+ _cached_graph: FlyGraph | None = None
104
+ _cached_source_id: int | None = None
105
+ _cached_shuffle_seed: int | None = None
106
+
107
+ def __init__(
108
+ self,
109
+ graph: FlyGraph,
110
+ n_features: int,
111
+ steps: int = 4,
112
+ leak: float = 0.5,
113
+ max_log_gain: float = math.log(2.0),
114
+ seed: int = 0,
115
+ device: str = "cpu",
116
+ verbose: int = 0,
117
+ shuffle_seed: int = 20260915,
118
+ ):
119
+ cls = self.__class__
120
+ if (
121
+ cls._cached_graph is None
122
+ or cls._cached_source_id != id(graph)
123
+ or cls._cached_shuffle_seed != int(shuffle_seed)
124
+ ):
125
+ if verbose >= 2:
126
+ print("[ShuffledTrainableFly] building shuffled connectome...")
127
+
128
+ edge_index = graph.edge_index.cpu()
129
+ base_weight = graph.base_weight.cpu()
130
+
131
+ # prepare_malecns.py convention:
132
+ # edge_index[0] = post, edge_index[1] = pre
133
+ post = edge_index[0]
134
+ pre = edge_index[1]
135
+ E = post.numel()
136
+
137
+ gen = torch.Generator(device="cpu")
138
+ gen.manual_seed(int(shuffle_seed))
139
+ perm = torch.randperm(E, generator=gen)
140
+ shuffled_post = post[perm]
141
+ shuffled_weight = base_weight.clone()
142
+
143
+ incoming_abs = torch.bincount(
144
+ shuffled_post,
145
+ weights=shuffled_weight.abs(),
146
+ minlength=graph.num_nodes,
147
+ )
148
+ shuffled_weight = (
149
+ shuffled_weight
150
+ / incoming_abs[shuffled_post].clamp_min(1e-12)
151
+ )
152
+
153
+ shuffled_edge_index = torch.stack([shuffled_post, pre], dim=0)
154
+ cls._cached_graph = FlyGraph(
155
+ num_nodes=graph.num_nodes,
156
+ edge_index=shuffled_edge_index,
157
+ base_weight=shuffled_weight,
158
+ sensory_idx=graph.sensory_idx,
159
+ readout_idx=graph.readout_idx,
160
+ )
161
+ cls._cached_source_id = id(graph)
162
+ cls._cached_shuffle_seed = int(shuffle_seed)
163
+
164
+ if verbose >= 2:
165
+ print(
166
+ f"[ShuffledTrainableFly] done: "
167
+ f"{graph.num_nodes:,} neurons, {E:,} shuffled edges"
168
+ )
169
+
170
+ super().__init__(
171
+ graph=cls._cached_graph,
172
+ n_features=n_features,
173
+ steps=steps,
174
+ leak=leak,
175
+ max_log_gain=max_log_gain,
176
+ seed=seed,
177
+ device=device,
178
+ verbose=verbose,
179
+ )
180
+
181
+
182
+ class FrozenTrainableFly(TrainableFly):
183
+ """Real connectome with all neuron/sensory gains frozen; readout only trains."""
184
+
185
+ def __init__(
186
+ self,
187
+ graph: FlyGraph,
188
+ n_features: int,
189
+ steps: int = 4,
190
+ leak: float = 0.5,
191
+ max_log_gain: float = math.log(2.0),
192
+ seed: int = 0,
193
+ device: str = "cpu",
194
+ verbose: int = 0,
195
+ ):
196
+ super().__init__(
197
+ graph=graph,
198
+ n_features=n_features,
199
+ steps=steps,
200
+ leak=leak,
201
+ max_log_gain=max_log_gain,
202
+ seed=seed,
203
+ device=device,
204
+ verbose=verbose,
205
+ )
206
+
207
+ self.pre_theta.requires_grad_(False)
208
+ self.post_theta.requires_grad_(False)
209
+ self.sensor_theta.requires_grad_(False)
210
+
211
+ if verbose >= 2:
212
+ n_trainable = sum(
213
+ p.numel() for p in self.parameters() if p.requires_grad
214
+ )
215
+ print(
216
+ f"[FrozenTrainableFly] trainable params = "
217
+ f"{n_trainable:,} (readout only)"
218
+ )
219
+
220
+
221
+ FLY_MODES = {
222
+ "real": TrainableFly,
223
+ "shuffled": ShuffledTrainableFly,
224
+ "frozen": FrozenTrainableFly,
225
+ }
@@ -0,0 +1,34 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from functools import lru_cache
5
+ from pathlib import Path
6
+
7
+ import torch
8
+
9
+
10
+ @dataclass
11
+ class FlyGraph:
12
+ num_nodes: int
13
+ edge_index: torch.Tensor
14
+ base_weight: torch.Tensor
15
+ sensory_idx: torch.Tensor
16
+ readout_idx: torch.Tensor
17
+
18
+ @classmethod
19
+ def load(cls, path: str | Path) -> "FlyGraph":
20
+ d = torch.load(path, map_location="cpu", weights_only=False)
21
+ return cls(
22
+ int(d["num_nodes"]),
23
+ d["edge_index"].long(),
24
+ d["base_weight"].float(),
25
+ d["sensory_idx"].long(),
26
+ d["readout_idx"].long(),
27
+ )
28
+
29
+
30
+ @lru_cache(maxsize=4)
31
+ def load_graph_cached(path: str) -> FlyGraph:
32
+ """Load a graph once per absolute path inside the current Python process."""
33
+ resolved = str(Path(path).expanduser().resolve())
34
+ return FlyGraph.load(resolved)
@@ -0,0 +1,145 @@
1
+ Metadata-Version: 2.4
2
+ Name: flyboost-connectome
3
+ Version: 0.1.0
4
+ Summary: Scikit-learn compatible boosting estimators built on a Drosophila connectome reservoir
5
+ Author: FlyBoost contributors
6
+ Requires-Python: >=3.10
7
+ Description-Content-Type: text/markdown
8
+ Requires-Dist: numpy>=1.26
9
+ Requires-Dist: scikit-learn>=1.6
10
+ Requires-Dist: torch>=2.4
11
+ Provides-Extra: dev
12
+ Requires-Dist: pytest>=8; extra == "dev"
13
+ Requires-Dist: build>=1.2; extra == "dev"
14
+
15
+ # FlyBoost
16
+
17
+ A small scikit-learn-compatible wrapper around the FlyBoost prototype from the
18
+ research notebook. The connectome tensor file is **not** bundled; point
19
+ `graph_path` at your existing `malecns_nomotor.pt`.
20
+
21
+ ## Install
22
+
23
+ From the project directory:
24
+
25
+ ```bash
26
+ pip install .
27
+ ```
28
+
29
+ Editable development install:
30
+
31
+ ```bash
32
+ pip install -e .
33
+ ```
34
+
35
+ You can also install the source archive directly:
36
+
37
+ ```bash
38
+ pip install flyboost-connectome-0.1.0.tar.gz
39
+ ```
40
+
41
+ ## Binary classification
42
+
43
+ ```python
44
+ from flyboost import FlyBoostClassifier
45
+
46
+ model = FlyBoostClassifier(
47
+ graph_path="data/malecns_nomotor.pt",
48
+ n_flies=8,
49
+ epochs_per_fly=8,
50
+ fly_steps=8,
51
+ batch_size=32,
52
+ fly_lr=2e-3,
53
+ fly_mode="real", # "real" | "shuffled" | "frozen"
54
+ random_state=42,
55
+ verbose=1,
56
+ )
57
+
58
+ model.fit(
59
+ X_train,
60
+ y_train,
61
+ eval_set=[(X_val, y_val)],
62
+ )
63
+
64
+ pred = model.predict(X_val)
65
+ proba = model.predict_proba(X_val)
66
+ print(model.score(X_val, y_val))
67
+ print(model.evals_result())
68
+ ```
69
+
70
+ Switching controls requires only one parameter:
71
+
72
+ ```python
73
+ real = FlyBoostClassifier(fly_mode="real", ...)
74
+ shuffled = FlyBoostClassifier(fly_mode="shuffled", ...)
75
+ frozen = FlyBoostClassifier(fly_mode="frozen", ...)
76
+ ```
77
+
78
+ All boosted shuffled flies share one shuffled topology for the same graph and
79
+ `shuffle_seed`; their sensory mappings still differ by stage seed.
80
+
81
+ ## Regression
82
+
83
+ ```python
84
+ from flyboost import FlyBoostRegressor
85
+
86
+ reg = FlyBoostRegressor(
87
+ graph_path="data/malecns_nomotor.pt",
88
+ n_flies=8,
89
+ epochs_per_fly=8,
90
+ fly_mode="real",
91
+ verbose=1,
92
+ )
93
+ reg.fit(X_train, y_train, eval_set=[(X_val, y_val)])
94
+ yhat = reg.predict(X_val)
95
+ print(reg.score(X_val, y_val)) # sklearn R^2
96
+ ```
97
+
98
+ ## sklearn ecosystem
99
+
100
+ Because the estimators inherit from `BaseEstimator` and the appropriate mixin,
101
+ normal sklearn parameter plumbing works:
102
+
103
+ ```python
104
+ from sklearn.base import clone
105
+ from sklearn.pipeline import make_pipeline
106
+ from sklearn.preprocessing import StandardScaler
107
+ from sklearn.model_selection import GridSearchCV
108
+
109
+ base = FlyBoostClassifier(graph_path="data/malecns_nomotor.pt")
110
+ clone(base)
111
+
112
+ pipe = make_pipeline(StandardScaler(), base)
113
+
114
+ search = GridSearchCV(
115
+ base,
116
+ {
117
+ "fly_mode": ["real", "shuffled", "frozen"],
118
+ "n_flies": [1, 8, 64],
119
+ "epochs_per_fly": [1, 8, 64],
120
+ },
121
+ cv=3,
122
+ )
123
+ ```
124
+
125
+ For the compute-matched control experiments, set only:
126
+
127
+ ```python
128
+ FlyBoostClassifier(n_flies=1, epochs_per_fly=64, ...)
129
+ FlyBoostClassifier(n_flies=8, epochs_per_fly=8, ...)
130
+ FlyBoostClassifier(n_flies=64, epochs_per_fly=1, ...)
131
+ ```
132
+
133
+ ## Verbosity
134
+
135
+ - `verbose=0`: no training prints
136
+ - `verbose=1`: stage metrics
137
+ - `verbose>=2`: per-fly epoch metrics plus control-construction diagnostics
138
+
139
+ ## Current scope
140
+
141
+ - classifier: binary targets
142
+ - regressor: single-output targets
143
+ - `eval_set=[(X_val, y_val), ...]` supported
144
+ - `evals_result()` stores stage-wise metrics
145
+ - graph file stays external to keep the package lightweight
@@ -0,0 +1,12 @@
1
+ README.md
2
+ pyproject.toml
3
+ src/flyboost/__init__.py
4
+ src/flyboost/estimators.py
5
+ src/flyboost/fly.py
6
+ src/flyboost/graph.py
7
+ src/flyboost_connectome.egg-info/PKG-INFO
8
+ src/flyboost_connectome.egg-info/SOURCES.txt
9
+ src/flyboost_connectome.egg-info/dependency_links.txt
10
+ src/flyboost_connectome.egg-info/requires.txt
11
+ src/flyboost_connectome.egg-info/top_level.txt
12
+ tests/test_estimators.py
@@ -0,0 +1,7 @@
1
+ numpy>=1.26
2
+ scikit-learn>=1.6
3
+ torch>=2.4
4
+
5
+ [dev]
6
+ pytest>=8
7
+ build>=1.2
@@ -0,0 +1,99 @@
1
+ import numpy as np
2
+ import torch
3
+ from sklearn.base import clone
4
+ from sklearn.pipeline import make_pipeline
5
+ from sklearn.preprocessing import StandardScaler
6
+
7
+ from flyboost import FlyBoostClassifier, FlyBoostRegressor
8
+
9
+
10
+ def _write_toy_graph(path):
11
+ # Small directed graph in the same [post, pre] format as MaleCNS.
12
+ n = 12
13
+ pre = torch.tensor([0,1,2,3,4,5,6,7,8,9,10,11,0,2,4,6,8,10])
14
+ post = torch.tensor([1,2,3,4,5,6,7,8,9,10,11,0,6,7,8,9,10,11])
15
+ w = torch.tensor([
16
+ 0.5,0.4,0.3,0.4,0.5,0.3,0.4,0.5,0.3,0.4,0.5,0.3,
17
+ -0.2,0.2,-0.2,0.2,-0.2,0.2,
18
+ ], dtype=torch.float32)
19
+ torch.save({
20
+ "num_nodes": n,
21
+ "edge_index": torch.stack([post, pre]),
22
+ "base_weight": w,
23
+ "sensory_idx": torch.tensor([0,1,2,3,4,5]),
24
+ "readout_idx": torch.tensor([8,9,10,11]),
25
+ }, path)
26
+
27
+
28
+ def test_classifier_modes_and_sklearn_plumbing(tmp_path):
29
+ graph = tmp_path / "toy.pt"
30
+ _write_toy_graph(graph)
31
+ rng = np.random.default_rng(0)
32
+ X = rng.normal(size=(32, 4)).astype(np.float32)
33
+ y = (X[:, 0] + 0.5 * X[:, 1] > 0).astype(int)
34
+
35
+ for mode in ["real", "shuffled", "frozen"]:
36
+ clf = FlyBoostClassifier(
37
+ graph_path=str(graph),
38
+ n_flies=2,
39
+ fly_steps=2,
40
+ epochs_per_fly=1,
41
+ batch_size=8,
42
+ fly_lr=1e-2,
43
+ fly_mode=mode,
44
+ random_state=1,
45
+ verbose=0,
46
+ )
47
+ clone(clf)
48
+ clf.fit(X, y, eval_set=[(X, y)])
49
+ pred = clf.predict(X)
50
+ proba = clf.predict_proba(X)
51
+ assert pred.shape == (len(X),)
52
+ assert proba.shape == (len(X), 2)
53
+ assert np.allclose(proba.sum(axis=1), 1, atol=1e-5)
54
+ assert len(clf.evals_result()["validation_0"]["logloss"]) == 2
55
+
56
+
57
+ def test_pipeline(tmp_path):
58
+ graph = tmp_path / "toy.pt"
59
+ _write_toy_graph(graph)
60
+ rng = np.random.default_rng(1)
61
+ X = rng.normal(size=(24, 3)).astype(np.float32)
62
+ y = (X[:, 0] > 0).astype(int)
63
+ pipe = make_pipeline(
64
+ StandardScaler(),
65
+ FlyBoostClassifier(
66
+ graph_path=str(graph),
67
+ n_flies=1,
68
+ fly_steps=1,
69
+ epochs_per_fly=1,
70
+ batch_size=8,
71
+ fly_lr=1e-2,
72
+ verbose=0,
73
+ ),
74
+ )
75
+ pipe.fit(X, y)
76
+ assert pipe.predict(X).shape == (len(X),)
77
+
78
+
79
+ def test_regressor(tmp_path):
80
+ graph = tmp_path / "toy.pt"
81
+ _write_toy_graph(graph)
82
+ rng = np.random.default_rng(2)
83
+ X = rng.normal(size=(32, 4)).astype(np.float32)
84
+ y = (2 * X[:, 0] - X[:, 1] + 0.1 * rng.normal(size=32)).astype(np.float32)
85
+ reg = FlyBoostRegressor(
86
+ graph_path=str(graph),
87
+ n_flies=2,
88
+ fly_steps=2,
89
+ epochs_per_fly=1,
90
+ batch_size=8,
91
+ fly_lr=1e-2,
92
+ fly_mode="real",
93
+ verbose=0,
94
+ )
95
+ clone(reg)
96
+ reg.fit(X, y, eval_set=[(X, y)])
97
+ pred = reg.predict(X)
98
+ assert pred.shape == (len(X),)
99
+ assert len(reg.evals_result()["validation_0"]["rmse"]) == 2