carve-validate 1.0.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 (35) hide show
  1. carve_validate-1.0.0/LICENSE +21 -0
  2. carve_validate-1.0.0/PKG-INFO +176 -0
  3. carve_validate-1.0.0/README.md +125 -0
  4. carve_validate-1.0.0/pyproject.toml +91 -0
  5. carve_validate-1.0.0/setup.cfg +4 -0
  6. carve_validate-1.0.0/src/carve/__init__.py +8 -0
  7. carve_validate-1.0.0/src/carve/_accuracy.py +42 -0
  8. carve_validate-1.0.0/src/carve/_consensus.py +213 -0
  9. carve_validate-1.0.0/src/carve/_grids.py +153 -0
  10. carve_validate-1.0.0/src/carve/_output.py +132 -0
  11. carve_validate-1.0.0/src/carve/_pipeline.py +182 -0
  12. carve_validate-1.0.0/src/carve/_plotting.py +1568 -0
  13. carve_validate-1.0.0/src/carve/_runner.py +561 -0
  14. carve_validate-1.0.0/src/carve/_selection.py +401 -0
  15. carve_validate-1.0.0/src/carve/_types.py +130 -0
  16. carve_validate-1.0.0/src/carve/_utils.py +306 -0
  17. carve_validate-1.0.0/src/carve/api.py +1763 -0
  18. carve_validate-1.0.0/src/carve/cluster.py +332 -0
  19. carve_validate-1.0.0/src/carve_validate.egg-info/PKG-INFO +176 -0
  20. carve_validate-1.0.0/src/carve_validate.egg-info/SOURCES.txt +33 -0
  21. carve_validate-1.0.0/src/carve_validate.egg-info/dependency_links.txt +1 -0
  22. carve_validate-1.0.0/src/carve_validate.egg-info/requires.txt +34 -0
  23. carve_validate-1.0.0/src/carve_validate.egg-info/top_level.txt +1 -0
  24. carve_validate-1.0.0/tests/test_accuracy.py +89 -0
  25. carve_validate-1.0.0/tests/test_api.py +594 -0
  26. carve_validate-1.0.0/tests/test_cluster.py +249 -0
  27. carve_validate-1.0.0/tests/test_consensus.py +260 -0
  28. carve_validate-1.0.0/tests/test_grids.py +184 -0
  29. carve_validate-1.0.0/tests/test_output.py +115 -0
  30. carve_validate-1.0.0/tests/test_pipeline.py +174 -0
  31. carve_validate-1.0.0/tests/test_plotting.py +502 -0
  32. carve_validate-1.0.0/tests/test_runner.py +339 -0
  33. carve_validate-1.0.0/tests/test_selection.py +317 -0
  34. carve_validate-1.0.0/tests/test_types.py +60 -0
  35. carve_validate-1.0.0/tests/test_utils.py +234 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Kai Wycik
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,176 @@
1
+ Metadata-Version: 2.4
2
+ Name: carve-validate
3
+ Version: 1.0.0
4
+ Summary: Cluster Analysis with Resampling for Validation and Exploration: stability- and generalizability-based clustering validation.
5
+ Author-email: Kai Wycik <kai.wycik@columbia.edu>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/DataSlingers/CARVE
8
+ Project-URL: Repository, https://github.com/DataSlingers/CARVE
9
+ Project-URL: Issues, https://github.com/DataSlingers/CARVE/issues
10
+ Keywords: clustering,validation,stability,consensus,scRNA-seq,scikit-learn
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Science/Research
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Topic :: Scientific/Engineering :: Bio-Informatics
15
+ Requires-Python: <3.13,>=3.12
16
+ Description-Content-Type: text/markdown
17
+ License-File: LICENSE
18
+ Requires-Dist: numpy<2.1,>=2.0
19
+ Requires-Dist: scipy<1.17,>=1.16
20
+ Requires-Dist: scikit-learn<1.8,>=1.7.2
21
+ Requires-Dist: pandas<2.3,>=2.2
22
+ Requires-Dist: joblib<1.6,>=1.5
23
+ Requires-Dist: tqdm<5,>=4.67
24
+ Requires-Dist: numba<0.61,>=0.60
25
+ Requires-Dist: llvmlite<0.44,>=0.43
26
+ Requires-Dist: umap-learn>=0.5.6
27
+ Requires-Dist: pynndescent<0.6,>=0.5.10
28
+ Requires-Dist: matplotlib>=3.9.4
29
+ Provides-Extra: dev
30
+ Requires-Dist: ruff; extra == "dev"
31
+ Requires-Dist: pytest; extra == "dev"
32
+ Requires-Dist: pytest-cov; extra == "dev"
33
+ Provides-Extra: notebooks
34
+ Requires-Dist: jupyterlab<5,>=4.2; extra == "notebooks"
35
+ Requires-Dist: ipykernel<7,>=6.29; extra == "notebooks"
36
+ Requires-Dist: ipywidgets>=8.1.7; extra == "notebooks"
37
+ Requires-Dist: plotly>=6.3.0; extra == "notebooks"
38
+ Requires-Dist: rpy2>=3.6.3; extra == "notebooks"
39
+ Requires-Dist: pyreadr>=0.5.3; extra == "notebooks"
40
+ Requires-Dist: scanpy>=1.11.4; extra == "notebooks"
41
+ Requires-Dist: scikit-misc>=0.5.1; extra == "notebooks"
42
+ Requires-Dist: anywidget>=0.9.18; extra == "notebooks"
43
+ Requires-Dist: igraph>=1.0.0; extra == "notebooks"
44
+ Requires-Dist: leidenalg>=0.11.0; extra == "notebooks"
45
+ Requires-Dist: pyteomics>=4.7.5; extra == "notebooks"
46
+ Requires-Dist: lxml>=6.0.2; extra == "notebooks"
47
+ Requires-Dist: scvi>=0.6.8; extra == "notebooks"
48
+ Requires-Dist: cmap>=0.7.0; extra == "notebooks"
49
+ Requires-Dist: glasbey>=0.3.0; extra == "notebooks"
50
+ Dynamic: license-file
51
+
52
+ [![CI](https://github.com/DataSlingers/CARVE/actions/workflows/ci.yml/badge.svg)](https://github.com/DataSlingers/CARVE/actions/workflows/ci.yml)
53
+ [![R CMD check](https://github.com/DataSlingers/CARVE/actions/workflows/r-ci.yml/badge.svg)](https://github.com/DataSlingers/CARVE/actions/workflows/r-ci.yml)
54
+ ![Python 3.12](https://img.shields.io/badge/python-3.12-blue.svg)
55
+ ![Version](https://img.shields.io/badge/version-1.0.0-orange.svg)
56
+
57
+ # CARVE
58
+
59
+ **Cluster Analysis with Resampling for Validation and Exploration**
60
+
61
+ Choosing the number of clusters is hard, especially for high-dimensional biological data where standard internal clustering validation indices (CVIs) are often unreliable. CARVE measures clustering robustness through two resampling-based concepts: **stability** (reproducibility of cluster assignments under data subsampling) and **generalizability** (agreement between held-out cluster labels and predictions from a classifier trained on a subsample of the data). CARVE reports global, cluster-level, and sample-level diagnostics with visualizations, all through a scikit-learn-compatible API.
62
+
63
+ <p align="center">
64
+ <img src="https://raw.githubusercontent.com/DataSlingers/CARVE/main/carve_overview.png" width="700" alt="CARVE overview">
65
+ </p>
66
+
67
+ ## Features
68
+
69
+ - Scikit-learn-compatible API: `CARVE` extends `BaseEstimator` with a `fit` / `get_labels` / `get_k` workflow
70
+ - Stability (ARI on subsample overlap) and generalizability (ARI on held-out predictions) metrics
71
+ - Diagnostics at the global, per-cluster, and per-sample level
72
+ - Metrics: stability and generalizability ARIs, consensus PAC, Gini, cross-entropy, and predictive accuracy
73
+ - Selection rules: `max`, `1se` (one-standard-error), and `quantile`
74
+ - Custom spectral clustering with self-tuning affinity (based on Zelnik-Manor & Perona, *Self-Tuning Spectral Clustering*, NeurIPS 2004)
75
+ - Plots: metric-over-*k* curves, consensus heatmaps, box plots, violin plots, and scatter plots
76
+ - Parallel resampling via joblib (`n_jobs`)
77
+
78
+ ## Installation
79
+
80
+ CARVE requires **Python 3.12**.
81
+
82
+ ```bash
83
+ pip install carve-validate
84
+ ```
85
+
86
+ The distribution is named `carve-validate`; the import name is `carve`:
87
+
88
+ ```python
89
+ from carve import CARVE
90
+ ```
91
+
92
+ ### From source (development)
93
+
94
+ ```bash
95
+ git clone https://github.com/DataSlingers/CARVE.git
96
+ cd CARVE
97
+ pip install -e ".[dev]" # linting + testing
98
+ pip install -e ".[notebooks]" # Jupyter, Scanpy, scVI, etc.
99
+ ```
100
+
101
+ ## Quick Start
102
+
103
+ ```python
104
+ from carve import CARVE
105
+ from sklearn.datasets import make_blobs
106
+
107
+ # Generate synthetic data
108
+ X, y_true = make_blobs(n_samples=500, n_features=10, centers=5, random_state=42)
109
+
110
+ # Fit CARVE
111
+ carve = CARVE(n_clusters=10, n_resamples=120, subsample_ratio=0.7, n_jobs=4)
112
+ carve.fit(X)
113
+
114
+ # Select best k and retrieve labels
115
+ k = carve.get_k(measure="generalizability", rule="1se")
116
+ labels = carve.get_labels(measure="generalizability", rule="1se")
117
+ print(f"Selected k={k}")
118
+ ```
119
+
120
+ See [`notebooks/Tutorial.ipynb`](notebooks/Tutorial.ipynb) for a walkthrough, and [`notebooks/case_studies/`](notebooks/case_studies/) for real-world analyses on scRNA-seq and mass cytometry datasets.
121
+
122
+ ## Visualization
123
+
124
+ ```python
125
+ # Metric curves across k
126
+ carve.plot_metric_over_n_clusters(measure="stability", rule="1se")
127
+
128
+ # Consensus heatmap for the selected solution
129
+ carve.plot_consensus_matrix(measure="generalizability", rule="1se")
130
+
131
+ # Per-cluster stability violin plot
132
+ carve.plot_cluster_violin(source="gini", measure="generalizability", rule="1se")
133
+
134
+ # 2D scatter with score-encoded marker size and opacity
135
+ carve.plot_cluster_scatter(source="gini", measure="generalizability", rule="1se")
136
+ ```
137
+
138
+ All plotting methods return a matplotlib `Axes` object and accept `save` and `dpi` parameters for export.
139
+
140
+ ## Citation
141
+
142
+ If you use CARVE in your research, please cite:
143
+
144
+ > Wycik, K. R., Tang, T. M., Zikry, T. M., & Allen, G. I. (2026). *CARVE: Cluster
145
+ > Analysis with Resampling for Validation and Exploration.* Zenodo.
146
+ > https://doi.org/10.5281/zenodo.20448965
147
+
148
+ ```bibtex
149
+ @software{wycik2026carve,
150
+ author = {Wycik, Kai R. and Tang, Tiffany M. and Zikry, Tarek M. and Allen, Genevera I.},
151
+ title = {{CARVE}: Cluster Analysis with Resampling for Validation and Exploration},
152
+ year = {2026},
153
+ publisher = {Zenodo},
154
+ doi = {10.5281/zenodo.20448965},
155
+ url = {https://doi.org/10.5281/zenodo.20448965}
156
+ }
157
+ ```
158
+
159
+ ## Authors
160
+
161
+ - [Kai R. Wycik](mailto:kai.wycik@columbia.edu) — Columbia University
162
+ - Tiffany M. Tang — University of Notre Dame
163
+ - Tarek M. Zikry — UNC Chapel Hill
164
+ - Genevera I. Allen — Columbia University
165
+
166
+ ## Contributing
167
+
168
+ Contributions are welcome! Please open an [issue](https://github.com/DataSlingers/CARVE/issues) or submit a pull request.
169
+
170
+ This project uses [Ruff](https://docs.astral.sh/ruff/) for linting and formatting, and [pytest](https://docs.pytest.org/) for testing:
171
+
172
+ ```bash
173
+ ruff check src/ # lint
174
+ ruff format src/ # format
175
+ pytest -v # run tests
176
+ ```
@@ -0,0 +1,125 @@
1
+ [![CI](https://github.com/DataSlingers/CARVE/actions/workflows/ci.yml/badge.svg)](https://github.com/DataSlingers/CARVE/actions/workflows/ci.yml)
2
+ [![R CMD check](https://github.com/DataSlingers/CARVE/actions/workflows/r-ci.yml/badge.svg)](https://github.com/DataSlingers/CARVE/actions/workflows/r-ci.yml)
3
+ ![Python 3.12](https://img.shields.io/badge/python-3.12-blue.svg)
4
+ ![Version](https://img.shields.io/badge/version-1.0.0-orange.svg)
5
+
6
+ # CARVE
7
+
8
+ **Cluster Analysis with Resampling for Validation and Exploration**
9
+
10
+ Choosing the number of clusters is hard, especially for high-dimensional biological data where standard internal clustering validation indices (CVIs) are often unreliable. CARVE measures clustering robustness through two resampling-based concepts: **stability** (reproducibility of cluster assignments under data subsampling) and **generalizability** (agreement between held-out cluster labels and predictions from a classifier trained on a subsample of the data). CARVE reports global, cluster-level, and sample-level diagnostics with visualizations, all through a scikit-learn-compatible API.
11
+
12
+ <p align="center">
13
+ <img src="https://raw.githubusercontent.com/DataSlingers/CARVE/main/carve_overview.png" width="700" alt="CARVE overview">
14
+ </p>
15
+
16
+ ## Features
17
+
18
+ - Scikit-learn-compatible API: `CARVE` extends `BaseEstimator` with a `fit` / `get_labels` / `get_k` workflow
19
+ - Stability (ARI on subsample overlap) and generalizability (ARI on held-out predictions) metrics
20
+ - Diagnostics at the global, per-cluster, and per-sample level
21
+ - Metrics: stability and generalizability ARIs, consensus PAC, Gini, cross-entropy, and predictive accuracy
22
+ - Selection rules: `max`, `1se` (one-standard-error), and `quantile`
23
+ - Custom spectral clustering with self-tuning affinity (based on Zelnik-Manor & Perona, *Self-Tuning Spectral Clustering*, NeurIPS 2004)
24
+ - Plots: metric-over-*k* curves, consensus heatmaps, box plots, violin plots, and scatter plots
25
+ - Parallel resampling via joblib (`n_jobs`)
26
+
27
+ ## Installation
28
+
29
+ CARVE requires **Python 3.12**.
30
+
31
+ ```bash
32
+ pip install carve-validate
33
+ ```
34
+
35
+ The distribution is named `carve-validate`; the import name is `carve`:
36
+
37
+ ```python
38
+ from carve import CARVE
39
+ ```
40
+
41
+ ### From source (development)
42
+
43
+ ```bash
44
+ git clone https://github.com/DataSlingers/CARVE.git
45
+ cd CARVE
46
+ pip install -e ".[dev]" # linting + testing
47
+ pip install -e ".[notebooks]" # Jupyter, Scanpy, scVI, etc.
48
+ ```
49
+
50
+ ## Quick Start
51
+
52
+ ```python
53
+ from carve import CARVE
54
+ from sklearn.datasets import make_blobs
55
+
56
+ # Generate synthetic data
57
+ X, y_true = make_blobs(n_samples=500, n_features=10, centers=5, random_state=42)
58
+
59
+ # Fit CARVE
60
+ carve = CARVE(n_clusters=10, n_resamples=120, subsample_ratio=0.7, n_jobs=4)
61
+ carve.fit(X)
62
+
63
+ # Select best k and retrieve labels
64
+ k = carve.get_k(measure="generalizability", rule="1se")
65
+ labels = carve.get_labels(measure="generalizability", rule="1se")
66
+ print(f"Selected k={k}")
67
+ ```
68
+
69
+ See [`notebooks/Tutorial.ipynb`](notebooks/Tutorial.ipynb) for a walkthrough, and [`notebooks/case_studies/`](notebooks/case_studies/) for real-world analyses on scRNA-seq and mass cytometry datasets.
70
+
71
+ ## Visualization
72
+
73
+ ```python
74
+ # Metric curves across k
75
+ carve.plot_metric_over_n_clusters(measure="stability", rule="1se")
76
+
77
+ # Consensus heatmap for the selected solution
78
+ carve.plot_consensus_matrix(measure="generalizability", rule="1se")
79
+
80
+ # Per-cluster stability violin plot
81
+ carve.plot_cluster_violin(source="gini", measure="generalizability", rule="1se")
82
+
83
+ # 2D scatter with score-encoded marker size and opacity
84
+ carve.plot_cluster_scatter(source="gini", measure="generalizability", rule="1se")
85
+ ```
86
+
87
+ All plotting methods return a matplotlib `Axes` object and accept `save` and `dpi` parameters for export.
88
+
89
+ ## Citation
90
+
91
+ If you use CARVE in your research, please cite:
92
+
93
+ > Wycik, K. R., Tang, T. M., Zikry, T. M., & Allen, G. I. (2026). *CARVE: Cluster
94
+ > Analysis with Resampling for Validation and Exploration.* Zenodo.
95
+ > https://doi.org/10.5281/zenodo.20448965
96
+
97
+ ```bibtex
98
+ @software{wycik2026carve,
99
+ author = {Wycik, Kai R. and Tang, Tiffany M. and Zikry, Tarek M. and Allen, Genevera I.},
100
+ title = {{CARVE}: Cluster Analysis with Resampling for Validation and Exploration},
101
+ year = {2026},
102
+ publisher = {Zenodo},
103
+ doi = {10.5281/zenodo.20448965},
104
+ url = {https://doi.org/10.5281/zenodo.20448965}
105
+ }
106
+ ```
107
+
108
+ ## Authors
109
+
110
+ - [Kai R. Wycik](mailto:kai.wycik@columbia.edu) — Columbia University
111
+ - Tiffany M. Tang — University of Notre Dame
112
+ - Tarek M. Zikry — UNC Chapel Hill
113
+ - Genevera I. Allen — Columbia University
114
+
115
+ ## Contributing
116
+
117
+ Contributions are welcome! Please open an [issue](https://github.com/DataSlingers/CARVE/issues) or submit a pull request.
118
+
119
+ This project uses [Ruff](https://docs.astral.sh/ruff/) for linting and formatting, and [pytest](https://docs.pytest.org/) for testing:
120
+
121
+ ```bash
122
+ ruff check src/ # lint
123
+ ruff format src/ # format
124
+ pytest -v # run tests
125
+ ```
@@ -0,0 +1,91 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "carve-validate"
7
+ version = "1.0.0"
8
+ description = "Cluster Analysis with Resampling for Validation and Exploration: stability- and generalizability-based clustering validation."
9
+ readme = "README.md"
10
+ requires-python = ">=3.12,<3.13"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ authors = [
14
+ { name = "Kai Wycik", email = "kai.wycik@columbia.edu" },
15
+ ]
16
+ keywords = [
17
+ "clustering",
18
+ "validation",
19
+ "stability",
20
+ "consensus",
21
+ "scRNA-seq",
22
+ "scikit-learn",
23
+ ]
24
+ classifiers = [
25
+ "Development Status :: 3 - Alpha",
26
+ "Intended Audience :: Science/Research",
27
+ "Programming Language :: Python :: 3.12",
28
+ "Topic :: Scientific/Engineering :: Bio-Informatics",
29
+ ]
30
+ dependencies = [
31
+ "numpy>=2.0,<2.1",
32
+ "scipy>=1.16,<1.17",
33
+ "scikit-learn>=1.7.2,<1.8",
34
+ "pandas>=2.2,<2.3",
35
+ "joblib>=1.5,<1.6",
36
+ "tqdm>=4.67,<5",
37
+ "numba>=0.60,<0.61",
38
+ "llvmlite>=0.43,<0.44",
39
+ "umap-learn>=0.5.6",
40
+ "pynndescent>=0.5.10,<0.6",
41
+ "matplotlib>=3.9.4",
42
+ ]
43
+
44
+ [project.urls]
45
+ Homepage = "https://github.com/DataSlingers/CARVE"
46
+ Repository = "https://github.com/DataSlingers/CARVE"
47
+ Issues = "https://github.com/DataSlingers/CARVE/issues"
48
+
49
+ [project.optional-dependencies]
50
+ dev = [
51
+ "ruff",
52
+ "pytest",
53
+ "pytest-cov",
54
+ ]
55
+ notebooks = [
56
+ "jupyterlab>=4.2,<5",
57
+ "ipykernel>=6.29,<7",
58
+ "ipywidgets>=8.1.7",
59
+ "plotly>=6.3.0",
60
+ "rpy2>=3.6.3",
61
+ "pyreadr>=0.5.3",
62
+ "scanpy>=1.11.4",
63
+ "scikit-misc>=0.5.1",
64
+ "anywidget>=0.9.18",
65
+ "igraph>=1.0.0",
66
+ "leidenalg>=0.11.0",
67
+ "pyteomics>=4.7.5",
68
+ "lxml>=6.0.2",
69
+ "scvi>=0.6.8",
70
+ "cmap>=0.7.0",
71
+ "glasbey>=0.3.0",
72
+ ]
73
+
74
+ [tool.setuptools.packages.find]
75
+ where = ["src"]
76
+ exclude = ["carve.sim", "carve.sim.*"]
77
+
78
+ [tool.pytest.ini_options]
79
+ testpaths = ["tests"]
80
+
81
+ [tool.coverage.run]
82
+ source = ["carve"]
83
+ omit = ["*/carve/sim/*"]
84
+
85
+ [tool.coverage.report]
86
+ show_missing = true
87
+
88
+ [dependency-groups]
89
+ dev = [
90
+ "optuna>=4.8.0",
91
+ ]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,8 @@
1
+ """CARVE: Cluster Analysis with Resampling for Validation and Exploration."""
2
+
3
+ __version__ = "1.0.0"
4
+
5
+ from .api import CARVE
6
+ from .cluster import SpectralClusteringCARVE
7
+
8
+ __all__ = ["__version__", "CARVE", "SpectralClusteringCARVE"]
@@ -0,0 +1,42 @@
1
+ """Accuracy-based generalizability metrics."""
2
+
3
+ import numpy as np
4
+
5
+ from ._utils import align_cluster_labels
6
+
7
+ # (sample_indices, true_labels, predicted_labels) triple from a prediction run
8
+ PredictionRun = tuple[np.ndarray, np.ndarray, np.ndarray]
9
+
10
+
11
+ def compute_generalizability_scores(
12
+ n_samples: int,
13
+ runs: list[PredictionRun],
14
+ ) -> np.ndarray:
15
+ """Compute per-sample generalizability from prediction runs.
16
+
17
+ Parameters
18
+ ----------
19
+ n_samples : int
20
+ Total number of samples in the original dataset.
21
+ runs : list of tuple
22
+ Each run is (sample_indices, true_labels, predicted_labels).
23
+
24
+ Returns
25
+ -------
26
+ scores : ndarray of shape (n_samples,)
27
+ Per-sample accuracy across available runs. Samples never evaluated
28
+ receive score 0.
29
+ """
30
+ correct = np.zeros(n_samples, int)
31
+ total = np.zeros(n_samples, int)
32
+
33
+ for sample_idx, true_labels, predicted_labels in runs:
34
+ aligned_predictions = align_cluster_labels(true_labels, predicted_labels)
35
+ correct_mask = (true_labels == aligned_predictions).astype(int)
36
+ np.add.at(correct, sample_idx, correct_mask)
37
+ np.add.at(total, sample_idx, 1)
38
+
39
+ out = np.zeros(n_samples, float)
40
+ valid = total > 0
41
+ out[valid] = correct[valid] / total[valid]
42
+ return out
@@ -0,0 +1,213 @@
1
+ """Consensus matrix utilities and stability metrics.
2
+
3
+ This module builds consensus matrices from repeated clustering runs and
4
+ derives stability statistics such as Gini, cross-entropy, and PAC.
5
+ """
6
+
7
+ import numpy as np
8
+ from scipy.cluster.hierarchy import linkage, leaves_list
9
+ from scipy.spatial.distance import squareform
10
+
11
+ # (sample_indices, labels) pair from a single clustering run
12
+ SampledLabels = tuple[np.ndarray, np.ndarray]
13
+
14
+
15
+ def compute_consensus_matrix(
16
+ n_samples: int,
17
+ runs: list[SampledLabels],
18
+ *,
19
+ return_counts: bool = False,
20
+ ) -> np.ndarray | tuple[np.ndarray, np.ndarray, np.ndarray]:
21
+ """Compute a consensus matrix from resampled clustering runs.
22
+
23
+ Parameters
24
+ ----------
25
+ n_samples : int
26
+ Total number of samples in the original dataset.
27
+ runs : list of tuple of (ndarray, ndarray)
28
+ Each run is (sample_indices, labels), where ``sample_indices`` are
29
+ integer indices into the original dataset and ``labels`` are cluster
30
+ assignments for those indices.
31
+ return_counts : bool, default=False
32
+ If True, also return raw co-cluster and co-sample counts.
33
+
34
+ Returns
35
+ -------
36
+ consensus_matrix : ndarray of shape (n_samples, n_samples)
37
+ Fraction of times each sample pair co-clustered when co-sampled.
38
+ Pairs never co-sampled are set to NaN.
39
+ co_cluster_counts : ndarray of shape (n_samples, n_samples)
40
+ Raw counts of co-clustering across runs (only if ``return_counts``).
41
+ co_sample_counts : ndarray of shape (n_samples, n_samples)
42
+ Raw counts of co-sampling across runs (only if ``return_counts``).
43
+ """
44
+ # co_cluster_counts = np.zeros((n_samples, n_samples), dtype=float)
45
+ # co_sample_counts = np.zeros((n_samples, n_samples), dtype=float)
46
+
47
+ # for sample_idx, labels in runs:
48
+ # co_sample_counts[np.ix_(sample_idx, sample_idx)] += 1
49
+
50
+ # for label in np.unique(labels):
51
+ # label_idx = sample_idx[labels == label]
52
+ # co_cluster_counts[np.ix_(label_idx, label_idx)] += 1
53
+
54
+ N = sum(len(np.unique(labels)) for _, labels in runs)
55
+
56
+ S = np.zeros((n_samples, len(runs)), dtype=np.float32)
57
+ B = np.zeros((n_samples, N), dtype=np.float32)
58
+
59
+ col = 0
60
+ for r, (sample_idx, labels) in enumerate(runs):
61
+ S[sample_idx, r] = 1
62
+ for label in np.unique(labels):
63
+ members = sample_idx[labels == label]
64
+ B[members, col] = 1
65
+ col += 1
66
+
67
+ co_sample_counts = S @ S.T
68
+ co_cluster_counts = B @ B.T
69
+
70
+ with np.errstate(divide="ignore", invalid="ignore"):
71
+ consensus_matrix = co_cluster_counts / co_sample_counts
72
+ consensus_matrix[co_sample_counts == 0] = np.nan
73
+
74
+ if return_counts:
75
+ return consensus_matrix, co_cluster_counts, co_sample_counts
76
+ else:
77
+ return consensus_matrix
78
+
79
+
80
+ def reorder_consensus_matrix(
81
+ consensus_matrix: np.ndarray,
82
+ *,
83
+ fill_nan_for_order: float = 0.0,
84
+ ) -> tuple[np.ndarray, np.ndarray]:
85
+ """Reorder a consensus matrix by hierarchical clustering of distances.
86
+
87
+ Parameters
88
+ ----------
89
+ consensus_matrix : ndarray of shape (n_samples, n_samples)
90
+ Consensus matrix with values in [0, 1] and NaNs for never-sampled pairs.
91
+ fill_nan_for_order : float, default=0.0
92
+ Value used to replace NaNs when computing the clustering order only.
93
+
94
+ Returns
95
+ -------
96
+ reordered : ndarray of shape (n_samples, n_samples)
97
+ Consensus matrix reordered by dendrogram leaves.
98
+ order : ndarray of shape (n_samples,)
99
+ The permutation of indices applied to rows and columns.
100
+ """
101
+ matrix_for_order = np.nan_to_num(consensus_matrix, nan=fill_nan_for_order)
102
+ distances = squareform(1.0 - matrix_for_order, checks=False)
103
+ linkage_matrix = linkage(distances, method="average")
104
+ order = leaves_list(linkage_matrix)
105
+
106
+ return consensus_matrix[np.ix_(order, order)], order
107
+
108
+
109
+ def compute_consensus_metrics(
110
+ consensus_matrices: list[np.ndarray],
111
+ ) -> tuple[list[np.ndarray], list[np.ndarray], list[float]]:
112
+ """Compute stability metrics for multiple consensus matrices.
113
+
114
+ Parameters
115
+ ----------
116
+ consensus_matrices : list of ndarray
117
+ Consensus matrices to evaluate.
118
+
119
+ Returns
120
+ -------
121
+ gini_list : list of ndarray
122
+ Per-sample stability scores based on the Gini-style statistic.
123
+ ce_list : list of ndarray
124
+ Per-sample stability scores based on cross-entropy.
125
+ pac_list : list of float
126
+ Global PAC-based stability scores (1 - PAC).
127
+ """
128
+ gini_list = []
129
+ ce_list = []
130
+ pac_list = []
131
+
132
+ for consensus_matrix in consensus_matrices:
133
+ s_gini, s_ce = stability_from_consensus(consensus_matrix)
134
+ pac = compute_consensus_pac(consensus_matrix)
135
+
136
+ gini_list.append(s_gini)
137
+ ce_list.append(s_ce)
138
+ pac_list.append(pac)
139
+
140
+ return gini_list, ce_list, pac_list
141
+
142
+
143
+ def stability_from_consensus(
144
+ consensus_matrix: np.ndarray,
145
+ ) -> tuple[np.ndarray, np.ndarray]:
146
+ """Compute per-sample stability scores from a consensus matrix.
147
+
148
+ Parameters
149
+ ----------
150
+ consensus_matrix : ndarray of shape (n_samples, n_samples)
151
+ Consensus matrix with values in [0, 1] and NaNs for never-sampled pairs.
152
+
153
+ Returns
154
+ -------
155
+ stability_gini : ndarray of shape (n_samples,)
156
+ Gini-based stability score per sample in [0, 1].
157
+ stability_ce : ndarray of shape (n_samples,)
158
+ Cross-entropy-based stability score per sample in [0, 1].
159
+ """
160
+ consensus_probs = np.array(consensus_matrix, dtype=float, copy=True)
161
+ np.fill_diagonal(consensus_probs, np.nan)
162
+
163
+ term = consensus_probs * (1.0 - consensus_probs)
164
+
165
+ clipped = np.clip(consensus_probs, 1e-12, 1.0 - 1e-12)
166
+ entropy = -(clipped * np.log(clipped) + (1.0 - clipped) * np.log(1.0 - clipped))
167
+
168
+ # Gini uncertainty in [0, 0.5]; CE uncertainty in [0, log2]
169
+ uncertainty_gini = 2.0 * np.nanmean(term, axis=1)
170
+ uncertainty_ce = np.nanmean(entropy, axis=1)
171
+
172
+ # Rescale to [0, 1] stability scores
173
+ stability_gini = 1.0 - np.clip(2.0 * uncertainty_gini, 0.0, 1.0)
174
+ stability_ce = 1.0 - np.clip(uncertainty_ce / np.log(2.0), 0.0, 1.0)
175
+
176
+ return stability_gini, stability_ce
177
+
178
+
179
+ def compute_consensus_pac(
180
+ consensus_matrix: np.ndarray,
181
+ *,
182
+ tau: float = 0.05,
183
+ ) -> float:
184
+ """Compute PAC-based stability score from a consensus matrix.
185
+
186
+ PAC measures the proportion of ambiguous consensus values in
187
+ (tau, 1-tau). This function returns 1 - PAC so that larger values
188
+ indicate greater stability.
189
+
190
+ Parameters
191
+ ----------
192
+ consensus_matrix : ndarray of shape (n_samples, n_samples)
193
+ Consensus matrix with values in [0, 1] and NaNs for never-sampled pairs.
194
+ tau : float, default=0.05
195
+ Ambiguity threshold defining the PAC interval.
196
+
197
+ Returns
198
+ -------
199
+ score : float
200
+ Stability score in [0, 1], or NaN if no valid pairs exist.
201
+ """
202
+ consensus_probs = np.array(consensus_matrix, dtype=float, copy=True)
203
+ n_samples = consensus_probs.shape[0]
204
+ mask = ~np.eye(n_samples, dtype=bool)
205
+ values = consensus_probs[mask]
206
+ values = values[~np.isnan(values)]
207
+
208
+ if values.size == 0:
209
+ return np.nan
210
+
211
+ ambiguous = ((values > tau) & (values < (1 - tau))).sum()
212
+ pac = ambiguous / values.size
213
+ return 1.0 - pac