Using PerturbVI with Your Data¶
Start with an expression matrix and perturbation assignments for the same cells.
1. Set up¶
uv pip install perturbvi matplotlib
from pathlib import Path
import pandas as pd
from perturbvi import PerturbData, fit_screen, load_screen, save_results
data_dir = Path("data")
result_dir = Path("results")
2. Prepare the inputs¶
| Input | Rows × columns | Contents |
|---|---|---|
X |
Cells × genes | Transformed expression |
G |
Cells × perturbations | Binary target or guide assignments |
covariates, optional |
Cells × covariates | Variables to regress out of expression |
Keep cells in the same order in every input. Use unique gene and perturbation names for the columns. If you have gene annotations, their IDs must match the expression matrix.
Before fitting, filter low-quality cells and genes, normalize expression, select genes, and assign perturbations to cells. PerturbVI expects these steps to be complete.
Controls and multiple perturbations¶
In G, use 1 when a cell has a perturbation and 0 otherwise. Cells with
multiple perturbations have multiple 1s:
| Cell assignment | G[CEBPE] |
G[RUNX1T1] |
|---|---|---|
| Control | 0 | 0 |
| CEBPE | 1 | 0 |
| RUNX1T1 | 0 | 1 |
| CEBPE + RUNX1T1 | 1 | 1 |
If G has a control column, control="Nontargeting" removes that column
while keeping the control cells. Without this option, the control column is
fitted like any other perturbation. If control cells already have all-zero
rows, leave control unset.
CSV or TSV¶
Use cell IDs as row labels and gene or perturbation names as column headers.
For TSV files, add sep="\t" to pd.read_csv().
X = pd.read_csv(data_dir / "expression.csv", index_col=0)
G = pd.read_csv(data_dir / "perturbations.csv", index_col=0)
data = PerturbData(X=X, G=G)
NumPy or sparse arrays
With arrays, provide the gene and perturbation names separately.
data = PerturbData(
X=X_arr,
G=G_arr,
gene_names=gene_names,
perturbation_names=perturbation_names,
)
AnnData¶
load_screen() reads AnnData objects, H5AD files, and AnnData Zarr folders.
It expects:
| Location | Contents |
|---|---|
adata.X |
Transformed expression; alternatively use a named layer |
adata.obs_names |
Cell identifiers |
adata.var_names |
Gene identifiers |
adata.obsm["G"] |
Binary pandas DataFrame, indexed by cell and with named columns |
adata.obs, optional |
Covariate columns |
data = load_screen(data_dir / "screen.h5ad")
You can also pass an already loaded AnnData object. To read expression from
adata.layers["transformed"], set x_key="transformed". To read assignments
from adata.obsm["perturbations"], set g_key="perturbations".
Build G from one target label per cell
Here adata.obs["target"] contains target names and the label control.
adata.obsm["G"] = adata.obs["target"].str.get_dummies().astype(int)
data = load_screen(adata, control="control")
Covariates¶
Pass covariates such as batch or mitochondrial percentage to adjust expression
before fitting. fit_screen() performs the regression automatically.
Numeric columns are treated as continuous values; text, categorical, and
boolean columns define groups. For batches numbered 1, 2, 3, and so on,
convert the column to category.
Covariates from AnnData or a CSV
With AnnData, provide column names from adata.obs:
data = load_screen(
data_dir / "screen.h5ad",
covariates=["batch", "percent_mito"],
)
With DataFrames, provide an aligned covariate table:
covariates = pd.read_csv(data_dir / "covariates.csv", index_col=0)
covariates["batch"] = covariates["batch"].astype("category")
data = PerturbData(
X=expression,
G=G,
covariates=covariates,
)
If a covariate is confounded with perturbation, correcting for it can also remove the perturbation signal. Skip correction if it has already been done.
3. Dataset examples¶
Choose an example that matches your screen, then continue to
fit and save. Each example creates data and result_dir.
Download the Datlinger, Adamson, or Norman H5AD files from
scPerturb into data_dir.
Preprocess raw counts
Install Scanpy and define this function before running an example:
uv pip install scanpy
import anndata as ad
import scanpy as sc
def transform_counts(
adata: ad.AnnData,
n_top_genes: int = 6_000,
min_genes: int = 200,
min_cells: int = 3,
max_pct_mt: float = 20.0,
) -> ad.AnnData:
adata = adata.copy()
adata.var["mt"] = adata.var_names.str.upper().str.startswith("MT-")
sc.pp.calculate_qc_metrics(
adata,
qc_vars=["mt"],
percent_top=None,
log1p=False,
inplace=True,
)
sc.pp.filter_cells(adata, min_genes=min_genes)
adata = adata[adata.obs["pct_counts_mt"] < max_pct_mt].copy()
sc.pp.filter_genes(adata, min_cells=min_cells)
sc.experimental.pp.highly_variable_genes(
adata,
flavor="pearson_residuals",
n_top_genes=min(n_top_genes, adata.n_vars),
subset=True,
)
sc.experimental.pp.normalize_pearson_residuals(adata)
return adata
This filters cells and genes, selects up to 6,000 genes by Pearson-residual
variance, and replaces adata.X with Pearson residuals. Adjust QC thresholds
for your screen. The MT- rule assumes human gene symbols in var_names.
Datlinger CROP-seq: one target per cell
In the Datlinger dataset, missing target labels identify controls. Replicate is categorical.
adata = ad.read_h5ad(data_dir / "DatlingerBock2017.h5ad")
adata.obs["target"] = adata.obs["target"].astype("string").fillna("control")
adata.obs["replicate"] = adata.obs["replicate"].astype("category")
adata = transform_counts(adata)
adata.obsm["G"] = adata.obs["target"].str.get_dummies().astype(int)
data = load_screen(
adata,
control="control",
covariates=["replicate", "percent_mito"],
)
result_dir = Path("results/datlinger")
Adamson CRISPRi: collapse guides to target genes
The Adamson dataset
stores labels such as CREB1_pDS269. Use the text before _ as the target;
62(mod) denotes controls and * labels are excluded.
adata = ad.read_h5ad(
data_dir / "AdamsonWeissman2016_GSM2406675_10X001.h5ad"
)
labels = adata.obs["perturbation"].astype("string").str.split("_", n=1).str[0]
adata.obs["target"] = labels.replace({"62(mod)": "control"}).mask(labels == "*")
adata = adata[adata.obs["target"].notna()].copy()
adata = transform_counts(adata)
adata.obsm["G"] = adata.obs["target"].str.get_dummies().astype(int)
data = load_screen(
adata,
control="control",
covariates=["percent_ribo"],
)
result_dir = Path("results/adamson")
Norman CRISPRa: single targets and target pairs
In the Norman dataset, split
target-pair labels such as CEBPE_RUNX1T1 into two active columns.
Controls have all-zero rows, as in the table above. This models additive
target effects; it does not add interaction terms.
adata = ad.read_h5ad(data_dir / "NormanWeissman2019_filtered.h5ad")
adata.obs["gemgroup"] = adata.obs["gemgroup"].astype("category")
adata = transform_counts(adata)
labels = adata.obs["perturbation"].astype("string")
G = labels.mask(labels == "control", "").str.get_dummies(sep="_").astype("int8")
assert (G.sum(axis=1).to_numpy() == adata.obs["nperts"].to_numpy()).all()
data = PerturbData(
X=adata.X,
G=G,
gene_names=adata.var_names,
covariates=adata.obs[["gemgroup", "percent_mito"]],
)
result_dir = Path("results/norman")
A375 10x CRISPR: count matrix and a separate calls table
This example uses a control/RAB1A subset. Prepare a barcode-indexed TSV
containing perturbation, log_total_counts, and percent_mito.
The H5 file supplies counts; final perturbation calls come from your
upstream calling pipeline.
matrix = sc.read_10x_h5(
data_dir / "a375_1k_filtered_feature_bc_matrix.h5",
gex_only=False,
)
adata = matrix[:, matrix.var["feature_types"] == "Gene Expression"].copy()
adata.var_names_make_unique()
calls = pd.read_csv(data_dir / "a375_10x_h5_metadata.tsv", sep="\t", index_col=0)
calls.index = calls.index.astype(str)
calls = calls[calls["perturbation"].isin(["control", "RAB1A"])]
adata = adata[calls.index].copy()
adata.obs = adata.obs.join(calls)
adata.obs["perturbation"] = adata.obs["perturbation"].astype("category")
adata = transform_counts(adata)
adata.obsm["G"] = (adata.obs["perturbation"] == "RAB1A").astype(int).to_frame(name="RAB1A")
data = load_screen(
adata,
covariates=["log_total_counts", "percent_mito"],
)
result_dir = Path("results/a375")
4. Fit and save¶
Fit the model using the data prepared above:
fit = fit_screen(
data,
z_dim=20,
l_dim=1000,
init="pca"
)
save_results(fit, result_dir)
| Setting | Meaning |
|---|---|
z_dim |
Number of latent factors |
l_dim |
Number of single-effect loading components per factor |
init |
Initialization: "pca" or "random" |
Adjust the number of factors and loading components for your analysis.
Genes are centered automatically; set standardize=True to also scale each
gene to unit variance.
See the API reference for all fitting options.
Fit from the command line
For an H5AD file with transformed expression in X and perturbation
assignments in obsm["G"]:
perturbvi fit data/screen.h5ad --output results --z-dim 20 --l-dim 1000
Add --control Nontargeting if that reference column should be dropped.
Run perturbvi fit --help for layer and covariate options.
Reuse covariate-corrected expression across fits
When fitting the same data several times, you can correct expression once:
from perturbvi import residualize_screen
resid = residualize_screen(data)
fit = fit_screen(
resid,
z_dim=20,
l_dim=1000,
init="pca"
)
5. Plot and interpret results¶
The LUHMES notebook shows how to plot and interpret the results, count significant genes, and run GO enrichment.