Skip to content

Genentech/perturbmatch

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

10 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

perturbmatch

Propensity-score matching for high-MOI Perturb-seq differential expression analysis.

perturbmatch provides cell-level propensity score matching to select balanced control (non-targeting control, NTC) cells for each perturbation in Perturb-seq experiments. In high-MOI screens, cells carry variable numbers of guide RNAs, creating confounding between guide multiplicity and technical covariates (library size, mitochondrial fraction, etc.). Rather than discarding multi-perturbed cells, perturbmatch matches them to appropriate controls within each multiplicity class, preserving statistical power while controlling for confounders.

Method overview

The core workflow:

  1. Stratify cells by multiplicity class (singlets, doublets, triplets, etc.) based on the number of detected guides per cell.
  2. Select target and NTC cells for a given perturbation within each class.
  3. Match target cells to NTC controls using nearest-neighbor propensity score matching (via MatchIt), balancing on covariates such as log-total counts, mitochondrial fraction, and ribosomal fraction.
  4. Combine matched cells across multiplicity classes into a single SingleCellExperiment for downstream differential expression.

For classes with extreme imbalance (many more NTCs than targets), a weighted-GLM propensity pre-filter removes outlier controls before matching. When the weighted GLM fails, the method falls back to balanced subsampling.

Alternatively, RunPerClassSampling() provides a random selection strategy (no propensity matching) as a drop-in replacement, useful as a baseline comparison or when matching is not needed.

Note: perturbmatch is agnostic to the differential expression method. The package handles cell selection and matching only -- it outputs a labeled SingleCellExperiment that can be passed to any DE framework. In practice, we use single-cell limma-voom as shown in the examples below, but any method that accepts a count matrix and design matrix will work.

Installation

# Install from GitHub
devtools::install_github("Genentech/perturbmatch")

Quick start

The package ships a small example SingleCellExperiment (MED19 perturbation from Replogle et al. 2022, ~5300 cells x 3000 genes) so you can run this end-to-end after install:

library(perturbmatch)
library(SingleCellExperiment)

# Load bundled example (Replogle K562 CRISPRi, MED19 target + NTC controls)
sce <- readRDS(system.file("extdata", "example_replogle_MED19.rds", package = "perturbmatch"))

# 1. Select target and NTC cells by multiplicity class
target.by.class <- StratifyCellsByClass(
  sce,
  get_cells_fn = function(s, nmin, nmax) GetTargetCellsReplogle(s, "MED19", nmin, nmax)
)

ntc.by.class <- StratifyCellsByClass(
  sce,
  get_cells_fn = function(s, nmin, nmax)
    GetNtcCellsReplogle(s, nmin, nmax, exclude.gene = "MED19")
)

# 2a. Run per-class propensity score matching (K=5 NTCs per target cell)
result <- RunPerClassMatching(
  sce,
  target.by.class = target.by.class,
  ntc.by.class    = ntc.by.class,
  match.K         = 5,
  match.covariates = c("log.total.counts", "frac.mt", "frac.ribo"),
  size.col         = "sizeFactor"
)

# 2b. OR use random sampling (no matching) as an alternative
result <- RunPerClassSampling(
  sce,
  target.by.class = target.by.class,
  ntc.by.class    = ntc.by.class,
  match.K         = 5,
  size.col        = "sizeFactor"
)

# 3. result$sce is a labeled SingleCellExperiment ready for any DE method.
#    Cells are labeled "zTarget" or "aNTC" in colData(result$sce)$label.
#    Pass counts(result$sce) and a design matrix to your preferred DE framework
#    (e.g., limma-voom, edgeR, DESeq2).

Adapting to your own data: GetTargetCellsReplogle and GetNtcCellsReplogle require two columns in colData(sce): genestr (comma-separated target gene names per cell, e.g. "MED19", "MED19,PARK7", or "non-targeting" for NTC controls) and nguides.fishash (integer guide count per cell). If your demultiplexing tool outputs guide-level assignments, collapse to gene names and count guides per cell. For fishash-style data with a perturbation indicator matrix, use GetTargetCells()/GetNtcPool() instead (see Example 1 below).

Key functions

Cell selection

