point-affinity-transformer 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.
Files changed (31) hide show
  1. point_affinity_transformer-0.1.0/.gitignore +19 -0
  2. point_affinity_transformer-0.1.0/LICENSE +21 -0
  3. point_affinity_transformer-0.1.0/PKG-INFO +132 -0
  4. point_affinity_transformer-0.1.0/README.md +95 -0
  5. point_affinity_transformer-0.1.0/pyproject.toml +61 -0
  6. point_affinity_transformer-0.1.0/src/pat/__init__.py +30 -0
  7. point_affinity_transformer-0.1.0/src/pat/__main__.py +3 -0
  8. point_affinity_transformer-0.1.0/src/pat/cli.py +85 -0
  9. point_affinity_transformer-0.1.0/src/pat/cluster.py +97 -0
  10. point_affinity_transformer-0.1.0/src/pat/inference.py +212 -0
  11. point_affinity_transformer-0.1.0/src/pat/io.py +49 -0
  12. point_affinity_transformer-0.1.0/src/pat/metrics.py +41 -0
  13. point_affinity_transformer-0.1.0/src/pat/model.py +311 -0
  14. point_affinity_transformer-0.1.0/src/pat/preprocess.py +68 -0
  15. point_affinity_transformer-0.1.0/src/pat/presets.py +25 -0
  16. point_affinity_transformer-0.1.0/src/pat/pretrained.py +117 -0
  17. point_affinity_transformer-0.1.0/src/pat/train/__init__.py +19 -0
  18. point_affinity_transformer-0.1.0/src/pat/train/augment.py +36 -0
  19. point_affinity_transformer-0.1.0/src/pat/train/checkpoint.py +28 -0
  20. point_affinity_transformer-0.1.0/src/pat/train/config.py +117 -0
  21. point_affinity_transformer-0.1.0/src/pat/train/data.py +255 -0
  22. point_affinity_transformer-0.1.0/src/pat/train/evaluate.py +78 -0
  23. point_affinity_transformer-0.1.0/src/pat/train/loop.py +174 -0
  24. point_affinity_transformer-0.1.0/tests/conftest.py +34 -0
  25. point_affinity_transformer-0.1.0/tests/test_cluster.py +36 -0
  26. point_affinity_transformer-0.1.0/tests/test_features.py +112 -0
  27. point_affinity_transformer-0.1.0/tests/test_metrics.py +21 -0
  28. point_affinity_transformer-0.1.0/tests/test_model.py +82 -0
  29. point_affinity_transformer-0.1.0/tests/test_pretrained.py +39 -0
  30. point_affinity_transformer-0.1.0/tests/test_segment.py +15 -0
  31. point_affinity_transformer-0.1.0/tests/test_train.py +66 -0
