Skip to content

AnnData / scanpy

mf.umap and mf.tsne are drop-ins for sc.tl.umap and sc.tl.tsne. They write the same slots, so everything downstream (sc.pl.umap, sc.pl.tsne, sc.pl.embedding) works unchanged. They need the anndata extra; scanpy itself is not a dependency.

uv pip install 'manifolds-rs[anndata]'

UMAP

import scanpy as sc
import manifolds_rs as mf

sc.pp.pca(adata)
sc.pp.neighbors(adata, n_neighbors=15)
mf.umap(adata)
sc.pl.umap(adata, color="leiden")

Like scanpy's, mf.umap embeds the sc.pp.neighbors graph rather than searching again. It reads the neighbour indices and distances back out of obsp["distances"] and the crate builds its own fuzzy graph from them, so n_neighbors and the metric are whatever sc.pp.neighbors used. Scanpy counts the cell itself and this does not, so n_neighbors=15 there is recorded as 14 here. The representation (use_rep, n_pcs) comes from the same uns["neighbors"]["params"] and feeds the initialisation.

obsp["distances"] has to hold the same number of neighbours on every row, which it does for sc.pp.neighbors with its default settings. A ragged graph is an error.

t-SNE

mf.tsne(adata)  # obsm["X_pca"]
mf.tsne(adata, n_pcs=30)  # the first 30 PCs
mf.tsne(adata, use_rep="X_scVI")  # any dense obsm key, or "X"

mf.tsne does its own 3 * perplexity neighbour search on the representation, as sc.tl.tsne does. The 15-neighbour graph from sc.pp.neighbors is far too narrow for t-SNE at the usual perplexity. There is no automatic PCA: without obsm["X_pca"] and no use_rep, it raises and tells you to run sc.pp.pca.

GPU

mf.umap(adata, device="gpu")
mf.tsne(adata, device="gpu", approx="fft_3k_gpu")

device="gpu" swaps in UMAPGpu / TSNEGpu, float32 throughout. See GPU for what runs on the device.

What gets written

Call obsm uns
mf.umap(adata) "X_umap" "umap"
mf.tsne(adata) "X_tsne" "tsne"
key_added="foo" "foo" "foo"

uns[...]["params"] holds the estimator's scalar parameters, plus device and neighbors_key (UMAP) or use_rep / n_pcs (t-SNE). Only scalars are kept, so the result survives write_h5ad. UMAP's a and b are not stored: the crate fits them from min_dist and spread and does not hand them back.

copy=True returns a modified copy and leaves adata alone.

Everything else

Any other constructor argument of the estimator goes through as a keyword:

mf.umap(adata, n_epochs=200, init="pca", optim_params=mf.UmapOptim(gamma=1.5))
mf.tsne(adata, learning_rate=500.0, ann="hnsw", metric="cosine")

Unknown names raise, same as the estimator constructors.

Benchmark

25,000 cells from mf.datasets.clustered(25_000, dim=50, n_clusters=8), sc.pp.pca(n_comps=30) and sc.pp.neighbors(n_neighbors=15) beforehand, not timed. Three repeats per row at seeds 0, 1 and 2, after an untimed warm-up (numba JIT for umap-learn, wgpu device initialisation). Cluster separation is the mean between-centroid distance over the mean within-cluster spread; higher means the clusters sit further apart than they are wide.

Call Time (s) Cluster separation
sc.tl.umap 10.64 ± 0.02 19.83 ± 0.90
mf.umap 1.48 ± 0.07 26.04 ± 0.27
mf.umap(device="gpu") 0.43 ± 0.01 19.06 ± 0.29
sc.tl.tsne(n_jobs=10) 47.21 ± 1.07 11.14 ± 0.00
mf.tsne 9.73 ± 0.27 8.71 ± 0.23
mf.tsne(device="gpu") 12.18 ± 0.32 8.72 ± 0.24
mf.tsne(device="gpu", approx="fft_3k_gpu") 4.89 ± 0.06 8.34 ± 0.14

Mean ± sd over the three repeats. Run on an Apple M1 Max (10 cores), scanpy 1.11.5. Defaults on both sides, with two things worth knowing: scanpy runs UMAP for 200 epochs above 10,000 cells where mf.umap runs 500, and sc.tl.tsne was given n_jobs=10 since its default is single-threaded. One synthetic dataset on one machine; measure on your own data before relying on it.