Function Description
GetTargetCells() Select target cells by guide name and multiplicity range (fishash-style data)
GetNtcPool() Select NTC control cells, excluding cells co-assigned with the target gene
GetTargetCellsReplogle() Target cell selection for Replogle-style data (comma-delimited genestr column)
GetNtcCellsReplogle() NTC selection for Replogle-style data, with restrictive/permissive modes
StratifyCellsByClass() Convenience wrapper to stratify cells into multiplicity classes (singlets, doublets, ..., 5+)

Matching and sampling

Function Description
RunPerClassMatching() Top-level function: propensity score matching within each multiplicity class, then combine
RunPerClassSampling() Top-level function: random NTC sampling (no matching). Drop-in replacement for RunPerClassMatching()
MatchClassCells() Per-class matching with weighted-GLM propensity filtering
SampleClassCells() Per-class random sampling. Drop-in replacement for MatchClassCells()
MatchCellsSce() Low-level MatchIt wrapper for nearest-neighbor GLM matching

Downstream analysis helpers

These utilities help evaluate DE results but do not perform DE themselves. The DE method is your choice.

Function Description
WrangleLimma() Extract a tidy data.frame from a limma fit object (convenience parser)
ComputeMetrics() Compute recall, precision, and cosine similarity vs. a reference result
rms_zscore() Root-mean-square z-score from a toptable
cosine_sim_tt() Cosine similarity between two toptables

Setup and utilities

Function Description
SetupSce() Label cells as target/NTC, run logNormCounts, prepare matching covariates
BuildMixedCellsForMoi() Build mixed singlet+multiplet cell sets for power analysis
CheckNullCbind() NULL-safe cbind for combining SCE pieces

Usage examples

Example 1: Fishash-style data (multiple MOI conditions)

This example uses bundled pilot DLD1 Perturb-seq data (ATIC_P1 perturbation, MOI 0.1 and MOI 1.0) to match target and NTC cells across MOI conditions. The example ships a pre-computed indicator matrix so CropQuest is not required.

library(perturbmatch)
library(SingleCellExperiment)

# Load bundled example (DLD1 pilot, ATIC_P1 target, 2 MOI conditions)
obj <- readRDS(system.file("extdata", "example_pilot_ATIC_P1.rds", package = "perturbmatch"))
sce     <- obj$sce
ind.mat <- obj$ind.mat

# Split into per-MOI SCE objects
sce.lst <- list(
  Low  = sce[, colData(sce)$MOI == "MOI_0_1"],
  High = sce[, colData(sce)$MOI == "MOI_1_0"]
)

# Subset indicator matrix per MOI
ind.lst <- lapply(sce.lst, function(s) ind.mat[, colnames(s)])

# Stratify cells by multiplicity for each MOI condition
target.cells.lst <- lapply(names(sce.lst), function(moi) {
  StratifyCellsByClass(sce.lst[[moi]], function(s, nmin, nmax)
    GetTargetCells(s, "ATIC_P1", nmin, nmax, softmatch = FALSE, ind.mat = ind.lst[[moi]]))
})
names(target.cells.lst) <- names(sce.lst)

ntc.pool.lst <- lapply(names(sce.lst), function(moi) {
  StratifyCellsByClass(sce.lst[[moi]], function(s, nmin, nmax)
    GetNtcPool(s, "ATIC_P1", nmin, nmax, softmatch = TRUE, ind.mat = ind.lst[[moi]]))
})
names(ntc.pool.lst) <- names(sce.lst)

# Reference fit: Low MOI singlets only
ref.result <- RunPerClassMatching(
  sce.lst[["Low"]],
  target.by.class = list(singlets = target.cells.lst[["Low"]]$singlets),
  ntc.by.class    = list(singlets = ntc.pool.lst[["Low"]]$singlets),
  match.K = 5
)

# High MOI: match singlets + doublets
result <- RunPerClassMatching(
  sce.lst[["High"]],
  target.by.class = list(singlets = target.cells.lst[["High"]]$singlets,
                         doublets = target.cells.lst[["High"]]$doublets),
  ntc.by.class    = list(singlets = ntc.pool.lst[["High"]]$singlets,
                         doublets = ntc.pool.lst[["High"]]$doublets),
  match.K = 5
)

Example 2: Replogle-style data

For Replogle et al. data where perturbation assignments are stored as a comma-delimited genestr column in colData.

library(perturbmatch)

