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.
- flyboost_connectome-0.1.0/PKG-INFO +145 -0
- flyboost_connectome-0.1.0/README.md +131 -0
- flyboost_connectome-0.1.0/pyproject.toml +25 -0
- flyboost_connectome-0.1.0/setup.cfg +4 -0
- flyboost_connectome-0.1.0/src/flyboost/__init__.py +14 -0
- flyboost_connectome-0.1.0/src/flyboost/estimators.py +508 -0
- flyboost_connectome-0.1.0/src/flyboost/fly.py +225 -0
- flyboost_connectome-0.1.0/src/flyboost/graph.py +34 -0
- flyboost_connectome-0.1.0/src/flyboost_connectome.egg-info/PKG-INFO +145 -0
- flyboost_connectome-0.1.0/src/flyboost_connectome.egg-info/SOURCES.txt +12 -0
- flyboost_connectome-0.1.0/src/flyboost_connectome.egg-info/dependency_links.txt +1 -0
- flyboost_connectome-0.1.0/src/flyboost_connectome.egg-info/requires.txt +7 -0
- flyboost_connectome-0.1.0/src/flyboost_connectome.egg-info/top_level.txt +1 -0
- flyboost_connectome-0.1.0/tests/test_estimators.py +99 -0
|
@@ -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,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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
flyboost
|
|
@@ -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
|