Multiview hypergraph trajectory inference and gene attribution for single-cell transcriptomics.
PseudoEmbed builds a hypergraph over cells from several complementary "views" of the same data — transcription-factor activity, pathway activity, and expression PCA — fuses them into one operator, and reads a pseudotime off that operator's spectral embedding. It then attributes the resulting trajectory structure back to hyperedges, driver programmes, and individual genes.
Two analyses are supported, and they are the whole of the public surface:
- (A) Multiview hypergraph analysis — construct the TF / pathway / PCA views, fuse them, and compute pseudotime.
- (B) Gene attribution analysis — rank the programmes and genes responsible for the trajectory and for a condition contrast along it.
An example tutorial notebooks in examples/:
richard2018_tcell_tutorial.ipynb— 250 real CD8⁺ T cells sampled at 0/1/3/6 h (Richard et al. 2018), downloaded on first run. Four surface proteins measured per cell never enter the model, so they are a held-out validation axis. Read this to see the workflow end to end on real data: build three views, fuse them, validate the axis against the held-out proteins, then decompose it into the named hyperedges that built it.
The data is fetched, not redistributed:
import pseudoembed
adata = pseudoembed.richard2018() # downloads E-MTAB-6051 once, then cachesSee examples/README.md for how to run them, and what to do
if the kernel dies with no traceback (a libomp clash inside decoupler).
Each view contributes hyperedges (sets of cells) with weights. From the incidence
matrix H, edge weights W, and the vertex/edge degree matrices Dv/De, the
per-view operator is
S = Dv^{-1/2} H W De^{-1} H^T Dv^{-1/2}
which is symmetric and positive semi-definite with eigenvalues in [0, 1]. Views
are combined either by averaging their operators (--fusion-method operator, the
default, "late" fusion) or by pooling their hyperedges before building a single
operator (--fusion-method edge, "early" fusion).
Pseudotime is the distance from a root cell in the scaled spectral embedding
emb[cell, j] = f(lambda_j) * phi_j[cell], where f is set by
--embedding-scale: dpt gives lambda/(1 - lambda) (default), power gives
lambda^t with t = --spectral-time-scale, and unit leaves eigenvalues raw.
The distance itself is either a graph geodesic on a kNN manifold
(--pseudotime-method geodesic, default) or a straight chord in the embedding
(chord).
decoupler is a required dependency, not an optional one: both the TF and the
pathway view are built from its univariate linear model (ULM) activity scores, so
neither analysis runs without it.
uv venv pseudoembed_env --python 3.12
source pseudoembed_env/bin/activate
uv pip install git+ssh://git@github.ibm.com/scDynamics/PseudoEmbed.gitgit clone git@github.ibm.com:scDynamics/PseudoEmbed.git
cd pseudoembed
uv pip install -e ".[dev]"Extras:
| Extra | Contents | When you need it |
|---|---|---|
dev |
pytest, pytest-cov, black, ruff, mypy | Running the test suite or linting |
full |
decoupler[full], dcor, igraph, gprofiler-official |
GO enrichment, distance correlation |
benchmark |
palantir, pyyaml | The experiments/benchmark evaluation suite |
hypergraph |
xgi | Optional hypergraph utilities |
notebook |
jupyterlab, ipywidgets | Interactive work |
The CLI has exactly two commands.
Filtering, normalisation, and highly-variable-gene selection. The PCA and pathway views both consume its output, so this is a prerequisite rather than a convenience.
pseudoembed preprocess \
--input-path raw.h5ad \
--output-path processed.h5ad \
--n-top-genes 5000 \
--batch-key experimentOptions: --input-path and --output-path (both required),
--experiment-filter, --min-genes (100), --min-cells (3),
--n-top-genes (5000), --batch-key (experiment).
The single entrypoint for both (A) and (B). Only --data-path and
--output-dir are required; the other 45 options have defaults.
pseudoembed hypergraph-analysis \
--data-path processed.h5ad \
--output-dir results/run1 \
--condition-key condition \
--control-label control \
--drug-label drug \
--cell-type-key cell_typeNote that --driver-analysis, --gene-attribution, --detect-branches, and
--tf-direction-from-construction all default to on. To get (A) alone, turn
the attribution stages off explicitly:
pseudoembed hypergraph-analysis \
--data-path processed.h5ad \
--output-dir results/pseudotime_only \
--no-driver-analysis --no-gene-attributionFrequently-used option groups:
- Views and fusion —
--fusion-method(operator|edge),--tf-weight,--pathway-weight,--pca-weight,--n-pca-dims,--top-n-tfs,--z-score-threshold,--organism - Readout —
--n-spectral-components,--embedding-scale,--spectral-time-scale,--pseudotime-method,--manifold-k,--root-markers,--min-root-corr,--n-states - Drivers —
--driver-batch-key,--driver-min-abs-log2,--driver-max-group-enrichment,--driver-n-boot,--driver-top-k,--fdr-threshold - Gene attribution —
--attribution-top-n-genes,--attribution-n-perm
Set --driver-batch-key, and check --driver-max-group-enrichment. Together
they guard against a programme being called a driver when its signal comes from a
single batch, but both need attention:
- Without
--driver-batch-keythe confound columns are never computed and every edge passes the screen. Pass a genuine batch variable — the experimental design variable is usually right. A label that merely looks like a batch (an artifact of cell annotation, say) yields confound statistics that appear real and mean nothing. --driver-max-group-enrichmentdefaults to1.15, but the quantity it thresholds is excess purity,(purity - background) / (1 - background), which is bounded above by1.0. Nothing can exceed 1.15, so the screen is inert at the default. Set a value in(0, 1)chosen from the observed distribution. On the SOW48 tutorial dataset, moving from the default to0.3cuts driver edges from 45 to 16.
Run pseudoembed hypergraph-analysis --help for the full annotated list.
Heavy dependencies are imported lazily (PEP 562), so import pseudoembed stays cheap and the real import happens on first attribute access.
import scanpy as sc
from pseudoembed import run_multiview_hypergraph_analysis, run_driver_analysis
adata = sc.read_h5ad("processed.h5ad")
# (A) Build the views, fuse them, compute pseudotime.
adata, drug_effects, objects = run_multiview_hypergraph_analysis(
adata,
condition_key="condition",
control_label="control",
drug_label="drug",
fusion_method="operator",
view_weights={"tf": 1.0, "pathway": 1.0, "pca": 1.0},
output_dir="results/run1",
)
# (B) Attribute the trajectory to programmes and genes.
# `objects` carries the hyperedges and incidence built in (A).
driver_results = run_driver_analysis(
adata,
objects,
condition_key="condition",
control_label="control",
drug_label="drug",
pseudotime_key="multiview_pseudotime",
views=("pathway", "tf"),
gene_attribution=True,
output_dir="results/run1",
)run_multiview_hypergraph_analysis returns (adata, drug_effects_df, objects_dict); pseudotime lands in adata.obs["multiview_pseudotime"]. Passing
that same objects dict into run_driver_analysis is what couples (B) to (A) —
it avoids rebuilding the hypergraph and guarantees the two stages describe the
same edges.
Available directly from the pseudoembed namespace:
Multiview hypergraph — run_multiview_hypergraph_analysis,
compute_multiview_pseudotime, build_view_operator, fuse_view_operators,
fuse_hyperedges, build_pathway_hypergraph, build_pca_hypergraph,
build_tf_hypergraph, compute_tf_activities, sensitivity_analysis
Gene attribution — run_driver_analysis, rank_drivers,
expand_programmes_to_genes, edge_gene_attribution,
aggregate_gene_attribution, build_multilevel_driver_report
Supporting — load_raw_data, run_functional_analysis, run_go_enrichment
pseudoembed/
core/
multiview_hypergraph.py # (A): views, operators, fusion, spectral readout
hypergraph_drivers.py # (B): run_driver_analysis orchestrator
hypergraph_gene_attribution.py # (B): edge_gene_attribution
hypergraph_interpret.py # shared: edge_condition_enrichment, BH-FDR
io.py # load_raw_data
analysis/
tf_hypergraph_drug_analysis.py # incidence + TF activities (shared leaf)
trajectory_differential_dynamics.py
publication_plots.py
functional.py # run_go_enrichment
cli/main.py # preprocess, hypergraph-analysis
core/__init__.py and analysis/__init__.py deliberately perform no eager
submodule imports — every module there reaches for scanpy, decoupler, or
matplotlib. Import submodules directly, or use the lazy top-level re-exports.
Written to --output-dir:
(A) adata_multiview_hypergraph.h5ad, multiview_drug_effects.csv,
multiview_sensitivity_analysis.csv, multiview_sensitivity_plot.png
(B) (prefixed hypergraph_ by default) ..._drivers_significant.csv,
..._drivers_all_edges.csv, ..._pathway_drivers.csv, ..._tf_drivers.csv,
..._gene_attribution_edge_level.csv, ..._genes_by_programme.csv,
..._genes_all.csv, ..._stability.csv, ..._edge_enrichment_raw.csv,
..._edge_contributions_raw.csv, ..._driver_summary.png
experiments/benchmark/ evaluates (A) against DPT and Palantir:
cd experiments
python -m benchmark.cli --config benchmark/configs/quick.yamlThe suite imports experiments/hypergraph_readout.py via sys.path rather than
the package. That module is a deliberate independent reimplementation, not a
prototype awaiting promotion — see its docstring. The benchmark's *_package
methods delegate to the shipped _hypergraph_spectral_pseudotime, while the
unsuffixed ones use the research library's candidate readouts; keeping the two
paths separate is what makes that comparison an ablation rather than a
package-vs-package tautology.
Expect float drift on the order of 1e-6 between runs of the *_package
methods, from unseeded ARPACK starting vectors. Per PACKAGE_REPRO_FLOOR = 1e-5,
only rank changes count as regressions.
benchmark/cache/ is not version-controlled and regenerates on first run.
pytest tests/ -q
ruff check pseudoembed experiments/benchmarkCoverage is intentionally not in the default addopts, since it roughly doubles
the run time. Request it explicitly:
pytest --cov=pseudoembed --cov-report=term-missing --cov-report=htmlLong-running CLI jobs are where libomp can segfault inside decoupler's ULM,
with no Python traceback. Run them in a conda environment where libomp is
installed once, rather than one mixing conda and pip OpenMP runtimes. The
tutorials in examples/ are small enough to run in a plain venv.
The bare results/ pattern matches at any depth, so
experiments/benchmark/results/ is ignored — including the raw_metrics.json
that --report-only reads. The blanket *.csv / *.png / *.pdf rules
likewise hide benchmark CSVs and the PDFs under docs/ and slides/. If you
want those artefacts versioned, root-anchor the pattern (/results/) and add
negations.
Read this before quoting a number out of this package.
The spectral embedding omits the D_v^{-1/2} back-transform. The operator is
built as S = Dv^{-1/2} H W De^{-1} H^T Dv^{-1/2}, but the eigenvectors are used
directly rather than mapped back through Dv^{-1/2}. The consequence is concrete:
sqrt(cell hyperedge degree) leaks into every pseudotime this package produces.
This is not fixed in this release, and pseudotime values will move when it is.
On raw pseudotime accuracy, DPT is usually competitive or better. Across an internal five-dataset sweep, diffusion pseudotime ranked higher than every PseudoEmbed variant on four of the five. Richard 2018 is the exception, and it is the dataset shipped here — so treat the tutorial's win as one measurement, not as a general claim.
What the method contributes is not a better pseudotime. It is that every hyperedge carries a name, so the axis can be attributed to named programmes and those programmes ordered by onset. That decomposition has no equivalent in a pairwise k-NN graph, and it is what the tutorials are built to demonstrate.
Views are not equally useful, and edge count is a confound. A view's share of
the axis scales with how many hyperedges it was allowed to build, so always read
the per-edge normalisation alongside the total (pseudoembed.plotting.view_share
plots both, and reports whether the ranking changes). Hyperedge size is the single
most important parameter: edges spanning a large fraction of the dataset flatten
the structure the trajectory is meant to find.
Tests. pytest -m "not slow" gives 438 passed, 11 failed, 1 skipped. None of
the 11 are defects in shipped code: 9 are stale assertions in
tests/test_benchmark_figures.py (the writer calls plt.close(fig) before the
test inspects plt.gcf(), so the test examines an empty figure), and 2 are a
pandas 3.0 / anndata ArrowStringArray incompatibility in one environment's test
fixture. They are documented rather than silenced.
Python >= 3.10, with numpy >= 1.21, pandas >= 1.3, scipy >= 1.7, scikit-learn >= 1.0, matplotlib >= 3.4, seaborn >= 0.11, scanpy >= 1.10, anndata >= 0.8, typer >= 0.9, and decoupler >= 2.0.
Matthew Madgwick — mattmadgwick@ibm.com