# Select cells using Replogle-style functions
singlet.targets <- GetTargetCellsReplogle(sce, "CTNNB1", n.min = 1, n.max = 1)
multi.targets   <- GetTargetCellsReplogle(sce, "CTNNB1", n.min = 2, n.max = 2)

ntc.singlets <- GetNtcCellsReplogle(sce, n.min = 1, n.max = 1,
                                     restrictive = FALSE, exclude.gene = "CTNNB1")
ntc.multi    <- GetNtcCellsReplogle(sce, n.min = 2, n.max = 2,
                                     restrictive = FALSE, exclude.gene = "CTNNB1")

# Match with Replogle-specific settings (sizeFactor instead of total.counts)
result <- RunPerClassMatching(
  sce,
  target.by.class = list(n1 = singlet.targets, n2 = multi.targets),
  ntc.by.class    = list(n1 = ntc.singlets, n2 = ntc.multi),
  match.K          = 5,
  match.covariates = c("log.total.counts", "frac.mt", "frac.ribo"),
  size.col         = "sizeFactor"
)

# Downstream: limma-voom DE and evaluation
tt <- WrangleLimma(fit)
metrics <- ComputeMetrics(tt, tt.ref, dat.ref,
                          lfc.cutoff = 0.1, lfsr.cutoff = 0.15)

Example 3: Random sampling (no matching)

RunPerClassSampling() is a drop-in replacement for RunPerClassMatching() that randomly selects K NTCs per target cell without propensity matching. This is useful as a baseline comparison to quantify the benefit of matching, or in settings where covariate balance is already adequate.

# Same interface as RunPerClassMatching -- just swap the function name
result <- RunPerClassSampling(
  sce,
  target.by.class = target.by.class,
  ntc.by.class    = ntc.by.class,
  match.K = 5
)

# The output is identical in structure: result$sce is a labeled SCE
# with the same "zTarget"/"aNTC" labeling, ready for DE analysis.
# result$match.info records match_method = "random_sample" for each class.

The processing scripts support switching between matching and random sampling via a --random_sample flag:

# With propensity score matching (default)
Rscript statistical_power_sublib2.R -guidename CTNNB1_P1 -outfgz out.tsv.gz -outrds out.rds

# With random sampling instead
Rscript statistical_power_sublib2.R -guidename CTNNB1_P1 -outfgz out.tsv.gz -outrds out.rds --random_sample

Differential expression (bring your own method)

perturbmatch outputs a labeled SingleCellExperiment with target cells (label == "zTarget") and matched/sampled NTC cells (label == "aNTC"). You then pass this to whatever DE framework you prefer. In the paper we use single-cell limma-voom:

library(limma)
library(edgeR)

# Design matrix: treatment effect + covariates
design <- model.matrix(~ 1 + label + nguides.fishash + log.total.counts + frac.mt + frac.ribo,
                       data = colData(result$sce))

# Fit limma-voom
dge <- DGEList(counts(result$sce))
dge <- calcNormFactors(dge)
v   <- voom(dge, design)
fit <- lmFit(v, design)
fit <- eBayes(fit)

# Wrangle results into a tidy data.frame
tt <- WrangleLimma(fit)

But you could equally use edgeR, DESeq2, or any other method -- perturbmatch is upstream of the DE step.

Propensity score filtering

When target cells are rare relative to NTCs (common in high-MOI experiments), the propensity score distribution of controls can be poorly overlapping with targets. MatchClassCells() supports optional propensity score filtering via ps_min and ps_max arguments:

result <- RunPerClassMatching(
  sce,
  target.by.class = target.by.class,
  ntc.by.class    = ntc.by.class,
  match.K = 5,
  ps_min  = 0.2,
  ps_max  = 0.8
)

The method uses a weighted GLM where sampling weights equalize effective class sizes, making propensity scores comparable on the [0, 1] scale before applying the fixed thresholds.

Related resources

Citation

If you use perturbmatch, please cite:

Yeung, J., Tan, J., et al. (2026). Joint analysis of multiply perturbed cells improves statistical power and cost efficiency in Perturb-seq. bioRxiv. https://doi.org/10.64898/2026.07.10.737863

License

MIT

About

No description, website, or topics provided.

Resources

License

Stars

1 star

Watchers

0 watching

Forks

Packages

 
 
 

Contributors

Languages