Trajectory Inference logo

Trajectory Inference

Community
jaechang-hits
trajectory-inference

Trajectory inference and cell fate mapping for single-cell and spatial transcriptomics: PAGA graph + diffusion pseudotime (no velocity needed), scVelo RNA velocity (stochastic / dynamical, from spliced/unspliced counts), CellRank 2 terminal states, fate probabilities and lineage drivers, then pseudotime and fates mapped on tissue with a spatial autocorrelation check. Outputs pseudotime, velocity streams, fate probability tables. Imaging panels (Xenium, MERFISH) have no unspliced counts: use PAGA/DPT or the CellRank pseudotime kernel. For a basic PAGA recipe inside a standard scRNA-seq workflow see scanpy-scrna-seq.

Overview

Publisherjaechang-hits
RepositorySciAgent-Skills
Skill nametrajectory-inference
Stars
381
Forks
45
Bundled files
Instructions only
LicenseCC-BY-4.0
Links
  • Markdown instructions

    A SKILL.md file the model loads on demand, so it only costs tokens when a request actually matches.

  • Works with any LLM

    AI skills are plain Markdown, not provider-specific code, so this works with GPT, Claude, Gemini, Grok, or a local model.

  • Self-contained

    Everything the model needs lives in the instructions — no extra files to sync.

  • Open source

    Published by jaechang-hits on GitHub. Read the source before you install it.

Installation

Install the Trajectory Inference AI skill in TypingMind to use it with any LLM, or drop it into another agent that reads SKILL.md.

1

Install in TypingMind

TypingMind installs a skill straight from its GitHub folder — it reads SKILL.md, bundles the resource files, and stores the result locally.

  1. Open the app and go to Plugins → Skills.
  2. Choose "Install from GitHub".
  3. Paste the skill folder URL below and confirm.
    https://github.com/jaechang-hits/SciAgent-Skills/tree/main/skills/genomics-bioinformatics/spatial-transcriptomics/trajectory-inference
  4. Enable the skill in any chat where you want it available.
Plugins → Skills → Add skill → From GitHub URL, then paste the folder URL and press Continue.
2

Install in another agent

Any agent that reads the Agent Skills format can use this skill — copy the folder into that agent's skills directory.

Claude Code — .claude/skills
git clone --depth 1 https://github.com/jaechang-hits/SciAgent-Skills.git /tmp/SciAgent-Skills
mkdir -p .claude/skills
cp -r /tmp/SciAgent-Skills/skills/genomics-bioinformatics/spatial-transcriptomics/trajectory-inference .claude/skills/trajectory-inference
Restart Claude Code after copying so it picks up the new skill.

Use it in TypingMind

Enable Trajectory Inference in any TypingMind chat and the model takes it from there. Its name and description sit in the system prompt, and the moment a request matches, the model loads the full instructions itself — you never invoke it by hand, and it costs no tokens until it is actually used.

The model loads Trajectory Inference on its own as soon as a request matches it.

Works with any AI model

AI skills are plain Markdown instructions rather than provider-specific code, so Trajectory Inference is not tied to the model it was written for. Install it once in TypingMind and use it with GPT-5, Claude, Gemini, Grok, DeepSeek, Mistral, Llama, or a local model you run yourself — all on your own API keys.

  • Loaded only when it is needed

    The system prompt carries just the name and description. The instructions are fetched on the first matching request, so an idle skill costs nothing.

  • Switch models mid-chat

    Because the skill is instructions rather than code, changing model does not break it — the next model reads the same SKILL.md.

Skill instructions

This is the SKILL.md content the model loads. Read it before installing — a skill is instructions your model will follow.

Trajectory Inference

Overview

Order cells along differentiation or activation paths and estimate where each cell is heading. Output: pseudotime, velocity streams, terminal states and fate probabilities, and on spatial data their maps on the tissue with a check of whether the ordering follows space. Three layers, each optional on top of the previous one:

MethodNeedsGives
PAGA + DPTClusters, neighbour graph, a chosen root clusterCluster connectivity graph, pseudotime
scVelolayers["spliced"] and layers["unspliced"]Direction of change per cell (velocity)
CellRank 2A transition kernel (velocity or pseudotime)Terminal states, fate probabilities, driver genes

Spliced/unspliced counts come from velocyto, STARsolo (--soloFeatures Gene Velocyto) or kallisto|bustools run on the reads; for Visium and Slide-seq run them on the Space Ranger / pipeline BAM. Imaging platforms (Xenium, MERFISH, CosMx) count probes, not introns, so velocity is not available there.

