campmc 0.1.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.
campmc/__init__.py ADDED
@@ -0,0 +1,13 @@
1
+ """CAMP metacell partitioning for AnnData (third-party portable implementation).
2
+
3
+ This package is not maintained by the authors of the CAMP paper. See the
4
+ upstream repository at https://github.com/danrongLi/CAMP for the reference code.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from campmc import metrics, pl, pp, rare, robustness, tl
10
+
11
+ __version__ = "0.1.0"
12
+
13
+ __all__ = ["__version__", "metrics", "pl", "pp", "rare", "robustness", "tl"]
campmc/_logging.py ADDED
@@ -0,0 +1,13 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+
5
+
6
+ def get_logger(name: str) -> logging.Logger:
7
+ logger = logging.getLogger(name)
8
+ if not logger.handlers:
9
+ handler = logging.StreamHandler()
10
+ handler.setFormatter(logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s"))
11
+ logger.addHandler(handler)
12
+ logger.setLevel(logging.INFO)
13
+ return logger
campmc/coreset.py ADDED
@@ -0,0 +1,54 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Sequence
4
+
5
+ import numpy as np
6
+ from anndata import AnnData
7
+ from scipy import sparse
8
+
9
+ from campmc._logging import get_logger
10
+
11
+ logger = get_logger(__name__)
12
+
13
+
14
+ def compute_q(adata: AnnData, *, rep_key: str = "X_pca") -> np.ndarray:
15
+ """Importance weights q(x) from distance to mean in representation space."""
16
+ X = adata.obsm[rep_key]
17
+ if sparse.issparse(X):
18
+ x_csr = X.tocsr(copy=False)
19
+ mu = np.asarray(x_csr.mean(axis=0)).ravel()
20
+ x_norm_sq = np.asarray(x_csr.power(2).sum(axis=1)).ravel()
21
+ mu_norm_sq = float(mu @ mu)
22
+ prod = x_csr @ mu
23
+ d_sq = x_norm_sq + mu_norm_sq - 2.0 * prod
24
+ else:
25
+ x_dense = np.asarray(X, dtype=np.float64)
26
+ mu = x_dense.mean(axis=0)
27
+ d_sq = np.sum((x_dense - mu) ** 2, axis=1)
28
+
29
+ n = adata.n_obs
30
+ denom = float(d_sq.sum())
31
+ if denom == 0.0:
32
+ logger.warning("All points coincide; returning uniform q.")
33
+ return np.full(n, 1.0 / n)
34
+
35
+ q = 0.5 * (1.0 / n) + 0.5 * (d_sq / denom)
36
+ q /= q.sum()
37
+ return q
38
+
39
+
40
+ def gammas_to_m(n_obs: int, gammas: Sequence[int]) -> list[int]:
41
+ return [max(1, int(n_obs / g)) for g in gammas]
42
+
43
+
44
+ def sample_coreset(
45
+ n_obs: int,
46
+ m: int,
47
+ q: np.ndarray,
48
+ *,
49
+ random_state: int = 0,
50
+ ) -> np.ndarray:
51
+ """Return `m` unique cell indices sampled with probabilities `q`."""
52
+ rng = np.random.default_rng(random_state)
53
+ m = min(m, n_obs)
54
+ return rng.choice(n_obs, size=m, replace=False, p=q)
campmc/io.py ADDED
@@ -0,0 +1,31 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ import pandas as pd
6
+ from anndata import AnnData
7
+
8
+
9
+ def write_membership(adata: AnnData, path: str | Path, *, key_added: str = "camp") -> None:
10
+ """Write partition labels and seed flags to CSV (CAMP membership schema)."""
11
+ path = Path(path)
12
+ label_col = key_added
13
+ seed_col = f"{key_added}_is_seed"
14
+ if label_col not in adata.obs:
15
+ raise KeyError(f"obs missing '{label_col}'")
16
+ if seed_col not in adata.obs:
17
+ raise KeyError(f"obs missing '{seed_col}'")
18
+
19
+ df = pd.DataFrame(
20
+ {
21
+ label_col: adata.obs[label_col].astype(str),
22
+ seed_col: adata.obs[seed_col].astype(bool),
23
+ },
24
+ index=adata.obs_names,
25
+ )
26
+ df.to_csv(path)
27
+
28
+
29
+ def read_membership(path: str | Path) -> pd.DataFrame:
30
+ path = Path(path)
31
+ return pd.read_csv(path, index_col=0)
@@ -0,0 +1,5 @@
1
+ from __future__ import annotations
2
+
3
+ from campmc.metrics.quality import quality
4
+
5
+ __all__ = ["quality"]
@@ -0,0 +1,105 @@
1
+ from __future__ import annotations
2
+
3
+ import numpy as np
4
+ import pandas as pd
5
+ import scanpy as sc
6
+ from anndata import AnnData
7
+ from scipy import sparse
8
+ from scipy.stats import entropy as scipy_entropy
9
+ from sklearn.metrics import pairwise_distances
10
+
11
+ from campmc._logging import get_logger
12
+
13
+ logger = get_logger(__name__)
14
+
15
+
16
+ def _purity(cell_labels: pd.Series) -> float:
17
+ counts = cell_labels.value_counts(dropna=False)
18
+ if len(counts) == 0:
19
+ return np.nan
20
+ return float(counts.max()) / float(counts.sum())
21
+
22
+
23
+ def _label_entropy(cell_labels: pd.Series) -> float:
24
+ counts = cell_labels.value_counts(dropna=False).values.astype(float)
25
+ if counts.sum() == 0:
26
+ return np.nan
27
+ p = counts / counts.sum()
28
+ return float(scipy_entropy(p, base=2))
29
+
30
+
31
+ def _compactness(diff_coords: np.ndarray) -> float:
32
+ return float(-np.var(diff_coords, axis=0).mean())
33
+
34
+
35
+ def _inv(expr_block) -> float:
36
+ x = expr_block.toarray() if sparse.issparse(expr_block) else np.asarray(expr_block)
37
+ gene_var = np.var(x, axis=0)
38
+ gene_mean = np.mean(x, axis=0)
39
+ return float(np.percentile(gene_var / (gene_mean + 1e-8), 95))
40
+
41
+
42
+ def _ensure_diffmap(adata: AnnData, use_rep: str) -> None:
43
+ if "X_diffmap" in adata.obsm:
44
+ return
45
+ if use_rep not in adata.obsm:
46
+ raise KeyError(f"Missing obsm['{use_rep}'] for diffusion map computation.")
47
+ sc.pp.neighbors(adata, use_rep=use_rep)
48
+ sc.tl.diffmap(adata)
49
+
50
+
51
+ def quality(
52
+ adata: AnnData,
53
+ *,
54
+ partition_key: str = "camp",
55
+ label_key: str | None = None,
56
+ use_rep: str = "X_pca",
57
+ ) -> pd.DataFrame:
58
+ """Per-metacell quality metrics (compactness, separation, INV, optional purity)."""
59
+ if partition_key not in adata.obs:
60
+ raise KeyError(f"obs missing '{partition_key}'")
61
+
62
+ work = adata.copy()
63
+ _ensure_diffmap(work, use_rep)
64
+ x_diff = work.obsm["X_diffmap"]
65
+ x_diff_dense = x_diff if not sparse.issparse(x_diff) else x_diff.toarray()
66
+ work.obs["metacell"] = work.obs[partition_key].astype(str)
67
+ grouped = work.obs.groupby("metacell", observed=True)
68
+
69
+ centroids: dict[str, np.ndarray] = {}
70
+ for metacell_id, group in grouped:
71
+ ix = work.obs.index.get_indexer(group.index)
72
+ centroids[metacell_id] = x_diff_dense[ix].mean(axis=0)
73
+
74
+ mc_ids = list(centroids.keys())
75
+ if not mc_ids:
76
+ return pd.DataFrame()
77
+
78
+ c_mat = np.vstack([centroids[mid] for mid in mc_ids])
79
+ dist = pairwise_distances(c_mat, c_mat)
80
+ np.fill_diagonal(dist, np.inf)
81
+ nearest_sep = np.min(dist, axis=1)
82
+ sep_map = {mid: float(nearest_sep[j]) for j, mid in enumerate(mc_ids)}
83
+
84
+ rows: list[dict] = []
85
+ for metacell_id, group in grouped:
86
+ ix = work.obs.index.get_indexer(group.index)
87
+ diff_coords = x_diff_dense[ix]
88
+ compact = _compactness(diff_coords)
89
+ separation = sep_map[metacell_id]
90
+ row = {
91
+ "metacell_id": metacell_id,
92
+ "size": len(group),
93
+ "compactness": compact,
94
+ "separation": separation,
95
+ "sc_ratio": separation / np.sqrt(max(1e-12, -compact)),
96
+ "INV": _inv(work.X[ix, :]),
97
+ }
98
+ if label_key is not None and label_key in work.obs:
99
+ labels = work.obs.loc[group.index, label_key]
100
+ row["purity"] = _purity(labels.astype(str))
101
+ row["label_entropy"] = _label_entropy(labels.astype(str))
102
+ rows.append(row)
103
+
104
+ logger.info("quality metrics for %d metacells", len(rows))
105
+ return pd.DataFrame(rows)
campmc/pl/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ from __future__ import annotations
2
+
3
+ from campmc.pl.plots import metric_ecdf, metric_lines
4
+
5
+ __all__ = ["metric_ecdf", "metric_lines"]
campmc/pl/plots.py ADDED
@@ -0,0 +1,45 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ import matplotlib.pyplot as plt
6
+ import numpy as np
7
+ import pandas as pd
8
+ import seaborn as sns
9
+
10
+
11
+ def metric_ecdf(values: np.ndarray, out_png: str | Path, *, title: str = "Metric ECDF") -> None:
12
+ vals = np.asarray(values, dtype=float)
13
+ vals = vals[np.isfinite(vals)]
14
+ if vals.size == 0:
15
+ raise ValueError("No finite values to plot.")
16
+ x = np.sort(vals)
17
+ y = np.arange(1, x.size + 1) / x.size
18
+ fig, ax = plt.subplots(figsize=(5, 4))
19
+ ax.plot(x, y, linewidth=2)
20
+ ax.set_xlabel("value")
21
+ ax.set_ylabel("ECDF")
22
+ ax.set_title(title)
23
+ fig.tight_layout()
24
+ fig.savefig(out_png, dpi=150)
25
+ plt.close(fig)
26
+
27
+
28
+ def metric_lines(
29
+ df: pd.DataFrame,
30
+ out_png: str | Path,
31
+ *,
32
+ x_col: str,
33
+ y_col: str,
34
+ hue_col: str | None = None,
35
+ title: str = "Metric",
36
+ ) -> None:
37
+ fig, ax = plt.subplots(figsize=(6, 4))
38
+ if hue_col:
39
+ sns.lineplot(data=df, x=x_col, y=y_col, hue=hue_col, ax=ax)
40
+ else:
41
+ sns.lineplot(data=df, x=x_col, y=y_col, ax=ax)
42
+ ax.set_title(title)
43
+ fig.tight_layout()
44
+ fig.savefig(out_png, dpi=150)
45
+ plt.close(fig)
campmc/pp/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ from __future__ import annotations
2
+
3
+ from campmc.pp.preprocess import preprocess
4
+
5
+ __all__ = ["preprocess"]
@@ -0,0 +1,49 @@
1
+ from __future__ import annotations
2
+
3
+ import numpy as np
4
+ import scanpy as sc
5
+ from anndata import AnnData
6
+ from scipy import sparse
7
+ from scipy.sparse import csr_matrix
8
+
9
+ from campmc._logging import get_logger
10
+
11
+ logger = get_logger(__name__)
12
+
13
+
14
+ def preprocess(
15
+ adata: AnnData,
16
+ *,
17
+ min_genes: int = 200,
18
+ min_cells: int = 3,
19
+ n_hvg: int = 2000,
20
+ n_pcs: int = 50,
21
+ target_sum: float = 1e4,
22
+ copy: bool = False,
23
+ ) -> AnnData | None:
24
+ if copy:
25
+ adata = adata.copy()
26
+
27
+ sc.pp.filter_cells(adata, min_genes=min_genes)
28
+ sc.pp.filter_genes(adata, min_cells=min_cells)
29
+
30
+ x_dense = adata.X.A if sparse.issparse(adata.X) else adata.X
31
+ if np.any(x_dense < 0):
32
+ if sparse.issparse(adata.X):
33
+ adata.X = csr_matrix(np.maximum(adata.X.toarray(), 0))
34
+ else:
35
+ adata.X = np.maximum(adata.X, 0)
36
+
37
+ sc.pp.normalize_total(adata, target_sum=target_sum)
38
+ sc.pp.log1p(adata)
39
+ if not sparse.issparse(adata.X):
40
+ adata.X = csr_matrix(adata.X)
41
+
42
+ sc.pp.highly_variable_genes(adata, n_top_genes=min(n_hvg, adata.n_vars - 1), flavor="seurat")
43
+ keep = np.asarray(adata.var["highly_variable"]).ravel()
44
+ adata._inplace_subset_var(keep)
45
+
46
+ sc.pp.scale(adata, max_value=10)
47
+ sc.tl.pca(adata, n_comps=min(n_pcs, adata.n_vars - 1, adata.n_obs - 1), svd_solver="arpack")
48
+ logger.info("preprocess done: shape=%s", adata.shape)
49
+ return adata if copy else None
@@ -0,0 +1,5 @@
1
+ from __future__ import annotations
2
+
3
+ from campmc.rare.recovery import identify_rare_types, recovery_scores
4
+
5
+ __all__ = ["identify_rare_types", "recovery_scores"]
@@ -0,0 +1,72 @@
1
+ from __future__ import annotations
2
+
3
+ import pandas as pd
4
+ from anndata import AnnData
5
+ from sklearn.metrics import adjusted_rand_score, normalized_mutual_info_score
6
+
7
+
8
+ def identify_rare_types(labels: pd.Series, *, rare_threshold: float = 0.01) -> pd.DataFrame:
9
+ counts = labels.value_counts(dropna=False)
10
+ freq = counts / counts.sum()
11
+ rare = freq[freq <= rare_threshold].sort_values()
12
+ return pd.DataFrame(
13
+ {
14
+ "cell_type": rare.index.astype(str),
15
+ "n_cells": counts.loc[rare.index].values.astype(int),
16
+ "fraction": rare.values.astype(float),
17
+ }
18
+ )
19
+
20
+
21
+ def _balanced_accuracy(true: pd.Series, pred: pd.Series, rare_types: list[str]) -> float:
22
+ recalls: list[float] = []
23
+ for ct in rare_types:
24
+ pos_mask = true == ct
25
+ n_pos = int(pos_mask.sum())
26
+ if n_pos == 0:
27
+ continue
28
+ tp = int(((pred == ct) & pos_mask).sum())
29
+ recalls.append(tp / n_pos)
30
+ if not recalls:
31
+ return float("nan")
32
+ return float(sum(recalls) / len(recalls))
33
+
34
+
35
+ def recovery_scores(
36
+ adata: AnnData,
37
+ *,
38
+ partition_key: str,
39
+ label_key: str,
40
+ rare_threshold: float = 0.01,
41
+ ) -> pd.DataFrame:
42
+ if partition_key not in adata.obs:
43
+ raise KeyError(partition_key)
44
+ if label_key not in adata.obs:
45
+ raise KeyError(label_key)
46
+
47
+ labels = adata.obs[label_key].astype(str)
48
+ assign = adata.obs[partition_key].astype(str)
49
+ rare_df = identify_rare_types(labels, rare_threshold=rare_threshold)
50
+ rare_types = rare_df["cell_type"].tolist()
51
+
52
+ contingency = pd.crosstab(assign, labels)
53
+ mc_majority = contingency.idxmax(axis=1)
54
+ pred = assign.map(mc_majority).astype(str)
55
+
56
+ mask = labels.isin(rare_types)
57
+ ari = float("nan")
58
+ nmi = float("nan")
59
+ if mask.sum() > 1:
60
+ ari = float(adjusted_rand_score(labels.loc[mask], pred.loc[mask]))
61
+ nmi = float(normalized_mutual_info_score(labels.loc[mask], pred.loc[mask]))
62
+
63
+ return pd.DataFrame(
64
+ [
65
+ {
66
+ "balanced_accuracy": _balanced_accuracy(labels, pred, rare_types),
67
+ "ari": ari,
68
+ "nmi": nmi,
69
+ "n_rare_types": len(rare_types),
70
+ }
71
+ ]
72
+ )
@@ -0,0 +1,9 @@
1
+ from __future__ import annotations
2
+
3
+ from campmc.robustness.sweeps import (
4
+ build_or_load_base_cache,
5
+ build_or_load_hvg_pca_cache,
6
+ run_hvg_pca_sweep,
7
+ )
8
+
9
+ __all__ = ["build_or_load_base_cache", "build_or_load_hvg_pca_cache", "run_hvg_pca_sweep"]
@@ -0,0 +1,93 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+ import numpy as np
7
+ import scanpy as sc
8
+ from anndata import AnnData
9
+ from scipy import sparse
10
+
11
+ from campmc._logging import get_logger
12
+
13
+ logger = get_logger(__name__)
14
+
15
+
16
+ def _mkdir(path: Path) -> None:
17
+ path.mkdir(parents=True, exist_ok=True)
18
+
19
+
20
+ def build_or_load_base_cache(
21
+ input_file: str | Path,
22
+ dataset_name: str,
23
+ cache_dir: str | Path,
24
+ *,
25
+ min_genes: int = 200,
26
+ min_cells: int = 3,
27
+ normalize_target_sum: float = 1e4,
28
+ ) -> Path:
29
+ cache_dir = Path(cache_dir)
30
+ _mkdir(cache_dir)
31
+ out = cache_dir / f"{dataset_name}_BASE_log1p.h5ad"
32
+ if out.exists():
33
+ logger.info("Using base cache %s", out)
34
+ return out
35
+
36
+ adata = sc.read_h5ad(input_file)
37
+ sc.pp.filter_cells(adata, min_genes=min_genes)
38
+ sc.pp.filter_genes(adata, min_cells=min_cells)
39
+ if sparse.issparse(adata.X):
40
+ adata.X.data = np.maximum(adata.X.data, 0)
41
+ sc.pp.normalize_total(adata, target_sum=normalize_target_sum)
42
+ sc.pp.log1p(adata)
43
+ adata.write_h5ad(out, compression="gzip")
44
+ meta = cache_dir / f"{dataset_name}_BASE_log1p.meta.json"
45
+ meta.write_text(
46
+ json.dumps({"dataset_name": dataset_name, "source": str(input_file)}, indent=2),
47
+ encoding="utf-8",
48
+ )
49
+ return out
50
+
51
+
52
+ def build_or_load_hvg_pca_cache(
53
+ base_h5ad: str | Path,
54
+ dataset_name: str,
55
+ cache_dir: str | Path,
56
+ *,
57
+ n_hvg: int = 2000,
58
+ max_pcs: int = 50,
59
+ random_state: int = 0,
60
+ ) -> Path:
61
+ cache_dir = Path(cache_dir)
62
+ _mkdir(cache_dir)
63
+ out = cache_dir / f"{dataset_name}_HVG{n_hvg}_PCA{max_pcs}.h5ad"
64
+ if out.exists():
65
+ logger.info("Using HVG/PCA cache %s", out)
66
+ return out
67
+
68
+ adata = sc.read_h5ad(base_h5ad)
69
+ n_hvg_use = min(n_hvg, adata.n_vars)
70
+ sc.pp.highly_variable_genes(adata, n_top_genes=n_hvg_use, flavor="seurat")
71
+ adata = adata[:, adata.var["highly_variable"]].copy()
72
+ sc.pp.scale(adata, max_value=10)
73
+ n_pcs = min(max_pcs, max(2, adata.n_vars - 1), adata.n_obs - 1)
74
+ sc.tl.pca(adata, n_comps=n_pcs, svd_solver="arpack", random_state=random_state)
75
+ adata.write_h5ad(out, compression="gzip")
76
+ return out
77
+
78
+
79
+ def run_hvg_pca_sweep(
80
+ input_file: str | Path,
81
+ dataset_name: str,
82
+ cache_dir: str | Path,
83
+ hvg_values: list[int],
84
+ pca_values: list[int],
85
+ ) -> list[AnnData]:
86
+ """Build cached AnnData objects for each HVG/PCA combination."""
87
+ base = build_or_load_base_cache(input_file, dataset_name, cache_dir)
88
+ results: list[AnnData] = []
89
+ for n_hvg in hvg_values:
90
+ for n_pcs in pca_values:
91
+ path = build_or_load_hvg_pca_cache(base, dataset_name, cache_dir, n_hvg=n_hvg, max_pcs=n_pcs)
92
+ results.append(sc.read_h5ad(path))
93
+ return results
campmc/tl/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ from __future__ import annotations
2
+
3
+ from campmc.tl.partition import partition
4
+
5
+ __all__ = ["partition"]
@@ -0,0 +1,18 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Literal
4
+
5
+ from campmc.tl.assign import camp1, camp2, camp3, camp4
6
+
7
+ Method = Literal["camp1", "camp2", "camp3", "camp4"]
8
+
9
+ _DISPATCH = {
10
+ "camp1": camp1,
11
+ "camp2": camp2,
12
+ "camp3": camp3,
13
+ "camp4": camp4,
14
+ }
15
+
16
+
17
+ def get_assigner(method: Method):
18
+ return _DISPATCH[method]
@@ -0,0 +1,24 @@
1
+ from __future__ import annotations
2
+
3
+ import numpy as np
4
+ from sklearn.cluster import KMeans
5
+
6
+
7
+ def assign_labels(
8
+ X: np.ndarray,
9
+ seed_idx: np.ndarray,
10
+ *,
11
+ random_state: int = 0,
12
+ ) -> np.ndarray:
13
+ seed_emb = X[seed_idx]
14
+ m = seed_emb.shape[0]
15
+ km = KMeans(
16
+ n_clusters=m,
17
+ init=seed_emb,
18
+ n_init=1,
19
+ max_iter=10,
20
+ algorithm="lloyd",
21
+ random_state=random_state,
22
+ )
23
+ km.fit(X)
24
+ return km.labels_.astype(np.int64)
@@ -0,0 +1,43 @@
1
+ from __future__ import annotations
2
+
3
+ import numpy as np
4
+
5
+
6
+ def _unit_rows(X: np.ndarray) -> np.ndarray:
7
+ norms = np.linalg.norm(X, axis=1, keepdims=True)
8
+ norms = np.maximum(norms, 1e-12)
9
+ return X / norms
10
+
11
+
12
+ def assign_labels(
13
+ X: np.ndarray,
14
+ seed_idx: np.ndarray,
15
+ *,
16
+ random_state: int = 0,
17
+ ) -> np.ndarray:
18
+ del random_state
19
+ x_unit = _unit_rows(np.asarray(X, dtype=np.float64))
20
+ seeds = _unit_rows(x_unit[seed_idx])
21
+ m = seeds.shape[0]
22
+ bs = 100_000
23
+ parts: list[np.ndarray] = []
24
+ for s in range(0, x_unit.shape[0], bs):
25
+ block = x_unit[s : s + bs] @ seeds.T
26
+ parts.append(np.argmax(block, axis=1))
27
+ labels_idx = np.concatenate(parts, axis=0)
28
+
29
+ new_seeds = np.zeros_like(seeds)
30
+ for k in range(m):
31
+ members = labels_idx == k
32
+ if np.any(members):
33
+ c = x_unit[members].mean(axis=0)
34
+ nrm = np.linalg.norm(c)
35
+ new_seeds[k] = c / nrm if nrm > 0 else seeds[k]
36
+ else:
37
+ new_seeds[k] = seeds[k]
38
+
39
+ parts = []
40
+ for s in range(0, x_unit.shape[0], bs):
41
+ block = x_unit[s : s + bs] @ new_seeds.T
42
+ parts.append(np.argmax(block, axis=1))
43
+ return np.concatenate(parts, axis=0).astype(np.int64)
@@ -0,0 +1,37 @@
1
+ from __future__ import annotations
2
+
3
+ import numpy as np
4
+ from sklearn.metrics import pairwise_distances
5
+
6
+
7
+ def assign_labels(
8
+ X: np.ndarray,
9
+ seed_idx: np.ndarray,
10
+ *,
11
+ random_state: int = 0,
12
+ k_scale: int = 15,
13
+ tau: float = 1.0,
14
+ batch_size: int = 20_000,
15
+ eps: float = 1e-8,
16
+ ) -> np.ndarray:
17
+ del random_state
18
+ x_pca = np.asarray(X, dtype=np.float32)
19
+ xs = x_pca[seed_idx]
20
+ m = xs.shape[0]
21
+ k = max(1, min(k_scale, m - 1))
22
+
23
+ d_ss = pairwise_distances(xs, xs, metric="euclidean")
24
+ sigma_seed = np.partition(d_ss, kth=k, axis=1)[:, k].astype(np.float32)
25
+ sigma_seed = np.maximum(sigma_seed, eps)
26
+
27
+ parts: list[np.ndarray] = []
28
+ for s in range(0, x_pca.shape[0], batch_size):
29
+ xu = x_pca[s : s + batch_size]
30
+ d_us = pairwise_distances(xu, xs, metric="euclidean")
31
+ sigma_cell = np.partition(d_us, kth=k, axis=1)[:, k].astype(np.float32)
32
+ sigma_cell = np.maximum(sigma_cell, eps)
33
+ denom = tau * (sigma_cell[:, None] * sigma_seed[None, :]) + eps
34
+ w = np.exp(-(d_us**2) / denom, dtype=np.float32)
35
+ parts.append(np.argmax(w, axis=1))
36
+
37
+ return np.concatenate(parts, axis=0).astype(np.int64)
@@ -0,0 +1,40 @@
1
+ from __future__ import annotations
2
+
3
+ import numpy as np
4
+ import SEACells.build_graph as seacell_build_graph
5
+ from anndata import AnnData
6
+ from scipy.sparse import csr_matrix
7
+ from tqdm import std as tqdm_std
8
+
9
+ # SEACells uses notebook tqdm, which breaks outside Jupyter.
10
+ seacell_build_graph.tqdm = tqdm_std.tqdm
11
+
12
+
13
+ def assign_labels(
14
+ adata: AnnData,
15
+ seed_names: list[str],
16
+ *,
17
+ reduction_key: str = "X_pca",
18
+ n_neighbors: int = 30,
19
+ graph_construction: str = "union",
20
+ chunk_size: int = 20_000,
21
+ ) -> np.ndarray:
22
+ g = seacell_build_graph.SEACellGraph(adata, reduction_key, verbose=False)
23
+ k_mat = g.rbf(k=n_neighbors, graph_construction=graph_construction)
24
+
25
+ k_mat = k_mat.tocsr()
26
+ n_sub = adata.n_obs
27
+ m_seeds = len(seed_names)
28
+ seed_idx = [adata.obs_names.get_loc(s) for s in seed_names]
29
+
30
+ data = np.ones(m_seeds, dtype=np.float32)
31
+ rows = np.array(seed_idx, dtype=int)
32
+ cols = np.arange(m_seeds, dtype=int)
33
+ b_mat = csr_matrix((data, (rows, cols)), shape=(n_sub, m_seeds))
34
+
35
+ labels = np.empty(n_sub, dtype=int)
36
+ for start in range(0, n_sub, chunk_size):
37
+ end = min(start + chunk_size, n_sub)
38
+ block = k_mat[start:end, :].dot(b_mat).toarray()
39
+ labels[start:end] = block.argmax(axis=1)
40
+ return labels.astype(np.int64)
campmc/tl/partition.py ADDED
@@ -0,0 +1,126 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Sequence
4
+ from typing import Literal
5
+
6
+ import numpy as np
7
+ from anndata import AnnData
8
+
9
+ from campmc._logging import get_logger
10
+ from campmc.coreset import compute_q, gammas_to_m, sample_coreset
11
+ from campmc.tl.assign import camp1, camp2, camp3, camp4
12
+
13
+ logger = get_logger(__name__)
14
+
15
+ Method = Literal["camp1", "camp2", "camp3", "camp4"]
16
+
17
+
18
+ def _partition_one_batch(
19
+ adata: AnnData,
20
+ *,
21
+ method: Method,
22
+ gamma: int,
23
+ m: int,
24
+ batch_key: str | None,
25
+ reduction_key: str,
26
+ random_state: int,
27
+ ) -> tuple[np.ndarray, np.ndarray]:
28
+ labels = np.empty(adata.n_obs, dtype=object)
29
+ is_seed = np.zeros(adata.n_obs, dtype=bool)
30
+
31
+ if batch_key is None or batch_key not in adata.obs:
32
+ batches = [("__all__", np.arange(adata.n_obs))]
33
+ else:
34
+ batches = []
35
+ for anno in adata.obs[batch_key].astype(str).unique():
36
+ mask = (adata.obs[batch_key].astype(str) == anno).to_numpy()
37
+ batches.append((anno, np.where(mask)[0]))
38
+
39
+ q_global = compute_q(adata, rep_key=reduction_key)
40
+
41
+ for anno, idx in batches:
42
+ if idx.size <= 1:
43
+ labels[idx] = f"{anno}-metacell-1"
44
+ continue
45
+
46
+ q_sub = q_global[idx]
47
+ q_sub = q_sub / q_sub.sum()
48
+ m_here = min(m, idx.size)
49
+ local_seed = sample_coreset(idx.size, m_here, q_sub, random_state=random_state)
50
+ global_seed = idx[local_seed]
51
+ seeds = adata.obs_names[global_seed].tolist()
52
+
53
+ x_emb = np.asarray(adata.obsm[reduction_key][idx])
54
+ if method == "camp1":
55
+ lab_local = camp1.assign_labels(x_emb, local_seed, random_state=random_state)
56
+ elif method == "camp2":
57
+ lab_local = camp2.assign_labels(x_emb, local_seed, random_state=random_state)
58
+ elif method == "camp3":
59
+ lab_local = camp3.assign_labels(x_emb, local_seed, random_state=random_state)
60
+ else:
61
+ sub = adata[idx].copy()
62
+ lab_local = camp4.assign_labels(sub, seeds, reduction_key=reduction_key)
63
+
64
+ label_str = [f"seed{lab}-{gamma}-{anno}" for lab in lab_local]
65
+ labels[idx] = label_str
66
+ is_seed[global_seed] = True
67
+
68
+ return labels, is_seed
69
+
70
+
71
+ def partition(
72
+ adata: AnnData,
73
+ *,
74
+ method: Method = "camp1",
75
+ gamma: int | None = None,
76
+ gammas: Sequence[int] | None = None,
77
+ key_added: str = "camp",
78
+ batch_key: str | None = None,
79
+ reduction_key: str = "X_pca",
80
+ random_state: int = 0,
81
+ copy: bool = False,
82
+ ) -> AnnData | None:
83
+ if reduction_key not in adata.obsm:
84
+ msg = f"Missing obsm['{reduction_key}']; run campmc.pp.preprocess or add PCA first."
85
+ raise KeyError(msg)
86
+ if (gamma is None) == (gammas is None):
87
+ raise ValueError("Provide exactly one of gamma or gammas.")
88
+
89
+ target = adata.copy() if copy else adata
90
+ meta: dict = {"method": method, "reduction_key": reduction_key, "random_state": random_state}
91
+
92
+ if gamma is not None:
93
+ m = gammas_to_m(target.n_obs, [gamma])[0]
94
+ labels, is_seed = _partition_one_batch(
95
+ target,
96
+ method=method,
97
+ gamma=gamma,
98
+ m=m,
99
+ batch_key=batch_key,
100
+ reduction_key=reduction_key,
101
+ random_state=random_state,
102
+ )
103
+ target.obs[key_added] = labels
104
+ target.obs[f"{key_added}_is_seed"] = is_seed
105
+ meta["gamma"] = gamma
106
+ meta["m"] = m
107
+ else:
108
+ assert gammas is not None
109
+ meta["gammas"] = list(gammas)
110
+ for g in gammas:
111
+ m = gammas_to_m(target.n_obs, [g])[0]
112
+ labels, is_seed = _partition_one_batch(
113
+ target,
114
+ method=method,
115
+ gamma=g,
116
+ m=m,
117
+ batch_key=batch_key,
118
+ reduction_key=reduction_key,
119
+ random_state=random_state,
120
+ )
121
+ target.obs[f"{key_added}_{g}"] = labels
122
+ target.obs[f"{key_added}_{g}_is_seed"] = is_seed
123
+
124
+ target.uns[key_added] = meta
125
+ logger.info("partition complete: method=%s key=%s", method, key_added)
126
+ return target if copy else None
@@ -0,0 +1,93 @@
1
+ Metadata-Version: 2.4
2
+ Name: campmc
3
+ Version: 0.1.0
4
+ Summary: Third-party portable Python package for CAMP metacell partitioning (not the official paper repo)
5
+ License: MIT
6
+ License-File: LICENSE
7
+ Requires-Python: >=3.12
8
+ Requires-Dist: anndata>=0.11
9
+ Requires-Dist: celltypist
10
+ Requires-Dist: matplotlib
11
+ Requires-Dist: numpy
12
+ Requires-Dist: pandas
13
+ Requires-Dist: scanpy>=1.10
14
+ Requires-Dist: scikit-learn
15
+ Requires-Dist: scipy
16
+ Requires-Dist: seaborn
17
+ Requires-Dist: seacells
18
+ Description-Content-Type: text/markdown
19
+
20
+ # CAMP (Coreset Accelerated Metacell Partitioning)
21
+
22
+ Third-party, installable Python reimplementation of the CAMP metacell methods for use with [AnnData](https://anndata.readthedocs.io/) and [Scanpy](https://scanpy.readthedocs.io/).
23
+
24
+ ## About this project
25
+
26
+ **This repository is not maintained by the authors of the CAMP paper.** It is an independent packaging effort that ports the research code from the upstream [CAMP](https://github.com/danrongLi/CAMP) repository into a reusable library (`import campmc as cp`, PyPI name **campmc**).
27
+
28
+ Goals of this project:
29
+
30
+ - Make CAMP1–4 and related helpers **portable** (no hardcoded cluster paths, scanpy-style API).
31
+ - Provide tests, documentation, and a normal Python package layout.
32
+
33
+ For the **official reference implementation**, paper benchmarks, and experiment scripts, use the upstream CAMP repository. Behavior here should match the published algorithms in spirit, but this package is maintained separately.
34
+
35
+ If you use the CAMP method, please cite the [bioRxiv preprint](https://www.biorxiv.org/content/10.64898/2025.12.11.693725v1) (Li, Ko & Canzar, 2025; [doi:10.64898/2025.12.11.693725](https://doi.org/10.64898/2025.12.11.693725)) — see [Citation](#citation) below.
36
+
37
+ ## Install
38
+
39
+ ```bash
40
+ cd mycamp
41
+ uv sync --group dev
42
+ ```
43
+
44
+ ## Usage (scanpy-style)
45
+
46
+ ```python
47
+ import campmc as cp
48
+ import scanpy as sc
49
+
50
+ adata = sc.datasets.pbmc68k_reduced()
51
+ cp.tl.partition(adata, method="camp3", gamma=25, random_state=0)
52
+ cp.metrics.quality(adata, partition_key="camp", label_key="louvain")
53
+ ```
54
+
55
+ ## Documentation
56
+
57
+ Build HTML docs locally (same stack as [Scanpy](https://scanpy.readthedocs.io/): Sphinx, MyST, `scanpydoc` theme):
58
+
59
+ ```bash
60
+ uv sync --group doc
61
+ uv run sphinx-build -M html docs docs/_build
62
+ ```
63
+
64
+ ## Citation
65
+
66
+ If you use the CAMP method, please cite the preprint:
67
+
68
+ > Li, D., Ko, Y. K., & Canzar, S. (2025). CAMP: Coreset Accelerated Metacell Partitioning enables scalable analysis of single-cell data. *bioRxiv*. [https://doi.org/10.64898/2025.12.11.693725](https://doi.org/10.64898/2025.12.11.693725)
69
+
70
+ Preprint: [bioRxiv](https://www.biorxiv.org/content/10.64898/2025.12.11.693725v1)
71
+
72
+ ```bibtex
73
+ @article{li2025camp,
74
+ title = {CAMP: Coreset Accelerated Metacell Partitioning enables scalable analysis of single-cell data},
75
+ author = {Li, Danrong and Ko, Young Kun and Canzar, Stefan},
76
+ journal = {bioRxiv},
77
+ year = {2025},
78
+ doi = {10.64898/2025.12.11.693725},
79
+ url = {https://www.biorxiv.org/content/10.64898/2025.12.11.693725v1}
80
+ }
81
+ ```
82
+
83
+ This **campmc** package is a third-party implementation; citing the preprint refers to the CAMP method, not this repository.
84
+
85
+ ## Tests
86
+
87
+ ```bash
88
+ uv run pytest -q
89
+ ```
90
+
91
+ ## License
92
+
93
+ MIT — see [LICENSE](LICENSE). Algorithm code is adapted from the upstream [CAMP](https://github.com/danrongLi/CAMP) repository (MIT, © danrongLi). This **campmc** package is a separate third-party distribution; it is not affiliated with or endorsed by the original CAMP authors unless they state otherwise.
@@ -0,0 +1,25 @@
1
+ campmc/__init__.py,sha256=ni5zlyMT2jNEjrCqBSRWLeDWWHLC9JjLs1RR-o_6Yzg,435
2
+ campmc/_logging.py,sha256=qt8qwtCqsgrnRjDfaXVeFKkWXCt4m5Vjk1tiuqQs4aQ,400
3
+ campmc/coreset.py,sha256=aPAAkf3kgjAOzuhhbOF1IqwW7pduvwOStC038T5cU0E,1530
4
+ campmc/io.py,sha256=efcHvQ9qAIVtbYlLhIw72Gd-dieoXqYo0WXfp4cbqA4,881
5
+ campmc/metrics/__init__.py,sha256=0JArPO_fvx38oBp4uIbUXK3B8YDiHuPjWPTQzS7iX2Y,102
6
+ campmc/metrics/quality.py,sha256=SIZiFaZEghlkxPxV4HmPAeMEB0Z62lNzjTouI4YgGa0,3484
7
+ campmc/pl/__init__.py,sha256=yc_YTdvzJkkDvBQShnlcu-YX1-49_1qK81lcyJQ6lkY,133
8
+ campmc/pl/plots.py,sha256=8vkPXWEzXsoqh4At0dCwtTBuevt5IKo87fvGtdmQPUs,1168
9
+ campmc/pp/__init__.py,sha256=Jk4W9hlfE5AAp0bbEFVCSqpg52kXDVlBOGlaQCC-d5Y,106
10
+ campmc/pp/preprocess.py,sha256=1jniPi4miC3Z0NuWMhHHf5Q7nbjPKOws3UycBynbe0c,1428
11
+ campmc/rare/__init__.py,sha256=3f9kMUVMsJvV-fFhcBQx0-v7278Xg9b9cHmAaKQ_RVg,160
12
+ campmc/rare/recovery.py,sha256=7NE9Q2WJnyteLUvahqaq2LbRsKz5qDCLYH2dyGxSomo,2226
13
+ campmc/robustness/__init__.py,sha256=kQyC00qIQiy_U7ba9rkBohSDP9zBKLk6qLLKiKRAXEA,255
14
+ campmc/robustness/sweeps.py,sha256=6mYOBIYmi9K3WJy9bpstmdVVcbw_S6T9qot0AL5ogPQ,2775
15
+ campmc/tl/__init__.py,sha256=SDkbHdHobQgr6CWEipKMQcbMt8WDXTGNfbxTocu3bpA,103
16
+ campmc/tl/partition.py,sha256=Lj9Y0A19CTnLw0tDOOlDj22eqFHRRFtfN71hozXHUBc,4147
17
+ campmc/tl/assign/__init__.py,sha256=YuVyX_SjQ5PZM8Cxi7SPjuzljSs-qidqc-JaU15t3-E,336
18
+ campmc/tl/assign/camp1.py,sha256=S_QOJbND51g67E451byXrPA_VSLgjR53m27_IRFKzcQ,482
19
+ campmc/tl/assign/camp2.py,sha256=9xRqq6-2pFBZ33cv58-eDx968VNCeW_hiS9_6hVdtBE,1233
20
+ campmc/tl/assign/camp3.py,sha256=Uy_mij7YonLCxuHvVuVSFJ8PksDii53e-6bvWgrrXC4,1189
21
+ campmc/tl/assign/camp4.py,sha256=F73gFJsdoA-_ihW4QzKCkI4VNklXOue176zx9AvgxbA,1294
22
+ campmc-0.1.0.dist-info/METADATA,sha256=Q5JyRj4fiYYcvumGBXUTuL6CjmPk6a_FJlnDocYXtwE,3496
23
+ campmc-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
24
+ campmc-0.1.0.dist-info/licenses/LICENSE,sha256=rtg4nzu6kvO55VOPUn0GpuaKmS2zmkEEcLt7ZZcpYUk,1066
25
+ campmc-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 danrongLi
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.