topometry 1.0.2__tar.gz → 1.1.2__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.
- {topometry-1.0.2 → topometry-1.1.2}/PKG-INFO +46 -1
- {topometry-1.0.2 → topometry-1.1.2}/README.md +35 -0
- {topometry-1.0.2 → topometry-1.1.2}/pyproject.toml +3 -0
- {topometry-1.0.2 → topometry-1.1.2}/setup.cfg +16 -1
- topometry-1.1.2/tests/test_cca_integration.py +248 -0
- topometry-1.1.2/tests/test_cca_mapping.py +205 -0
- topometry-1.1.2/tests/test_cosine_knn_distances.py +363 -0
- topometry-1.1.2/tests/test_kernel_units_and_backends.py +130 -0
- topometry-1.1.2/tests/test_standing_issues.py +852 -0
- {topometry-1.0.2 → topometry-1.1.2}/topo/base/ann.py +190 -203
- topometry-1.1.2/topo/eval/__init__.py +7 -0
- {topometry-1.0.2 → topometry-1.1.2}/topo/eval/local_scores.py +40 -48
- {topometry-1.0.2 → topometry-1.1.2}/topo/eval/rmetric.py +21 -8
- {topometry-1.0.2 → topometry-1.1.2}/topo/layouts/isomap.py +5 -3
- {topometry-1.0.2 → topometry-1.1.2}/topo/layouts/projector.py +78 -103
- {topometry-1.0.2 → topometry-1.1.2}/topo/pipes.py +13 -4
- {topometry-1.0.2 → topometry-1.1.2}/topo/plot.py +1 -1
- {topometry-1.0.2 → topometry-1.1.2}/topo/single_cell.py +2530 -70
- {topometry-1.0.2 → topometry-1.1.2}/topo/spectral/_spectral.py +31 -2
- {topometry-1.0.2 → topometry-1.1.2}/topo/spectral/eigen.py +10 -7
- {topometry-1.0.2 → topometry-1.1.2}/topo/topograph.py +191 -220
- {topometry-1.0.2 → topometry-1.1.2}/topo/tpgraph/cknn.py +31 -19
- {topometry-1.0.2 → topometry-1.1.2}/topo/tpgraph/intrinsic_dim.py +31 -40
- {topometry-1.0.2 → topometry-1.1.2}/topo/tpgraph/kernels.py +125 -146
- {topometry-1.0.2 → topometry-1.1.2}/topo/utils/_utils.py +25 -0
- {topometry-1.0.2 → topometry-1.1.2}/topo/utils/umap_utils.py +1 -1
- topometry-1.1.2/topo/version.py +1 -0
- {topometry-1.0.2 → topometry-1.1.2}/topometry.egg-info/PKG-INFO +46 -1
- {topometry-1.0.2 → topometry-1.1.2}/topometry.egg-info/SOURCES.txt +5 -0
- topometry-1.1.2/topometry.egg-info/requires.txt +18 -0
- topometry-1.0.2/topo/eval/__init__.py +0 -3
- topometry-1.0.2/topo/version.py +0 -1
- topometry-1.0.2/topometry.egg-info/requires.txt +0 -7
- {topometry-1.0.2 → topometry-1.1.2}/LICENSE +0 -0
- {topometry-1.0.2 → topometry-1.1.2}/setup.py +0 -0
- {topometry-1.0.2 → topometry-1.1.2}/topo/__init__.py +0 -0
- {topometry-1.0.2 → topometry-1.1.2}/topo/base/__init__.py +0 -0
- {topometry-1.0.2 → topometry-1.1.2}/topo/base/dists.py +0 -0
- {topometry-1.0.2 → topometry-1.1.2}/topo/base/sparse.py +0 -0
- {topometry-1.0.2 → topometry-1.1.2}/topo/eval/global_scores.py +0 -0
- {topometry-1.0.2 → topometry-1.1.2}/topo/eval/topo_metrics.py +0 -0
- {topometry-1.0.2 → topometry-1.1.2}/topo/layouts/__init__.py +0 -0
- {topometry-1.0.2 → topometry-1.1.2}/topo/layouts/graph_utils.py +0 -0
- {topometry-1.0.2 → topometry-1.1.2}/topo/layouts/map.py +0 -0
- {topometry-1.0.2 → topometry-1.1.2}/topo/spectral/__init__.py +0 -0
- {topometry-1.0.2 → topometry-1.1.2}/topo/spectral/umap_layouts.py +0 -0
- {topometry-1.0.2 → topometry-1.1.2}/topo/tpgraph/__init__.py +0 -0
- {topometry-1.0.2 → topometry-1.1.2}/topo/tpgraph/fuzzy.py +0 -0
- {topometry-1.0.2 → topometry-1.1.2}/topo/utils/__init__.py +0 -0
- {topometry-1.0.2 → topometry-1.1.2}/topometry.egg-info/dependency_links.txt +0 -0
- {topometry-1.0.2 → topometry-1.1.2}/topometry.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: topometry
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.1.2
|
|
4
4
|
Summary: Tools for single-cell geometric analysis, clustering and visualization.
|
|
5
5
|
Home-page: https://github.com/davisidarta/topometry
|
|
6
6
|
Author: David Sidarta Oliveira
|
|
@@ -18,7 +18,17 @@ Requires-Dist: scikit-learn
|
|
|
18
18
|
Requires-Dist: matplotlib
|
|
19
19
|
Requires-Dist: pandas
|
|
20
20
|
Requires-Dist: numba
|
|
21
|
+
Requires-Dist: joblib
|
|
21
22
|
Requires-Dist: setuptools
|
|
23
|
+
Provides-Extra: test
|
|
24
|
+
Requires-Dist: pytest; extra == "test"
|
|
25
|
+
Requires-Dist: hnswlib; extra == "test"
|
|
26
|
+
Requires-Dist: scanpy; extra == "test"
|
|
27
|
+
Requires-Dist: anndata; extra == "test"
|
|
28
|
+
Requires-Dist: scikit-misc; extra == "test"
|
|
29
|
+
Requires-Dist: igraph; extra == "test"
|
|
30
|
+
Requires-Dist: leidenalg; extra == "test"
|
|
31
|
+
Requires-Dist: ipython; extra == "test"
|
|
22
32
|
Dynamic: license-file
|
|
23
33
|
|
|
24
34
|
[](https://pypi.org/project/topometry/)
|
|
@@ -73,6 +83,8 @@ Then you can install TopoMetry from PyPI:
|
|
|
73
83
|
pip install topometry
|
|
74
84
|
```
|
|
75
85
|
|
|
86
|
+
Neighbor search uses [hnswlib](https://github.com/nmslib/hnswlib) when it is installed (`pip install hnswlib`), which is recommended for all but small datasets. Without it, TopoMetry falls back to nmslib if present, and otherwise to scikit-learn's exact search. PaCMAP layouts need `pip install pacmap`.
|
|
87
|
+
|
|
76
88
|
|
|
77
89
|
## Tutorials and documentation
|
|
78
90
|
|
|
@@ -100,6 +112,39 @@ sc.pl.embedding(adata, basis='TopoPaCMAP', color='topo_clusters')
|
|
|
100
112
|
adata.write_h5ad("pbmc3k_topometry.h5ad")
|
|
101
113
|
```
|
|
102
114
|
|
|
115
|
+
## Changelog
|
|
116
|
+
|
|
117
|
+
**v1.1.2** — Notebook display
|
|
118
|
+
|
|
119
|
+
- Displaying a `TopOGraph` or `Kernel` in a Jupyter notebook, or listing its attributes with `dir()` or tab completion, failed with recent scikit-learn, which reads every attribute of an estimator: a `Kernel` property raised `ValueError`, and another started an all-pairs shortest-path computation. These objects now show their own text summary, and listing attributes evaluates nothing.
|
|
120
|
+
- The PDF report named two `adata.obsm` keys that do not exist; it now names `X_ms_spectral_scaffold`.
|
|
121
|
+
|
|
122
|
+
**v1.1.1** — Fixes to standing issues
|
|
123
|
+
|
|
124
|
+
⚠️ Results computed with a cosine metric change, and `base_metric='cosine'` is the default. Analyses run with any release from 0.2.0.0 to 1.1.0 on a cosine metric should be re-run. Euclidean graphs and the default kernels are unchanged on the hnswlib and nmslib backends.
|
|
125
|
+
|
|
126
|
+
- **Cosine neighbor graphs held similarities instead of distances.** Within each neighborhood the closest points got the lowest kernel weights. Neighbor sets were right; the weights were not. Cosine kernels, geodesics and intrinsic-dimension estimates are now computed on angles, and kernels have no self-loops.
|
|
127
|
+
- **Neighbor search.** A missing backend falls back to the next available of hnswlib, nmslib and scikit-learn with a warning; every backend returns the same number of neighbors; `backend='nmslib'` is honoured; nmslib no longer returns squared distances for dense input.
|
|
128
|
+
- **Kernel options that did not do what they say** now do: CkNN (`delta` is passed on and usually needs tuning), neighborhood expansion, `use_angular=False`, `pairwise=True`.
|
|
129
|
+
- **Projections.** Isomap and the MDE recipes are run on distances rather than on the diffusion operator; UMAP and landmarks work; a standalone `Projector` builds proper affinities.
|
|
130
|
+
- **Reproducibility.** With `n_jobs=1` and a `random_state`, two fits agree bit for bit. Eigenvector signs no longer flip between runs.
|
|
131
|
+
- **TopOGraph.** `global_id`, `local_ids()`, `global_id_mle()` and `global_id_fsa()` return the estimates (the scaffold size is `n_scaffold_components`); `pseudotime` and `spectral_selectivity` pair each eigenvector with its own eigenvalue; saving no longer strips the fitted object.
|
|
132
|
+
- **Riemannian diagnostics** keep the Laplacian sparse, and the plotting wrapper honours `diffusion_t` and `groupby`.
|
|
133
|
+
- **Single-cell wrappers.** `tp.sc.preprocess` no longer modifies its input; BBKNN integration builds kernels from distances; PaCMAP is optional; `fit_adata` works without hnswlib.
|
|
134
|
+
- Compatibility with current scikit-learn, SciPy and matplotlib.
|
|
135
|
+
|
|
136
|
+
**v1.1.0** — Batch integration and data mapping
|
|
137
|
+
- CCA-anchor batch correction (Seurat v3-style) via `tp.sc.run_cca_integration`
|
|
138
|
+
- Reference atlas persistence (`save_cca_reference` / `load_cca_reference`) and sequential query mapping (`map_to_cca_reference`)
|
|
139
|
+
- High-level preparation utilities (`prepare_for_integration`, `prepare_for_mapping`, `find_mapping_order`)
|
|
140
|
+
- Neighbourhood-based integration quality metrics (`compute_all_integration_metrics`: kNN purity, kNN mixing, iLISI, cLISI, ARI, NMI)
|
|
141
|
+
|
|
142
|
+
**v1.0.x** — Complete overhaul
|
|
143
|
+
- Redesigned user API with `tp.sc.fit_adata` and `tp.sc.run_and_report` one-liner workflows
|
|
144
|
+
- New utilities for single-cell analysis: intrinsic dimensionality, spectral selectivity, feature modes, graph-signal filtering, imputation
|
|
145
|
+
- Overhauled geometry-preservation metrics (PF1, PJS, SP) and Riemannian diagnostics (pullback metric, deformation maps)
|
|
146
|
+
- Full compatibility with the `scverse` ecosystem (scanpy, scVelo, AnnData)
|
|
147
|
+
|
|
103
148
|
#### Citation
|
|
104
149
|
|
|
105
150
|
---
|
|
@@ -50,6 +50,8 @@ Then you can install TopoMetry from PyPI:
|
|
|
50
50
|
pip install topometry
|
|
51
51
|
```
|
|
52
52
|
|
|
53
|
+
Neighbor search uses [hnswlib](https://github.com/nmslib/hnswlib) when it is installed (`pip install hnswlib`), which is recommended for all but small datasets. Without it, TopoMetry falls back to nmslib if present, and otherwise to scikit-learn's exact search. PaCMAP layouts need `pip install pacmap`.
|
|
54
|
+
|
|
53
55
|
|
|
54
56
|
## Tutorials and documentation
|
|
55
57
|
|
|
@@ -77,6 +79,39 @@ sc.pl.embedding(adata, basis='TopoPaCMAP', color='topo_clusters')
|
|
|
77
79
|
adata.write_h5ad("pbmc3k_topometry.h5ad")
|
|
78
80
|
```
|
|
79
81
|
|
|
82
|
+
## Changelog
|
|
83
|
+
|
|
84
|
+
**v1.1.2** — Notebook display
|
|
85
|
+
|
|
86
|
+
- Displaying a `TopOGraph` or `Kernel` in a Jupyter notebook, or listing its attributes with `dir()` or tab completion, failed with recent scikit-learn, which reads every attribute of an estimator: a `Kernel` property raised `ValueError`, and another started an all-pairs shortest-path computation. These objects now show their own text summary, and listing attributes evaluates nothing.
|
|
87
|
+
- The PDF report named two `adata.obsm` keys that do not exist; it now names `X_ms_spectral_scaffold`.
|
|
88
|
+
|
|
89
|
+
**v1.1.1** — Fixes to standing issues
|
|
90
|
+
|
|
91
|
+
⚠️ Results computed with a cosine metric change, and `base_metric='cosine'` is the default. Analyses run with any release from 0.2.0.0 to 1.1.0 on a cosine metric should be re-run. Euclidean graphs and the default kernels are unchanged on the hnswlib and nmslib backends.
|
|
92
|
+
|
|
93
|
+
- **Cosine neighbor graphs held similarities instead of distances.** Within each neighborhood the closest points got the lowest kernel weights. Neighbor sets were right; the weights were not. Cosine kernels, geodesics and intrinsic-dimension estimates are now computed on angles, and kernels have no self-loops.
|
|
94
|
+
- **Neighbor search.** A missing backend falls back to the next available of hnswlib, nmslib and scikit-learn with a warning; every backend returns the same number of neighbors; `backend='nmslib'` is honoured; nmslib no longer returns squared distances for dense input.
|
|
95
|
+
- **Kernel options that did not do what they say** now do: CkNN (`delta` is passed on and usually needs tuning), neighborhood expansion, `use_angular=False`, `pairwise=True`.
|
|
96
|
+
- **Projections.** Isomap and the MDE recipes are run on distances rather than on the diffusion operator; UMAP and landmarks work; a standalone `Projector` builds proper affinities.
|
|
97
|
+
- **Reproducibility.** With `n_jobs=1` and a `random_state`, two fits agree bit for bit. Eigenvector signs no longer flip between runs.
|
|
98
|
+
- **TopOGraph.** `global_id`, `local_ids()`, `global_id_mle()` and `global_id_fsa()` return the estimates (the scaffold size is `n_scaffold_components`); `pseudotime` and `spectral_selectivity` pair each eigenvector with its own eigenvalue; saving no longer strips the fitted object.
|
|
99
|
+
- **Riemannian diagnostics** keep the Laplacian sparse, and the plotting wrapper honours `diffusion_t` and `groupby`.
|
|
100
|
+
- **Single-cell wrappers.** `tp.sc.preprocess` no longer modifies its input; BBKNN integration builds kernels from distances; PaCMAP is optional; `fit_adata` works without hnswlib.
|
|
101
|
+
- Compatibility with current scikit-learn, SciPy and matplotlib.
|
|
102
|
+
|
|
103
|
+
**v1.1.0** — Batch integration and data mapping
|
|
104
|
+
- CCA-anchor batch correction (Seurat v3-style) via `tp.sc.run_cca_integration`
|
|
105
|
+
- Reference atlas persistence (`save_cca_reference` / `load_cca_reference`) and sequential query mapping (`map_to_cca_reference`)
|
|
106
|
+
- High-level preparation utilities (`prepare_for_integration`, `prepare_for_mapping`, `find_mapping_order`)
|
|
107
|
+
- Neighbourhood-based integration quality metrics (`compute_all_integration_metrics`: kNN purity, kNN mixing, iLISI, cLISI, ARI, NMI)
|
|
108
|
+
|
|
109
|
+
**v1.0.x** — Complete overhaul
|
|
110
|
+
- Redesigned user API with `tp.sc.fit_adata` and `tp.sc.run_and_report` one-liner workflows
|
|
111
|
+
- New utilities for single-cell analysis: intrinsic dimensionality, spectral selectivity, feature modes, graph-signal filtering, imputation
|
|
112
|
+
- Overhauled geometry-preservation metrics (PF1, PJS, SP) and Riemannian diagnostics (pullback metric, deformation maps)
|
|
113
|
+
- Full compatibility with the `scverse` ecosystem (scanpy, scVelo, AnnData)
|
|
114
|
+
|
|
80
115
|
#### Citation
|
|
81
116
|
|
|
82
117
|
---
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[metadata]
|
|
2
2
|
name = topometry
|
|
3
|
-
version =
|
|
3
|
+
version = attr: topo.version.__version__
|
|
4
4
|
author = David Sidarta Oliveira
|
|
5
5
|
author_email = david.oliveira@dpag.ox.ac.uk
|
|
6
6
|
description = Tools for single-cell geometric analysis, clustering and visualization.
|
|
@@ -22,10 +22,25 @@ install_requires =
|
|
|
22
22
|
matplotlib
|
|
23
23
|
pandas
|
|
24
24
|
numba
|
|
25
|
+
joblib
|
|
25
26
|
setuptools
|
|
26
27
|
packages = find:
|
|
27
28
|
python_requires = >=3.6
|
|
28
29
|
|
|
30
|
+
[options.packages.find]
|
|
31
|
+
include = topo*
|
|
32
|
+
|
|
33
|
+
[options.extras_require]
|
|
34
|
+
test =
|
|
35
|
+
pytest
|
|
36
|
+
hnswlib
|
|
37
|
+
scanpy
|
|
38
|
+
anndata
|
|
39
|
+
scikit-misc
|
|
40
|
+
igraph
|
|
41
|
+
leidenalg
|
|
42
|
+
ipython
|
|
43
|
+
|
|
29
44
|
[egg_info]
|
|
30
45
|
tag_build =
|
|
31
46
|
tag_date = 0
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
import scipy.sparse as sp
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
# CCA anchoring searches neighbors with hnswlib directly
|
|
6
|
+
pytest.importorskip("hnswlib")
|
|
7
|
+
import anndata
|
|
8
|
+
from anndata import AnnData
|
|
9
|
+
from topo.single_cell import run_cca_integration
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _make_adata(n: int, p: int, label: str, seed: int,
|
|
13
|
+
n_types: int = 4) -> AnnData:
|
|
14
|
+
"""
|
|
15
|
+
Structured toy data: n_types Gaussian clusters, integer counts,
|
|
16
|
+
~80% sparsity. Passes _is_raw_counts() heuristic.
|
|
17
|
+
"""
|
|
18
|
+
rng = np.random.default_rng(seed)
|
|
19
|
+
ct = rng.integers(0, n_types, n)
|
|
20
|
+
centers = rng.normal(0, 3, (n_types, p))
|
|
21
|
+
X = np.exp(centers[ct] + rng.normal(0, 0.8, (n, p)))
|
|
22
|
+
X[rng.random((n, p)) < 0.8] = 0.0
|
|
23
|
+
X = np.round(X).astype(np.float32) # integer-valued floats → raw counts
|
|
24
|
+
a = AnnData(X=sp.csr_matrix(X))
|
|
25
|
+
a.obs_names = [f"{label}_c{i}" for i in range(n)]
|
|
26
|
+
a.var_names = [f"gene{j:04d}" for j in range(p)]
|
|
27
|
+
a.obs["batch"] = label
|
|
28
|
+
return a
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# ── Test 1: Basic two-batch, all adaptive params ──────────────────────
|
|
32
|
+
def test_two_batch_adaptive():
|
|
33
|
+
a = _make_adata(300, 500, "A", seed=0)
|
|
34
|
+
b = _make_adata(280, 500, "B", seed=1)
|
|
35
|
+
result = run_cca_integration([a, b], scale_output=False)
|
|
36
|
+
|
|
37
|
+
assert result.n_obs == 580
|
|
38
|
+
# n_vars must equal the recorded n_features value
|
|
39
|
+
assert result.n_vars == result.uns["cca_integration"]["n_features"]
|
|
40
|
+
assert result.n_vars >= 100, "Expected at least 100 integration features"
|
|
41
|
+
assert sp.issparse(result.X)
|
|
42
|
+
assert result.X.dtype == np.float32
|
|
43
|
+
assert set(result.obs["batch"].unique()) == {"A", "B"}
|
|
44
|
+
assert "original" in result.layers
|
|
45
|
+
assert "cca_integration" in result.uns
|
|
46
|
+
|
|
47
|
+
meta = result.uns["cca_integration"]
|
|
48
|
+
assert meta["n_datasets"] == 2
|
|
49
|
+
assert meta["correction_mode"] == "symmetric"
|
|
50
|
+
assert meta["n_components"] == 30
|
|
51
|
+
assert meta["n_anchors_filt_per_merge"][0] > 0, \
|
|
52
|
+
"Structured data should yield anchors"
|
|
53
|
+
# All per-merge lists must have exactly N-1 = 1 entry for 2 datasets
|
|
54
|
+
for key in ["k_anchor_per_merge", "k_filter_per_merge",
|
|
55
|
+
"k_score_per_merge", "k_weight_per_merge",
|
|
56
|
+
"sd_bandwidth_per_merge", "n_anchors_raw_per_merge",
|
|
57
|
+
"n_anchors_filt_per_merge"]:
|
|
58
|
+
assert len(meta[key]) == 1, f"{key} should have 1 entry"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# ── Test 2: Output is in log-normalised space (not z-scored) ──────────
|
|
62
|
+
def test_output_is_lognorm_space():
|
|
63
|
+
a = _make_adata(300, 500, "A", seed=2)
|
|
64
|
+
b = _make_adata(300, 500, "B", seed=3)
|
|
65
|
+
result = run_cca_integration([a, b], scale_output=False)
|
|
66
|
+
|
|
67
|
+
X = result.X.toarray()
|
|
68
|
+
assert X.mean() > 0.0, "Mean must be > 0 in lognorm space"
|
|
69
|
+
assert X.max() > 1.0, "Max must be > 1 in lognorm space"
|
|
70
|
+
assert X.min() >= 0.0, "Values must be >= 0 (clamp enforced)"
|
|
71
|
+
|
|
72
|
+
X_orig = result.layers["original"].toarray()
|
|
73
|
+
assert X_orig.mean() > 0.0
|
|
74
|
+
assert X_orig.max() > 1.0
|
|
75
|
+
assert X_orig.min() >= 0.0
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# ── Test 3: Symmetric correction — both batches shift ─────────────────
|
|
79
|
+
def test_symmetric_correction_both_batches_shift():
|
|
80
|
+
a = _make_adata(300, 500, "A", seed=4)
|
|
81
|
+
b = _make_adata(300, 500, "B", seed=5)
|
|
82
|
+
result = run_cca_integration([a, b], scale_output=False)
|
|
83
|
+
|
|
84
|
+
X_orig = result.layers["original"].toarray()
|
|
85
|
+
X_corr = result.X.toarray()
|
|
86
|
+
batch = result.obs["batch"].values
|
|
87
|
+
|
|
88
|
+
idx_a = np.where(batch == "A")[0]
|
|
89
|
+
idx_b = np.where(batch == "B")[0]
|
|
90
|
+
|
|
91
|
+
shift_a = np.abs(X_corr[idx_a].mean() - X_orig[idx_a].mean())
|
|
92
|
+
shift_b = np.abs(X_corr[idx_b].mean() - X_orig[idx_b].mean())
|
|
93
|
+
assert shift_a > 0.0, "Batch A must shift (symmetric correction)"
|
|
94
|
+
assert shift_b > 0.0, "Batch B must shift (symmetric correction)"
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# ── Test 4: Single AnnData + batch_key ────────────────────────────────
|
|
98
|
+
def test_single_adata_batch_key():
|
|
99
|
+
a = _make_adata(200, 400, "A", seed=6)
|
|
100
|
+
b = _make_adata(200, 400, "B", seed=7)
|
|
101
|
+
combined = anndata.concat([a, b])
|
|
102
|
+
result = run_cca_integration(combined, batch_key="batch", scale_output=False)
|
|
103
|
+
assert result.n_obs == 400
|
|
104
|
+
assert set(result.obs["batch"].unique()) == {"A", "B"}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
# ── Test 5: Three-batch guide tree ────────────────────────────────────
|
|
108
|
+
def test_three_batches_guide_tree():
|
|
109
|
+
batches = [_make_adata(150, 400, f"B{i}", seed=i) for i in range(3)]
|
|
110
|
+
result = run_cca_integration(batches, scale_output=False)
|
|
111
|
+
assert result.n_obs == 450
|
|
112
|
+
assert set(result.obs["batch"].unique()) == {"B0", "B1", "B2"}
|
|
113
|
+
meta = result.uns["cca_integration"]
|
|
114
|
+
assert len(meta["merge_order"]) == 2
|
|
115
|
+
assert len(meta["n_anchors_filt_per_merge"]) == 2
|
|
116
|
+
assert len(meta["n_anchors_raw_per_merge"]) == 2
|
|
117
|
+
assert len(meta["k_anchor_per_merge"]) == 2
|
|
118
|
+
assert len(meta["sd_bandwidth_per_merge"]) == 2
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
# ── Test 6: Manual hyperparameters respected ──────────────────────────
|
|
122
|
+
def test_manual_hyperparameters():
|
|
123
|
+
a = _make_adata(200, 400, "A", seed=8)
|
|
124
|
+
b = _make_adata(200, 400, "B", seed=9)
|
|
125
|
+
result = run_cca_integration(
|
|
126
|
+
[a, b],
|
|
127
|
+
n_features=200, n_components=15,
|
|
128
|
+
k_anchor=5, k_filter=60, k_score=20, k_weight=50,
|
|
129
|
+
sd_bandwidth=1.0, scale_output=False)
|
|
130
|
+
assert result.n_vars == 200
|
|
131
|
+
meta = result.uns["cca_integration"]
|
|
132
|
+
assert meta["n_components"] == 15
|
|
133
|
+
assert meta["k_anchor_per_merge"][0] == 5
|
|
134
|
+
assert meta["k_score_per_merge"][0] == 20
|
|
135
|
+
assert meta["k_filter_per_merge"][0] == 60
|
|
136
|
+
assert meta["sd_bandwidth_per_merge"][0] == 1.0
|
|
137
|
+
assert meta["k_weight_per_merge"][0] == 50
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
# ── Test 7: Zero-anchor fallback — no crash ───────────────────────────
|
|
141
|
+
def test_zero_anchor_fallback():
|
|
142
|
+
import warnings
|
|
143
|
+
X_a = sp.random(40, 100, density=0.1, format="csr", random_state=0)
|
|
144
|
+
X_b = sp.random(40, 100, density=0.1, format="csr", random_state=1)
|
|
145
|
+
a = AnnData(X=X_a.astype(np.float32))
|
|
146
|
+
b = AnnData(X=X_b.astype(np.float32))
|
|
147
|
+
a.obs_names = [f"A{i}" for i in range(40)]
|
|
148
|
+
b.obs_names = [f"B{i}" for i in range(40)]
|
|
149
|
+
a.var_names = b.var_names = [f"g{j}" for j in range(100)]
|
|
150
|
+
a.obs["batch"] = "A"
|
|
151
|
+
b.obs["batch"] = "B"
|
|
152
|
+
with warnings.catch_warnings(record=True):
|
|
153
|
+
warnings.simplefilter("always")
|
|
154
|
+
result = run_cca_integration(
|
|
155
|
+
[a, b], n_features=80, n_components=5,
|
|
156
|
+
k_anchor=2, k_filter=0, scale_output=False)
|
|
157
|
+
assert result.n_obs == 80
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
# ── Test 8: raw >= filt anchor counts ─────────────────────────────────
|
|
161
|
+
def test_anchor_counts_raw_ge_filtered():
|
|
162
|
+
a = _make_adata(200, 400, "A", seed=10)
|
|
163
|
+
b = _make_adata(200, 400, "B", seed=11)
|
|
164
|
+
result = run_cca_integration([a, b], scale_output=False)
|
|
165
|
+
meta = result.uns["cca_integration"]
|
|
166
|
+
assert meta["n_anchors_raw_per_merge"][0] >= \
|
|
167
|
+
meta["n_anchors_filt_per_merge"][0] >= 0
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
# ── Test 9: Corrected values non-negative ─────────────────────────────
|
|
171
|
+
def test_corrected_values_nonnegative():
|
|
172
|
+
a = _make_adata(300, 500, "A", seed=14)
|
|
173
|
+
b = _make_adata(300, 500, "B", seed=15)
|
|
174
|
+
result = run_cca_integration([a, b], scale_output=False)
|
|
175
|
+
X = result.X.toarray()
|
|
176
|
+
assert X.min() >= 0.0, f"Min corrected value is {X.min():.4f} < 0"
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
# ── Test 10: Multithreaded — no gross divergence ───────────────────────
|
|
180
|
+
def test_multithreaded_no_gross_divergence():
|
|
181
|
+
a = _make_adata(200, 400, "A", seed=12)
|
|
182
|
+
b = _make_adata(200, 400, "B", seed=13)
|
|
183
|
+
r1 = run_cca_integration([a.copy(), b.copy()], n_jobs=1, seed=0, scale_output=False)
|
|
184
|
+
r2 = run_cca_integration([a.copy(), b.copy()], n_jobs=2, seed=0, scale_output=False)
|
|
185
|
+
diff = np.abs((r1.X - r2.X).toarray())
|
|
186
|
+
assert diff.max() < 0.5, \
|
|
187
|
+
f"Multithreaded result diverges too much: max diff = {diff.max():.4f}"
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
# ── Test 11: Real data smoke test ─────────────────────────────────────
|
|
191
|
+
def test_pbmc3k_smoke():
|
|
192
|
+
import scanpy as sc_
|
|
193
|
+
adata = sc_.datasets.pbmc3k()
|
|
194
|
+
rng = np.random.default_rng(42)
|
|
195
|
+
adata.obs["batch"] = rng.choice(["b1", "b2"], size=adata.n_obs)
|
|
196
|
+
|
|
197
|
+
result = run_cca_integration(adata, batch_key="batch", n_jobs=2, scale_output=False)
|
|
198
|
+
|
|
199
|
+
assert result.n_obs == adata.n_obs
|
|
200
|
+
assert sp.issparse(result.X)
|
|
201
|
+
assert result.X.dtype == np.float32
|
|
202
|
+
|
|
203
|
+
meta = result.uns["cca_integration"]
|
|
204
|
+
assert meta["n_anchors_filt_per_merge"][0] > 0, \
|
|
205
|
+
"Expected anchors on structured PBMC data"
|
|
206
|
+
assert meta["correction_mode"] == "symmetric"
|
|
207
|
+
assert result.n_vars == meta["n_features"]
|
|
208
|
+
|
|
209
|
+
X = result.X.toarray()
|
|
210
|
+
assert X.mean() > 0.0, "Output must be in lognorm space"
|
|
211
|
+
assert X.min() >= 0.0, "Output must be >= 0"
|
|
212
|
+
|
|
213
|
+
print(
|
|
214
|
+
f"\nPBMC smoke: {result.n_obs} cells, {result.n_vars} features\n"
|
|
215
|
+
f" k_anchor: {meta['k_anchor_per_merge']}\n"
|
|
216
|
+
f" n_anchors: raw={meta['n_anchors_raw_per_merge']} "
|
|
217
|
+
f"filt={meta['n_anchors_filt_per_merge']}\n"
|
|
218
|
+
f" sd_bandwidth:{meta['sd_bandwidth_per_merge']}"
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
# ── Test 12: CCA gene loadings from raw U ─────────────────────────────
|
|
223
|
+
def test_cca_gene_loadings_from_raw_u():
|
|
224
|
+
"""
|
|
225
|
+
U_k must be computed from raw singular vectors, not row-normalised ones.
|
|
226
|
+
Verify that column norms of U_k are 1.0 and cc_a rows are unit-norm.
|
|
227
|
+
"""
|
|
228
|
+
from topo.single_cell import _compute_cca
|
|
229
|
+
rng = np.random.default_rng(0)
|
|
230
|
+
n_a, n_b, p = 80, 60, 200
|
|
231
|
+
X_a = rng.standard_normal((n_a, p)).astype(np.float32)
|
|
232
|
+
X_b = rng.standard_normal((n_b, p)).astype(np.float32)
|
|
233
|
+
cc_a, cc_b, U_k = _compute_cca(X_a, X_b, n_components=10, seed=0)
|
|
234
|
+
|
|
235
|
+
# U_k columns must be unit-norm (column-normalised)
|
|
236
|
+
col_norms = np.linalg.norm(U_k, axis=0)
|
|
237
|
+
np.testing.assert_allclose(col_norms, 1.0, atol=1e-5,
|
|
238
|
+
err_msg="U_k column norms must equal 1.0 (column-normalised)")
|
|
239
|
+
|
|
240
|
+
# cc_a rows must be unit-norm (row-normalised)
|
|
241
|
+
row_norms = np.linalg.norm(cc_a, axis=1)
|
|
242
|
+
np.testing.assert_allclose(row_norms, 1.0, atol=1e-5,
|
|
243
|
+
err_msg="cc_a row norms must equal 1.0 (row-normalised)")
|
|
244
|
+
|
|
245
|
+
# Shape checks
|
|
246
|
+
assert U_k.shape == (p, min(10, min(n_a, n_b) - 1))
|
|
247
|
+
assert cc_a.shape[0] == n_a
|
|
248
|
+
assert cc_b.shape[0] == n_b
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
import scipy.sparse as sp
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
# CCA anchoring searches neighbors with hnswlib directly
|
|
6
|
+
pytest.importorskip("hnswlib")
|
|
7
|
+
import anndata
|
|
8
|
+
from anndata import AnnData
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from topo.single_cell import (
|
|
11
|
+
run_cca_integration,
|
|
12
|
+
save_cca_reference,
|
|
13
|
+
load_cca_reference,
|
|
14
|
+
map_to_cca_reference,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _make_adata(n, p, label, seed, n_types=4):
|
|
19
|
+
"""Same helper as in test_cca_integration.py."""
|
|
20
|
+
rng = np.random.default_rng(seed)
|
|
21
|
+
ct = rng.integers(0, n_types, n)
|
|
22
|
+
centers = rng.normal(0, 3, (n_types, p))
|
|
23
|
+
X = np.exp(centers[ct] + rng.normal(0, 0.8, (n, p)))
|
|
24
|
+
X[rng.random((n, p)) < 0.8] = 0.0
|
|
25
|
+
X = np.round(X).astype(np.float32)
|
|
26
|
+
a = AnnData(X=sp.csr_matrix(X))
|
|
27
|
+
a.obs_names = [f"{label}_c{i}" for i in range(n)]
|
|
28
|
+
a.var_names = [f"gene{j:04d}" for j in range(p)]
|
|
29
|
+
a.obs["batch"] = label
|
|
30
|
+
return a
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@pytest.fixture
|
|
34
|
+
def reference(tmp_path):
|
|
35
|
+
"""A completed integration result saved to disk."""
|
|
36
|
+
a = _make_adata(200, 400, "A", seed=0)
|
|
37
|
+
b = _make_adata(200, 400, "B", seed=1)
|
|
38
|
+
adata_int = run_cca_integration([a, b], scale_output=False)
|
|
39
|
+
ref_path = tmp_path / "reference.h5ad"
|
|
40
|
+
save_cca_reference(adata_int, ref_path)
|
|
41
|
+
return adata_int, ref_path
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# ── Test 1: run_cca_integration stores reference fields ──────────────────
|
|
45
|
+
def test_integration_stores_reference_fields():
|
|
46
|
+
a = _make_adata(200, 400, "A", seed=0)
|
|
47
|
+
b = _make_adata(200, 400, "B", seed=1)
|
|
48
|
+
result = run_cca_integration([a, b], scale_output=False)
|
|
49
|
+
|
|
50
|
+
assert "cca_loadings" in result.varm, \
|
|
51
|
+
"varm['cca_loadings'] must be stored by run_cca_integration"
|
|
52
|
+
assert "X_cca" in result.obsm, \
|
|
53
|
+
"obsm['X_cca'] must be stored by run_cca_integration"
|
|
54
|
+
assert "ref_mu" in result.uns["cca_integration"]
|
|
55
|
+
assert "ref_sigma" in result.uns["cca_integration"]
|
|
56
|
+
|
|
57
|
+
U_k = result.varm["cca_loadings"]
|
|
58
|
+
cc_ref = result.obsm["X_cca"]
|
|
59
|
+
n_comp = result.uns["cca_integration"]["n_components"]
|
|
60
|
+
|
|
61
|
+
assert U_k.shape == (result.n_vars, n_comp), \
|
|
62
|
+
f"Expected ({result.n_vars}, {n_comp}), got {U_k.shape}"
|
|
63
|
+
assert cc_ref.shape == (result.n_obs, n_comp), \
|
|
64
|
+
f"Expected ({result.n_obs}, {n_comp}), got {cc_ref.shape}"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# ── Test 2: save and load round-trip ─────────────────────────────────────
|
|
68
|
+
def test_save_load_roundtrip(tmp_path, reference):
|
|
69
|
+
adata_int, ref_path = reference
|
|
70
|
+
loaded = load_cca_reference(ref_path)
|
|
71
|
+
|
|
72
|
+
assert loaded.n_obs == adata_int.n_obs
|
|
73
|
+
assert loaded.n_vars == adata_int.n_vars
|
|
74
|
+
assert "cca_loadings" in loaded.varm
|
|
75
|
+
assert "X_cca" in loaded.obsm
|
|
76
|
+
np.testing.assert_allclose(
|
|
77
|
+
loaded.varm["cca_loadings"],
|
|
78
|
+
adata_int.varm["cca_loadings"], atol=1e-5)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# ── Test 3: load_cca_reference raises on missing fields ──────────────────
|
|
82
|
+
def test_load_raises_on_missing_fields(tmp_path):
|
|
83
|
+
bad = AnnData(X=sp.csr_matrix(np.ones((10, 5), dtype=np.float32)))
|
|
84
|
+
bad.obs_names = [f"c{i}" for i in range(10)]
|
|
85
|
+
bad.var_names = [f"g{j}" for j in range(5)]
|
|
86
|
+
bad_path = tmp_path / "bad.h5ad"
|
|
87
|
+
bad.write_h5ad(bad_path)
|
|
88
|
+
with pytest.raises(ValueError, match="missing required fields"):
|
|
89
|
+
load_cca_reference(bad_path)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# ── Test 4: query_only — basic structure ─────────────────────────────────
|
|
93
|
+
def test_query_only_basic(reference):
|
|
94
|
+
adata_int, _ = reference
|
|
95
|
+
query = _make_adata(80, 400, "Q", seed=5)
|
|
96
|
+
result = map_to_cca_reference(query, adata_int, mode="query_only")
|
|
97
|
+
|
|
98
|
+
assert result.n_obs == 80
|
|
99
|
+
assert result.n_vars == adata_int.n_vars
|
|
100
|
+
assert sp.issparse(result.X)
|
|
101
|
+
assert result.X.dtype == np.float32
|
|
102
|
+
assert "original" in result.layers
|
|
103
|
+
assert "cca_mapping" in result.uns
|
|
104
|
+
assert result.uns["cca_mapping"]["mode"] == "query_only"
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
# ── Test 5: query_only — corrected values non-negative ───────────────────
|
|
108
|
+
def test_query_only_nonnegative(reference):
|
|
109
|
+
adata_int, _ = reference
|
|
110
|
+
query = _make_adata(80, 400, "Q", seed=6)
|
|
111
|
+
result = map_to_cca_reference(query, adata_int, mode="query_only")
|
|
112
|
+
X = result.X.toarray()
|
|
113
|
+
assert X.min() >= 0.0, f"Corrected values must be >= 0, got min={X.min()}"
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
# ── Test 6: query_only — reference stays frozen ───────────────────────────
|
|
117
|
+
def test_query_only_reference_frozen(reference):
|
|
118
|
+
adata_int, _ = reference
|
|
119
|
+
X_ref_before = adata_int.X.toarray().copy()
|
|
120
|
+
query = _make_adata(80, 400, "Q", seed=7)
|
|
121
|
+
_ = map_to_cca_reference(query, adata_int, mode="query_only")
|
|
122
|
+
X_ref_after = adata_int.X.toarray()
|
|
123
|
+
np.testing.assert_array_equal(X_ref_before, X_ref_after,
|
|
124
|
+
err_msg="Reference expression must not be modified in query_only mode")
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
# ── Test 7: query_only — query cells shift ───────────────────────────────
|
|
128
|
+
def test_query_only_cells_shift(reference):
|
|
129
|
+
adata_int, _ = reference
|
|
130
|
+
query = _make_adata(150, 400, "Q", seed=8)
|
|
131
|
+
result = map_to_cca_reference(query, adata_int, mode="query_only")
|
|
132
|
+
X_corr = result.X.toarray()
|
|
133
|
+
X_orig = result.layers["original"].toarray()
|
|
134
|
+
assert np.abs(X_corr - X_orig).max() > 0.0, \
|
|
135
|
+
"Corrected and original query expression should differ"
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
# ── Test 8: query_only from disk ─────────────────────────────────────────
|
|
139
|
+
def test_query_only_from_path(tmp_path, reference):
|
|
140
|
+
adata_int, ref_path = reference
|
|
141
|
+
query = _make_adata(80, 400, "Q", seed=9)
|
|
142
|
+
result = map_to_cca_reference(query, ref_path, mode="query_only")
|
|
143
|
+
assert result.n_obs == 80
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
# ── Test 9: partial gene coverage — warn and impute ──────────────────────
|
|
147
|
+
def test_partial_gene_coverage_warns(reference):
|
|
148
|
+
import warnings
|
|
149
|
+
adata_int, _ = reference
|
|
150
|
+
features = list(adata_int.var_names)
|
|
151
|
+
query_full = _make_adata(80, 400, "Q", seed=10)
|
|
152
|
+
query_partial = query_full[:, query_full.var_names[:300]].copy()
|
|
153
|
+
coverage = len([g for g in features if g in set(query_partial.var_names)])
|
|
154
|
+
frac = coverage / len(features)
|
|
155
|
+
if frac >= 0.8:
|
|
156
|
+
with warnings.catch_warnings(record=True) as w:
|
|
157
|
+
warnings.simplefilter("always")
|
|
158
|
+
result = map_to_cca_reference(
|
|
159
|
+
query_partial, adata_int, mode="query_only")
|
|
160
|
+
assert result.n_obs == 80
|
|
161
|
+
else:
|
|
162
|
+
with pytest.raises(ValueError, match="covers only"):
|
|
163
|
+
map_to_cca_reference(
|
|
164
|
+
query_partial, adata_int,
|
|
165
|
+
mode="query_only", min_shared_features=0.8)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
# ── Test 10: full_reintegration — output contains all cells ──────────────
|
|
169
|
+
def test_full_reintegration_all_cells(reference):
|
|
170
|
+
adata_int, _ = reference
|
|
171
|
+
n_ref = adata_int.n_obs
|
|
172
|
+
query = _make_adata(80, 400, "Q", seed=11)
|
|
173
|
+
result = map_to_cca_reference(query, adata_int, mode="full_reintegration")
|
|
174
|
+
assert result.n_obs == n_ref + 80
|
|
175
|
+
assert result.n_vars == adata_int.n_vars
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
# ── Test 11: full_reintegration — feature set preserved ──────────────────
|
|
179
|
+
def test_full_reintegration_features_fixed(reference):
|
|
180
|
+
adata_int, _ = reference
|
|
181
|
+
query = _make_adata(80, 400, "Q", seed=12)
|
|
182
|
+
result = map_to_cca_reference(query, adata_int, mode="full_reintegration")
|
|
183
|
+
assert list(result.var_names) == list(adata_int.var_names), \
|
|
184
|
+
"full_reintegration must preserve the reference feature set"
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
# ── Test 12: invalid mode raises ─────────────────────────────────────────
|
|
188
|
+
def test_invalid_mode_raises(reference):
|
|
189
|
+
adata_int, _ = reference
|
|
190
|
+
query = _make_adata(80, 400, "Q", seed=13)
|
|
191
|
+
with pytest.raises(ValueError, match="mode must be"):
|
|
192
|
+
map_to_cca_reference(query, adata_int, mode="invalid")
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
# ── Test 13: query_only — run log populated ──────────────────────────────
|
|
196
|
+
def test_query_only_run_log(reference):
|
|
197
|
+
adata_int, _ = reference
|
|
198
|
+
query = _make_adata(150, 400, "Q", seed=14)
|
|
199
|
+
result = map_to_cca_reference(query, adata_int, mode="query_only")
|
|
200
|
+
meta = result.uns["cca_mapping"]
|
|
201
|
+
assert isinstance(meta["n_anchors_raw"], int)
|
|
202
|
+
assert isinstance(meta["n_anchors_filt"], int)
|
|
203
|
+
assert meta["n_anchors_raw"] >= meta["n_anchors_filt"] >= 0
|
|
204
|
+
assert meta["projection_method"] == "back_projected_U_k"
|
|
205
|
+
assert meta["n_ref_cells"] == adata_int.n_obs
|