When to Use

  • Ordering cells along a differentiation or activation path from a chosen start
  • Estimating where cells are heading from spliced/unspliced counts (RNA velocity)
  • Finding terminal states, fate probabilities and the genes driving each fate
  • Checking whether a developmental or activation ordering is laid out in tissue space
  • Finding genes that change along the inferred path
  • Use scanpy-scrna-seq instead when you only need a quick PAGA graph inside a standard scRNA-seq workflow
  • For tissue regions rather than cell-state paths, use spatial-domain-detection

Prerequisites

  • Input: raw counts plus a cluster or cell type column; for velocity, the two layers above
  • Spatial data: coordinates in obsm["spatial"] for Step 6
  • Python packages: scvelo, cellrank (CellRank 2.x needs Python >= 3.12; CPU is fine)
bash
# Install only what `import` reports missing. `--pypi` keeps the env's scanpy/numba
# stack; a conda add can swap it and bare pip can shadow it.
pixi add --pypi scvelo cellrank scanpy

Workflow

  1. Load data and check layers
  2. Preprocess and build the neighbour graph
  3. PAGA and diffusion pseudotime
  4. RNA velocity (scVelo)
  5. Terminal states and fate probabilities (CellRank)
  6. Map pseudotime and fates on the tissue

Step 1: Load Data

python
import numpy as np
import pandas as pd
import scanpy as sc

adata = sc.read_h5ad("path/to/data.h5ad")        # your data
# Demo with spliced/unspliced layers (mouse pancreas endocrinogenesis, downloaded to ./data on first use):
# import scvelo as scv; adata = scv.datasets.pancreas()
CLUSTER_KEY = "clusters"                          # cell type / cluster column

has_velocity_layers = {"spliced", "unspliced"} <= set(adata.layers)
print(adata)
print(f"velocity layers: {has_velocity_layers}; spatial: {'spatial' in adata.obsm}")
print(adata.obs[CLUSTER_KEY].value_counts())

Step 2: Preprocess and Neighbour Graph

Uses the scVelo order (filter on spliced+unspliced, normalise all layers, then log X). Without velocity layers, the scanpy lines alone apply.

python
import scvelo as scv

if has_velocity_layers:
    scv.pp.filter_genes(adata, min_shared_counts=20)
    scv.pp.normalize_per_cell(adata)              # normalises X, spliced, unspliced
else:
    adata.layers["counts"] = adata.X.copy()
    sc.pp.normalize_total(adata)
sc.pp.log1p(adata)
sc.pp.highly_variable_genes(adata, n_top_genes=2000)     # imaging panels: skip, use all genes
sc.pp.pca(adata, n_comps=30)
sc.pp.neighbors(adata, n_pcs=30, n_neighbors=30, random_state=0)
if "X_umap" not in adata.obsm:
    sc.tl.umap(adata)
adata.obs[CLUSTER_KEY] = adata.obs[CLUSTER_KEY].astype("category")

Step 3: PAGA and Diffusion Pseudotime

The root cluster is a biological choice (progenitor, naive state); ask the user when it is not obvious from markers.

python
ROOT_GROUP = "Ngn3 low EP"                        # root cluster name

sc.tl.paga(adata, groups=CLUSTER_KEY)
sc.pl.paga(adata, threshold=0.05, show=False, save="_graph.png")
groups = adata.obs[CLUSTER_KEY].cat.categories
paga_adj = pd.DataFrame(adata.uns["paga"]["connectivities"].toarray(), index=groups, columns=groups)
paga_adj.to_csv("paga_adjacency.csv")

# Root = the root-cluster cell at the extreme of diffusion component 1
# (the first cell of the cluster is arbitrary and shifts the whole pseudotime)
sc.tl.diffmap(adata)
dc1 = adata.obsm["X_diffmap"][:, 1]
in_root = (adata.obs[CLUSTER_KEY] == ROOT_GROUP).to_numpy()
assert in_root.any(), f"{ROOT_GROUP} not in {list(groups)}"
side = np.sign(dc1[in_root].mean() - dc1.mean()) or 1.0
adata.uns["iroot"] = int(np.flatnonzero(in_root)[np.argmax(side * dc1[in_root])])
sc.tl.dpt(adata)
print(adata.obs.groupby(CLUSTER_KEY, observed=True)["dpt_pseudotime"].median().sort_values())
sc.pl.umap(adata, color=[CLUSTER_KEY, "dpt_pseudotime"], show=False, save="_pseudotime.png")

