A comprehensive guide to computational tools for reconstructing cellular developmental trajectories
Walk into a room full of half-built LEGO models, every one frozen at a different stage. Nobody filmed any single model going up, yet the build order is still recoverable from how finished each one looks. That room is a scRNA-seq snapshot: thousands of cells caught mid-differentiation, and trajectory inference is the argument you make from the still image.
Trajectory inference (TI) methods reconstruct the dynamic processes of cellular differentiation, development, and state transitions from single-cell data. These computational approaches allow researchers to understand how cells progress through different states over time, identify key transition points, and discover the genes that drive these changes.
Selecting the right trajectory inference method depends on several factors:
These methods infer future cell states by modeling the relationship between unspliced and spliced mRNA:
Nature
The pioneering RNA velocity method that introduced the concept of using splicing dynamics to predict future cell states.
Nature Biotechnology
An improved RNA velocity framework with dynamical modeling that accounts for transcriptional induction, repression, and steady-state. Most widely used velocity method.
Nature Methods
Probabilistic RNA velocity inference using variational inference with uncertainty quantification.
Nature Communications
Unified RNA velocity framework using unified latent time modeling across the transcriptome via Radial Basis Functions.
These methods use optimal transport theory to match cells across conditions or timepoints:
Cell
Uses optimal transport to infer developmental trajectories and fate probabilities across timepoints.
Nature (preprint 2023)
Multi-Omics Single-Cell Optimal Transport - the most scalable OT framework, handling over 1.7 million cells with linear time complexity.
NeurIPS 2024
Gene-regulated neural optimal transport with uncertainty quantification for trajectory inference using flow matching.
Traditional approaches that construct trajectories using dimensionality reduction and graph structures:
Nature
Advanced trajectory inference framework using UMAP and principal graphs, designed to scale to millions of cells and handle discontinuous trajectories. Used to analyze the Mouse Organogenesis Cell Atlas (2 million cells).
Nature Methods
Earlier version using reversed graph embedding for trajectory reconstruction. Still widely used for smaller datasets.
BMC Genomics
Flexible trajectory inference using cluster-based minimum spanning trees and principal curves.
Genome Biology
Partition-based graph abstraction that creates coarse-grained trajectory representations. Exceptionally scalable: 1.3M cells in 90 seconds.
Methods that combine multiple signals or integrate different trajectory inference approaches:
Nature Methods
Unified framework combining RNA velocity, pseudotime, gene expression, and experimental time for robust fate predictions. Scales to millions of cells.
Nature Communications
Feature selection for trajectory analysis that identifies genes driving dynamic processes.
Purpose-built tools for specific biological questions or data types:
Scientific Reports
Discovers novel cell types and subpopulations along developmental trajectories using cross-timepoint analysis.
Nature Methods (online Nov 2023)
Specialized method for B cell development using class-switch recombination as molecular clock.
Nature Machine Intelligence
Models growth dynamics explicitly during trajectory inference to account for proliferation using dynamic unbalanced optimal transport.
Nature Communications
Learns potential landscapes from temporal single-cell data to predict differentiation trajectories. Developed by Gifford lab at MIT CSAIL.
Nature Protocols
Trajectory visualization tool optimized for flow and mass cytometry data using force-directed graph layouts.
Nature Methods
Hierarchical lineage tree inference with probabilistic modeling for complex multi-branching developmental systems using Hodge Laplacian decomposition.
This table provides a comprehensive comparison of key features across different trajectory inference methods:
| Method | Year | Max Scale | Multiple Timepoints | Single Snapshot | Spatial Data | Multimodal | Growth/Death | Key Strength |
|---|---|---|---|---|---|---|---|---|
| Monocle 3 | 2019 | 2M+ cells | β | β | β | β | β | Ultra-scalable, discontinuous trajectories, convergent fates |
| Monocle 2 | 2017 | ~50K cells | β | β | β | β | β | DDRTree algorithm, well-established |
| MOSCOT | 2025 | 1.7M+ cells | β | β | β | β | β | Most scalable OT, spatial, multimodal |
| scVelo | 2020 | ~100K cells | β | β | β | β | β | Dynamic velocity, most popular |
| GENOT | 2024 | Variable | β | β | β | β | β | Stochastic OT, uncertainty quantification |
| CellRank 2 | 2024 | 1.3M+ cells | β | β | β | β | β | Multi-view integration |
| veloVI | 2024 | ~50K cells | β | β | β | β | β | Uncertainty quantification |
| UniTVelo | 2022 | ~100K cells | β | β | β | β | β | Batch integration |
| CASi | 2024 | ~100K cells | β | β | β | β | β | Novel cell type discovery |
| sciCSR | 2024 | ~50K cells | β | β | β | β | β | B cell specialization |
| Slingshot | 2018 | ~50K cells | β | β | β | β | β | Flexible, Bioconductor integration |
| PAGA | 2019 | ~200K cells | β | β | β | β | β | Graph abstraction, exploratory |
RNA Velocity (scVelo, veloVI):
Optimal Transport (MOSCOT, GENOT):
Graph-Based (Monocle 3, PAGA, Slingshot):
The walkthrough below takes a QC'd AnnData object end to end through a standard Python trajectory stack: neighbor graph and UMAP, Leiden clusters, PAGA for coarse topology, diffusion pseudotime for a continuous ordering, RNA velocity with scVelo, and finally CellRank to turn velocity plus connectivity into terminal states and fate probabilities. Every step is retrospective and computational β the point is to interrogate an existing dataset, not to generate new material.
Scanpy, scVelo, and CellRank evolve quickly and pin overlapping dependencies, so install them into one dedicated environment rather than adding them to a general analysis env. CellRank 2 expects scvelo >= 0.3 and scanpy >= 1.9; installing all three in a single pip call lets the resolver pick a mutually consistent set.
# conda / mamba recommended
conda create -n traj python=3.10 -y
conda activate traj
# install together so the resolver reconciles shared pins
pip install "scanpy>=1.9" "scvelo>=0.3" "cellrank>=2.0" leidenalg igraph
# only needed if you must GENERATE spliced/unspliced counts yourself
# pip install velocyto scvelo[louvain] loompy
# (alternative: STARsolo --soloFeatures Gene Velocyto, or kb-python with --workflow lamanno)
Hardware. A laptop with 16 GB RAM comfortably handles ~50k cells through PAGA, DPT, and the stochastic velocity model. The scVelo dynamical model is the bottleneck: it fits a full splicing ODE per gene by EM and can take hours on 100k cells, so run it on a compute node with n_jobs set to the available cores. CellRank's kernels and GPCCA decomposition are sparse-matrix operations and stay cheap by comparison. No GPU is required anywhere in this pipeline.
AnnData β the container everything reads and writes: adata.X (expression), adata.obs (per-cell annotation, where pseudotime and cluster labels land), adata.obsm (embeddings such as X_pca, X_umap, X_diffmap), adata.uns (graph and PAGA results)adata.layers["spliced"] / adata.layers["unspliced"] β hard prerequisite for RNA velocity. These are separate count matrices produced by a velocity-aware quantifier (velocyto on the CellRanger BAM, STARsolo Velocyto mode, or kb-python's La Manno workflow). They cannot be derived from a standard filtered count matrix: the spliced/unspliced split requires read-to-intron alignment, and that information is gone once you have only a gene-by-cell matrix..loom), loaded with scv.read() and merged onto your QC'd object with scv.utils.merge(); barcode suffixes rarely match out of the box and usually need cleaning firstadata.obsp["connectivities"] and adata.obsp["distances"]; PAGA, DPT, and CellRank's connectivity kernel are all functions of this single graph, so its parameters propagate everywhereadata.layers["velocity"] (per-gene rates), adata.uns["velocity_graph"] (cell-cell transition scores); the dynamical model additionally writes adata.obs["latent_time"]adata.obsm["lineages_fwd"] holding per-cell absorption probabilities toward each terminal stateStart from the QC-filtered object, build the graph, run PAGA for topology, anchor a root cell using biology and compute diffusion pseudotime, then bring in the spliced/unspliced layers for velocity and hand both signals to CellRank.
import scanpy as sc
import scvelo as scv
import cellrank as cr
import numpy as np
# 1. Start from a QC'd object (doublets removed, ambient corrected).
# Trajectory methods are extremely sensitive to junk cells: a stray
# doublet sits "between" two clusters and looks exactly like a
# transitional state to any graph-based method.
adata = sc.read_h5ad("adata_qc.h5ad")
adata.layers["counts"] = adata.X.copy() # keep raw counts for later
# 2. Standard normalisation -> HVGs -> PCA
sc.pp.normalize_total(adata, target_sum=1e4)
sc.pp.log1p(adata)
adata.raw = adata # log-norm values for plots/markers
sc.pp.highly_variable_genes(adata, n_top_genes=2000)
adata = adata[:, adata.var.highly_variable].copy()
sc.pp.scale(adata, max_value=10)
sc.tl.pca(adata, n_comps=50, svd_solver="arpack")
# 3. Neighbour graph -> UMAP -> Leiden.
# n_neighbors is the single most consequential knob downstream: PAGA,
# DPT and CellRank's connectivity kernel all read this same graph.
sc.pp.neighbors(adata, n_neighbors=15, n_pcs=30)
sc.tl.umap(adata)
sc.tl.leiden(adata, resolution=1.0, key_added="leiden")
# 4. PAGA: coarse-grained topology between clusters. Read this BEFORE
# committing to a pseudotime -- it tells you whether the data is one
# connected continuum or several disconnected islands.
sc.tl.paga(adata, groups="leiden")
sc.pl.paga(adata, threshold=0.05, show=False) # prune weak edges
sc.tl.umap(adata, init_pos="paga") # PAGA-initialised embedding
# 5. Diffusion pseudotime. The root must come from BIOLOGY, not convenience:
# pick the cluster expressing your known progenitor/stem markers, then
# take an extreme cell within it along the first diffusion component.
sc.tl.diffmap(adata)
root_cluster = "3" # e.g. HSC/progenitor cluster
mask = (adata.obs["leiden"] == root_cluster).values
dc1 = adata.obsm["X_diffmap"][:, 1]
adata.uns["iroot"] = np.flatnonzero(mask)[np.argmin(dc1[mask])]
sc.tl.dpt(adata, n_dcs=15) # writes adata.obs["dpt_pseudotime"]
# Sanity check: pseudotime should INCREASE away from the progenitors.
# If your known mature markers sit at low pseudotime, the root is wrong
# and the whole trajectory is silently reversed.
print(adata.obs.groupby("leiden")["dpt_pseudotime"].median())
# 6. RNA velocity. This step REQUIRES spliced/unspliced layers -- they come
# from velocyto/STARsolo/kb-python, and cannot be recovered from a plain
# filtered count matrix. Merge the loom onto the QC'd object by barcode.
ldata = scv.read("sample.loom", cache=True)
ldata.var_names_make_unique()
adata = scv.utils.merge(adata, ldata) # intersects on barcodes
assert "spliced" in adata.layers and "unspliced" in adata.layers
scv.pp.filter_and_normalize(adata, min_shared_counts=20, n_top_genes=2000)
scv.pp.moments(adata, n_pcs=30, n_neighbors=30) # kNN-smoothed first/second moments
# stochastic: seconds-to-minutes, assumes a common steady state
scv.tl.velocity(adata, mode="stochastic")
# dynamical: fits the full splicing ODE per gene by EM. Much slower
# (hours at 100k cells) but relaxes the steady-state assumption and is the
# only mode that yields latent time.
# scv.tl.recover_dynamics(adata, n_jobs=8)
# scv.tl.velocity(adata, mode="dynamical")
# scv.tl.latent_time(adata) # adata.obs["latent_time"]
scv.tl.velocity_graph(adata, n_jobs=8)
scv.pl.velocity_embedding_stream(adata, basis="umap", color="leiden")
# Confidence diagnostics -- low values mean the arrows are not trustworthy.
scv.tl.velocity_confidence(adata)
scv.pl.scatter(adata, c=["velocity_length", "velocity_confidence"], cmap="coolwarm")
# 7. CellRank: combine a directional signal (velocity) with a similarity
# signal (connectivity). The connectivity kernel regularises velocity,
# which is noisy at the single-cell level.
vk = cr.kernels.VelocityKernel(adata).compute_transition_matrix()
ck = cr.kernels.ConnectivityKernel(adata).compute_transition_matrix()
combined = 0.8 * vk + 0.2 * ck # weights are a real choice
g = cr.estimators.GPCCA(combined)
g.compute_schur(n_components=20)
g.compute_macrostates(n_states=6, cluster_key="leiden")
g.predict_terminal_states() # inspect against known endpoints
g.compute_fate_probabilities() # adata.obsm["lineages_fwd"]
g.plot_fate_probabilities(same_plot=False)
# 8. Genes correlated with commitment to one fate
drivers = g.compute_lineage_drivers(lineages=["Erythroid"])
print(drivers.head(20))
adata.write_h5ad("adata_trajectory.h5ad")
sc.tl.dpt will happily run from any iroot and return a smooth, plausible-looking ordering. Choose the root from progenitor markers, then verify that known mature populations land at high pseudotime β nothing in the output warns you when the direction is flipped.velocity_confidence and check a few phase portraits with scv.pl.velocity() instead of trusting the streamline plot alone.n_neighbors in sc.pp.neighbors and scv.pp.moments, the number of PCs, and the PAGA edge threshold all change which branches appear. Small neighborhoods fragment a real continuum; large ones bridge unrelated populations. Re-run the pipeline across a parameter grid and only report branches that survive.batch or orig.ident β if a branch is one sample, it is technical.