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,
)
../_images/fe7352446f65190b3ea9a66416758272bcb23852c449affe6f9e3b9a5c979118.png

Pitfalls

  • Scanpy is not a dependency of infomap. infomap.tl.infomap reads an AnnData you already built. Install the AnnData support with pip install "infomap[anndata]", and install Scanpy itself separately.

  • Results follow the neighbour graph. They depend on your sc.pp.neighbors settings (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 reads adata.obsp["connectivities"] by default and accepts neighbors_key, obsp, and adjacency to point at a different graph. It writes a categorical column to adata.obs[key_added] and metadata to adata.uns[key_added].

  • infomap.run() partitions any SciPy sparse matrix directly (use infomap.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 accepts depth for sub-module assignments.

  • infomap.Result.codelength is the map equation value for the best partition; it is also at adata.uns[key_added]["codelength"] after tl.infomap.

  • infomap.Result.num_top_modules is the number of top-level modules.

Key infomap.tl.infomap keyword arguments:

Argument

Default

Purpose

key_added

"infomap"

Column name in adata.obs / key in adata.uns

obsp

None (uses "connectivities")

Select a specific adata.obsp slot

neighbors_key

None

Resolve graph via adata.uns[neighbors_key]

adjacency

None

Pass a sparse matrix directly

directed

False

Treat graph as directed

use_weights

True

Use edge weights; False for unweighted

seed

Infomap’s 123

Random seed forwarded to Infomap

num_trials

Infomap’s 1

Independent restarts; more trials give a more stable partition

Going deeper

  • Companion notebook: examples/notebooks/compare-infomap-scanpy-workflow.ipynb gives a fuller comparison with AMI/NMI scoring and graph-selection notes.

  • Building a network shows how Network.from_scipy_sparse_matrix handles directed, undirected, and weighted graphs.

  • The map equation is the objective Infomap minimises.

  • The map equation’s source paper [Rosvall and Bergstrom, 2008].