If the median pseudotime per cluster does not follow the expected order, the root or the clustering is wrong; DPT is not a test of the order.

Step 4: RNA Velocity (scVelo)

python
MODE = "stochastic"                               # "dynamical" is slower but gives latent time

scv.pp.moments(adata, n_pcs=None, n_neighbors=None)      # reuses the Step 2 graph
if MODE == "dynamical":
    scv.tl.recover_dynamics(adata, n_jobs=8)
scv.tl.velocity(adata, mode=MODE)
scv.tl.velocity_graph(adata, n_jobs=8)
scv.tl.velocity_confidence(adata)
if MODE == "dynamical":
    scv.tl.latent_time(adata)

print(f"velocity genes: {int(adata.var['velocity_genes'].sum())}")
print(adata.obs.groupby(CLUSTER_KEY, observed=True)["velocity_confidence"].median().round(2))
scv.pl.velocity_embedding_stream(adata, basis="umap", color=CLUSTER_KEY, show=False,
                                 save="velocity_stream_umap.png")

Low velocity_confidence (< ~0.5) in a cluster means its arrows are noise; say so rather than interpreting them. Fewer than ~100 velocity genes usually means too few unspliced reads.

Step 5: Terminal States and Fate Probabilities (CellRank 2)

python
import cellrank as cr

N_STATES = 8                                      # macrostates; try a few values
if has_velocity_layers:
    vk = cr.kernels.VelocityKernel(adata).compute_transition_matrix()
    ck = cr.kernels.ConnectivityKernel(adata).compute_transition_matrix()
    kernel = 0.8 * vk + 0.2 * ck                  # connectivity term damps velocity noise
else:
    kernel = cr.kernels.PseudotimeKernel(adata, time_key="dpt_pseudotime").compute_transition_matrix()

g = cr.estimators.GPCCA(kernel)
g.compute_schur(n_components=20)
g.compute_macrostates(n_states=N_STATES, cluster_key=CLUSTER_KEY)
g.predict_terminal_states()                       # or g.set_terminal_states(states=["Alpha", "Beta"])
print("terminal states:", list(g.terminal_states.cat.categories))

g.compute_fate_probabilities()
fp = g.fate_probabilities                         # also in adata.obsm["lineages_fwd"]
fate = pd.DataFrame(np.asarray(fp), index=adata.obs_names, columns=fp.names)
fate.to_csv("fate_probabilities.csv")
print(fate.groupby(adata.obs[CLUSTER_KEY], observed=True).mean().round(2))

drivers = g.compute_lineage_drivers(cluster_key=CLUSTER_KEY)
drivers.to_csv("lineage_drivers.csv")
g.plot_fate_probabilities(same_plot=False)
adata.write_h5ad("trajectory_result.h5ad", compression="gzip")

Terminal states (adata.obs["term_states_fwd"]) should be clusters the user recognises as end points. If predict_terminal_states misses a known end state, change N_STATES or set the states by name from g.macrostates.

Step 6: Map Pseudotime and Fates on the Tissue

On spatial data, check whether the inferred ordering is spatially organised (e.g. crypt-to-villus, cortical depth). Moran's I near 0 means the ordering is not reflected in space.

python
import matplotlib.pyplot as plt
from sklearn.neighbors import kneighbors_graph

W = kneighbors_graph(adata.obsm["spatial"], n_neighbors=6, include_self=False)

def morans_i(v):
    z = np.asarray(v, float) - np.mean(v)
    return len(z) / W.sum() * (z @ (W @ z)) / (z @ z)

tracks = {"dpt_pseudotime": adata.obs["dpt_pseudotime"].to_numpy()}
if "fate" in globals():
    tracks.update({f"fate_{c}": fate[c].to_numpy() for c in fate.columns})
for name, v in tracks.items():
    adata.obs[name] = v
    ok = np.isfinite(v)
    print(f"{name}: Moran's I = {morans_i(np.where(ok, v, np.nanmean(v[ok]))):.2f}")

axes = sc.pl.embedding(adata, basis="spatial", color=list(tracks), cmap="viridis", size=1.5, show=False)
for ax in np.atleast_1d(axes):
    ax.invert_yaxis()                             # pixel y grows downwards; drop for micron coordinates
