carve-validate 1.0.0__py3-none-any.whl

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.
carve/__init__.py ADDED
@@ -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"]
carve/_accuracy.py ADDED
@@ -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
carve/_consensus.py ADDED
@@ -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
carve/_grids.py ADDED
@@ -0,0 +1,153 @@
1
+ """Default estimator and preprocessing grids for CARVE."""
2
+
3
+ from typing import Literal
4
+
5
+ import numpy as np
6
+ from sklearn.cluster import KMeans, AgglomerativeClustering
7
+ from sklearn.decomposition import PCA
8
+ from sklearn.manifold import TSNE
9
+ from sklearn.neighbors import NearestNeighbors
10
+ from sklearn.preprocessing import FunctionTransformer, StandardScaler
11
+ from umap import UMAP
12
+
13
+ from .cluster import SpectralClusteringCARVE
14
+ from ._types import GridSpec, PreprocSpec
15
+
16
+
17
+ def estimate_knn_gamma(
18
+ X: np.ndarray,
19
+ n_neighbors: int = 7,
20
+ multipliers: tuple[float, ...] = (0.5, 1.0, 2.0),
21
+ ) -> list[float]:
22
+ """Estimate RBF gamma values using a k-NN median heuristic.
23
+
24
+ Fits a NearestNeighbors model, takes the k-th neighbor distance for
25
+ each point, computes sigma = median(kth_distances), and returns
26
+ gamma = 1 / (2 * (m * sigma)^2) for each multiplier m.
27
+
28
+ Parameters
29
+ ----------
30
+ X : ndarray of shape (n_samples, n_features)
31
+ Input data.
32
+ n_neighbors : int, default=7
33
+ Number of neighbors.
34
+ multipliers : tuple of float, default=(0.5, 1.0, 2.0)
35
+ Scale multipliers applied to sigma.
36
+
37
+ Returns
38
+ -------
39
+ gammas : list of float
40
+ Gamma values spanning a range around the local scale.
41
+ """
42
+ nn = NearestNeighbors(n_neighbors=n_neighbors)
43
+ nn.fit(X)
44
+ dists, _ = nn.kneighbors(X)
45
+ kth_dists = dists[:, -1]
46
+ sigma = float(np.median(kth_dists))
47
+ if sigma <= 0:
48
+ sigma = (
49
+ float(np.mean(kth_dists[kth_dists > 0])) if np.any(kth_dists > 0) else 1.0
50
+ )
51
+ return [float(1.0 / (2.0 * (m * sigma) ** 2)) for m in multipliers]
52
+
53
+
54
+ def default_estimator_grids(
55
+ X: np.ndarray,
56
+ n_clusters: int | np.ndarray = 10,
57
+ preset: Literal["light", "full"] = "light",
58
+ ) -> list[GridSpec]:
59
+ """Return default clustering estimator grids.
60
+
61
+ Parameters
62
+ ----------
63
+ X : ndarray of shape (n_samples, n_features)
64
+ Input data used to derive data-driven hyperparameters (e.g., gamma).
65
+ n_clusters : int or ndarray, default=10
66
+ Number(s) of clusters to evaluate.
67
+ preset : {"light", "full"}, default="light"
68
+ ``"light"`` includes KMeans, Ward-linkage agglomerative, and
69
+ self-tuning spectral clustering. ``"full"`` adds average- and
70
+ single-linkage agglomerative clustering and RBF-kernel spectral
71
+ clustering with data-driven gamma.
72
+
73
+ Returns
74
+ -------
75
+ grids : list of tuple
76
+ List of (EstimatorClass, param_grid) tuples suitable for
77
+ ``sklearn.model_selection.ParameterGrid``.
78
+ """
79
+ ks = list(np.asarray(n_clusters).tolist())
80
+
81
+ grids: list[GridSpec] = [
82
+ (KMeans, {"n_clusters": ks}),
83
+ (AgglomerativeClustering, {"n_clusters": ks, "linkage": ["ward"]}),
84
+ (SpectralClusteringCARVE, {"n_clusters": ks, "affinity": ["self_tuning"]}),
85
+ ]
86
+
87
+ if preset == "full":
88
+ grids.append(
89
+ (
90
+ AgglomerativeClustering,
91
+ {"n_clusters": ks, "linkage": ["average", "single", "complete"]},
92
+ ),
93
+ )
94
+
95
+ grids.append(
96
+ (
97
+ SpectralClusteringCARVE,
98
+ {"n_clusters": ks, "affinity": ["rbf"], "gamma": estimate_knn_gamma(X)},
99
+ ),
100
+ )
101
+
102
+ return grids
103
+
104
+
105
+ def default_normalization_options() -> list[PreprocSpec]:
106
+ """Return default normalization preprocessing options.
107
+
108
+ Returns
109
+ -------
110
+ options : list of tuple
111
+ List of (TransformerClass, param_grid) pairs.
112
+ """
113
+ return [
114
+ (FunctionTransformer, {}),
115
+ (StandardScaler, {}),
116
+ (FunctionTransformer, {"func": [np.log1p]}),
117
+ ]
118
+
119
+
120
+ def default_dim_reduction_options(
121
+ X: np.ndarray,
122
+ subsample_ratio: float = 0.6,
123
+ ) -> list[PreprocSpec]:
124
+ """Return default dimensionality reduction options.
125
+
126
+ Parameters
127
+ ----------
128
+ X : ndarray of shape (n_samples, n_features)
129
+ Input data used to determine feasible component counts.
130
+ subsample_ratio : float, default=0.6
131
+ Subsample ratio used in validation; constrains DR hyperparameters.
132
+
133
+ Returns
134
+ -------
135
+ options : list of tuple
136
+ List of (TransformerClass, param_grid) pairs.
137
+ """
138
+ n_samples, p = X.shape
139
+ min_n = int(round(n_samples * (1 - subsample_ratio))) - 1
140
+
141
+ return [
142
+ (FunctionTransformer, {}),
143
+ (PCA, {"n_components": list(range(2, min(min_n, p)))}),
144
+ (TSNE, {"n_components": [2], "perplexity": list(range(5, min(min_n, 51)))}),
145
+ (
146
+ UMAP,
147
+ {
148
+ "n_components": list(range(2, min(min_n, p))),
149
+ "n_neighbors": list(range(5, 51)),
150
+ "min_dist": [0.1],
151
+ },
152
+ ),
153
+ ]
carve/_output.py ADDED
@@ -0,0 +1,132 @@
1
+ """Console output helpers for CARVE runs."""
2
+
3
+ import numpy as np
4
+ import pandas as pd
5
+ from typing import Any
6
+ from sklearn.base import ClusterMixin
7
+ from sklearn.model_selection import ParameterGrid
8
+ from tqdm.auto import tqdm
9
+
10
+ from ._types import EstimatorRecord, GridSpec
11
+
12
+
13
+ def _print_run_header(
14
+ X: np.ndarray,
15
+ n_clusters: int | np.ndarray,
16
+ n_resamples: int,
17
+ subsample_ratio: float,
18
+ estimator_grids: list[GridSpec],
19
+ n_jobs: int,
20
+ randomize_preprocessing: bool,
21
+ random_state: int | None,
22
+ verbose: int,
23
+ ) -> None:
24
+ """Print a standard header describing the validation configuration.
25
+
26
+ Parameters
27
+ ----------
28
+ X : ndarray of shape (n_samples, n_features)
29
+ Input data.
30
+ n_clusters : int or ndarray
31
+ Number(s) of clusters evaluated.
32
+ n_resamples : int
33
+ Number of resampling iterations.
34
+ subsample_ratio : float
35
+ Proportion of samples in each subsample.
36
+ estimator_grids : list of tuple
37
+ Estimator classes and parameter grids.
38
+ n_jobs : int
39
+ Parallelism used for runs.
40
+ randomize_preprocessing : bool
41
+ Whether preprocessing is randomized.
42
+ random_state : int or None
43
+ Random seed.
44
+ verbose : int
45
+ Verbosity level; prints only if >= 2.
46
+ """
47
+ if verbose < 2:
48
+ return
49
+
50
+ total_configs = sum(len(ParameterGrid(g)) for _, g in estimator_grids)
51
+ line = "=" * 60
52
+
53
+ print(f"[CARVE] {line}")
54
+ print("[CARVE] Validation Settings:")
55
+ print(f"[CARVE] n_samples : {X.shape[0]}")
56
+ print(f"[CARVE] n_features : {X.shape[1]}")
57
+ print(f"[CARVE] n_clusters : {n_clusters}")
58
+ print(f"[CARVE] n_resamples : {n_resamples}")
59
+ print(f"[CARVE] subsample_ratio : {subsample_ratio}")
60
+ print(f"[CARVE] n_jobs : {n_jobs}")
61
+ print(f"[CARVE] total configs : {total_configs}")
62
+ print(f"[CARVE] randomize_preproc : {randomize_preprocessing}")
63
+ print(f"[CARVE] random_state : {random_state}")
64
+ print(f"[CARVE] {line}")
65
+ print("\n[CARVE] Starting validation ...\n")
66
+
67
+
68
+ def _print_run_footer(
69
+ estimator_df: pd.DataFrame,
70
+ verbose: int,
71
+ ) -> None:
72
+ """Print a standard footer after validation completes.
73
+
74
+ Parameters
75
+ ----------
76
+ estimator_df : pandas.DataFrame
77
+ Results table with one row per estimator configuration.
78
+ verbose : int
79
+ Verbosity level; prints only if >= 2.
80
+ """
81
+ if verbose < 2:
82
+ return
83
+ print(
84
+ f"\n[CARVE] finished. evaluated {len(estimator_df)} estimator configurations."
85
+ )
86
+
87
+
88
+ def _log_config_progress(
89
+ config_idx: int,
90
+ total_configs: int,
91
+ est_class: type[ClusterMixin],
92
+ params: dict[str, Any],
93
+ record: EstimatorRecord,
94
+ pbar_obj: tqdm | None,
95
+ verbose: int = 0,
96
+ ) -> None:
97
+ """Log per-configuration progress during grid evaluation.
98
+
99
+ Parameters
100
+ ----------
101
+ config_idx : int
102
+ 1-based index of the current configuration.
103
+ total_configs : int
104
+ Total number of configurations in the grid.
105
+ est_class : type
106
+ Estimator class being evaluated.
107
+ params : dict
108
+ Hyperparameters for the current configuration.
109
+ record : dict
110
+ Metrics for the current configuration.
111
+ pbar_obj : tqdm or None
112
+ Optional progress-bar object used to display messages.
113
+ verbose : int, default=0
114
+ Verbosity level; logs only if >= 1.
115
+ """
116
+ if verbose <= 0:
117
+ return
118
+
119
+ n_clusters = params.get("n_clusters", "?")
120
+ msg = (
121
+ f"[CARVE] [{config_idx}/{total_configs}] "
122
+ f"est={est_class.__name__} "
123
+ f"n_clusters={n_clusters} | "
124
+ f"ARI_stab={record['ari_stability']:.3f}±{record['ari_stability_se']:.3f} "
125
+ f"ARI_gen={record['ari_generalizability']:.3f}±{record['ari_generalizability_se']:.3f} "
126
+ # f"ARI_avg={record['ari_average']:.3f}±{record['ari_average_se']:.3f}"
127
+ )
128
+
129
+ if pbar_obj is not None:
130
+ pbar_obj.write(msg)
131
+ else:
132
+ print(msg)