Scanpy and AnnData¶
Workflow
At a glance
Pull Infomap straight into your single-cell analysis pipeline: point it at the kNN connectivity graph Scanpy already built, get back AnnData-compatible cluster labels, and compare with Leiden in a few lines.
Infomap in a single-cell pipeline¶
Single-cell RNA-seq pipelines, Scanpy chief among them, cluster cells by building a \(k\)-nearest-neighbour graph in PCA-reduced space and then running a graph-partitioning algorithm. Leiden is the standard default; it optimises a modularity-style objective with a tunable resolution parameter. Infomap optimises the map equation instead: it finds where a random walker on the connectivity graph gets trapped and treats those flow-trapping regions as modules.
Both algorithms take the same weighted neighbour graph as input and produce a
partition of observations as output, so swapping in Infomap means calling a
different function. The infomap.tl.infomap() function follows Scanpy tl
conventions: it reads adata.obsp["connectivities"] by default, writes a
pandas categorical column to adata.obs, and records run metadata in
adata.uns. Scanpy itself is not imported by the Infomap package.
Beyond the high-level helper, the sparse matrix underlying any adata.obsp
slot is a standard SciPy CSR matrix; you can hand it directly to
infomap.run() and use the full Result API (hierarchical modules, codelength,
multiple trials) without any AnnData involvement.
The neighbour graph as flow¶
The neighbour graph Scanpy builds is a network: cells are nodes, and each edge carries a connectivity weight reflecting how similar two cells are in PCA space. Infomap treats it as a flow network and looks for regions where a random walker, hopping between cells in proportion to edge weights, tends to get trapped. A cluster of cells with strong internal connectivity and weak cross-cluster links is exactly such a region, and those regions become the modules (see The map equation).
This flow-centric view often agrees with Leiden, but the two can diverge: Leiden’s partition depends on its resolution parameter, and the connectivity graph may be asymmetric. Running both and comparing is a useful sanity check, especially for datasets where cluster sizes vary widely.
Cluster three cell populations¶
Setup: synthetic AnnData with three clusters¶
The example is fully synthetic and small so the build completes in a few seconds. Three tight Gaussian blobs in 10-dimensional space give Scanpy’s neighbour graph a clear community structure that both algorithms should recover.
import warnings
warnings.filterwarnings("ignore", message="IProgress not found.*")
warnings.filterwarnings("ignore", category=FutureWarning)
import numpy as np
import pandas as pd
import scipy.sparse as sp
from sklearn.datasets import make_blobs
import scanpy as sc
import infomap
# Three-cluster synthetic dataset: 150 cells, 10 PCA-like features
X, truth = make_blobs(
n_samples=150,
centers=3,
n_features=10,
cluster_std=1.2,
random_state=42,
)
adata = sc.AnnData(X.astype(np.float32))
adata.obs["true_label"] = pd.Categorical([str(l) for l in truth])
print(adata)
print("True cluster sizes:", adata.obs["true_label"].value_counts().to_dict())
AnnData object with n_obs × n_vars = 150 × 10
obs: 'true_label'
True cluster sizes: {'0': 50, '1': 50, '2': 50}
Build the kNN graph¶
sc.pp.neighbors computes a weighted \(k\)-nearest-neighbour graph and stores two
sparse matrices in adata.obsp:
connectivities: symmetrised connectivity weights used by clustering tools.distances: raw distances, not used for clustering.
sc.pp.neighbors(adata, n_neighbors=15, random_state=42)
print("obsp keys:", list(adata.obsp.keys()))
A = adata.obsp["connectivities"]
print(f"Connectivity matrix: {A.shape}, {A.nnz} non-zeros")
obsp keys: ['distances', 'connectivities']
Connectivity matrix: (150, 150), 2906 non-zeros
Cluster with Leiden and Infomap¶
Both algorithms read the same connectivity matrix. The infomap.tl.infomap
helper mirrors the Scanpy tl calling convention and writes results directly
into adata.
# Leiden: Scanpy's standard community detection
sc.tl.leiden(
adata,
key_added="leiden",
random_state=42,
flavor="igraph",
n_iterations=2,
directed=False,
)
# Infomap: reads adata.obsp["connectivities"] by default
infomap.tl.infomap(
adata,
key_added="infomap",
seed=123,
num_trials=5,
)
print("Leiden modules: ", adata.obs["leiden"].nunique())
print("Infomap modules:", adata.obs["infomap"].nunique())
print("\nInfomap run metadata:")
for k, v in adata.uns["infomap"].items():
print(f" {k}: {v}")
Leiden modules: 3
Infomap modules: 3
Infomap run metadata:
params: {'directed': False, 'use_weights': True, 'neighbors_key': None, 'obsp': 'connectivities', 'args': None, 'silent': True, 'no_file_output': True, 'seed': 123, 'num_trials': 5}
n_modules: 3
codelength: 5.544780005954122
The codelength in adata.uns["infomap"] is the map equation value for the
best partition Infomap found; a lower codelength means the partition compresses
the random walk more efficiently (see The map equation).
Compare the two labelings¶
Because both columns are categorical strings stored in adata.obs, a simple
cross-tabulation reveals how much the two partitions agree.
ct = pd.crosstab(
adata.obs["infomap"],
adata.obs["leiden"],
rownames=["Infomap"],
colnames=["Leiden"],
)
ct
| Leiden | 0 | 1 | 2 |
|---|---|---|---|
| Infomap | |||
| 1 | 50 | 0 | 0 |
| 2 | 0 | 50 | 0 |
| 3 | 0 | 0 | 50 |
A near-diagonal table, one Infomap module per Leiden module with no cells split across modules, is a sign that the two algorithms are finding the same structure. On messier real datasets you will see more off-diagonal entries, which can guide interpretation: cells that switch label between algorithms tend to sit at cluster boundaries.
Using the lower-level sparse-matrix API¶
If you want hierarchical modules, flow values, or finer control over Infomap
options, skip the tl helper and use the core API directly. The connectivity
matrix is a standard SciPy CSR matrix that you can pass straight to
infomap.run().
A = adata.obsp["connectivities"]
result = infomap.run(A, silent=True, seed=123, num_trials=5)
print(f"Modules: {result.num_top_modules}")
print(f"Codelength: {result.codelength:.4f} bits/step")
# Write labels back into adata.obs
modules = result.modules() # {node_index: module_id}
adata.obs["infomap_lowlevel"] = pd.Categorical(
[str(modules[i]) for i in range(adata.n_obs)]
)
adata.obs[["infomap", "infomap_lowlevel"]].value_counts()
Modules: 3
Codelength: 5.5448 bits/step
infomap infomap_lowlevel
1 1 50
2 2 50
3 3 50
Name: count, dtype: int64
The two agree because the tl helper and the core API call the same engine with
the same options; the helper only adds the AnnData bookkeeping.
Visualise with UMAP¶
Scanpy’s UMAP embedding uses the same neighbour graph and gives a 2-D view of cell layout. Colouring by ground truth, Infomap, and Leiden side by side is the standard visual QC step.
sc.tl.umap(adata, random_state=42)
sc.pl.umap(
adata,
color=["true_label", "infomap", "leiden"],
wspace=0.4,
show=True,
)
Pitfalls¶
Scanpy is not a dependency of
infomap.infomap.tl.infomapreads an AnnData you already built. Install the AnnData support withpip install "infomap[anndata]", and install Scanpy itself separately.Results follow the neighbour graph. They depend on your
sc.pp.neighborssettings (n_neighbors, the representation), so build that graph deliberately.Compare with another method. Cross-tabulate against Leiden; the cells that switch labels between the two are where the objectives disagree.
API pointers¶
infomap.tl.infomap()is the AnnData-aware helper. It readsadata.obsp["connectivities"]by default and acceptsneighbors_key,obsp, andadjacencyto point at a different graph. It writes a categorical column toadata.obs[key_added]and metadata toadata.uns[key_added].infomap.run()partitions any SciPy sparse matrix directly (useinfomap.Network.from_scipy_sparse_matrix()for non-default loading); reach for it when you need hierarchical output or flow values.infomap.Result.modules()returns{node_index: module_id}and acceptsdepthfor sub-module assignments.infomap.Result.codelengthis the map equation value for the best partition; it is also atadata.uns[key_added]["codelength"]aftertl.infomap.infomap.Result.num_top_modulesis the number of top-level modules.
Key infomap.tl.infomap keyword arguments:
Argument |
Default |
Purpose |
|---|---|---|
|
|
Column name in |
|
|
Select a specific |
|
|
Resolve graph via |
|
|
Pass a sparse matrix directly |
|
|
Treat graph as directed |
|
|
Use edge weights; |
|
Infomap’s |
Random seed forwarded to Infomap |
|
Infomap’s |
Independent restarts; more trials give a more stable partition |
Going deeper¶
Companion notebook:
examples/notebooks/compare-infomap-scanpy-workflow.ipynbgives a fuller comparison with AMI/NMI scoring and graph-selection notes.Building a network shows how
Network.from_scipy_sparse_matrixhandles directed, undirected, and weighted graphs.The map equation is the objective Infomap minimises.
The map equation’s source paper [Rosvall and Bergstrom, 2008].