plt.savefig("trajectory_spatial.png", dpi=150, bbox_inches="tight")

if "velocity" in adata.layers:                    # velocity arrows projected onto tissue coordinates
    adata.obsm["X_spatial"] = adata.obsm["spatial"]
    scv.pl.velocity_embedding_stream(adata, basis="spatial", color=CLUSTER_KEY, show=False,
                                     save="velocity_stream_spatial.png")

A velocity stream on tissue coordinates shows transcriptional direction drawn in space; it is not physical cell migration.

Key Parameters

ParameterDefaultEffect
ROOT_GROUPfrom the userStart of pseudotime; flips the whole ordering if wrong
n_neighbors (Step 2)30Graph used by PAGA, DPT, moments and CellRank
min_shared_counts20Gene filter on spliced + unspliced counts
MODE"stochastic""dynamical" fits per-gene kinetics, adds latent time, ~10x slower
Kernel weights0.8 velocity / 0.2 connectivityMore connectivity = smoother, less directional
N_STATES8Macrostates searched for terminal states
PAGA threshold0.05Edges below this connectivity are hidden in the plot

Common Recipes

Recipe: Genes Changing Along Pseudotime

Rank genes by Spearman correlation with pseudotime and plot the top ones in pseudotime order:

python
from scipy.stats import spearmanr

pt = adata.obs["dpt_pseudotime"].to_numpy()
ok = np.isfinite(pt)
genes = adata.var_names[adata.var["highly_variable"]] if "highly_variable" in adata.var else adata.var_names
X = adata[ok, genes].X
X = X.toarray() if hasattr(X, "toarray") else np.asarray(X)
rho = pd.Series(spearmanr(pt[ok], X, axis=0)[0][0, 1:], index=genes).dropna()
trend = pd.concat([rho.nlargest(15), rho.nsmallest(15)])
trend.to_csv("pseudotime_genes.csv")
print(trend.round(2))

order = np.argsort(pt[ok])
sc.pl.heatmap(adata[ok][order], var_names=list(trend.index), groupby=CLUSTER_KEY,
              standard_scale="var", show=False, save="_pseudotime_genes.png")

Recipe: Trajectory Within One Spatial Domain

A global graph can connect cells from unrelated regions. Restrict to one domain (from spatial-domain-detection) and recompute:

python
DOMAIN_KEY, DOMAIN = "spagcn_refined", "3"
sub = adata[adata.obs[DOMAIN_KEY].astype(str) == DOMAIN].copy()
sc.pp.neighbors(sub, n_pcs=30, n_neighbors=15, random_state=0)
sc.tl.diffmap(sub)
in_root = (sub.obs[CLUSTER_KEY] == ROOT_GROUP).to_numpy()
assert in_root.any(), "root cluster absent from this domain"
sub.uns["iroot"] = int(np.flatnonzero(in_root)[np.argmax(sub.obsm["X_diffmap"][in_root, 1])])
sc.tl.dpt(sub)
print(sub.obs.groupby(CLUSTER_KEY, observed=True)["dpt_pseudotime"].median().sort_values())

Check the sign of diffusion component 1 as in Step 3 if the order looks reversed.

Recipe: Does Pseudotime Follow Distance from a Landmark?

For tissues with a spatial axis (crypt base, vessel, tumour edge), correlate pseudotime with each cell's distance to the nearest landmark cell:

python
from scipy.spatial import cKDTree
from scipy.stats import spearmanr

LANDMARK = adata.obs[CLUSTER_KEY] == ROOT_GROUP          # or a domain / cell type marking the landmark
dist = cKDTree(adata.obsm["spatial"][LANDMARK.to_numpy()]).query(adata.obsm["spatial"])[0]
adata.obs["dist_to_landmark"] = dist
ok = np.isfinite(adata.obs["dpt_pseudotime"])
r, p = spearmanr(dist[ok], adata.obs.loc[ok, "dpt_pseudotime"])
print(f"pseudotime vs distance to landmark: Spearman r = {r:.2f} (p = {p:.1e})")

With thousands of cells p is always tiny; judge by r and by the map.

