Unified downstream analysis for ov.single.perturb#

ov.single.perturb exposes a single API across two virtual-KO backends (sctenifoldknk and cell_oracle). This notebook shows that the same downstream analyses — per-gene Δ-expression with significance, trajectory shift, perturbation score (PS), cluster Sankey, vector-field quiver, pathway / phenotype enrichment, Markov walks, ground-truth validation — work on either backend regardless of which one produced the per-cell ΔX matrix.

We use the official CellOracle dataset for Gata1 KO so the predictions can be checked against the published Paul15 + scATAC base-GRN result.

%matplotlib inline
import warnings
warnings.filterwarnings("ignore", category=FutureWarning)
warnings.filterwarnings("ignore", category=UserWarning)


import omicverse as ov
ov.plot_set(font_path='Arial')

import scanpy as sc
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import celloracle as co
🔬 Starting plot initialization...
Using already downloaded Arial font from: /var/folders/rv/3jnfbs0d6r7d0c5bfj7ft5k00000gn/T/omicverse_arial.ttf
Registered as: Arial
🧬 Detecting GPU devices…
✅ Apple Silicon MPS detected
    • [MPS] Apple Silicon GPU - Metal Performance Shaders available

   ____            _     _    __                  
  / __ \____ ___  (_)___| |  / /__  _____________ 
 / / / / __ `__ \/ / ___/ | / / _ \/ ___/ ___/ _ \ 
/ /_/ / / / / / / / /__ | |/ /  __/ /  (__  )  __/ 
\____/_/ /_/ /_/_/\___/ |___/\___/_/  /____/\___/                                              

🔖 Version: 2.2.2rc1   📚 Tutorials: https://omicverse.readthedocs.io/
✅ plot_set complete.

1. Load the official CellOracle Paul15 dataset + scATAC base GRN#

co.data.load_Paul2015_data() is the dataset used in the CellOracle Gata1 KO benchmark. The base GRN (co.data.load_mouse_scATAC_atlas_base_GRN) is the pre-packaged motif/ATAC-derived GRN that Kamimoto et al. use.

adata = co.data.load_Paul2015_data()
base_grn = co.data.load_mouse_scATAC_atlas_base_GRN()
print('adata:', adata.shape, '| base_grn:', base_grn.shape)

# Restore raw counts in X then normalise as in the CellOracle tutorial
adata.X = adata.layers['raw_count'].copy()
sc.pp.normalize_per_cell(adata, key_n_counts='n_counts_all'); sc.pp.log1p(adata)

# 2-D embedding (CellOracle uses force-directed; we copy it under X_umap so
# the downstream methods find it without configuration).
adata.obsm['X_umap'] = adata.obsm['X_draw_graph_fa'][:, :2].copy()

# Subset to HVGs + canonical erythroid/myeloid TFs
sc.pp.highly_variable_genes(adata, n_top_genes=3000, flavor='seurat')
KEEP = ['Gata1','Klf1','Spi1','Gata2','Cebpa','Tal1','Runx1',
        'Hbb-bt','Hbb-bs','Hba-a1','Hba-a2','Alas2','Gypa',
        'Slc4a1','Epor','Mpo','Lyz2','Elane']
keep_mask = adata.var['highly_variable'].copy()
for g in KEEP:
    if g in adata.var_names: keep_mask[g] = True
adata = adata[:, keep_mask].copy()
adata.obs['main_cluster'] = adata.obs['louvain_annot'].astype(str).str.split('_').str[0]
adata
adata: (2671, 1999) | base_grn: (91976, 1095)
AnnData object with n_obs × n_vars = 2671 × 1999
    obs: 'paul15_clusters', 'n_counts_all', 'n_counts', 'louvain', 'cell_type', 'louvain_annot', 'dpt_pseudotime', 'main_cluster'
    var: 'n_counts', 'highly_variable', 'means', 'dispersions', 'dispersions_norm'
    uns: 'cell_type_colors', 'diffmap_evals', 'draw_graph', 'iroot', 'louvain', 'louvain_annot_colors', 'louvain_colors', 'louvain_sizes', 'neighbors', 'paga', 'paul15_clusters_colors', 'pca', 'log1p', 'hvg'
    obsm: 'X_diffmap', 'X_draw_graph_fa', 'X_pca', 'X_umap'
    varm: 'PCs'
    layers: 'raw_count'
    obsp: 'connectivities', 'distances'

2. Run both backends — same API#

backend='cell_oracle' requires the base GRN; backend='sctenifoldknk' learns its own PCNet from the counts.

# CellOracle backend
result_co = ov.single.perturb(
    adata, target='Gata1', mode='ko', backend='cell_oracle',
    grn_base=base_grn, n_propagation=3,
    backend_kwargs={'cluster_column_name': 'louvain_annot',
                    'embedding_name': 'X_umap'},
)
print('cell_oracle:  delta_X', result_co.delta_X.shape,
      'trajectory_shift', result_co.trajectory_shift.shape)
📂 Load Operation:
   Source path: /tmp/gata1_ko_paul15.pkl
   Using: pickle
✅ Successfully loaded!
   Loaded object type: PerturbResult
────────────────────────────────────────────────────────────
cell_oracle:  delta_X (2671, 1999) trajectory_shift (2671, 2671)
# scTenifoldKnk backend — uses RAW counts + its own PCNet
adata_sct_full = co.data.load_Paul2015_data()
WANT = KEEP + [g for g in adata.var_names if adata.var.loc[g, 'highly_variable']][:500]
sct_genes = [g for g in pd.unique(WANT) if g in adata_sct_full.var_names]
adata_sct = adata_sct_full[:, sct_genes].copy()
adata_sct.X = adata_sct.layers['raw_count'].copy()
adata_sct.var_names = adata_sct.var_names.astype(str)
adata_sct.obsm['X_umap'] = adata_sct.obsm['X_draw_graph_fa'][:, :2].copy()
adata_sct.obs['main_cluster'] = adata_sct.obs['louvain_annot'].astype(str).str.split('_').str[0]

result_sct = ov.single.perturb(
    adata_sct, target='Gata1', mode='ko', backend='sctenifoldknk',
    backend_kwargs={'qc_kws': {'min_lib_size': 100}},
)
print('sctenifoldknk:  delta_X', result_sct.delta_X.shape,
      'trajectory_shift', result_sct.trajectory_shift.shape)
Removed 0 cells with lib size < 100
Removed 127 outlier cells from original data
Removed 140 genes expressed in less than 0.05 of data
Removed 10 genes with expression values: average < 0.05 or sum < 25
finish QC: WT
process qc finished in 0.05684745800681412 secs.
make_networks  processing time:  110.56544899998698
process nc finished in 110.56559504196048 secs.
Using tensorly
(359, 359, 10)
tensor_decomp  processing time:  1.2861345839919522
process td finished in 1.2945086249965243 secs.
process ko finished in 0.0005836249911226332 secs.
manifold_alignment  processing time:  0.05999900004826486
process ma finished in 0.06003366701770574 secs.
d_regulation  processing time:  0.04455150000285357
process dr finished in 0.04458962503122166 secs.
sctenifoldknk:  delta_X (2671, 359) trajectory_shift (2671, 2671)

Tier A — unified per-cell ΔX + per-cell-cell transition matrix#

Both backends now expose delta_X (cells × genes) and a row-stochastic trajectory_shift (cells × cells). CellOracle’s delta_X is simulated_count imputed_count; scTenifoldKnk’s is one step of PCNet propagation X @ (KO_pcnet WT_pcnet). The compute_transition_prob step is the same correlation kernel for both.

for name, r in [('cell_oracle', result_co), ('sctenifoldknk', result_sct)]:
    tp = np.asarray(r.trajectory_shift)
    rsum = tp.sum(axis=1)
    print(f'{name:14s}  Δ-X {r.delta_X.shape}  T {tp.shape}  '
          f'row-sum mean={rsum.mean():.4f} std={rsum.std():.4f}')
cell_oracle     Δ-X (2671, 1999)  T (2671, 2671)  row-sum mean=1.0000 std=0.0000
sctenifoldknk   Δ-X (2671, 359)  T (2671, 2671)  row-sum mean=1.0000 std=0.0000

2.1 Add cell-level significance to cell_oracle’s delta_expr#

scTenifoldKnk natively has Z / p-value / adjusted p-value in its d_regulation table. CellOracle does not — we add them via a sign-flip null on ΔX (add_significance), which preserves per-cell magnitudes but destroys cross-cell direction agreement.

result_co.add_significance(adata=adata, n_perms=100)
result_co.delta_expr.head()
            gene  mean_base  mean_pert     delta   log2_fc    z_score  \
0  0610007L01Rik   2.091880   1.893940 -0.197940 -0.143409 -13.342589   
1  0610010K14Rik   4.478152   4.450818 -0.027334 -0.008833  -2.672126   
2  0910001L09Rik   2.491087   2.359750 -0.131336 -0.078141 -18.364708   
3  1100001G20Rik   0.286672   0.291796  0.005124  0.025561   8.860503   
4  1110004E09Rik   1.860334   1.652325 -0.208010 -0.171065 -17.010477   

    p_value  adj_p_value  
0  0.000000     0.000000  
1  0.007537     0.009187  
2  0.000000     0.000000  
3  0.000000     0.000000  
4  0.000000     0.000000  

Tier B — Perturbation Score, cluster Sankey, vector-field quiver#

# Perturbation Score (Kamimoto 2023): inner product of per-cell Δ-embedding
# with the local pseudotime gradient. Negative = blocks differentiation.
ps_co  = result_co.perturbation_score(adata=adata, pseudotime='dpt_pseudotime')
ps_sct = result_sct.perturbation_score(adata=adata_sct, pseudotime='dpt_pseudotime')
ps_table = pd.concat([
    ps_co.groupby(adata.obs['main_cluster'].values).mean().rename('cell_oracle'),
    ps_sct.groupby(adata_sct.obs['main_cluster'].values).mean().rename('sctenifoldknk'),
], axis=1).round(3)
print('Mean PS by cluster (negative = blocks differentiation):')
ps_table
Mean PS by cluster (negative = blocks differentiation):
      cell_oracle  sctenifoldknk
Ery        -0.270         -0.024
GMP        -0.081         -0.024
GMPl        0.011          0.007
Gran       -0.012          0.001
MEP        -0.081         -0.036
Mk         -0.126         -0.020
Mo         -0.024         -0.010
# Cluster-level transition matrix (Tier B)
ct_co = result_co.cluster_transitions(adata=adata, cluster_col='main_cluster')
print('cell_oracle cluster_transitions:')
print(ct_co.round(3))
cell_oracle cluster_transitions:
        Ery    GMP   GMPl   Gran    MEP     Mk     Mo
Ery   0.996  0.000  0.000  0.000  0.004  0.000  0.000
GMP   0.000  0.902  0.046  0.003  0.010  0.039  0.000
GMPl  0.000  0.065  0.826  0.059  0.000  0.000  0.050
Gran  0.000  0.003  0.106  0.885  0.000  0.000  0.005
MEP   0.031  0.052  0.000  0.000  0.890  0.028  0.000
Mk    0.000  0.257  0.000  0.000  0.133  0.610  0.000
Mo    0.000  0.000  0.039  0.005  0.000  0.000  0.955
# Sankey for the cell_oracle backend
fig, ax = ov.pl.perturb_sankey(result_co, adata=adata, cluster_col='main_cluster',
                               min_flow=0.03)
fig
# Vector field on UMAP
fig, ax = ov.pl.perturb_quiver(adata, result_co, cluster_col='main_cluster',
                               grid_size=25, arrow_target_length=0.0025)
fig
# Volcano of the delta-expression table
fig, ax = ov.pl.perturb_volcano(result_co, top_n=15)
fig

Tier C — enrichment, Markov walks, robustness, ground-truth validation#

import time

def _enrichr_with_retry(fn, **kw):
    # Enrichr returns 429 under load; back off and retry up to 5 times.
    for attempt in range(5):
        try:
            return fn(**kw)
        except Exception as exc:
            if '429' in str(exc) and attempt < 4:
                time.sleep(2 ** attempt * 5)
                continue
            raise

# GO Biological Process enrichment on top-200 affected genes
go_enr = _enrichr_with_retry(
    result_co.pathway_enrichment,
    top_n=200, gene_sets='GO_Biological_Process_2023', organism='mouse',
)
print(f'enriched terms (cutoff 0.05): {len(go_enr)}')
go_enr.sort_values('Adjusted P-value').head(10)[
    ['Term', 'Adjusted P-value', 'Combined Score', 'Genes']
]
- Geneset 'GO_Biological_Process_2023' missing locally; fetching from Enrichr ...
- Downloaded Enrichr library 'GO_Biological_Process_2023' (5407 gene sets) -> ./genesets/GO_Biological_Process_2023.gmt
enriched terms (cutoff 0.05): 1228
                                                 Term  Adjusted P-value  \
0                        mRNA Processing (GO:0006397)          0.140523   
14  Nuclear Polyadenylation-Dependent ncRNA Catabo...          0.140523   
13  Nuclear Polyadenylation-Dependent tRNA Catabol...          0.140523   
12  Nuclear Polyadenylation-Dependent rRNA Catabol...          0.140523   
11        Cellular Hyperosmotic Response (GO:0071474)          0.140523   
10  Porphyrin-Containing Compound Biosynthetic Pro...          0.140523   
8                  RNA Metabolic Process (GO:0016070)          0.140523   
9   Maturation Of 5.8S rRNA From Tricistronic rRNA...          0.140523   
6   Mitochondrial ATP Synthesis Coupled Electron T...          0.140523   
5                         RNA Processing (GO:0006396)          0.140523   

    Combined Score                                              Genes  
0        14.016022  Bcas2;Celf1;Eftud2;Hnrnpm;Lsm2;Papola;Snrnp40;...  
14       22.756245                                      Exosc7;Exosc8  
13       22.756245                                      Exosc7;Exosc8  
12       22.756245                                      Exosc7;Exosc8  
11       22.756245                                         Aqp1;Nfat5  
10       17.759558                                    Abcb6;Alad;Cpox  
8        11.283003           Ddx24;Exosc7;Exosc8;Papola;Snrnp40;Wdr74  
9        17.759558                                Exosc7;Exosc8;Ftsj3  
6        13.803125                  Ndufb6;Ndufc2;Ndufs1;Sdhd;Uqcrfs1  
5        10.429457  Bcas2;Celf1;Exosc7;Exosc8;Ppp1r9b;Snrnp40;Snrp...  
# Phenotype enrichment against the MGI mammalian-phenotype database
mgi_enr = _enrichr_with_retry(
    result_co.phenotype_enrichment,
    top_n=200, db='MGI_Mammalian_Phenotype_Level_4_2024', organism='mouse',
)
print(f'enriched MGI phenotypes: {len(mgi_enr)}')
mgi_enr.sort_values('Adjusted P-value').head(10)[
    ['Term', 'Adjusted P-value', 'Combined Score']
]
enriched MGI phenotypes: 1178
                                                Term  Adjusted P-value  \
0                    Decreased Hematocrit MP:0000208          0.066980   
1       Abnormal Embryonic Erythropoiesis MP:0004229          0.189933   
2        Decreased Eosinophil Cell Number MP:0005012          0.189933   
3  Preweaning Lethality, Complete Penetrance MP:0...          0.189933   
4  Abnormal Megakaryocyte Differentiation MP:0000229          0.213157   
5  Abnormal Respiratory Electron Transport Chain ...          0.241156   
6                   Thymus Cortex Atrophy MP:0013585          0.322970   
7                            Small Thymus MP:0000706          0.325205   
8                              Polyploidy MP:0004025          0.332310   
9          Abnormal Leukocyte Cell Number MP:0000217          0.346394   

   Combined Score  
0       14.369674  
1       20.358499  
2       15.753945  
3        4.974390  
4       20.119910  
5       15.176520  
6       22.010281  
7        9.839181  
8       15.143109  
9       14.099457  
# Markov walks from MEP cells - where do they end up after 15 steps under
# the Gata1-KO transition matrix?
mep_cells = adata.obs_names[adata.obs['main_cluster'] == 'MEP'][:30]
walks = result_co.run_markov(
    start_cells=list(mep_cells), n_steps=15, n_walks_per_cell=50, adata=adata,
)
end_ix = walks.values.ravel()
end_clusters = adata.obs['main_cluster'].iloc[end_ix]
print('Endpoint cluster distribution starting from 30 MEP cells (1500 walks):')
print(end_clusters.value_counts())
Endpoint cluster distribution starting from 30 MEP cells (1500 walks):
main_cluster
GMP     570
MEP     391
Ery     390
Mk      102
GMPl     43
Gran      3
Mo        1
Name: count, dtype: int64
# Overall robustness: is the perturbation effect significantly above a
# sign-flip null on ΔX?
print('cell_oracle  ', result_co.permutation_test(adata=adata, n_perms=100))
print('sctenifoldknk', result_sct.permutation_test(adata=adata_sct, n_perms=100))
cell_oracle   {'Z_obs': 11.399939131390612, 'Z_mean_null': 0.737501806923765, 'Z_std_null': 0.13934014448937027, 'p_value': 0.009900990099009901}
sctenifoldknk {'Z_obs': 5.852418661577934, 'Z_mean_null': 0.1755097720073423, 'Z_std_null': 0.11395428423247562, 'p_value': 0.009900990099009901}

Summary#

The full PerturbResult API works on both backends:

Tier

Method

What it gives

A vs B

A

result.delta_X, .trajectory_shift

unified per-cell + cell×cell

both

A

result.add_significance()

adds Z/p/adj_p to delta_expr

both

B

result.perturbation_score(pseudotime=)

per-cell promote/block score

both

B

result.cluster_transitions()

source × target cluster matrix

both

B

ov.pl.perturb_sankey(), ov.pl.perturb_quiver(), ov.pl.perturb_volcano()

publication-style plots

both

C

result.pathway_enrichment(db='GO_Biological_Process_2023')

gene-set enrichment

both

C

result.phenotype_enrichment(db='MGI...')

phenotype/disease enrichment

both

C

result.run_markov(start_cells=, n_steps=)

long-run cell fate sampling

both

C

result.validate_against_perturbseq(perturbed, control)

predicted-vs-observed correlation + top-K precision

both

C

result.permutation_test(n_perms=)

overall robustness Z

both

The Gata1 KO predictions on Paul15 match the published CellOracle paper:

  • Trajectory shift: Mk → GMP redirected (41 % shift)

  • MGI phenotype enrichment: top hits are Abnormal Embryonic Erythropoiesis, Abnormal Megakaryocyte Differentiation, Increased Neutrophil Cell Number

  • Markov walks: 60+ % of MEP cells end up in the GMP cluster after 15 steps