flywire-gnn 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.
- flywire_gnn-0.1.0/PKG-INFO +114 -0
- flywire_gnn-0.1.0/README.md +92 -0
- flywire_gnn-0.1.0/flywire_gnn/__init__.py +6 -0
- flywire_gnn-0.1.0/flywire_gnn/dataset.py +441 -0
- flywire_gnn-0.1.0/flywire_gnn/models.py +57 -0
- flywire_gnn-0.1.0/flywire_gnn/train.py +149 -0
- flywire_gnn-0.1.0/flywire_gnn.egg-info/PKG-INFO +114 -0
- flywire_gnn-0.1.0/flywire_gnn.egg-info/SOURCES.txt +12 -0
- flywire_gnn-0.1.0/flywire_gnn.egg-info/dependency_links.txt +1 -0
- flywire_gnn-0.1.0/flywire_gnn.egg-info/requires.txt +12 -0
- flywire_gnn-0.1.0/flywire_gnn.egg-info/top_level.txt +1 -0
- flywire_gnn-0.1.0/pyproject.toml +35 -0
- flywire_gnn-0.1.0/setup.cfg +4 -0
- flywire_gnn-0.1.0/tests/test_pipeline.py +140 -0
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: flywire-gnn
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: The FlyWire FAFB Drosophila connectome as a ready-to-train PyTorch Geometric dataset, with baseline models and reproducible benchmarks.
|
|
5
|
+
Author: Omkar
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: FlyWire, https://flywire.ai
|
|
8
|
+
Project-URL: Zenodo, https://doi.org/10.5281/zenodo.10676866
|
|
9
|
+
Keywords: connectome,flywire,drosophila,gnn,graph-neural-network,neuroscience,pytorch,benchmark
|
|
10
|
+
Requires-Python: >=3.10
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
Requires-Dist: numpy>=1.24
|
|
13
|
+
Requires-Dist: pandas>=2.0
|
|
14
|
+
Requires-Dist: pyarrow>=14.0
|
|
15
|
+
Requires-Dist: torch>=2.0
|
|
16
|
+
Requires-Dist: torch_geometric>=2.4
|
|
17
|
+
Requires-Dist: scikit-learn>=1.3
|
|
18
|
+
Provides-Extra: hub
|
|
19
|
+
Requires-Dist: huggingface_hub>=0.20; extra == "hub"
|
|
20
|
+
Provides-Extra: dev
|
|
21
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
22
|
+
|
|
23
|
+
# flywire-gnn
|
|
24
|
+
|
|
25
|
+
**The complete fruit-fly brain connectome (FlyWire FAFB v783) as a ready-to-train PyTorch Geometric dataset** — 139,255 proofread neurons, 2.7M directed synaptic connections, cell-type labels on every neuron, deterministic stratified splits, and three reproducible baselines. One `pip install`, one class, no authentication, no left-over data-wrangling.
|
|
26
|
+
|
|
27
|
+
## Install
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
pip install torch --index-url https://download.pytorch.org/whl/cpu
|
|
31
|
+
pip install torch_geometric
|
|
32
|
+
pip install git+https://github.com/omkar-dhakane/flywire-gnn.git # or: pip install -e .
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Tested with Python 3.13, torch 2.14.0+cpu, torch_geometric 2.8.0.post1, pyarrow 22.0, numpy 2.3.1. Older `torch>=2.0` / `torch_geometric>=2.4` should work; the benchmark numbers above are from the tested versions.
|
|
36
|
+
|
|
37
|
+
## Quickstart (the whole thing)
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
from flywire_gnn import FlyWireFAFB
|
|
41
|
+
|
|
42
|
+
ds = FlyWireFAFB() # downloads 852MB + 1.1MB + 27MB once, caches locally
|
|
43
|
+
data = ds.data # torch_geometric.data.Data
|
|
44
|
+
train_mask, val_mask, test_mask = ds.splits() # deterministic, stratified, seed 42
|
|
45
|
+
print(data) # Data(x=[139255, 174], edge_index=[2, 2700513], edge_attr=[2700513, 7], y=[139255])
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Everything is cached in `~/.cache/flywire_gnn` — subsequent loads are instant.
|
|
49
|
+
|
|
50
|
+
## The dataset
|
|
51
|
+
|
|
52
|
+
| | |
|
|
53
|
+
|---|---|
|
|
54
|
+
| Nodes | 139,255 proofread neurons (whole adult female fly brain) |
|
|
55
|
+
| Edges | 2,700,513 unique directed pairs at ≥5 synapses (15,091,983 at ≥1) |
|
|
56
|
+
| Node features (`x`, 174-dim) | wiring structure: degrees/synapse totals, per-neuropil in/out profiles across 79 brain regions, in/out neurotransmitter profiles |
|
|
57
|
+
| Edge attributes (`edge_attr`, 7-dim) | `log1p(syn_count)` + 6 synapse-weighted neurotransmitter probabilities |
|
|
58
|
+
| Labels (`y`) | `super_class`: 9 classes covering **100% of nodes** (also available: `cell_class`, `cell_sub_class`, `cell_type`) |
|
|
59
|
+
| Task | node classification from wiring structure |
|
|
60
|
+
| Splits | 70/15/15 train/val/test, stratified, seed 42 |
|
|
61
|
+
|
|
62
|
+
Data sources (all public, **no auth needed**):
|
|
63
|
+
- Connectivity: FlyWire Whole-brain Connectome Connectivity Data v783 — [Zenodo, CC-BY-4.0](https://doi.org/10.5281/zenodo.10676866)
|
|
64
|
+
- Annotation: [Schlegel et al. 2024](https://doi.org/10.1038/s41586-024-07686-5), Supplementary Data 5
|
|
65
|
+
- Packaged copies (parquet, pair-level): [huggingface.co/datasets/SLOP011/flywire-fafb-connectome](https://huggingface.co/datasets/SLOP011/flywire-fafb-connectome)
|
|
66
|
+
|
|
67
|
+
## Leaderboard — node classification on `super_class`
|
|
68
|
+
|
|
69
|
+
Real runs, full-batch, CPU, seed 42, `min_synapses=5`, hidden 128 (see `flywire_gnn/train.py`):
|
|
70
|
+
|
|
71
|
+
| Model | Test accuracy | Macro-F1 | Best val acc (epoch) | Epochs | Wall time |
|
|
72
|
+
|---|---|---|---|---|---|
|
|
73
|
+
| MLP (features only) | **0.9851** | 0.7115 | 0.9860 (200) | 200 | 3.5 min |
|
|
74
|
+
| GraphSAGE | 0.9812 | **0.7563** | 0.9820 (150) | 150 | 13 min |
|
|
75
|
+
| GCN | 0.9166 | 0.4702 | 0.9202 (180) | 200 | 23.5 min |
|
|
76
|
+
|
|
77
|
+
**What the benchmark shows**: wiring-profile features alone nearly saturate accuracy (98.5%) — most of a cell's coarse class is readable directly from its projection pattern. Mean-aggregated message passing (GraphSAGE) roughly matches features and wins on the rare classes (best macro-F1); GCN's symmetric normalization over-smooths and trails. If your architecture can't beat 0.9851 accuracy *and* 0.7563 macro-F1 on these exact splits, it isn't adding anything over a feature baseline.
|
|
78
|
+
|
|
79
|
+
### Reproduce
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
python -m flywire_gnn.train --models mlp,gcn,sage # all three
|
|
83
|
+
python -m flywire_gnn.train --model sage --epochs 150 --seed 42
|
|
84
|
+
python -m flywire_gnn.train --model sage --labels cell_class # harder multi-class task
|
|
85
|
+
python -m flywire_gnn.train --model sage --min-synapses 1 # dense 15M-edge graph
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## Design choices (and why)
|
|
89
|
+
|
|
90
|
+
- **`min_synapses=5`** is the published FAFB "connection" threshold (same default as Codex). Pass `min_synapses=1` for the full-density graph.
|
|
91
|
+
- **Node features come from the ≥1-synapse wiring** (a neuron's total projection profile); edges are filtered by `min_synapses`. So features are the same regardless of the edge threshold you benchmark.
|
|
92
|
+
- **Stratified splits on labeled nodes only**: with `super_class`, all 139,255 nodes are labeled. Deterministic via `seed=42` (NumPy `RandomState` + sklearn stratified split).
|
|
93
|
+
- The raw 9.5 GB per-synapse file (`flywire_synapses_783.feather`) is **not** needed: the 852 MB pair×neuropil table is sufficient.
|
|
94
|
+
|
|
95
|
+
## Not in v1 (deliberately)
|
|
96
|
+
|
|
97
|
+
Mesh/skeleton loading (use `fafbseg` + meshparty), other connectomes (MANC/MAOL/MCNS/BANC), hosted leaderboard server, spiking neural simulation, per-synapse link prediction. The package is intentionally complete at this scope.
|
|
98
|
+
|
|
99
|
+
## Tests
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
pytest tests/ -v
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Covers: graph assembly counts on a synthetic graph, feature finiteness, split disjointness/determinism, model forward + backward smoke tests, and (if the real cache is present) the real 139,255-node / 2,700,513-edge invariants.
|
|
106
|
+
|
|
107
|
+
## License & citation
|
|
108
|
+
|
|
109
|
+
Code: MIT. Data: **CC-BY-4.0**. Using the dataset means citing:
|
|
110
|
+
|
|
111
|
+
1. **Dorkenwald et al.** 2024. *Neuronal wiring diagram of an adult brain.* Nature. [doi:10.1038/s41586-024-07558-y](https://doi.org/10.1038/s41586-024-07558-y)
|
|
112
|
+
2. **Schlegel et al.** 2024. *Whole-brain annotation and multi-connectome cell typing of Drosophila.* Nature. [doi:10.1038/s41586-024-07686-5](https://doi.org/10.1038/s41586-024-07686-5)
|
|
113
|
+
|
|
114
|
+
Not affiliated with the FlyWire Consortium. Interactive exploration: [codex.flywire.ai](https://codex.flywire.ai) — analysis in Python: [navis](https://github.com/navis-org/navis) / [fafbseg](https://github.com/navis-org/fafbseg-py).
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# flywire-gnn
|
|
2
|
+
|
|
3
|
+
**The complete fruit-fly brain connectome (FlyWire FAFB v783) as a ready-to-train PyTorch Geometric dataset** — 139,255 proofread neurons, 2.7M directed synaptic connections, cell-type labels on every neuron, deterministic stratified splits, and three reproducible baselines. One `pip install`, one class, no authentication, no left-over data-wrangling.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install torch --index-url https://download.pytorch.org/whl/cpu
|
|
9
|
+
pip install torch_geometric
|
|
10
|
+
pip install git+https://github.com/omkar-dhakane/flywire-gnn.git # or: pip install -e .
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Tested with Python 3.13, torch 2.14.0+cpu, torch_geometric 2.8.0.post1, pyarrow 22.0, numpy 2.3.1. Older `torch>=2.0` / `torch_geometric>=2.4` should work; the benchmark numbers above are from the tested versions.
|
|
14
|
+
|
|
15
|
+
## Quickstart (the whole thing)
|
|
16
|
+
|
|
17
|
+
```python
|
|
18
|
+
from flywire_gnn import FlyWireFAFB
|
|
19
|
+
|
|
20
|
+
ds = FlyWireFAFB() # downloads 852MB + 1.1MB + 27MB once, caches locally
|
|
21
|
+
data = ds.data # torch_geometric.data.Data
|
|
22
|
+
train_mask, val_mask, test_mask = ds.splits() # deterministic, stratified, seed 42
|
|
23
|
+
print(data) # Data(x=[139255, 174], edge_index=[2, 2700513], edge_attr=[2700513, 7], y=[139255])
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Everything is cached in `~/.cache/flywire_gnn` — subsequent loads are instant.
|
|
27
|
+
|
|
28
|
+
## The dataset
|
|
29
|
+
|
|
30
|
+
| | |
|
|
31
|
+
|---|---|
|
|
32
|
+
| Nodes | 139,255 proofread neurons (whole adult female fly brain) |
|
|
33
|
+
| Edges | 2,700,513 unique directed pairs at ≥5 synapses (15,091,983 at ≥1) |
|
|
34
|
+
| Node features (`x`, 174-dim) | wiring structure: degrees/synapse totals, per-neuropil in/out profiles across 79 brain regions, in/out neurotransmitter profiles |
|
|
35
|
+
| Edge attributes (`edge_attr`, 7-dim) | `log1p(syn_count)` + 6 synapse-weighted neurotransmitter probabilities |
|
|
36
|
+
| Labels (`y`) | `super_class`: 9 classes covering **100% of nodes** (also available: `cell_class`, `cell_sub_class`, `cell_type`) |
|
|
37
|
+
| Task | node classification from wiring structure |
|
|
38
|
+
| Splits | 70/15/15 train/val/test, stratified, seed 42 |
|
|
39
|
+
|
|
40
|
+
Data sources (all public, **no auth needed**):
|
|
41
|
+
- Connectivity: FlyWire Whole-brain Connectome Connectivity Data v783 — [Zenodo, CC-BY-4.0](https://doi.org/10.5281/zenodo.10676866)
|
|
42
|
+
- Annotation: [Schlegel et al. 2024](https://doi.org/10.1038/s41586-024-07686-5), Supplementary Data 5
|
|
43
|
+
- Packaged copies (parquet, pair-level): [huggingface.co/datasets/SLOP011/flywire-fafb-connectome](https://huggingface.co/datasets/SLOP011/flywire-fafb-connectome)
|
|
44
|
+
|
|
45
|
+
## Leaderboard — node classification on `super_class`
|
|
46
|
+
|
|
47
|
+
Real runs, full-batch, CPU, seed 42, `min_synapses=5`, hidden 128 (see `flywire_gnn/train.py`):
|
|
48
|
+
|
|
49
|
+
| Model | Test accuracy | Macro-F1 | Best val acc (epoch) | Epochs | Wall time |
|
|
50
|
+
|---|---|---|---|---|---|
|
|
51
|
+
| MLP (features only) | **0.9851** | 0.7115 | 0.9860 (200) | 200 | 3.5 min |
|
|
52
|
+
| GraphSAGE | 0.9812 | **0.7563** | 0.9820 (150) | 150 | 13 min |
|
|
53
|
+
| GCN | 0.9166 | 0.4702 | 0.9202 (180) | 200 | 23.5 min |
|
|
54
|
+
|
|
55
|
+
**What the benchmark shows**: wiring-profile features alone nearly saturate accuracy (98.5%) — most of a cell's coarse class is readable directly from its projection pattern. Mean-aggregated message passing (GraphSAGE) roughly matches features and wins on the rare classes (best macro-F1); GCN's symmetric normalization over-smooths and trails. If your architecture can't beat 0.9851 accuracy *and* 0.7563 macro-F1 on these exact splits, it isn't adding anything over a feature baseline.
|
|
56
|
+
|
|
57
|
+
### Reproduce
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
python -m flywire_gnn.train --models mlp,gcn,sage # all three
|
|
61
|
+
python -m flywire_gnn.train --model sage --epochs 150 --seed 42
|
|
62
|
+
python -m flywire_gnn.train --model sage --labels cell_class # harder multi-class task
|
|
63
|
+
python -m flywire_gnn.train --model sage --min-synapses 1 # dense 15M-edge graph
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Design choices (and why)
|
|
67
|
+
|
|
68
|
+
- **`min_synapses=5`** is the published FAFB "connection" threshold (same default as Codex). Pass `min_synapses=1` for the full-density graph.
|
|
69
|
+
- **Node features come from the ≥1-synapse wiring** (a neuron's total projection profile); edges are filtered by `min_synapses`. So features are the same regardless of the edge threshold you benchmark.
|
|
70
|
+
- **Stratified splits on labeled nodes only**: with `super_class`, all 139,255 nodes are labeled. Deterministic via `seed=42` (NumPy `RandomState` + sklearn stratified split).
|
|
71
|
+
- The raw 9.5 GB per-synapse file (`flywire_synapses_783.feather`) is **not** needed: the 852 MB pair×neuropil table is sufficient.
|
|
72
|
+
|
|
73
|
+
## Not in v1 (deliberately)
|
|
74
|
+
|
|
75
|
+
Mesh/skeleton loading (use `fafbseg` + meshparty), other connectomes (MANC/MAOL/MCNS/BANC), hosted leaderboard server, spiking neural simulation, per-synapse link prediction. The package is intentionally complete at this scope.
|
|
76
|
+
|
|
77
|
+
## Tests
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
pytest tests/ -v
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Covers: graph assembly counts on a synthetic graph, feature finiteness, split disjointness/determinism, model forward + backward smoke tests, and (if the real cache is present) the real 139,255-node / 2,700,513-edge invariants.
|
|
84
|
+
|
|
85
|
+
## License & citation
|
|
86
|
+
|
|
87
|
+
Code: MIT. Data: **CC-BY-4.0**. Using the dataset means citing:
|
|
88
|
+
|
|
89
|
+
1. **Dorkenwald et al.** 2024. *Neuronal wiring diagram of an adult brain.* Nature. [doi:10.1038/s41586-024-07558-y](https://doi.org/10.1038/s41586-024-07558-y)
|
|
90
|
+
2. **Schlegel et al.** 2024. *Whole-brain annotation and multi-connectome cell typing of Drosophila.* Nature. [doi:10.1038/s41586-024-07686-5](https://doi.org/10.1038/s41586-024-07686-5)
|
|
91
|
+
|
|
92
|
+
Not affiliated with the FlyWire Consortium. Interactive exploration: [codex.flywire.ai](https://codex.flywire.ai) — analysis in Python: [navis](https://github.com/navis-org/navis) / [fafbseg](https://github.com/navis-org/fafbseg-py).
|
|
@@ -0,0 +1,441 @@
|
|
|
1
|
+
"""The FlyWire FAFB (v783) Drosophila connectome as a PyTorch Geometric dataset.
|
|
2
|
+
|
|
3
|
+
Data sources (all public, no authentication required):
|
|
4
|
+
- Connectivity: FlyWire Whole-brain Connectome Connectivity Data v783
|
|
5
|
+
(Zenodo, CC-BY-4.0, doi:10.5281/zenodo.10676866), Dorkenwald et al., Nature 2024
|
|
6
|
+
- Cell annotations: Supplementary Data 5 of Schlegel et al., Nature 2024
|
|
7
|
+
(doi:10.1038/s41586-024-07686-5)
|
|
8
|
+
|
|
9
|
+
Graph layout:
|
|
10
|
+
- x: float32 [N, 174] node features derived from wiring
|
|
11
|
+
- edge_index: int64 [2, E] directed edges (pre -> post), pair-level, filtered
|
|
12
|
+
to synapses >= min_synapses (default 5, the published FAFB
|
|
13
|
+
"connection" threshold)
|
|
14
|
+
- edge_attr: float32 [E, 7]: [log1p(syn_count), 6 weighted NT probabilities]
|
|
15
|
+
- y: int64 [N] label index (-1 = unlabeled)
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import hashlib
|
|
21
|
+
import os
|
|
22
|
+
import time
|
|
23
|
+
import urllib.request
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Dict, Optional, Tuple
|
|
26
|
+
|
|
27
|
+
import numpy as np
|
|
28
|
+
import pandas as pd
|
|
29
|
+
import pyarrow as pa
|
|
30
|
+
import pyarrow.compute as pc
|
|
31
|
+
import pyarrow.feather
|
|
32
|
+
import torch
|
|
33
|
+
|
|
34
|
+
ZENODO_DOI = "10.5281/zenodo.10676866"
|
|
35
|
+
|
|
36
|
+
CONNECTIONS_URL = (
|
|
37
|
+
"https://zenodo.org/records/10676866/files/"
|
|
38
|
+
"proofread_connections_783.feather?download=1"
|
|
39
|
+
)
|
|
40
|
+
CONNECTIONS_MD5 = "f48f972d262323a102aed49af1396b8a"
|
|
41
|
+
CONNECTIONS_SIZE = 852_022_274
|
|
42
|
+
|
|
43
|
+
ROOT_IDS_URL = (
|
|
44
|
+
"https://zenodo.org/records/10676866/files/"
|
|
45
|
+
"proofread_root_ids_783.npy?download=1"
|
|
46
|
+
)
|
|
47
|
+
ROOT_IDS_MD5 = "e0e6c19732fd8c7a4e39a2d170105421"
|
|
48
|
+
ROOT_IDS_SIZE = 1_114_168
|
|
49
|
+
|
|
50
|
+
LABELS_URL = (
|
|
51
|
+
"https://media.springernature.com/original/springer-static/esm/"
|
|
52
|
+
"art%3A10.1038%2Fs41586-024-07686-5/MediaObjects/"
|
|
53
|
+
"41586_2024_7686_MOESM5_ESM.tsv"
|
|
54
|
+
)
|
|
55
|
+
LABELS_SIZE = 27_015_208
|
|
56
|
+
|
|
57
|
+
LABEL_COLUMNS = ("super_class", "cell_class", "cell_sub_class", "cell_type")
|
|
58
|
+
|
|
59
|
+
NT_AVG_COLUMNS = ("gaba_avg", "ach_avg", "glut_avg", "oct_avg", "ser_avg", "da_avg")
|
|
60
|
+
NT_NAMES = ("gaba", "ach", "glut", "oct", "ser", "da")
|
|
61
|
+
|
|
62
|
+
DEFAULT_SEED = 42
|
|
63
|
+
DEFAULT_RATIOS = (0.7, 0.15, 0.15)
|
|
64
|
+
DEFAULT_MIN_SYNAPSES = 5
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _default_cache_dir() -> Path:
|
|
68
|
+
env = os.environ.get("FLYWIRE_GNN_CACHE")
|
|
69
|
+
if env:
|
|
70
|
+
return Path(env)
|
|
71
|
+
return Path.home() / ".cache" / "flywire_gnn"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _md5(path: Path, chunk_size: int = 1 << 22) -> str:
|
|
75
|
+
h = hashlib.md5()
|
|
76
|
+
with open(path, "rb") as f:
|
|
77
|
+
for chunk in iter(lambda: f.read(chunk_size), b""):
|
|
78
|
+
h.update(chunk)
|
|
79
|
+
return h.hexdigest()
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _download(url: str, dest: Path, expected_size: Optional[int] = None) -> None:
|
|
83
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
84
|
+
tmp = dest.with_suffix(dest.suffix + ".part")
|
|
85
|
+
for attempt in range(5):
|
|
86
|
+
existing = tmp.stat().st_size if tmp.exists() else 0
|
|
87
|
+
headers = {"Range": f"bytes={existing}-"} if existing else {}
|
|
88
|
+
req = urllib.request.Request(url, headers=headers)
|
|
89
|
+
mode = "ab" if existing else "wb"
|
|
90
|
+
try:
|
|
91
|
+
with urllib.request.urlopen(req, timeout=180) as r, open(tmp, mode) as f:
|
|
92
|
+
while chunk := r.read(1 << 22):
|
|
93
|
+
f.write(chunk)
|
|
94
|
+
if expected_size is None or tmp.stat().st_size >= expected_size:
|
|
95
|
+
tmp.replace(dest)
|
|
96
|
+
return
|
|
97
|
+
except Exception as e:
|
|
98
|
+
if attempt == 4:
|
|
99
|
+
raise RuntimeError(f"download failed after 5 attempts: {url}") from e
|
|
100
|
+
time.sleep(2 * (attempt + 1))
|
|
101
|
+
raise RuntimeError(f"download failed: {url}")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def aggregate(connections_path: Path) -> Dict[str, object]:
|
|
105
|
+
"""Memory-safe aggregation of the pair-by-neuropil connection table.
|
|
106
|
+
|
|
107
|
+
Returns numpy/pandas frames:
|
|
108
|
+
pairs: dict of columns for unique (pre, post) pairs with
|
|
109
|
+
syn_count and NT weighted sums (all at >=1 synapse)
|
|
110
|
+
neuro_pre / neuro_post: (root_id, neuropil, syn) frames
|
|
111
|
+
node_out / node_in: per-root syn totals and NT weighted sums
|
|
112
|
+
neuropils: sorted list of all neuropil names
|
|
113
|
+
"""
|
|
114
|
+
tbl = pa.feather.read_table(connections_path, memory_map=True)
|
|
115
|
+
tbl = tbl.filter(
|
|
116
|
+
pc.not_equal(pc.field("pre_pt_root_id"), pc.field("post_pt_root_id"))
|
|
117
|
+
)
|
|
118
|
+
syn_f = tbl.column("syn_count").cast(pa.float64())
|
|
119
|
+
w_cols = []
|
|
120
|
+
for c in NT_AVG_COLUMNS:
|
|
121
|
+
w = c.replace("_avg", "_w")
|
|
122
|
+
tbl = tbl.append_column(w, pc.multiply(tbl.column(c), syn_f))
|
|
123
|
+
w_cols.append(w)
|
|
124
|
+
|
|
125
|
+
pairs_t = tbl.group_by(["pre_pt_root_id", "post_pt_root_id"]).aggregate(
|
|
126
|
+
[("syn_count", "sum")] + [(w, "sum") for w in w_cols]
|
|
127
|
+
)
|
|
128
|
+
pairs = {
|
|
129
|
+
c: pairs_t.column(c).combine_chunks().to_numpy(zero_copy_only=False)
|
|
130
|
+
for c in pairs_t.column_names
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
def as_frame(t):
|
|
134
|
+
return t.to_pandas()
|
|
135
|
+
|
|
136
|
+
neuro_pre = as_frame(
|
|
137
|
+
tbl.group_by(["pre_pt_root_id", "neuropil"]).aggregate([("syn_count", "sum")])
|
|
138
|
+
).rename(columns={"pre_pt_root_id": "root_id", "syn_count_sum": "syn"})
|
|
139
|
+
|
|
140
|
+
neuro_post = as_frame(
|
|
141
|
+
tbl.group_by(["post_pt_root_id", "neuropil"]).aggregate([("syn_count", "sum")])
|
|
142
|
+
).rename(columns={"post_pt_root_id": "root_id", "syn_count_sum": "syn"})
|
|
143
|
+
|
|
144
|
+
node_out = as_frame(
|
|
145
|
+
tbl.group_by("pre_pt_root_id").aggregate(
|
|
146
|
+
[("syn_count", "sum")] + [(w, "sum") for w in w_cols]
|
|
147
|
+
)
|
|
148
|
+
).rename(columns={"pre_pt_root_id": "root_id", "syn_count_sum": "syn_total"})
|
|
149
|
+
node_in = as_frame(
|
|
150
|
+
tbl.group_by("post_pt_root_id").aggregate(
|
|
151
|
+
[("syn_count", "sum")] + [(w, "sum") for w in w_cols]
|
|
152
|
+
)
|
|
153
|
+
).rename(columns={"post_pt_root_id": "root_id", "syn_count_sum": "syn_total"})
|
|
154
|
+
|
|
155
|
+
neuropils = sorted(
|
|
156
|
+
set(neuro_pre["neuropil"].unique()) | set(neuro_post["neuropil"].unique())
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
return {
|
|
160
|
+
"pairs": pairs,
|
|
161
|
+
"neuro_pre": neuro_pre,
|
|
162
|
+
"neuro_post": neuro_post,
|
|
163
|
+
"node_out": node_out,
|
|
164
|
+
"node_in": node_in,
|
|
165
|
+
"neuropils": neuropils,
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def assemble(
|
|
170
|
+
agg: Dict[str, object],
|
|
171
|
+
node_ids: np.ndarray,
|
|
172
|
+
labels_df: pd.DataFrame,
|
|
173
|
+
label_col: str,
|
|
174
|
+
min_synapses: int,
|
|
175
|
+
) -> Dict[str, object]:
|
|
176
|
+
"""Build graph tensors from aggregated frames. Pure numpy/torch, testable."""
|
|
177
|
+
node_ids = np.sort(np.asarray(node_ids, dtype=np.int64))
|
|
178
|
+
n = len(node_ids)
|
|
179
|
+
|
|
180
|
+
def map_ids(arr: np.ndarray) -> Tuple[np.ndarray, np.ndarray]:
|
|
181
|
+
arr = np.asarray(arr, dtype=np.int64)
|
|
182
|
+
pos = np.searchsorted(node_ids, arr)
|
|
183
|
+
pos_c = np.clip(pos, 0, n - 1)
|
|
184
|
+
ok = node_ids[pos_c] == arr
|
|
185
|
+
return pos_c, ok
|
|
186
|
+
|
|
187
|
+
pairs = agg["pairs"]
|
|
188
|
+
pre = pairs["pre_pt_root_id"]
|
|
189
|
+
post = pairs["post_pt_root_id"]
|
|
190
|
+
syn = pairs["syn_count_sum"].astype(np.int64)
|
|
191
|
+
wsum_names = [f"{s}_w_sum" for s in NT_NAMES]
|
|
192
|
+
|
|
193
|
+
upos, uok = map_ids(pre)
|
|
194
|
+
vpos, vok = map_ids(post)
|
|
195
|
+
valid = uok & vok
|
|
196
|
+
dropped_edges = int((~valid).sum())
|
|
197
|
+
|
|
198
|
+
keep = (syn >= min_synapses) & valid
|
|
199
|
+
u = upos[keep]
|
|
200
|
+
v = vpos[keep]
|
|
201
|
+
s = syn[keep]
|
|
202
|
+
wsum = np.stack([pairs[w][keep] for w in wsum_names], axis=1).astype(np.float64)
|
|
203
|
+
nt_edge = (wsum / np.maximum(s, 1)[:, None]).astype(np.float32)
|
|
204
|
+
|
|
205
|
+
edge_index = torch.from_numpy(np.stack([u, v])).long()
|
|
206
|
+
edge_attr = torch.from_numpy(
|
|
207
|
+
np.column_stack([np.log1p(s.astype(np.float64)), nt_edge])
|
|
208
|
+
).float()
|
|
209
|
+
|
|
210
|
+
upos_all, _ = map_ids(pre[valid])
|
|
211
|
+
vpos_all, _ = map_ids(post[valid])
|
|
212
|
+
s_all = syn[valid]
|
|
213
|
+
|
|
214
|
+
x = np.zeros((n, 4 + 2 * len(agg["neuropils"]) + 12), dtype=np.float32)
|
|
215
|
+
x[:, 0] = np.log1p(np.bincount(upos_all, minlength=n).astype(np.float32))
|
|
216
|
+
x[:, 1] = np.log1p(np.bincount(vpos_all, minlength=n).astype(np.float32))
|
|
217
|
+
x[:, 2] = np.log1p(
|
|
218
|
+
np.bincount(upos_all, weights=s_all, minlength=n).astype(np.float32)
|
|
219
|
+
)
|
|
220
|
+
x[:, 3] = np.log1p(
|
|
221
|
+
np.bincount(vpos_all, weights=s_all, minlength=n).astype(np.float32)
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
neuropils = agg["neuropils"]
|
|
225
|
+
n_n = len(neuropils)
|
|
226
|
+
np_name2idx = {name: i for i, name in enumerate(neuropils)}
|
|
227
|
+
|
|
228
|
+
def neuro_matrix(df: pd.DataFrame, col0: int) -> None:
|
|
229
|
+
rpos, ok = map_ids(df["root_id"].to_numpy())
|
|
230
|
+
codes = df["neuropil"].map(np_name2idx).to_numpy(dtype=np.int64)
|
|
231
|
+
vals = df["syn"].to_numpy(dtype=np.float64)
|
|
232
|
+
np.add.at(x, (rpos[ok], col0 + codes[ok]), np.log1p(vals[ok]))
|
|
233
|
+
|
|
234
|
+
neuro_matrix(agg["neuro_pre"], 4)
|
|
235
|
+
neuro_matrix(agg["neuro_post"], 4 + n_n)
|
|
236
|
+
|
|
237
|
+
col_nt = 4 + 2 * n_n
|
|
238
|
+
|
|
239
|
+
def nt_profiles(df: pd.DataFrame, col0: int) -> None:
|
|
240
|
+
rpos, ok = map_ids(df["root_id"].to_numpy())
|
|
241
|
+
tot = df["syn_total"].to_numpy(dtype=np.float64)
|
|
242
|
+
for j, name in enumerate(NT_NAMES):
|
|
243
|
+
w = df[f"{name}_w_sum"].to_numpy(dtype=np.float64)
|
|
244
|
+
profile = np.where(tot > 0, w / np.maximum(tot, 1), 0.0)
|
|
245
|
+
x[rpos[ok], col0 + j] = profile[ok].astype(np.float32)
|
|
246
|
+
|
|
247
|
+
nt_profiles(agg["node_out"], col_nt)
|
|
248
|
+
nt_profiles(agg["node_in"], col_nt + 6)
|
|
249
|
+
|
|
250
|
+
y = np.full(n, -1, dtype=np.int64)
|
|
251
|
+
label_names: list = []
|
|
252
|
+
if labels_df is not None and label_col in labels_df.columns:
|
|
253
|
+
sub = labels_df[["root_id", label_col]].dropna().copy()
|
|
254
|
+
labels = sub[label_col].astype(str)
|
|
255
|
+
label_names = sorted(labels.unique())
|
|
256
|
+
enc = {name: i for i, name in enumerate(label_names)}
|
|
257
|
+
rpos, ok = map_ids(sub["root_id"].to_numpy())
|
|
258
|
+
y[rpos[ok]] = labels.map(enc).to_numpy()[ok]
|
|
259
|
+
|
|
260
|
+
return {
|
|
261
|
+
"x": torch.from_numpy(x),
|
|
262
|
+
"edge_index": edge_index,
|
|
263
|
+
"edge_attr": edge_attr,
|
|
264
|
+
"y": torch.from_numpy(y),
|
|
265
|
+
"node_ids": torch.from_numpy(node_ids),
|
|
266
|
+
"neuropils": neuropils,
|
|
267
|
+
"label_col": label_col,
|
|
268
|
+
"label_names": label_names,
|
|
269
|
+
"meta": {
|
|
270
|
+
"num_nodes": n,
|
|
271
|
+
"num_edges": int(edge_index.shape[1]),
|
|
272
|
+
"dropped_edges_unmapped_root": dropped_edges,
|
|
273
|
+
"min_synapses": min_synapses,
|
|
274
|
+
"feature_dim": int(x.shape[1]),
|
|
275
|
+
"labeled_nodes": int((y >= 0).sum()),
|
|
276
|
+
},
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def make_splits(
|
|
281
|
+
y: torch.Tensor,
|
|
282
|
+
seed: int = DEFAULT_SEED,
|
|
283
|
+
ratios: Tuple[float, float, float] = DEFAULT_RATIOS,
|
|
284
|
+
stratify: bool = True,
|
|
285
|
+
) -> Tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
|
|
286
|
+
y_np = y.numpy()
|
|
287
|
+
n = len(y_np)
|
|
288
|
+
labeled = np.where(y_np >= 0)[0]
|
|
289
|
+
labeled.sort()
|
|
290
|
+
if stratify and len(labeled) > 0:
|
|
291
|
+
from sklearn.model_selection import train_test_split
|
|
292
|
+
|
|
293
|
+
try:
|
|
294
|
+
train_idx, rest = train_test_split(
|
|
295
|
+
labeled,
|
|
296
|
+
train_size=ratios[0],
|
|
297
|
+
random_state=seed,
|
|
298
|
+
stratify=y_np[labeled],
|
|
299
|
+
)
|
|
300
|
+
rel = ratios[2] / (ratios[1] + ratios[2])
|
|
301
|
+
val_idx, test_idx = train_test_split(
|
|
302
|
+
np.sort(rest), test_size=rel, random_state=seed, stratify=y_np[np.sort(rest)]
|
|
303
|
+
)
|
|
304
|
+
except ValueError:
|
|
305
|
+
rng = np.random.RandomState(seed)
|
|
306
|
+
idx = labeled.copy()
|
|
307
|
+
rng.shuffle(idx)
|
|
308
|
+
k1 = int(len(idx) * ratios[0])
|
|
309
|
+
k2 = int(len(idx) * (ratios[0] + ratios[1]))
|
|
310
|
+
train_idx, val_idx, test_idx = idx[:k1], idx[k1:k2], idx[k2:]
|
|
311
|
+
else:
|
|
312
|
+
rng = np.random.RandomState(seed)
|
|
313
|
+
idx = rng.permutation(n if len(labeled) == 0 else labeled.copy())
|
|
314
|
+
k1 = int(len(idx) * ratios[0])
|
|
315
|
+
k2 = int(len(idx) * (ratios[0] + ratios[1]))
|
|
316
|
+
train_idx, val_idx, test_idx = idx[:k1], idx[k1:k2], idx[k2:]
|
|
317
|
+
|
|
318
|
+
def mask(idx) -> torch.Tensor:
|
|
319
|
+
m = np.zeros(n, dtype=bool)
|
|
320
|
+
m[np.asarray(idx, dtype=np.int64)] = True
|
|
321
|
+
return torch.from_numpy(m)
|
|
322
|
+
|
|
323
|
+
return mask(train_idx), mask(val_idx), mask(test_idx)
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
class FlyWireFAFB:
|
|
327
|
+
"""FlyWire FAFB v783 connectome as a ready-to-train graph dataset.
|
|
328
|
+
|
|
329
|
+
Parameters
|
|
330
|
+
----------
|
|
331
|
+
root:
|
|
332
|
+
Cache directory (default ~/.cache/flywire_gnn or $FLYWIRE_GNN_CACHE).
|
|
333
|
+
labels:
|
|
334
|
+
Annotation column to use for node classification:
|
|
335
|
+
'super_class' (default), 'cell_class', 'cell_sub_class', 'cell_type'.
|
|
336
|
+
min_synapses:
|
|
337
|
+
Minimum synapse count for a directed edge between two neurons.
|
|
338
|
+
Default 5 (the published FAFB 'connection' threshold). Set 1 for the
|
|
339
|
+
densest graph.
|
|
340
|
+
seed:
|
|
341
|
+
Seed for the train/val/test split (default 42).
|
|
342
|
+
download:
|
|
343
|
+
Download the source files if they are not already in `root`.
|
|
344
|
+
"""
|
|
345
|
+
|
|
346
|
+
def __init__(
|
|
347
|
+
self,
|
|
348
|
+
root: Optional[str] = None,
|
|
349
|
+
labels: str = "super_class",
|
|
350
|
+
min_synapses: int = DEFAULT_MIN_SYNAPSES,
|
|
351
|
+
seed: int = DEFAULT_SEED,
|
|
352
|
+
download: bool = True,
|
|
353
|
+
) -> None:
|
|
354
|
+
if labels not in LABEL_COLUMNS:
|
|
355
|
+
raise ValueError(f"labels must be one of {LABEL_COLUMNS}, got {labels!r}")
|
|
356
|
+
self.root = Path(root) if root else _default_cache_dir()
|
|
357
|
+
self.root.mkdir(parents=True, exist_ok=True)
|
|
358
|
+
self.labels_col = labels
|
|
359
|
+
self.min_synapses = int(min_synapses)
|
|
360
|
+
self.seed = seed
|
|
361
|
+
|
|
362
|
+
self._conn = self.root / "proofread_connections_783.feather"
|
|
363
|
+
self._ids = self.root / "proofread_root_ids_783.npy"
|
|
364
|
+
self._labels = self.root / "annotations_v783.tsv"
|
|
365
|
+
self._cache_path = (
|
|
366
|
+
self.root / f"processed_v783_{labels}_syn{self.min_synapses}.pt"
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
if self._cache_path.exists():
|
|
370
|
+
self._cache = torch.load(self._cache_path, weights_only=True)
|
|
371
|
+
else:
|
|
372
|
+
if not download:
|
|
373
|
+
raise FileNotFoundError(
|
|
374
|
+
f"no processed cache at {self._cache_path} and download=False"
|
|
375
|
+
)
|
|
376
|
+
self._ensure_raw()
|
|
377
|
+
self._cache = self._build()
|
|
378
|
+
torch.save(self._cache, self._cache_path)
|
|
379
|
+
|
|
380
|
+
def _ensure_raw(self) -> None:
|
|
381
|
+
if not self._conn.exists():
|
|
382
|
+
_download(CONNECTIONS_URL, self._conn, CONNECTIONS_SIZE)
|
|
383
|
+
if os.path.getsize(self._conn) != CONNECTIONS_SIZE or (
|
|
384
|
+
_md5(self._conn) != CONNECTIONS_MD5
|
|
385
|
+
):
|
|
386
|
+
raise RuntimeError("connection file failed integrity check")
|
|
387
|
+
if not self._ids.exists():
|
|
388
|
+
_download(ROOT_IDS_URL, self._ids, ROOT_IDS_SIZE)
|
|
389
|
+
if _md5(self._ids) != ROOT_IDS_MD5:
|
|
390
|
+
raise RuntimeError("root-id file failed integrity check")
|
|
391
|
+
if not self._labels.exists():
|
|
392
|
+
_download(LABELS_URL, self._labels, LABELS_SIZE)
|
|
393
|
+
|
|
394
|
+
def _build(self) -> Dict[str, object]:
|
|
395
|
+
agg = aggregate(self._conn)
|
|
396
|
+
node_ids = np.load(self._ids)
|
|
397
|
+
labels_df = pd.read_csv(self._labels, sep="\t", low_memory=False)
|
|
398
|
+
out = assemble(
|
|
399
|
+
agg,
|
|
400
|
+
node_ids=node_ids,
|
|
401
|
+
labels_df=labels_df,
|
|
402
|
+
label_col=self.labels_col,
|
|
403
|
+
min_synapses=self.min_synapses,
|
|
404
|
+
)
|
|
405
|
+
return out
|
|
406
|
+
|
|
407
|
+
@property
|
|
408
|
+
def data(self):
|
|
409
|
+
from torch_geometric.data import Data
|
|
410
|
+
|
|
411
|
+
c = self._cache
|
|
412
|
+
d = Data(x=c["x"], edge_index=c["edge_index"], edge_attr=c["edge_attr"], y=c["y"])
|
|
413
|
+
d.node_ids = c["node_ids"]
|
|
414
|
+
d.num_classes = len(c["label_names"])
|
|
415
|
+
return d
|
|
416
|
+
|
|
417
|
+
def splits(
|
|
418
|
+
self, seed: Optional[int] = None, ratios: Tuple[float, float, float] = DEFAULT_RATIOS
|
|
419
|
+
) -> Tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
|
|
420
|
+
return make_splits(self._cache["y"], seed=seed or self.seed, ratios=ratios)
|
|
421
|
+
|
|
422
|
+
def summary(self) -> str:
|
|
423
|
+
m = self._cache["meta"]
|
|
424
|
+
return (
|
|
425
|
+
"FlyWire FAFB v783\n"
|
|
426
|
+
f" nodes: {m['num_nodes']:,}\n"
|
|
427
|
+
f" edges: {m['num_edges']:,} (min_synapses={m['min_synapses']})\n"
|
|
428
|
+
f" dropped edges: {m['dropped_edges_unmapped_root']:,}\n"
|
|
429
|
+
f" feature dim: {m['feature_dim']}\n"
|
|
430
|
+
f" labeled nodes: {m['labeled_nodes']:,} ({m['labeled_nodes']/m['num_nodes']*100:.1f}%)\n"
|
|
431
|
+
f" task labels: {self._cache['label_col']} "
|
|
432
|
+
f"({len(self._cache['label_names'])} classes)\n"
|
|
433
|
+
f" source: {ZENODO_DOI}"
|
|
434
|
+
)
|
|
435
|
+
|
|
436
|
+
def __repr__(self) -> str:
|
|
437
|
+
return (
|
|
438
|
+
f"FlyWireFAFB(labels={self.labels_col!r}, min_synapses={self.min_synapses}, "
|
|
439
|
+
f"nodes={self._cache['meta']['num_nodes']}, "
|
|
440
|
+
f"edges={self._cache['meta']['num_edges']})"
|
|
441
|
+
)
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Baseline models for FlyWire FAFB node classification."""
|
|
2
|
+
|
|
3
|
+
import torch
|
|
4
|
+
import torch.nn.functional as F
|
|
5
|
+
from torch import nn
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class MLP(nn.Module):
|
|
9
|
+
def __init__(self, in_dim, hidden=128, out_dim=None, num_layers=3, dropout=0.5):
|
|
10
|
+
super().__init__()
|
|
11
|
+
out_dim = out_dim or hidden
|
|
12
|
+
dims = [in_dim] + [hidden] * (num_layers - 1) + [out_dim]
|
|
13
|
+
self.layers = nn.ModuleList(
|
|
14
|
+
nn.Linear(dims[i], dims[i + 1]) for i in range(len(dims) - 1)
|
|
15
|
+
)
|
|
16
|
+
self.dropout = dropout
|
|
17
|
+
|
|
18
|
+
def forward(self, x, edge_index=None):
|
|
19
|
+
for layer in self.layers[:-1]:
|
|
20
|
+
x = F.dropout(F.relu(layer(x)), self.dropout, training=self.training)
|
|
21
|
+
return self.layers[-1](x)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class GCN(nn.Module):
|
|
25
|
+
def __init__(self, in_dim, hidden=128, out_dim=None, num_layers=3, dropout=0.5):
|
|
26
|
+
super().__init__()
|
|
27
|
+
from torch_geometric.nn import GCNConv
|
|
28
|
+
|
|
29
|
+
out_dim = out_dim or hidden
|
|
30
|
+
dims = [in_dim] + [hidden] * (num_layers - 1) + [out_dim]
|
|
31
|
+
self.convs = nn.ModuleList(
|
|
32
|
+
GCNConv(dims[i], dims[i + 1]) for i in range(len(dims) - 1)
|
|
33
|
+
)
|
|
34
|
+
self.dropout = dropout
|
|
35
|
+
|
|
36
|
+
def forward(self, x, edge_index):
|
|
37
|
+
for conv in self.convs[:-1]:
|
|
38
|
+
x = F.dropout(F.relu(conv(x, edge_index)), self.dropout, training=self.training)
|
|
39
|
+
return self.convs[-1](x, edge_index)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class GraphSAGE(nn.Module):
|
|
43
|
+
def __init__(self, in_dim, hidden=128, out_dim=None, num_layers=3, dropout=0.5):
|
|
44
|
+
super().__init__()
|
|
45
|
+
from torch_geometric.nn import SAGEConv
|
|
46
|
+
|
|
47
|
+
out_dim = out_dim or hidden
|
|
48
|
+
dims = [in_dim] + [hidden] * (num_layers - 1) + [out_dim]
|
|
49
|
+
self.convs = nn.ModuleList(
|
|
50
|
+
SAGEConv(dims[i], dims[i + 1]) for i in range(len(dims) - 1)
|
|
51
|
+
)
|
|
52
|
+
self.dropout = dropout
|
|
53
|
+
|
|
54
|
+
def forward(self, x, edge_index):
|
|
55
|
+
for conv in self.convs[:-1]:
|
|
56
|
+
x = F.dropout(F.relu(conv(x, edge_index)), self.dropout, training=self.training)
|
|
57
|
+
return self.convs[-1](x, edge_index)
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""Train a baseline model on FlyWire FAFB node classification."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import time
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
import torch
|
|
11
|
+
import torch.nn.functional as F
|
|
12
|
+
|
|
13
|
+
from .dataset import FlyWireFAFB
|
|
14
|
+
from .models import GCN, GraphSAGE, MLP
|
|
15
|
+
|
|
16
|
+
MODELS = {"mlp": MLP, "gcn": GCN, "sage": GraphSAGE}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def evaluate(model, data, mask, model_name):
|
|
20
|
+
model.eval()
|
|
21
|
+
with torch.no_grad():
|
|
22
|
+
out = model(data.x, data.edge_index)
|
|
23
|
+
pred = out[mask].argmax(dim=1)
|
|
24
|
+
y = data.y[mask]
|
|
25
|
+
acc = (pred == y).float().mean().item()
|
|
26
|
+
correct = pred == y
|
|
27
|
+
f1_sum, f1_n = 0.0, 0
|
|
28
|
+
for c in range(out.shape[1]):
|
|
29
|
+
c_pred = pred == c
|
|
30
|
+
c_true = y == c
|
|
31
|
+
tp = (c_pred & c_true & (y >= 0)).sum().item()
|
|
32
|
+
fp = (c_pred & ~c_true & (y >= 0)).sum().item()
|
|
33
|
+
fn = (~c_pred & c_true).sum().item()
|
|
34
|
+
if c_true.sum().item() == 0:
|
|
35
|
+
continue
|
|
36
|
+
f1_sum += 2 * tp / max(2 * tp + fp + fn, 1)
|
|
37
|
+
f1_n += 1
|
|
38
|
+
return acc, f1_sum / max(f1_n, 1)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def train_one(
|
|
42
|
+
model_name,
|
|
43
|
+
data,
|
|
44
|
+
masks,
|
|
45
|
+
hidden=128,
|
|
46
|
+
epochs=200,
|
|
47
|
+
lr=0.01,
|
|
48
|
+
weight_decay=5e-4,
|
|
49
|
+
seed=42,
|
|
50
|
+
device="cpu",
|
|
51
|
+
log_every=25,
|
|
52
|
+
):
|
|
53
|
+
np.random.seed(seed)
|
|
54
|
+
torch.manual_seed(seed)
|
|
55
|
+
device = torch.device(device)
|
|
56
|
+
data = data.to(device)
|
|
57
|
+
train_mask, val_mask, test_mask = [m.to(device) for m in masks]
|
|
58
|
+
|
|
59
|
+
model = MODELS[model_name](
|
|
60
|
+
data.x.shape[1], hidden=hidden, out_dim=int(data.y.max().item()) + 1
|
|
61
|
+
).to(device)
|
|
62
|
+
opt = torch.optim.Adam(model.parameters(), lr=lr, weight_decay=weight_decay)
|
|
63
|
+
|
|
64
|
+
best_val, best_state, best_epoch = -1.0, None, -1
|
|
65
|
+
t0 = time.time()
|
|
66
|
+
for epoch in range(1, epochs + 1):
|
|
67
|
+
model.train()
|
|
68
|
+
opt.zero_grad()
|
|
69
|
+
out = model(data.x, data.edge_index)
|
|
70
|
+
loss = F.cross_entropy(out[train_mask], data.y[train_mask])
|
|
71
|
+
loss.backward()
|
|
72
|
+
opt.step()
|
|
73
|
+
if epoch % 10 == 0 or epoch == epochs:
|
|
74
|
+
acc_v, _ = evaluate(model, data, val_mask, model_name)
|
|
75
|
+
improved = acc_v > best_val
|
|
76
|
+
if improved:
|
|
77
|
+
best_val = acc_v
|
|
78
|
+
best_state = {k: v.detach().cpu().clone() for k, v in model.state_dict().items()}
|
|
79
|
+
best_epoch = epoch
|
|
80
|
+
if log_every and epoch % log_every == 0:
|
|
81
|
+
print(
|
|
82
|
+
f" epoch {epoch:4d}/{epochs} loss={loss.item():.4f} "
|
|
83
|
+
f"val_acc={acc_v:.4f} best_val={best_val:.4f}",
|
|
84
|
+
flush=True,
|
|
85
|
+
)
|
|
86
|
+
model.load_state_dict(best_state)
|
|
87
|
+
acc_t, f1_t = evaluate(model, data, test_mask, model_name)
|
|
88
|
+
return {
|
|
89
|
+
"model": model_name,
|
|
90
|
+
"test_acc": round(acc_t, 4),
|
|
91
|
+
"test_macro_f1": round(f1_t, 4),
|
|
92
|
+
"best_val_acc": round(best_val, 4),
|
|
93
|
+
"best_epoch": best_epoch,
|
|
94
|
+
"epochs": epochs,
|
|
95
|
+
"seed": seed,
|
|
96
|
+
"min_synapses": None,
|
|
97
|
+
"wall_time_s": round(time.time() - t0, 1),
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def main():
|
|
102
|
+
ap = argparse.ArgumentParser(description="FlyWire FAFB baseline training")
|
|
103
|
+
ap.add_argument("--model", choices=list(MODELS), default="gcn")
|
|
104
|
+
ap.add_argument("--models", default=None,
|
|
105
|
+
help="comma-separated list to run several, e.g. mlp,gcn,sage")
|
|
106
|
+
ap.add_argument("--labels", default="super_class",
|
|
107
|
+
choices=["super_class", "cell_class", "cell_sub_class", "cell_type"])
|
|
108
|
+
ap.add_argument("--min-synapses", type=int, default=5)
|
|
109
|
+
ap.add_argument("--epochs", type=int, default=200)
|
|
110
|
+
ap.add_argument("--hidden", type=int, default=128)
|
|
111
|
+
ap.add_argument("--lr", type=float, default=0.01)
|
|
112
|
+
ap.add_argument("--weight-decay", type=float, default=5e-4)
|
|
113
|
+
ap.add_argument("--seed", type=int, default=42)
|
|
114
|
+
ap.add_argument("--device", default="cpu")
|
|
115
|
+
ap.add_argument("--root", default=None, help="dataset cache dir")
|
|
116
|
+
args = ap.parse_args()
|
|
117
|
+
|
|
118
|
+
names = args.models.split(",") if args.models else [args.model]
|
|
119
|
+
|
|
120
|
+
print(f"Loading FlyWire FAFB v783 (labels={args.labels}, min_synapses={args.min_synapses})...")
|
|
121
|
+
ds = FlyWireFAFB(root=args.root, labels=args.labels, min_synapses=args.min_synapses)
|
|
122
|
+
data = ds.data
|
|
123
|
+
masks = ds.splits(seed=args.seed)
|
|
124
|
+
print(ds.summary())
|
|
125
|
+
n_train = int(masks[0].sum())
|
|
126
|
+
n_val = int(masks[1].sum())
|
|
127
|
+
n_test = int(masks[2].sum())
|
|
128
|
+
print(f" splits: train={n_train:,} val={n_val:,} test={n_test:,}")
|
|
129
|
+
|
|
130
|
+
results = []
|
|
131
|
+
for name in names:
|
|
132
|
+
print(f"\n=== {name.upper()} (hidden={args.hidden}, epochs={args.epochs}, seed={args.seed}) ===")
|
|
133
|
+
r = train_one(
|
|
134
|
+
name, data, masks, hidden=args.hidden, epochs=args.epochs,
|
|
135
|
+
lr=args.lr, weight_decay=args.weight_decay, seed=args.seed,
|
|
136
|
+
device=args.device, log_every=25,
|
|
137
|
+
)
|
|
138
|
+
r["min_synapses"] = args.min_synapses
|
|
139
|
+
results.append(r)
|
|
140
|
+
print(f"RESULT {json.dumps(r)}")
|
|
141
|
+
|
|
142
|
+
print("\n=== SUMMARY ===")
|
|
143
|
+
for r in results:
|
|
144
|
+
print(f"{r['model']:>5} acc={r['test_acc']:.4f} macro_f1={r['test_macro_f1']:.4f} "
|
|
145
|
+
f"val={r['best_val_acc']:.4f} epochs_used={r['best_epoch']}")
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
if __name__ == "__main__":
|
|
149
|
+
main()
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: flywire-gnn
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: The FlyWire FAFB Drosophila connectome as a ready-to-train PyTorch Geometric dataset, with baseline models and reproducible benchmarks.
|
|
5
|
+
Author: Omkar
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: FlyWire, https://flywire.ai
|
|
8
|
+
Project-URL: Zenodo, https://doi.org/10.5281/zenodo.10676866
|
|
9
|
+
Keywords: connectome,flywire,drosophila,gnn,graph-neural-network,neuroscience,pytorch,benchmark
|
|
10
|
+
Requires-Python: >=3.10
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
Requires-Dist: numpy>=1.24
|
|
13
|
+
Requires-Dist: pandas>=2.0
|
|
14
|
+
Requires-Dist: pyarrow>=14.0
|
|
15
|
+
Requires-Dist: torch>=2.0
|
|
16
|
+
Requires-Dist: torch_geometric>=2.4
|
|
17
|
+
Requires-Dist: scikit-learn>=1.3
|
|
18
|
+
Provides-Extra: hub
|
|
19
|
+
Requires-Dist: huggingface_hub>=0.20; extra == "hub"
|
|
20
|
+
Provides-Extra: dev
|
|
21
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
22
|
+
|
|
23
|
+
# flywire-gnn
|
|
24
|
+
|
|
25
|
+
**The complete fruit-fly brain connectome (FlyWire FAFB v783) as a ready-to-train PyTorch Geometric dataset** — 139,255 proofread neurons, 2.7M directed synaptic connections, cell-type labels on every neuron, deterministic stratified splits, and three reproducible baselines. One `pip install`, one class, no authentication, no left-over data-wrangling.
|
|
26
|
+
|
|
27
|
+
## Install
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
pip install torch --index-url https://download.pytorch.org/whl/cpu
|
|
31
|
+
pip install torch_geometric
|
|
32
|
+
pip install git+https://github.com/omkar-dhakane/flywire-gnn.git # or: pip install -e .
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Tested with Python 3.13, torch 2.14.0+cpu, torch_geometric 2.8.0.post1, pyarrow 22.0, numpy 2.3.1. Older `torch>=2.0` / `torch_geometric>=2.4` should work; the benchmark numbers above are from the tested versions.
|
|
36
|
+
|
|
37
|
+
## Quickstart (the whole thing)
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
from flywire_gnn import FlyWireFAFB
|
|
41
|
+
|
|
42
|
+
ds = FlyWireFAFB() # downloads 852MB + 1.1MB + 27MB once, caches locally
|
|
43
|
+
data = ds.data # torch_geometric.data.Data
|
|
44
|
+
train_mask, val_mask, test_mask = ds.splits() # deterministic, stratified, seed 42
|
|
45
|
+
print(data) # Data(x=[139255, 174], edge_index=[2, 2700513], edge_attr=[2700513, 7], y=[139255])
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Everything is cached in `~/.cache/flywire_gnn` — subsequent loads are instant.
|
|
49
|
+
|
|
50
|
+
## The dataset
|
|
51
|
+
|
|
52
|
+
| | |
|
|
53
|
+
|---|---|
|
|
54
|
+
| Nodes | 139,255 proofread neurons (whole adult female fly brain) |
|
|
55
|
+
| Edges | 2,700,513 unique directed pairs at ≥5 synapses (15,091,983 at ≥1) |
|
|
56
|
+
| Node features (`x`, 174-dim) | wiring structure: degrees/synapse totals, per-neuropil in/out profiles across 79 brain regions, in/out neurotransmitter profiles |
|
|
57
|
+
| Edge attributes (`edge_attr`, 7-dim) | `log1p(syn_count)` + 6 synapse-weighted neurotransmitter probabilities |
|
|
58
|
+
| Labels (`y`) | `super_class`: 9 classes covering **100% of nodes** (also available: `cell_class`, `cell_sub_class`, `cell_type`) |
|
|
59
|
+
| Task | node classification from wiring structure |
|
|
60
|
+
| Splits | 70/15/15 train/val/test, stratified, seed 42 |
|
|
61
|
+
|
|
62
|
+
Data sources (all public, **no auth needed**):
|
|
63
|
+
- Connectivity: FlyWire Whole-brain Connectome Connectivity Data v783 — [Zenodo, CC-BY-4.0](https://doi.org/10.5281/zenodo.10676866)
|
|
64
|
+
- Annotation: [Schlegel et al. 2024](https://doi.org/10.1038/s41586-024-07686-5), Supplementary Data 5
|
|
65
|
+
- Packaged copies (parquet, pair-level): [huggingface.co/datasets/SLOP011/flywire-fafb-connectome](https://huggingface.co/datasets/SLOP011/flywire-fafb-connectome)
|
|
66
|
+
|
|
67
|
+
## Leaderboard — node classification on `super_class`
|
|
68
|
+
|
|
69
|
+
Real runs, full-batch, CPU, seed 42, `min_synapses=5`, hidden 128 (see `flywire_gnn/train.py`):
|
|
70
|
+
|
|
71
|
+
| Model | Test accuracy | Macro-F1 | Best val acc (epoch) | Epochs | Wall time |
|
|
72
|
+
|---|---|---|---|---|---|
|
|
73
|
+
| MLP (features only) | **0.9851** | 0.7115 | 0.9860 (200) | 200 | 3.5 min |
|
|
74
|
+
| GraphSAGE | 0.9812 | **0.7563** | 0.9820 (150) | 150 | 13 min |
|
|
75
|
+
| GCN | 0.9166 | 0.4702 | 0.9202 (180) | 200 | 23.5 min |
|
|
76
|
+
|
|
77
|
+
**What the benchmark shows**: wiring-profile features alone nearly saturate accuracy (98.5%) — most of a cell's coarse class is readable directly from its projection pattern. Mean-aggregated message passing (GraphSAGE) roughly matches features and wins on the rare classes (best macro-F1); GCN's symmetric normalization over-smooths and trails. If your architecture can't beat 0.9851 accuracy *and* 0.7563 macro-F1 on these exact splits, it isn't adding anything over a feature baseline.
|
|
78
|
+
|
|
79
|
+
### Reproduce
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
python -m flywire_gnn.train --models mlp,gcn,sage # all three
|
|
83
|
+
python -m flywire_gnn.train --model sage --epochs 150 --seed 42
|
|
84
|
+
python -m flywire_gnn.train --model sage --labels cell_class # harder multi-class task
|
|
85
|
+
python -m flywire_gnn.train --model sage --min-synapses 1 # dense 15M-edge graph
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## Design choices (and why)
|
|
89
|
+
|
|
90
|
+
- **`min_synapses=5`** is the published FAFB "connection" threshold (same default as Codex). Pass `min_synapses=1` for the full-density graph.
|
|
91
|
+
- **Node features come from the ≥1-synapse wiring** (a neuron's total projection profile); edges are filtered by `min_synapses`. So features are the same regardless of the edge threshold you benchmark.
|
|
92
|
+
- **Stratified splits on labeled nodes only**: with `super_class`, all 139,255 nodes are labeled. Deterministic via `seed=42` (NumPy `RandomState` + sklearn stratified split).
|
|
93
|
+
- The raw 9.5 GB per-synapse file (`flywire_synapses_783.feather`) is **not** needed: the 852 MB pair×neuropil table is sufficient.
|
|
94
|
+
|
|
95
|
+
## Not in v1 (deliberately)
|
|
96
|
+
|
|
97
|
+
Mesh/skeleton loading (use `fafbseg` + meshparty), other connectomes (MANC/MAOL/MCNS/BANC), hosted leaderboard server, spiking neural simulation, per-synapse link prediction. The package is intentionally complete at this scope.
|
|
98
|
+
|
|
99
|
+
## Tests
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
pytest tests/ -v
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Covers: graph assembly counts on a synthetic graph, feature finiteness, split disjointness/determinism, model forward + backward smoke tests, and (if the real cache is present) the real 139,255-node / 2,700,513-edge invariants.
|
|
106
|
+
|
|
107
|
+
## License & citation
|
|
108
|
+
|
|
109
|
+
Code: MIT. Data: **CC-BY-4.0**. Using the dataset means citing:
|
|
110
|
+
|
|
111
|
+
1. **Dorkenwald et al.** 2024. *Neuronal wiring diagram of an adult brain.* Nature. [doi:10.1038/s41586-024-07558-y](https://doi.org/10.1038/s41586-024-07558-y)
|
|
112
|
+
2. **Schlegel et al.** 2024. *Whole-brain annotation and multi-connectome cell typing of Drosophila.* Nature. [doi:10.1038/s41586-024-07686-5](https://doi.org/10.1038/s41586-024-07686-5)
|
|
113
|
+
|
|
114
|
+
Not affiliated with the FlyWire Consortium. Interactive exploration: [codex.flywire.ai](https://codex.flywire.ai) — analysis in Python: [navis](https://github.com/navis-org/navis) / [fafbseg](https://github.com/navis-org/fafbseg-py).
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
flywire_gnn/__init__.py
|
|
4
|
+
flywire_gnn/dataset.py
|
|
5
|
+
flywire_gnn/models.py
|
|
6
|
+
flywire_gnn/train.py
|
|
7
|
+
flywire_gnn.egg-info/PKG-INFO
|
|
8
|
+
flywire_gnn.egg-info/SOURCES.txt
|
|
9
|
+
flywire_gnn.egg-info/dependency_links.txt
|
|
10
|
+
flywire_gnn.egg-info/requires.txt
|
|
11
|
+
flywire_gnn.egg-info/top_level.txt
|
|
12
|
+
tests/test_pipeline.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
flywire_gnn
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "flywire-gnn"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "The FlyWire FAFB Drosophila connectome as a ready-to-train PyTorch Geometric dataset, with baseline models and reproducible benchmarks."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "Omkar" }]
|
|
13
|
+
keywords = ["connectome", "flywire", "drosophila", "gnn", "graph-neural-network", "neuroscience", "pytorch", "benchmark"]
|
|
14
|
+
dependencies = [
|
|
15
|
+
"numpy>=1.24",
|
|
16
|
+
"pandas>=2.0",
|
|
17
|
+
"pyarrow>=14.0",
|
|
18
|
+
"torch>=2.0",
|
|
19
|
+
"torch_geometric>=2.4",
|
|
20
|
+
"scikit-learn>=1.3",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
[project.optional-dependencies]
|
|
24
|
+
hub = ["huggingface_hub>=0.20"]
|
|
25
|
+
dev = ["pytest>=7.0"]
|
|
26
|
+
|
|
27
|
+
[project.urls]
|
|
28
|
+
FlyWire = "https://flywire.ai"
|
|
29
|
+
Zenodo = "https://doi.org/10.5281/zenodo.10676866"
|
|
30
|
+
|
|
31
|
+
[tool.setuptools.packages.find]
|
|
32
|
+
include = ["flywire_gnn*"]
|
|
33
|
+
|
|
34
|
+
[tool.pytest.ini_options]
|
|
35
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Tests for the flywire_gnn pipeline.
|
|
2
|
+
|
|
3
|
+
Synthetic-data tests run anywhere. The real-data smoke test is skipped unless
|
|
4
|
+
a processed cache is present (i.e. after a build on the developer machine).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
import pandas as pd
|
|
11
|
+
import pytest
|
|
12
|
+
import torch
|
|
13
|
+
|
|
14
|
+
from flywire_gnn import FlyWireFAFB, GraphSAGE, MLP
|
|
15
|
+
from flywire_gnn.dataset import assemble, make_splits
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def tiny_agg():
|
|
19
|
+
pairs = {
|
|
20
|
+
"pre_pt_root_id": np.array([1, 1, 2, 3, 4, 5], dtype=np.int64),
|
|
21
|
+
"post_pt_root_id": np.array([2, 3, 3, 1, 5, 4], dtype=np.int64),
|
|
22
|
+
"syn_count_sum": np.array([7, 3, 9, 2, 6, 11], dtype=np.int64),
|
|
23
|
+
"gaba_w_sum": np.array([0.5, 0.1, 8.0, 0.4, 5.0, 10.0]),
|
|
24
|
+
"ach_w_sum": np.array([6.0, 2.5, 0.5, 1.5, 0.5, 0.5]),
|
|
25
|
+
"glut_w_sum": np.array([0.3, 0.2, 0.2, 0.0, 0.4, 0.3]),
|
|
26
|
+
"oct_w_sum": np.zeros(6),
|
|
27
|
+
"ser_w_sum": np.zeros(6),
|
|
28
|
+
"da_w_sum": np.zeros(6),
|
|
29
|
+
}
|
|
30
|
+
neuro_pre = pd.DataFrame(
|
|
31
|
+
{
|
|
32
|
+
"root_id": np.array([1, 1, 2, 3, 4, 5], dtype=np.int64),
|
|
33
|
+
"neuropil": ["AL_L", "ME_R", "AL_L", "ME_R", "AL_L", "ME_R"],
|
|
34
|
+
"syn": np.array([7, 3, 9, 2, 6, 11], dtype=np.int64),
|
|
35
|
+
}
|
|
36
|
+
)
|
|
37
|
+
neuro_post = pd.DataFrame(
|
|
38
|
+
{
|
|
39
|
+
"root_id": np.array([2, 3, 3, 1, 5, 4], dtype=np.int64),
|
|
40
|
+
"neuropil": ["AL_L", "AL_L", "ME_R", "AL_L", "ME_R", "AL_L"],
|
|
41
|
+
"syn": np.array([7, 3, 9, 2, 6, 11], dtype=np.int64),
|
|
42
|
+
}
|
|
43
|
+
)
|
|
44
|
+
node_out = pd.DataFrame(
|
|
45
|
+
{
|
|
46
|
+
"root_id": np.array([1, 2, 3, 4, 5], dtype=np.int64),
|
|
47
|
+
"syn_total": np.array([10.0, 9.0, 2.0, 6.0, 11.0]),
|
|
48
|
+
"gaba_w_sum": np.array([0.6, 8.0, 0.4, 5.0, 10.0]),
|
|
49
|
+
"ach_w_sum": np.array([8.5, 0.5, 1.5, 0.5, 0.5]),
|
|
50
|
+
"glut_w_sum": np.array([0.5, 0.2, 0.0, 0.4, 0.3]),
|
|
51
|
+
"oct_w_sum": np.zeros(5),
|
|
52
|
+
"ser_w_sum": np.zeros(5),
|
|
53
|
+
"da_w_sum": np.zeros(5),
|
|
54
|
+
}
|
|
55
|
+
)
|
|
56
|
+
node_in = node_out.copy()
|
|
57
|
+
labels_df = pd.DataFrame(
|
|
58
|
+
{
|
|
59
|
+
"root_id": np.array([1, 2, 3, 4, 5, 6, 7, 8, 9, 10], dtype=np.int64),
|
|
60
|
+
"super_class": ["a", "b", "a", "b", "a", "b", "a", "b", "a", "b"],
|
|
61
|
+
}
|
|
62
|
+
)
|
|
63
|
+
return {
|
|
64
|
+
"pairs": pairs,
|
|
65
|
+
"neuro_pre": neuro_pre,
|
|
66
|
+
"neuro_post": neuro_post,
|
|
67
|
+
"node_out": node_out,
|
|
68
|
+
"node_in": node_in,
|
|
69
|
+
"neuropils": ["AL_L", "ME_R"],
|
|
70
|
+
}, labels_df
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_assemble_counts():
|
|
74
|
+
agg, labels_df = tiny_agg()
|
|
75
|
+
node_ids = np.arange(1, 11, dtype=np.int64)
|
|
76
|
+
out = assemble(agg, node_ids, labels_df, "super_class", min_synapses=5)
|
|
77
|
+
assert out["meta"]["num_nodes"] == 10
|
|
78
|
+
# pairs with syn >= 5: (1,2,7), (2,3,9), (4,5,6), (5,4,11)
|
|
79
|
+
assert out["meta"]["num_edges"] == 4
|
|
80
|
+
assert out["meta"]["feature_dim"] == 4 + 2 * 2 + 12
|
|
81
|
+
assert out["meta"]["labeled_nodes"] == 10
|
|
82
|
+
assert out["meta"]["dropped_edges_unmapped_root"] == 0
|
|
83
|
+
assert out["x"].shape == (10, 20)
|
|
84
|
+
assert out["edge_index"].dtype == torch.int64
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def test_features_finite():
|
|
88
|
+
agg, labels_df = tiny_agg()
|
|
89
|
+
node_ids = np.arange(1, 11, dtype=np.int64)
|
|
90
|
+
out = assemble(agg, node_ids, labels_df, "super_class", min_synapses=5)
|
|
91
|
+
assert torch.isfinite(out["x"]).all()
|
|
92
|
+
assert torch.isfinite(out["edge_attr"]).all()
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def test_splits_disjoint_and_deterministic():
|
|
96
|
+
y = torch.tensor([0, 1] * 50, dtype=torch.int64)
|
|
97
|
+
tm, vm, te = make_splits(y, seed=42)
|
|
98
|
+
assert not (tm & vm).any().item()
|
|
99
|
+
assert not (tm & te).any().item()
|
|
100
|
+
assert not (vm & te).any().item()
|
|
101
|
+
assert (tm | vm | te).all().item()
|
|
102
|
+
tm2, vm2, te2 = make_splits(y, seed=42)
|
|
103
|
+
assert (tm == tm2).all().item()
|
|
104
|
+
assert (vm == vm2).all().item()
|
|
105
|
+
assert (te == te2).all().item()
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def test_models_forward_small_graph():
|
|
109
|
+
torch.manual_seed(0)
|
|
110
|
+
n, e, f, c = 10, 40, 20, 3
|
|
111
|
+
x = torch.randn(n, f)
|
|
112
|
+
y = torch.randint(0, c, (n,))
|
|
113
|
+
edge_index = torch.randint(0, n, (2, e))
|
|
114
|
+
for model in [MLP(f, hidden=16, out_dim=c), GraphSAGE(f, hidden=16, out_dim=c)]:
|
|
115
|
+
out = model(x, edge_index)
|
|
116
|
+
assert out.shape == (n, c)
|
|
117
|
+
assert torch.isfinite(out).all()
|
|
118
|
+
loss = torch.nn.functional.cross_entropy(out, y)
|
|
119
|
+
loss.backward()
|
|
120
|
+
assert torch.isfinite(loss)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@pytest.mark.skipif(
|
|
124
|
+
not (Path("data") / "processed_v783_super_class_syn5.pt").exists(),
|
|
125
|
+
reason="real FAFB cache not built on this machine",
|
|
126
|
+
)
|
|
127
|
+
def test_real_cache_counts():
|
|
128
|
+
ds = FlyWireFAFB(root="data", labels="super_class", min_synapses=5, download=False)
|
|
129
|
+
m = ds._cache["meta"]
|
|
130
|
+
assert m["num_nodes"] == 139255
|
|
131
|
+
assert m["num_edges"] == 2700513
|
|
132
|
+
assert m["labeled_nodes"] == 139255
|
|
133
|
+
assert m["feature_dim"] == 174
|
|
134
|
+
assert len(ds._cache["label_names"]) == 9
|
|
135
|
+
tm, vm, te = ds.splits()
|
|
136
|
+
n = m["num_nodes"]
|
|
137
|
+
assert int(tm.sum()) + int(vm.sum()) + int(te.sum()) == n
|
|
138
|
+
assert not (tm & vm).any().item()
|
|
139
|
+
assert not (tm & te).any().item()
|
|
140
|
+
assert not (vm & te).any().item()
|