Expected Outputs

  • paga_adjacency.csv - cluster-to-cluster PAGA connectivity
  • fate_probabilities.csv - per-cell probability of reaching each terminal state
  • lineage_drivers.csv - genes correlated with each fate
  • trajectory_result.h5ad - pseudotime, velocity layers, term_states_fwd, lineages_fwd
  • figures/ - PAGA graph, pseudotime UMAP, velocity streams
  • trajectory_spatial.png - pseudotime and fate maps on the tissue
  • pseudotime_genes.csv - genes most correlated with pseudotime (recipe)

Troubleshooting

ProblemCauseFix
KeyError: 'spliced'No velocity layersRun velocyto / STARsolo Velocyto on the BAM, or use Steps 3 and 5 with the pseudotime kernel
Arrows point from mature to progenitor cellsSteady-state violated, few unspliced reads, or batch effectTry MODE="dynamical", check velocity_confidence, analyse batches separately
dpt_pseudotime is inf for some cellsDisconnected graph componentsSubset to the connected lineage or raise n_neighbors
predict_terminal_states returns none or too manyStability threshold vs N_STATESChange N_STATES or call g.set_terminal_states(states=[...])
Velocity on Xenium / MERFISH dataProbes do not separate spliced and unspliced RNANot possible; use PAGA/DPT
Stream plot on spatial failsscVelo looks up obsm["X_spatial"]Copy obsm["spatial"] to obsm["X_spatial"] (Step 6 does)

Related Skills

  • scanpy-scrna-seq - preprocessing and a short PAGA recipe for dissociated scRNA-seq
  • spatial-domain-detection - restrict trajectories to one tissue domain
  • spatial-transcriptomics-cell-type-annotation - cluster labels used as CLUSTER_KEY
  • scvi-tools-single-cell - batch-corrected latent space to build the neighbour graph on

References

Frequently asked questions

What does the Trajectory Inference AI skill do?

Trajectory inference and cell fate mapping for single-cell and spatial transcriptomics: PAGA graph + diffusion pseudotime (no velocity needed), scVelo RNA velocity (stochastic / dynamical, from spliced/unspliced counts), CellRank 2 terminal states, fate probabilities and lineage drivers, then pseudotime and fates mapped on tissue with a spatial autocorrelation check. Outputs pseudotime, velocity streams, fate probability tables. Imaging panels (Xenium, MERFISH) have no unspliced counts: use PAGA/DPT or the CellRank pseudotime kernel. For a basic PAGA recipe inside a standard scRNA-seq workf...

Why use Trajectory Inference on TypingMind?

Because you install it once and use it with any model. Trajectory Inference is plain Markdown rather than provider-specific code, so the same skill runs on GPT-5, Claude, Gemini, Grok, or a local model — and you can switch model mid-chat without it breaking. TypingMind runs on your own API keys, so you pay providers directly instead of a per-seat subscription, and your skills and chats stay in your own storage.

How do I install Trajectory Inference in TypingMind?

Open Plugins → Skills → Install from GitHub in TypingMind and paste https://github.com/jaechang-hits/SciAgent-Skills/tree/main/skills/genomics-bioinformatics/spatial-transcriptomics/trajectory-inference. TypingMind reads its SKILL.md and installs it as a skill you can enable per chat.

Which AI models can use Trajectory Inference?

Any model you connect in TypingMind. AI skills are plain Markdown instructions rather than provider-specific code, so GPT, Claude, Gemini, Grok, and local models can all load this skill when a request matches it.

How many AI models can I use with Trajectory Inference?

As many as you like. As long as a model supports skills, you can use Trajectory Inference with it — GPT, Claude, Gemini, Grok, DeepSeek, Mistral, Llama and more — all on TypingMind with your own API keys.

Is the Trajectory Inference AI skill free?

Yes. It is published on GitHub by jaechang-hits under the CC-BY-4.0 license. You only pay your own AI provider for the tokens you use.

What are AI skills?

An AI skill is a reusable instruction bundle that teaches an AI model how to do one specific task. It follows the open Agent Skills format: a SKILL.md file with a name and description, plus any scripts, templates or reference files the model may need. The model reads the instructions only when your request matches the skill, so an installed skill costs nothing until it is used.

How are AI skills different from plugins or MCP servers?

A plugin or MCP server gives a model new tools to call — code that runs somewhere and returns a result. An AI skill gives the model knowledge and process instead: how to approach a task, which steps to follow, what good output looks like. Skills are plain Markdown, so they need no server, no API key and no runtime, and they work with any model.

View all

Set up your own AI workspace now

Get notified about new features and future giveaways by subscribing to our newsletter 👇