@@ -0,0 +1,19 @@
1
+ wandb/*
2
+ output/*
3
+ outputs/*
4
+ logs/*
5
+ *.pyc
6
+ util/__pycache__/*
7
+ util/__pycache__/datasets.cpython-39.pyc
8
+ *.png
9
+ output.log
10
+ *.log
11
+ misc/*
12
+ data/*
13
+ site/
14
+ *.egg-info/
15
+ .pytest_cache/
16
+ ckpt/
17
+ dist/
18
+ .ipynb_checkpoints/
19
+ .cache/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 Jakob Troidl
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,132 @@
1
+ Metadata-Version: 2.5
2
+ Name: point-affinity-transformer
3
+ Version: 0.1.0
4
+ Summary: Point Affinity Transformers for global neuron shape reasoning
5
+ Project-URL: Paper, https://www.biorxiv.org/content/10.1101/2024.11.24.625067v1
6
+ Project-URL: Source, https://github.com/jakobtroidl/neuron-shape-reasoning
7
+ Author: Jakob Troidl
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: connectomics,neuron,point cloud,segmentation,transformer
11
+ Classifier: Intended Audience :: Science/Research
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Topic :: Scientific/Engineering :: Bio-Informatics
14
+ Requires-Python: >=3.9
15
+ Requires-Dist: navis>=1.4
16
+ Requires-Dist: numpy
17
+ Requires-Dist: pandas
18
+ Requires-Dist: pyyaml
19
+ Requires-Dist: safetensors>=0.4
20
+ Requires-Dist: scikit-learn>=1.2
21
+ Requires-Dist: torch>=2.1
22
+ Requires-Dist: tqdm
23
+ Provides-Extra: dev
24
+ Requires-Dist: einops; extra == 'dev'
25
+ Requires-Dist: pytest; extra == 'dev'
26
+ Requires-Dist: python-voi==0.0.2; extra == 'dev'
27
+ Requires-Dist: ruff; extra == 'dev'
28
+ Requires-Dist: timm; extra == 'dev'
29
+ Provides-Extra: docs
30
+ Requires-Dist: mkdocs-material>=9; extra == 'docs'
31
+ Requires-Dist: mkdocstrings[python]>=0.24; extra == 'docs'
32
+ Provides-Extra: pretrained
33
+ Requires-Dist: gdown>=5; extra == 'pretrained'
34
+ Provides-Extra: wandb
35
+ Requires-Dist: wandb; extra == 'wandb'
36
+ Description-Content-Type: text/markdown
37
+
38
+ [![Paper](https://img.shields.io/badge/paper-arxiv-blue.svg?colorB=4AC8F4)](https://www.biorxiv.org/content/10.1101/2024.11.24.625067v1)
39
+ [![Data](https://img.shields.io/badge/data-gdrive-red.svg?colorB=f25100)](https://drive.google.com/drive/folders/1vgPSYsqDJyhv1s9aD09GgXqAeGm0Rb1V?usp=sharing)
40
+ [![Models](https://img.shields.io/badge/models-gdrive-purple.svg?colorB=C46CFD)](https://drive.google.com/drive/folders/1vgPSYsqDJyhv1s9aD09GgXqAeGm0Rb1V?usp=sharing)
41
+
42
+ ## Point Affinity Transformers
43
+
44
+
45
+ ### Installation
46
+ ```bash
47
+ pip install point-affinity-transformer
48
+ ```
49
+ The package is imported as `pat`. Install a PyTorch build matching your CUDA version first ([pytorch.org](https://pytorch.org/get-started/locally/)).
50
+
51
+ ### Getting Started
52
+
53
+ ```python
54
+ import pat
55
+ from pat.io import read_swcs
56
+
57
+ points, gt = read_swcs(["a.swc", "b.swc"]) # (N, 3) coordinates in µm
58
+ points = points / 659.88 # your normalization: the same as for the training data
59
+ model = pat.load_pretrained("my_model", device="cuda") # directory from pat train or pat.save_pretrained
60
+
61
+ seg = pat.segment(model, points, threshold=0.8) # one label per point, -1 = noise
62
+ print(seg.num_neurons, pat.metrics.voi(seg.labels, gt))
63
+
64
+ enc = pat.encode(model, points) # encode once ...
65
+ A = enc.affinity(queries, others) # ... then query affinities between any points
66
+ ```
67
+
68
+ ### Point Clouds and Features
69
+
70
+ Point clouds of shape `(B, N, 3)` with `D` extra features are passed to the model as `(B, N, 3 + D)`. Training only needs a neuron label per point.
71
+
72
+ ```python
73
+ import torch
74
+ import torch.nn.functional as F
75
+ import pat
76
+
77
+ B, N, D = 8, 1024, 4 # random toy data: replace with your own
78
+ xyz = torch.rand(B, N, 3) * 2 - 1 # coordinates, already normalized to roughly [-1, 1]
79
+ features = torch.randn(B, N, D) # D features per point
80
+ labels = torch.randint(0, 3, (B, N)) # neuron id of every point (training targets)
81
+ pc = torch.cat([xyz, features], dim=-1) # (B, N, 3 + D)
82
+
83
+ model = pat.create_model("ae_d256_m64", depth=4, feature_dim=D)
84
+ optimizer = torch.optim.AdamW(model.parameters(), lr=1e-4)
85
+
86
+ # Training: sample random point pairs, target = 1 if both lie on the same neuron
87
+ def gather(x, idx): # x: (B, N, C), idx: (B, Q) -> (B, Q, C)
88
+ return x.gather(1, idx[..., None].expand(-1, -1, x.shape[-1]))
89
+
90
+ model.train()
91
+ for step in range(100):
92
+ i, j = torch.randint(0, N, (2, B, 2048)) # 2048 query pairs per cloud
93
+ target = (labels.gather(1, i) == labels.gather(1, j)).float()
94
+ logits = model(pc, None, gather(pc, i), gather(pc, j), logits=True) # mask=None: no padding
95
+ loss = F.binary_cross_entropy_with_logits(logits, target)
96
+ optimizer.zero_grad()
97
+ loss.backward()
98
+ optimizer.step()
99
+
100
+ # Inference
101
+ model.eval()
102
+ with torch.no_grad():
103
+ latents = model.encode(pc) # (B, num_latents, dim), once per cloud
104
+ affinity = model.decode(latents, pc[:, :5], pc[:, 5:10]) # (B, 5): any query pairs, with features
105
+
106
+ seg = pat.segment(model, pc[0]) # cluster one cloud: labels (N,), -1 = noise
107
+ pat.save_pretrained(model, "my_model") # reload with pat.load_pretrained("my_model")
108
+ ```
109
+
110
+ Coordinates should lie within roughly [-1, 1]. Clouds of different sizes can be padded with `pat.preprocess.pad_batch`, which returns the `mask` to pass instead of
111
+ `None`. A trained 3D model can be extended to `D` features with `pat.expand_features(model, D)` and then fine-tuned
112
+ the same way. To train from SWC files, run `pat train config.yaml` (examples in [`research/configs`](research/configs)),
113
+ optionally with `data.features: [radius]` or, from Python, `pat.train.train(cfg, feature_fn=...)`.
114
+
115
+ See the [quickstart notebook](examples/quickstart.ipynb) for an end-to-end example, and the documentation
116
+ (`pip install -e ".[docs]" && mkdocs serve`) for the API reference.
117
+
118
+ ### Citation
119
+ ```bibtex
120
+ @techreport{troidlgnsr2024,
121
+ title = {Global Neuron Shape Reasoning with Point Affinity Transformers},
122
+ author = {Troidl, Jakob and Knittel, Johannes and Li, Wanhua and Zhan, Fengnang and Pfister*, Hanspeter and Turaga*, Srinivas},
123
+ journal = {bioRxiv},
124
+ year = {2024},
125
+ publisher = {Cold Spring Harbor Laboratory},
126
+ keywords = {preprint}
127
+ }
128
+ ```
129
+
130
+ ### Acknowledgements
131
+ We acknowledge NSF grants CRCNS-2309041, NCS-FO-2124179, and NIH grant R01HD104969. We also thank the HHMI Janelia Visiting Scientist Program and the Harvard Data Science Initiative Postdoctoral Fellowship for their support. The code is partially based on [3DShape2VecSet](https://arxiv.org/abs/2301.11445) by Zhang et. al.
132
+
@@ -0,0 +1,95 @@
1
+ [![Paper](https://img.shields.io/badge/paper-arxiv-blue.svg?colorB=4AC8F4)](https://www.biorxiv.org/content/10.1101/2024.11.24.625067v1)
2
+ [![Data](https://img.shields.io/badge/data-gdrive-red.svg?colorB=f25100)](https://drive.google.com/drive/folders/1vgPSYsqDJyhv1s9aD09GgXqAeGm0Rb1V?usp=sharing)
3
+ [![Models](https://img.shields.io/badge/models-gdrive-purple.svg?colorB=C46CFD)](https://drive.google.com/drive/folders/1vgPSYsqDJyhv1s9aD09GgXqAeGm0Rb1V?usp=sharing)
4
+
5
+ ## Point Affinity Transformers
6
+
7
+
8
+ ### Installation
9
+ ```bash
10
+ pip install point-affinity-transformer
11
+ ```
12
+ The package is imported as `pat`. Install a PyTorch build matching your CUDA version first ([pytorch.org](https://pytorch.org/get-started/locally/)).
13
+
14
+ ### Getting Started
15
+
16
+ ```python
17
+ import pat
18
+ from pat.io import read_swcs
19
+
20
+ points, gt = read_swcs(["a.swc", "b.swc"]) # (N, 3) coordinates in µm
21
+ points = points / 659.88 # your normalization: the same as for the training data
22
+ model = pat.load_pretrained("my_model", device="cuda") # directory from pat train or pat.save_pretrained
23
+
24
+ seg = pat.segment(model, points, threshold=0.8) # one label per point, -1 = noise
25
+ print(seg.num_neurons, pat.metrics.voi(seg.labels, gt))
26
+
27
+ enc = pat.encode(model, points) # encode once ...
28
+ A = enc.affinity(queries, others) # ... then query affinities between any points
29
+ ```
30
+
31
+ ### Point Clouds and Features
32
+
33
+ Point clouds of shape `(B, N, 3)` with `D` extra features are passed to the model as `(B, N, 3 + D)`. Training only needs a neuron label per point.
34
+
35
+ ```python
36
+ import torch
37
+ import torch.nn.functional as F
38
+ import pat
39
+
40
+ B, N, D = 8, 1024, 4 # random toy data: replace with your own
41
+ xyz = torch.rand(B, N, 3) * 2 - 1 # coordinates, already normalized to roughly [-1, 1]
42
+ features = torch.randn(B, N, D) # D features per point
43
+ labels = torch.randint(0, 3, (B, N)) # neuron id of every point (training targets)
44
+ pc = torch.cat([xyz, features], dim=-1) # (B, N, 3 + D)
45
+
46
+ model = pat.create_model("ae_d256_m64", depth=4, feature_dim=D)
47
+ optimizer = torch.optim.AdamW(model.parameters(), lr=1e-4)
48
+
49
+ # Training: sample random point pairs, target = 1 if both lie on the same neuron
50
+ def gather(x, idx): # x: (B, N, C), idx: (B, Q) -> (B, Q, C)
51
+ return x.gather(1, idx[..., None].expand(-1, -1, x.shape[-1]))
52
+
53
+ model.train()
54
+ for step in range(100):
55
+ i, j = torch.randint(0, N, (2, B, 2048)) # 2048 query pairs per cloud
56
+ target = (labels.gather(1, i) == labels.gather(1, j)).float()
57
+ logits = model(pc, None, gather(pc, i), gather(pc, j), logits=True) # mask=None: no padding
58
+ loss = F.binary_cross_entropy_with_logits(logits, target)
59
+ optimizer.zero_grad()
60
+ loss.backward()
61
+ optimizer.step()
62
+
63
+ # Inference
64
+ model.eval()
65
+ with torch.no_grad():
66
+ latents = model.encode(pc) # (B, num_latents, dim), once per cloud
67
+ affinity = model.decode(latents, pc[:, :5], pc[:, 5:10]) # (B, 5): any query pairs, with features
68
+
69
+ seg = pat.segment(model, pc[0]) # cluster one cloud: labels (N,), -1 = noise
70
+ pat.save_pretrained(model, "my_model") # reload with pat.load_pretrained("my_model")
71
+ ```
72
+
73
+ Coordinates should lie within roughly [-1, 1]. Clouds of different sizes can be padded with `pat.preprocess.pad_batch`, which returns the `mask` to pass instead of
74
+ `None`. A trained 3D model can be extended to `D` features with `pat.expand_features(model, D)` and then fine-tuned
75
+ the same way. To train from SWC files, run `pat train config.yaml` (examples in [`research/configs`](research/configs)),
76
+ optionally with `data.features: [radius]` or, from Python, `pat.train.train(cfg, feature_fn=...)`.
77
+
78
+ See the [quickstart notebook](examples/quickstart.ipynb) for an end-to-end example, and the documentation
79
+ (`pip install -e ".[docs]" && mkdocs serve`) for the API reference.
80
+
81
+ ### Citation
82
+ ```bibtex
83
+ @techreport{troidlgnsr2024,
84
+ title = {Global Neuron Shape Reasoning with Point Affinity Transformers},
85
+ author = {Troidl, Jakob and Knittel, Johannes and Li, Wanhua and Zhan, Fengnang and Pfister*, Hanspeter and Turaga*, Srinivas},
86
+ journal = {bioRxiv},
87
+ year = {2024},
88
+ publisher = {Cold Spring Harbor Laboratory},
89
+ keywords = {preprint}
90
+ }
91
+ ```
92
+
93
+ ### Acknowledgements
94
+ We acknowledge NSF grants CRCNS-2309041, NCS-FO-2124179, and NIH grant R01HD104969. We also thank the HHMI Janelia Visiting Scientist Program and the Harvard Data Science Initiative Postdoctoral Fellowship for their support. The code is partially based on [3DShape2VecSet](https://arxiv.org/abs/2301.11445) by Zhang et. al.
95
+
@@ -0,0 +1,61 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "point-affinity-transformer"
7
+ version = "0.1.0"
8
+ description = "Point Affinity Transformers for global neuron shape reasoning"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ requires-python = ">=3.9"
13
+ authors = [{ name = "Jakob Troidl" }]
14
+ keywords = ["connectomics", "neuron", "point cloud", "transformer", "segmentation"]
15
+ classifiers = [
16
+ "Programming Language :: Python :: 3",
17
+ "Intended Audience :: Science/Research",
18
+ "Topic :: Scientific/Engineering :: Bio-Informatics",
19
+ ]
20
+ dependencies = [
21
+ "torch>=2.1",
22
+ "numpy",
23
+ "scikit-learn>=1.2",
24
+ "safetensors>=0.4",
25
+ "navis>=1.4",
26
+ "pandas",
27
+ "pyyaml",
28
+ "tqdm",
29
+ ]
30
+
31
+ [project.optional-dependencies]
32
+ pretrained = ["gdown>=5"]
33
+ wandb = ["wandb"]
34
+ docs = ["mkdocs-material>=9", "mkdocstrings[python]>=0.24"]
35
+ # timm, einops and python-voi are for comparing against research/legacy;
36
+ # python-voi 0.0.3 ships an empty wheel, so pin 0.0.2.
37
+ dev = ["pytest", "ruff", "timm", "einops", "python-voi==0.0.2"]
38
+
39
+ [project.scripts]
40
+ pat = "pat.cli:main"
41
+
42
+ [project.urls]
43
+ Paper = "https://www.biorxiv.org/content/10.1101/2024.11.24.625067v1"
44
+ Source = "https://github.com/jakobtroidl/neuron-shape-reasoning"
45
+
46
+ [tool.hatch.build.targets.wheel]
47
+ packages = ["src/pat"]
48
+
49
+ [tool.hatch.build.targets.sdist]
50
+ include = ["/src/pat", "/tests", "/README.md", "/LICENSE"]
51
+
52
+ [tool.pytest.ini_options]
53
+ testpaths = ["tests"]
54
+
55
+ [tool.ruff]
56
+ line-length = 120
57
+ src = ["src"]
58
+ target-version = "py39"
59
+
60
+ [tool.ruff.lint]
61
+ select = ["E", "F", "I", "B"]
@@ -0,0 +1,30 @@
1
+ """Point Affinity Transformer: global neuron shape reasoning on point clouds."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ from . import cluster, metrics
6
+ from .inference import EncodedCloud, Segmentation, encode, segment
7
+ from .model import PATConfig, PointAffinityTransformer, expand_features
8
+ from .presets import PRESETS, create_model
9
+ from .pretrained import load_pretrained, save_pretrained
10
+
11
+ try:
12
+ __version__ = version("point-affinity-transformer") # defined in pyproject.toml
13
+ except PackageNotFoundError: # running from a source tree without installing
14
+ __version__ = "unknown"
15
+
16
+ __all__ = [
17
+ "PATConfig",
18
+ "PointAffinityTransformer",
19
+ "EncodedCloud",
20
+ "Segmentation",
21
+ "encode",
22
+ "segment",
23
+ "create_model",
24
+ "PRESETS",
25
+ "load_pretrained",
26
+ "save_pretrained",
27
+ "expand_features",
28
+ "cluster",
29
+ "metrics",
30
+ ]
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ main()
@@ -0,0 +1,85 @@
1
+ """Command line interface: ``pat train | eval | convert``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+
7
+
8
+ def _train(args):
9
+ from .train import load_config, train
10
+
11
+ train(load_config(args.config, args.set))
12
+
13
+
14
+ def _eval(args):
15
+ import json
16
+
17
+ import torch
18
+
19
+ from .pretrained import load_pretrained
20
+ from .train import MergedNeuronDataset, SkeletonStore, TensorDirDataset, evaluate, format_results, load_config
21
+ from .train.data import read_root_ids
22
+
23
+ cfg = load_config(args.config, args.set)
24
+ torch.manual_seed(cfg.seed)
25
+ model = load_pretrained(args.model, device=args.device or cfg.device)
26
+ if args.items:
27
+ dataset = TensorDirDataset(args.items)
28
+ else:
29
+ store = SkeletonStore(cfg.data.swc_dir, cfg.data.units, cfg.data.dust_size, cfg.data.cache_dir,
30
+ features=cfg.data.features)
31
+ ids = read_root_ids(cfg.data.eval_neuron_ids or cfg.data.neuron_ids)
32
+ dataset = MergedNeuronDataset(store, ids, cfg.data, train=False)
33
+ thresholds = [float(t) for t in args.thresholds.split(",")]
34
+ results = evaluate(model, dataset, thresholds, min_cluster_size=args.min_cluster_size,
35
+ symmetrize=args.symmetrize, limit=args.limit)
36
+ print(format_results(results))
37
+ if args.out:
38
+ with open(args.out, "w") as f:
39
+ json.dump(results, f, indent=2)
40
+
41
+
42
+ def _convert(args):
43
+ import torch
44
+
45
+ from .pretrained import load_legacy_checkpoint, save_pretrained
46
+
47
+ model = load_legacy_checkpoint(args.checkpoint, preset=args.preset)
48
+ print(save_pretrained(model, args.out, dtype=torch.float16 if args.fp16 else None))
49
+
50
+
51
+ def main(argv=None):
52
+ parser = argparse.ArgumentParser(prog="pat", description="Point Affinity Transformer")
53
+ sub = parser.add_subparsers(dest="command", required=True)
54
+
55
+ p = sub.add_parser("train", help="train or fine-tune a model")
56
+ p.add_argument("config", help="YAML training config")
57
+ p.add_argument("--set", nargs="*", default=[], metavar="KEY=VALUE", help="override config entries")
58
+ p.set_defaults(func=_train)
59
+
60
+ p = sub.add_parser("eval", help="evaluate segmentation on merged clouds")
61
+ p.add_argument("config", nargs="?", help="YAML config describing the data (not needed with --items)")
62
+ p.add_argument("--model", required=True, help="model directory (from pat train or save_pretrained)")
63
+ p.add_argument("--items", help="directory of pre-generated items (pc_*.pt, labels_*.pt, mask_*.pt)")
64
+ p.add_argument("--thresholds", default="0.8")
65
+ p.add_argument("--min-cluster-size", type=int, default=30)
66
+ p.add_argument("--symmetrize", action="store_true", help="symmetrize affinities before clustering")
67
+ p.add_argument("--limit", type=int, help="evaluate only the first N items")
68
+ p.add_argument("--device")
69
+ p.add_argument("--out", help="write results as JSON")
70
+ p.add_argument("--set", nargs="*", default=[], metavar="KEY=VALUE")
71
+ p.set_defaults(func=_eval)
72
+
73
+ p = sub.add_parser("convert", help="convert a research .pth checkpoint into a model directory")
74
+ p.add_argument("checkpoint")
75
+ p.add_argument("out")
76
+ p.add_argument("--preset", help="model preset; overrides the one stored in the checkpoint")
77
+ p.add_argument("--fp16", action="store_true", help="store weights in float16")
78
+ p.set_defaults(func=_convert)
79
+
80
+ args = parser.parse_args(argv)
81
+ args.func(args)
82
+
83
+
84
+ if __name__ == "__main__":
85
+ main()
@@ -0,0 +1,97 @@
1
+ """Clustering of affinity matrices into neuron labels.
2
+
3
+ The label ``-1`` marks noise: DBSCAN outliers and, after
4
+ [`remove_small_clusters`][pat.cluster.remove_small_clusters], points of clusters
5
+ smaller than ``min_cluster_size``.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import numpy as np
11
+ import torch
12
+ from sklearn import cluster as _skcluster
13
+
14
+ NOISE = -1
15
+
16
+
17
+ def to_numpy(x) -> np.ndarray:
18
+ """Convert a tensor or array-like to a NumPy array."""
19
+ return x.detach().cpu().numpy() if torch.is_tensor(x) else np.asarray(x)
20
+
21
+
22
+ def num_clusters(labels) -> int:
23
+ """Number of clusters in ``labels`` numbered ``0..K-1``, ignoring noise (``-1``)."""
24
+ labels = torch.as_tensor(labels)
25
+ return int(labels.max().item()) + 1 if (labels >= 0).any() else 0
26
+
27
+
28
+ def agglomerative(distance, threshold: float = 0.8, linkage: str = "average") -> torch.Tensor:
29
+ """Agglomerative clustering of a precomputed ``(N, N)`` distance matrix.
30
+
31
+ Clusters are merged while their linkage distance is below ``threshold``.
32
+ """
33
+ d = to_numpy(distance)
34
+ model = _skcluster.AgglomerativeClustering(
35
+ n_clusters=None, metric="precomputed", distance_threshold=threshold, linkage=linkage
36
+ )
37
+ return torch.as_tensor(model.fit_predict(d), dtype=torch.int64)
38
+
39
+
40
+ def dbscan(distance, eps: float = 0.2, min_samples: int = 5) -> torch.Tensor:
41
+ """DBSCAN on a precomputed distance matrix. Noise points get label ``-1``."""
42
+ d = to_numpy(distance)
43
+ labels = _skcluster.DBSCAN(eps=eps, min_samples=min_samples, metric="precomputed").fit_predict(d)
44
+ return torch.as_tensor(labels, dtype=torch.int64)
45
+
46
+
47
+ def remove_small_clusters(labels: torch.Tensor, min_size: int) -> torch.Tensor:
48
+ """Mark clusters with fewer than ``min_size`` points as noise and relabel.
49
+
50
+ The remaining clusters are numbered ``0..K-1``, largest first. Existing
51
+ ``-1`` labels stay noise.
52
+ """
53
+ labels = torch.as_tensor(labels, dtype=torch.int64)
54
+ out = torch.full_like(labels, NOISE)
55
+ valid = labels >= 0
56
+ if not valid.any():
57
+ return out
58
+ ids, inverse, counts = torch.unique(labels[valid], return_inverse=True, return_counts=True)
59
+ keep = counts >= min_size
60
+ order = torch.argsort(counts.masked_fill(~keep, -1), descending=True, stable=True)
61
+ new_id = torch.full_like(ids, NOISE)
62
+ new_id[order[: int(keep.sum())]] = torch.arange(int(keep.sum()))
63
+ out[valid] = new_id[inverse]
64
+ return out
65
+
66
+
67
+ def cluster_affinity(
68
+ affinity,
69
+ threshold: float = 0.8,
70
+ min_cluster_size: int = 30,
71
+ method: str = "agglomerative",
72
+ symmetrize: bool = True,
73
+ ) -> torch.Tensor:
74
+ """Turn an ``(N, N)`` affinity matrix into one label per point.
75
+
76
+ Args:
77
+ affinity: Pairwise same-neuron probabilities.
78
+ threshold: Distance threshold on ``1 - affinity``. For average linkage,
79
+ two groups merge while their mean affinity exceeds ``1 - threshold``.
80
+ For DBSCAN this is ``eps``.
81
+ min_cluster_size: Smaller clusters become noise (``-1``). Use ``0`` to keep all.
82
+ method: ``"agglomerative"`` or ``"dbscan"``.
83
+ symmetrize: Average ``A`` and ``A.T`` first. The model is not exactly
84
+ symmetric in its two inputs; the paper evaluation used ``False``.
85
+ """
86
+ a = to_numpy(affinity).astype(np.float32)
87
+ if symmetrize:
88
+ a = 0.5 * (a + a.T)
89
+ dist = np.clip(1.0 - a, 0.0, None)
90
+ np.fill_diagonal(dist, 0.0)
91
+ if method == "agglomerative":
92
+ labels = agglomerative(dist, threshold=threshold)
93
+ elif method == "dbscan":
94
+ labels = dbscan(dist, eps=threshold)
95
+ else:
96
+ raise ValueError(f"Unknown method {method!r}")
97
+ return remove_small_clusters(labels, min_cluster_size) if min_cluster_size > 0 else labels