---
title: Using fishash to assign gRNAs in Perturbseq data
author: Jack Kamm
date: "Revised: 2026-07-25"
output:
  BiocStyle::html_document
package: fishash
bibliography: ref.bib
vignette: >
  %\VignetteIndexEntry{Using fishash to assign gRNAs in Perturbseq data}
  %\VignetteEngine{knitr::rmarkdown}
  %\VignetteEncoding{UTF-8}
---

```{r setup-opts, include = FALSE}
knitr::opts_chunk$set(
    collapse = TRUE,
    comment = "#>"
)
```

# Introduction

Fishash is a package for assigning guides to cells in Perturbseq
experiments, which combine single-cell RNAseq with CRISPR
perturbations.  During preprocessing, we must determine the
presence/absence of guides in each cell based on the CRISPR gRNA UMI
counts.  Fishash does this using a modified version of Fisher's test.

Other packages for performing perturbseq guide assignment include:

- R packages
  - `r Githubpkg("katsevich-lab/sceptre")`
- Python packages
  - [geomux](https://github.com/noamteyssier/geomux)
  - [crispat](https://github.com/velten-group/crispat)
  - [demuxEM](https://github.com/lilab-bcb/demuxEM)
- Command line tools
  - [CLEANSER](https://github.com/Gersbachlab-Bioinformatics/CLEANSER)

We compare fishash with the methods above in our preprint
[@kamm2026fishash].  We find fishash to be particularly well suited
for large perturbseq datasets (e.g. genome-wide perturbation screens)
in terms of accuracy, runtime, and memory usage.

# Installation

```{r install, eval=FALSE}
if (!requireNamespace("BiocManager", quietly = TRUE)) {
    install.packages("BiocManager")
}

BiocManager::install("fishash")
```

# Example TAPseq dataset

```{r load-packages, message = FALSE}
library(Matrix)
library(SummarizedExperiment)
library(SingleCellExperiment)

library(ggplot2)
library(dplyr)

library(fishash)
```

We illustrate fishash on a Targeted Perturbseq ("TAPseq") dataset
[@schraivogel2020targeted] dataset which is also bundled with the
[crispat](https://github.com/velten-group/crispat) package for gRNA
assignment.

The data consists of ~22,000 K562 cells from 2 samples with gene
expression for 72 genes (mostly on chromosome 8) and CRISPRi
perturbations consisting of 86 gRNAs. Note that TAPseq amplifies
transcripts from a panel of genes instead of sequencing the whole
transcriptome.

We represent the data as a `SingleCellExperiment`, with the
main experiment containing the gene expression sequencing counts.

```{r load-data, cache=TRUE}
data(tapseq_diffex)
tapseq_diffex
```

The SingleCellExperiment object also contains an alternative
experiment with the sequencing counts of the gRNA barcodes,
which we will use to call presence/absence of the gRNAs:

```{r print-altexp}
altExp(tapseq_diffex)
```

The CRISPRi perturbations target promoters of 10 genes and enhancers
of 4 genes. Each gene target has 4 guides targeting its promoter or
enhancers.  Additionally, there are 30 non-targeting control (NTC)
guides.

```{r table-grna}
rowData(altExp(tapseq_diffex)) |>
    as.data.frame() |>
    group_by(target_gene, target_element_type) |>
    summarize(n_guides = n(), .groups = "drop") |>
    arrange(target_element_type)
```

# Visualizing the guide counts

Before assigning the guides, it is good to visualize the count matrix.

One useful plot, originally proposed by the
[pertpy](https://pertpy.readthedocs.io/en/stable/tutorials/notebooks/guide_rna_assignment.html)
project,
is to represent the counts as a heatmap, with the cells sorted by
their top guide, creating a band near the diagonal:

```{r diag-heatmap, fig.width=9, fig.height=8, fig.wide=TRUE}
set.seed(12345)
diagonal_heatmap(counts(altExp(tapseq_diffex)), subsample_cells = 200)
```

Another useful plot is the histogram of the nonzero counts.  We plot
the histogram together with a weighted version of the histogram, which
rescales the y-axis to represent UMIs instead of the number of matrix
entries:

```{r weighted-hist, fig.width=7, fig.height=7}
nonzero_histogram_with_weighted(counts(altExp(tapseq_diffex)))
```

The top histogram represents the distribution for the number of UMIs
if we sampled a random nonzero matrix entry, whereas the bottom
histogram represents the distribution if we sampled a random UMI and
asked how many UMIs are in the same entry. If we think of the left
mode as "noise" and the right mode as "signal", the areas in the
weighted histogram provide a visual estimate of the signal-to-noise
ratio in the gRNA counts.

# Guide assignment

Next, we call `fishash()` to call presence/absence of the guides in each cell, based on the sequencing counts of the gRNA barcodes. Internally, for each (cell, gRNA) pair the function constructs a 2x2 table for the UMI counts, and then uses a one-sided Fisher's test to call presence/absence of the guide, depending on whether there is significant association of the cell and guide barcodes.

```{r run-fishash}
res_fishash <- fishash(counts(altExp(tapseq_diffex)), padj_cutoff = .05)
res_fishash
```

The return value is a SummarizedExperiment. The `assigned` assay is a
sparse boolean matrix indicating whether we call the guide present
(`|` means `TRUE` while `.` means `FALSE`):

```{r show-assigned}
assay(res_fishash, "assigned")[1:10, 1:10]
```

The colData shows the classification of each cell; the `assignment`
column is a comma-delimited string containing the guides called, while
the `demux_type` column indicates whether the cell contained 0, 1, or
2+ assigned guides (only the cells with a single guide will be kept
for downstream analysis):

```{r show-coldata}
colData(res_fishash)
```

We inspect the number of cells with 0 guides (unassigned), 1 guide (singlet),
or 2+ guides (doublet):

```{r bar-demux-type, fig.small=TRUE}
colData(res_fishash) |>
    as.data.frame() |>
    ggplot(aes(x = demux_type)) +
    geom_bar() +
    theme_bw(base_size = 16)
```

Alternatively, we can plot a histogram of the number of assigned
guides per cell by using the column sums of the assignment matrix:

```{r hist-n-assigned}
data.frame(
    n_assigned = colSums(assay(res_fishash, "assigned"))
) |>
    ggplot(aes(x = n_assigned)) +
    geom_histogram() +
    theme_bw(base_size = 16)
```

We can also inspect the log p-values from the Fisher test. Since we
use a one-sided test, this matrix is also sparse:

```{r show-logpval}
assay(res_fishash, "log_pval")[1:10, 1:10]
```

The metadata shows the p-value cutoff selected by the FDR procedure:

```{r show-metadata}
metadata(res_fishash)
```

We can inspect the classifier decision rule by plotting the test
statistic (the negative log p-value).  Ideally its histogram will look
bimodal, as it does below.  We also plot the test statistic against
the UMI count to understand how they correspond; we see that guides
with 2 counts are usually not assigned, and guides with 10+ counts
are nearly always assigned.

```{r xy-pvalues-cnt}
nz <- counts(altExp(tapseq_diffex)) > 0

data.frame(
    count = counts(altExp(tapseq_diffex))[nz],
    log_pval = assay(res_fishash, "log_pval")[nz],
    assigned = assay(res_fishash, "assigned")[nz]
) |>
    ggplot(aes(x = -log_pval, y = count, color = assigned)) +
    geom_point(
        shape = 1, alpha = .5,
        position = position_jitter(width = 0, height = .02)
    ) +
    geom_vline(
        xintercept = -metadata(res_fishash)$log_pval_cutoff,
        lty = "dotted"
    ) +
    scale_x_continuous(trans = "log1p", breaks = c(0, 10^(0:6))) +
    scale_y_log10() +
    theme_bw(base_size = 16) +
    theme(legend.position = "left") ->
p

if (require(ggExtra)) {
    p <- ggMarginal(
        p,
        type = "hist",
        groupFill = TRUE,
        xparams = list(bins = 50),
        yparams = list(bins = 50)
    )
}

p
```

# Downstream analysis

Finally, we briefly illustrate how the gRNA assignments may be used
in downstream analyses such as differential expression.

First, we subset to cells with a single guide detected.

```{r subset-singlet, cache=TRUE}
# Add fishash results to the SingleCellExperiment
colData(tapseq_diffex) <- cbind(colData(tapseq_diffex), colData(res_fishash))
assay(altExp(tapseq_diffex), "assigned") <- assay(res_fishash, "assigned")

# Subset to cells with a single guide
singlets <- tapseq_diffex[, colData(tapseq_diffex)$demux_type == "singlet"]

# Annotate cells with the perturbed gene
colData(singlets)$target_gene <- rowData(altExp(singlets))[
    colData(singlets)$assignment, "target_gene"
]
```

Next, we use `r Biocpkg("glmGamPoi")` to perform differential
expression of the cells from each guide against the NTC cells,
controlling for the sample. For the NTC guides, we compare them
against the other NTC guides in leave-one-out fashion.  For the cell
size factors, we use the geometric mean (CLR normalization) due to the
compositional nature of the data and low number of features (72
genes). We also shrink the log-fold-changes with a small ridge penalty
equivalent to a Normal prior with variance 1.

```{r run-glmgp, cache=TRUE}
if (require(glmGamPoi)) {
    # Run differential expression in a loop over guides
    lfc_prior_sd <- 1
    de_list <- lapply(
        rownames(altExp(singlets)),
        function(g) {
            # Subset to NTCs or cells with guide g
            keep <- with(
                colData(singlets),
                target_gene == "NTC" | assignment == g
            )
            sce_sub <- singlets[, keep]
            colData(sce_sub)$prtrb <- colData(sce_sub)$assignment == g
            # Run differential expression
            test_de(
                glm_gp(
                    counts(sce_sub),
                    design = ~ sample + prtrb,
                    col_data = colData(sce_sub),
                    size_factors = exp(colMeans(log1p(counts(sce_sub)))),
                    ridge_penalty = log(2) / lfc_prior_sd / sqrt(ncol(sce_sub)),
                    overdispersion_shrinkage = FALSE
                ),
                contrast = "prtrbTRUE"
            )
        }
    )
    names(de_list) <- rownames(altExp(singlets))

    # convert differential expression results to SummarizedExperiment
    de_summexp <- list()
    for (a in c("lfc", "adj_pval")) {
        de_summexp[[a]] <- sapply(
            de_list,
            function(x) setNames(x[, a], x[, "name"])
        )
    }
    de_summexp <- SummarizedExperiment(
        assays = de_summexp,
        colData = rowData(altExp(singlets))
    )
} else {
    de_summexp <- NULL
}
```

We then plot a heatmap of the log-fold changes; significant genes at FDR 5% are marked with a point.

```{r de-heatmap, fig.height=15, fig.width=15, fig.wide=TRUE}
if (!is.null(de_summexp) && require(ComplexHeatmap)) {
    Heatmap(
        t(assay(de_summexp, "lfc")),
        right_annotation = rowAnnotation(
            df = colData(de_summexp)[, "target_element_type", drop = FALSE],
            col = list(target_element_type = setNames(
                palette()[1:3],
                c("NTC", "enhancer", "promoter")
            ))
        ),
        layer_fun = function(j, i, x, y, w, h, col) {
            signif <- t(assay(de_summexp, "adj_pval"))[cbind(i, j)] <= .05
            grid.points(
                x[signif], y[signif],
                pch = 21, size = unit(3, "mm"),
                gp = gpar(col = "white", fill = "black", lwd = 2)
            )
        },
        cluster_rows = FALSE, cluster_columns = FALSE,
        heatmap_legend_param = list(title = "logFC")
    )
}
```

Overall the results look reasonable, with similar differential
expression results between the guides for the same target, and in
particular with the target gene usually being knocked down (e.g. ZFPM2
guides have negative logFC for ZFPM2 gene expression).  Some guides
should be investigated as outliers -- for example, the fourth UBR5
guide shares few DEGs with the other UBR5 guides and in particular
fails to knockdown the UBR5 gene.

The number of DEGs among the NTCs can also give a rough idea of the
empirical false positive rate or miscalibration of the results. Overall,
most NTC guides have no DEGs, with some exceptions (such as NTC-22)
which may be worth investigating as outliers.

Beyond the scope of this vignette, a more detailed analysis could
improve the quality of the DE results, such as by QC filtering of
cells, by controlling for confounders (such as cell state), by using
more sophisticated shrinkage estimators of the log-fold-changes, or by
improving estimates of the cell size factors or gene overdispersions.

# References {.unnumbered}

# Session info

```{r session-info}
sessionInfo()
```
