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.
Files changed (51) hide show
  1. {topometry-1.0.2 → topometry-1.1.2}/PKG-INFO +46 -1
  2. {topometry-1.0.2 → topometry-1.1.2}/README.md +35 -0
  3. {topometry-1.0.2 → topometry-1.1.2}/pyproject.toml +3 -0
  4. {topometry-1.0.2 → topometry-1.1.2}/setup.cfg +16 -1
  5. topometry-1.1.2/tests/test_cca_integration.py +248 -0
  6. topometry-1.1.2/tests/test_cca_mapping.py +205 -0
  7. topometry-1.1.2/tests/test_cosine_knn_distances.py +363 -0
  8. topometry-1.1.2/tests/test_kernel_units_and_backends.py +130 -0
  9. topometry-1.1.2/tests/test_standing_issues.py +852 -0
  10. {topometry-1.0.2 → topometry-1.1.2}/topo/base/ann.py +190 -203
  11. topometry-1.1.2/topo/eval/__init__.py +7 -0
  12. {topometry-1.0.2 → topometry-1.1.2}/topo/eval/local_scores.py +40 -48
  13. {topometry-1.0.2 → topometry-1.1.2}/topo/eval/rmetric.py +21 -8
  14. {topometry-1.0.2 → topometry-1.1.2}/topo/layouts/isomap.py +5 -3
  15. {topometry-1.0.2 → topometry-1.1.2}/topo/layouts/projector.py +78 -103
  16. {topometry-1.0.2 → topometry-1.1.2}/topo/pipes.py +13 -4
  17. {topometry-1.0.2 → topometry-1.1.2}/topo/plot.py +1 -1
  18. {topometry-1.0.2 → topometry-1.1.2}/topo/single_cell.py +2530 -70
  19. {topometry-1.0.2 → topometry-1.1.2}/topo/spectral/_spectral.py +31 -2
  20. {topometry-1.0.2 → topometry-1.1.2}/topo/spectral/eigen.py +10 -7
  21. {topometry-1.0.2 → topometry-1.1.2}/topo/topograph.py +191 -220
  22. {topometry-1.0.2 → topometry-1.1.2}/topo/tpgraph/cknn.py +31 -19
  23. {topometry-1.0.2 → topometry-1.1.2}/topo/tpgraph/intrinsic_dim.py +31 -40
  24. {topometry-1.0.2 → topometry-1.1.2}/topo/tpgraph/kernels.py +125 -146
  25. {topometry-1.0.2 → topometry-1.1.2}/topo/utils/_utils.py +25 -0
  26. {topometry-1.0.2 → topometry-1.1.2}/topo/utils/umap_utils.py +1 -1
  27. topometry-1.1.2/topo/version.py +1 -0
  28. {topometry-1.0.2 → topometry-1.1.2}/topometry.egg-info/PKG-INFO +46 -1
  29. {topometry-1.0.2 → topometry-1.1.2}/topometry.egg-info/SOURCES.txt +5 -0
  30. topometry-1.1.2/topometry.egg-info/requires.txt +18 -0
  31. topometry-1.0.2/topo/eval/__init__.py +0 -3
  32. topometry-1.0.2/topo/version.py +0 -1
  33. topometry-1.0.2/topometry.egg-info/requires.txt +0 -7
  34. {topometry-1.0.2 → topometry-1.1.2}/LICENSE +0 -0
  35. {topometry-1.0.2 → topometry-1.1.2}/setup.py +0 -0
  36. {topometry-1.0.2 → topometry-1.1.2}/topo/__init__.py +0 -0
  37. {topometry-1.0.2 → topometry-1.1.2}/topo/base/__init__.py +0 -0
  38. {topometry-1.0.2 → topometry-1.1.2}/topo/base/dists.py +0 -0
  39. {topometry-1.0.2 → topometry-1.1.2}/topo/base/sparse.py +0 -0
  40. {topometry-1.0.2 → topometry-1.1.2}/topo/eval/global_scores.py +0 -0
  41. {topometry-1.0.2 → topometry-1.1.2}/topo/eval/topo_metrics.py +0 -0
  42. {topometry-1.0.2 → topometry-1.1.2}/topo/layouts/__init__.py +0 -0
  43. {topometry-1.0.2 → topometry-1.1.2}/topo/layouts/graph_utils.py +0 -0
  44. {topometry-1.0.2 → topometry-1.1.2}/topo/layouts/map.py +0 -0
  45. {topometry-1.0.2 → topometry-1.1.2}/topo/spectral/__init__.py +0 -0
  46. {topometry-1.0.2 → topometry-1.1.2}/topo/spectral/umap_layouts.py +0 -0
  47. {topometry-1.0.2 → topometry-1.1.2}/topo/tpgraph/__init__.py +0 -0
  48. {topometry-1.0.2 → topometry-1.1.2}/topo/tpgraph/fuzzy.py +0 -0
  49. {topometry-1.0.2 → topometry-1.1.2}/topo/utils/__init__.py +0 -0
  50. {topometry-1.0.2 → topometry-1.1.2}/topometry.egg-info/dependency_links.txt +0 -0
  51. {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.0.2
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
  [![Latest PyPI version](https://img.shields.io/pypi/v/topometry.svg)](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
  ---
@@ -4,3 +4,6 @@ requires = [
4
4
  "wheel"
5
5
  ]
6
6
  build-backend = "setuptools.build_meta"
7
+
8
+ [tool.pytest.ini_options]
9
+ testpaths = ["tests"]
@@ -1,6 +1,6 @@
1
1
  [metadata]
2
2
  name = topometry
3
- version = 1.0.2
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