Skip to contents

About this vignette

This vignette compares the hit-finding methods of ScreenR with other tools for pooled screens:

  • MAGeCK RRA and MAGeCK MLE (Li et al. 2014, 2015), the most used tools;
  • BAGEL2 (Kim and Hart 2021), a supervised classifier of essential genes;
  • gscreend (Imkeller et al. 2020), a Bioconductor package for CRISPR screens;
  • two plain edgeR/limma workflows: edgeR quasi-likelihood test on the counts summed by gene, and limma-voom with camera on the barcodes of each gene (as proposed for shRNA and CRISPR screens by Dai et al. 2014).

The comparisons use

  1. simulated screens, where the true hits are known exactly;
  2. a published K562 shRNA essentiality screen (k562_count_table), where the core essential genes are the true hits and the non-essential genes the true negatives;
  3. a published HAP1 CRISPR-Cas9 essentiality screen, evaluated in the same way;
  4. the ScreenR example dataset (count_table), which has no ground truth.

The other tools are Python programs or packages that are not dependencies of ScreenR, so their results (MAGeCK 0.5.9.5, BAGEL2, gscreend 1.17.2, edgeR 4.0, limma 3.58) and the simulation results are precomputed and distributed in inst/extdata/benchmark. The ScreenR methods on the K562 screen are run here, so this vignette also shows how to compare ScreenR with other tools on your own data. The scripts that produced the precomputed results are in the benchmark folder of the GitHub repository.

Some differences in how the tools were run matter for the interpretation:

  • BAGEL2 is supervised: it needs genes known to be essential and non-essential to train its classifier. In the simulations it was trained on half of the true hits and half of the genes without effect, and on the real screens on the reference genes used for the evaluation (with 10-fold cross-validation, its standard use). It reports Bayes factors, not p-values, so it has no null calibration and its calls use its own FDR estimate.
  • MAGeCK MLE p-values were computed from its Wald statistic (pnorm(z), Benjamini-Hochberg), not from its permutation FDR.
  • gscreend accepts a single reference sample, so the control replicates were summed. It is deprecated in Bioconductor 3.19.
library(ScreenR)
library(dplyr)
library(tidyr)
library(ggplot2)
benchmark_dir <- system.file("extdata", "benchmark", package = "ScreenR")
labels <- c(
    ScreenR_Zscore_mean = "ScreenR Z-score (mean)",
    ScreenR_Zscore_edgeR = "ScreenR Z-score (edgeR)",
    ScreenR_CAMERA = "ScreenR CAMERA",
    ScreenR_FRY = "ScreenR FRY",
    ScreenR_ROAST = "ScreenR ROAST",
    ScreenR_Zscore_median = "ScreenR Z-score (median)",
    ScreenR_Zscore_controls = "ScreenR Z-score (controls)",
    ScreenR_CAMERA_storey = "ScreenR CAMERA (Storey)",
    ScreenR_legacy_rule = "ScreenR legacy rule",
    MAGeCK_RRA = "MAGeCK RRA",
    MAGeCK_RRA_controls = "MAGeCK RRA (controls)",
    MAGeCK_MLE = "MAGeCK MLE",
    BAGEL2 = "BAGEL2",
    gscreend = "gscreend",
    edgeR_gene_QL = "edgeR gene-level QL",
    limma_voom_camera = "limma-voom + camera"
)
colours <- c(
    "ScreenR Z-score (mean)" = "#1F5F55", "ScreenR Z-score (edgeR)" = "#93B5A3",
    "ScreenR CAMERA" = "#2E8B7A", "ScreenR FRY" = "#C8DCD0",
    "ScreenR ROAST" = "#7FBF7B", "ScreenR Z-score (median)" = "#4D9078",
    "ScreenR Z-score (controls)" = "#B8E0D2",
    "ScreenR CAMERA (Storey)" = "#5FA89A", "ScreenR legacy rule" = "grey50",
    "MAGeCK RRA" = "#E3A894", "MAGeCK RRA (controls)" = "#F2CDBF",
    "MAGeCK MLE" = "#B5523A", "BAGEL2" = "#5B4E9E", "gscreend" = "#D4A72C",
    "edgeR gene-level QL" = "#4C78A8", "limma-voom + camera" = "#9ECAE9"
)
# The methods shown in the figures (all methods are in the tables)
main <- c("ScreenR_Zscore_mean", "ScreenR_Zscore_edgeR", "ScreenR_CAMERA",
    "ScreenR_FRY", "MAGeCK_RRA", "MAGeCK_MLE", "BAGEL2", "gscreend",
    "edgeR_gene_QL", "limma_voom_camera")
scenarios <- c(
    S0_null = "No hits", S1_baseline = "Baseline",
    S2_weak_effect = "Weak effect", S3_two_replicates = "2 replicates",
    S4_noisy = "Noisy", S5_off_target = "Off-target"
)

Simulated screens

Design

Each simulated screen has 500 genes with 10 shRNAs each and 500 non-targeting shRNAs, with 3 control and 3 treated samples. The counts are negative binomial, and the abundance of the shRNAs in the library is log-normal. 10% of the genes are true hits, depleted in the treated samples: each of their shRNAs has its own knockdown efficiency and 20% of them do not work at all. Six scenarios were simulated, each with 5 random seeds:

Scenario Change from the baseline
No hits no true hits: every call is false
Baseline 3 vs 3 replicates, log2 fold change -1, dispersion 0.05
Weak effect log2 fold change -0.5
2 replicates 2 vs 2 replicates
Noisy dispersion 0.2 (four times more biological variability)
Off-target 3% of the shRNAs depleted whatever gene they target

The ScreenR methods are find_camera_hit() (with Benjamini-Hochberg or Storey FDR), find_roast_hit() (ROAST and FRY), find_gene_zscore_hit() (mean, median, with the non-targeting shRNAs as reference and on the statistics of compute_barcode_statistics()) and the legacy rule of find_zscore_hit(). The other tools are MAGeCK RRA (also with the non-targeting shRNAs as controls, --control-sgrna), MAGeCK MLE, BAGEL2, gscreend, the edgeR gene-level test and limma-voom with camera.

simulation <- bind_rows(
    read.csv(file.path(benchmark_dir, "simulation.csv")),
    read.csv(file.path(benchmark_dir, "simulation_competitors.csv"))
) %>%
    mutate(
        Method = factor(labels[method], levels = labels),
        Scenario = factor(scenarios[scenario], levels = scenarios)
    )
head(simulation)
#>                method alpha n_called FDP TPR null_p_below AUPRC  time scenario
#> 1          MAGeCK_RRA  0.01        0   0  NA        0.012    NA 0.708  S0_null
#> 2          MAGeCK_RRA  0.05        1   1  NA        0.048    NA 0.708  S0_null
#> 3          MAGeCK_RRA  0.10        1   1  NA        0.092    NA 0.708  S0_null
#> 4          MAGeCK_RRA  0.20        1   1  NA        0.198    NA 0.708  S0_null
#> 5 MAGeCK_RRA_controls  0.01        0   0  NA        0.006    NA 0.724  S0_null
#> 6 MAGeCK_RRA_controls  0.05        0   0  NA        0.046    NA 0.724  S0_null
#>   seed                Method Scenario
#> 1    1            MAGeCK RRA  No hits
#> 2    1            MAGeCK RRA  No hits
#> 3    1            MAGeCK RRA  No hits
#> 4    1            MAGeCK RRA  No hits
#> 5    1 MAGeCK RRA (controls)  No hits
#> 6    1 MAGeCK RRA (controls)  No hits

For each method, screen and nominal FDR level (alpha) the table has the number of genes called (n_called), the false discovery proportion (FDP, the fraction of called genes that are not true hits), the true positive rate (TPR), the fraction of genes without effect with p-value below alpha (null_p_below), the area under the precision-recall curve of the p-value ranking (AUPRC) and the run time in seconds.

Are the p-values valid?

In the screens without hits, a valid test has about 5% of the genes with p < 0.05.

simulation %>%
    filter(scenario == "S0_null", alpha == 0.05, !is.na(null_p_below),
        method != "ScreenR_legacy_rule") %>%
    group_by(Method) %>%
    summarise(fraction = mean(null_p_below), se = sd(null_p_below) / sqrt(n())) %>%
    ggplot(aes(fraction, Method, colour = Method)) +
    geom_vline(xintercept = 0.05, linetype = "dashed") +
    geom_pointrange(aes(xmin = fraction - 2 * se, xmax = fraction + 2 * se)) +
    scale_colour_manual(values = colours, guide = "none") +
    scale_y_discrete(limits = rev) +
    labs(x = "Fraction of genes with p < 0.05 (no true hits)", y = NULL) +
    theme_bw()

All the methods are close to 5% (BAGEL2 has no p-values). The legacy rule, which has no p-value, called on average 87 genes in these screens, all of them false.

Is the FDR controlled?

A method controls the FDR when the observed FDR (the mean false discovery proportion) is at or below the nominal level, the dashed line.

simulation %>%
    filter(scenario != "S0_null", method %in% main) %>%
    group_by(Method, Scenario, alpha) %>%
    summarise(FDR = mean(FDP), .groups = "drop") %>%
    ggplot(aes(alpha, FDR, colour = Method)) +
    geom_abline(linetype = "dashed", colour = "grey40") +
    geom_line() +
    geom_point(size = 1) +
    facet_wrap(~Scenario, nrow = 1) +
    scale_colour_manual(values = colours) +
    scale_x_continuous(breaks = c(0.01, 0.05, 0.1, 0.2)) +
    labs(x = "Nominal FDR", y = "Observed FDR", colour = NULL) +
    theme_bw() +
    theme(legend.position = "bottom", axis.text.x = element_text(angle = 90))

fdr_table <- simulation %>%
    filter(scenario != "S0_null", alpha == 0.05) %>%
    group_by(Method, Scenario) %>%
    summarise(FDP = mean(FDP), .groups = "drop") %>%
    pivot_wider(names_from = Scenario, values_from = FDP)
knitr::kable(fdr_table, digits = 3,
    caption = "Observed FDR at a nominal FDR of 5% (mean over 5 seeds).")
Observed FDR at a nominal FDR of 5% (mean over 5 seeds).
Method Baseline Weak effect 2 replicates Noisy Off-target
ScreenR Z-score (mean) 0.000 0.000 0.013 0.000 0.022
ScreenR Z-score (edgeR) 0.000 0.000 0.013 0.000 0.019
ScreenR CAMERA 0.000 0.000 0.014 0.000 0.024
ScreenR FRY 0.025 0.100 0.000 0.000 0.098
ScreenR ROAST 0.026 0.000 0.000 0.000 0.064
ScreenR Z-score (median) 0.013 0.000 0.023 0.031 0.022
ScreenR Z-score (controls) 0.046 0.039 0.075 0.056 0.053
ScreenR CAMERA (Storey) 0.000 0.000 0.014 0.000 0.024
ScreenR legacy rule 0.501 0.604 0.516 0.575 0.530
MAGeCK RRA 0.000 0.000 0.000 0.044 0.000
MAGeCK RRA (controls) 0.190 0.071 0.096 0.156 0.073
MAGeCK MLE 0.008 0.046 0.033 0.050 0.107
BAGEL2 0.077 0.050 0.061 0.063 0.073
gscreend 0.000 0.062 0.015 0.000 0.224
edgeR gene-level QL 0.024 0.077 0.015 0.028 0.164
limma-voom + camera 0.000 0.000 0.013 0.000 0.024

The ScreenR CAMERA and Z-score tests, limma-voom with camera and MAGeCK RRA stay below the nominal FDR in every scenario. BAGEL2 is slightly above it in most scenarios; MAGeCK MLE, gscreend and the edgeR gene-level test exceed it with off-target effects (gscreend up to about 20%); MAGeCK RRA with the non-targeting controls exceeds it in every scenario, and the legacy rule has about 50% false hits.

How many hits are found?

simulation %>%
    filter(scenario != "S0_null", alpha == 0.05, method %in% main) %>%
    group_by(Method, Scenario) %>%
    summarise(TPR = mean(TPR), .groups = "drop") %>%
    ggplot(aes(Scenario, TPR, fill = Method)) +
    geom_col(position = position_dodge(0.9)) +
    scale_fill_manual(values = colours) +
    labs(x = NULL, y = "True positive rate at 5% FDR", fill = NULL) +
    theme_bw() +
    theme(legend.position = "bottom")

The true positive rate depends on the FDR threshold, so it should be read together with the previous section: the legacy rule finds almost all the hits because it calls many false ones. The area under the precision-recall curve measures how well the true hits are ranked first, whatever the threshold:

simulation %>%
    filter(scenario != "S0_null", alpha == 0.05, !is.na(AUPRC),
        method %in% main) %>%
    group_by(Method, Scenario) %>%
    summarise(se = sd(AUPRC) / sqrt(n()), AUPRC = mean(AUPRC),
        .groups = "drop") %>%
    ggplot(aes(Scenario, AUPRC, colour = Method)) +
    geom_pointrange(aes(ymin = AUPRC - se, ymax = AUPRC + se),
        position = position_dodge(0.7), size = 0.3) +
    scale_colour_manual(values = colours) +
    labs(x = NULL, y = "Area under the precision-recall curve",
        colour = NULL) +
    theme_bw() +
    theme(legend.position = "bottom")

simulation %>%
    filter(scenario != "S0_null", alpha == 0.05) %>%
    group_by(Method) %>%
    summarise(`Observed FDR` = mean(FDP), TPR = mean(TPR),
        AUPRC = mean(AUPRC), `Time (s)` = median(time)) %>%
    knitr::kable(digits = 3,
        caption = "Mean over the five scenarios with hits, at 5% FDR.")
Mean over the five scenarios with hits, at 5% FDR.
Method Observed FDR TPR AUPRC Time (s)
ScreenR Z-score (mean) 0.007 0.630 0.945 1.701
ScreenR Z-score (edgeR) 0.007 0.650 0.946 1.660
ScreenR CAMERA 0.008 0.654 0.945 1.676
ScreenR FRY 0.045 0.269 0.848 1.560
ScreenR ROAST 0.018 0.247 0.852 3.431
ScreenR Z-score (median) 0.018 0.628 0.921 6.852
ScreenR Z-score (controls) 0.054 0.807 0.946 0.339
ScreenR CAMERA (Storey) 0.008 0.654 0.945 1.500
ScreenR legacy rule 0.545 0.912 NA NA
MAGeCK RRA 0.009 0.342 0.825 1.693
MAGeCK RRA (controls) 0.117 0.570 0.828 1.515
MAGeCK MLE 0.049 0.758 0.934 370.529
BAGEL2 0.065 0.769 0.939 39.744
gscreend 0.060 0.603 0.865 9.032
edgeR gene-level QL 0.062 0.596 0.867 0.125
limma-voom + camera 0.007 0.643 0.943 1.222

In these simulations the ScreenR CAMERA and Z-score tests rank the hits as well as the best tools (limma-voom with camera, BAGEL2 and MAGeCK MLE) and better than MAGeCK RRA, gscreend and the edgeR gene-level test, which lose most with weak effects or noisy data. At the same nominal FDR they are more conservative: BAGEL2 and MAGeCK MLE find more hits, but BAGEL2 is trained on known hits and both exceed the nominal FDR in some scenarios. ROAST and FRY test each gene on its own barcodes, so they have little power with two replicates or noisy data. limma-voom with camera behaves almost exactly like the ScreenR CAMERA test: they are the same kind of test, which ScreenR wraps in a complete workflow.

The simulations use a simple model (independent barcodes, the same number of barcodes for every gene, depletion only), so they show how the methods behave rather than how they rank on every real screen.

Before and after the calibration of ScreenR 1.15.10

The first version of this benchmark showed that the ScreenR tests were valid but too conservative (two-sided p-values used for a one-sided question and a binomial gene test with few possible p-values). ScreenR 1.15.10 fixed both.

before <- read.csv(file.path(benchmark_dir, "simulation_before_1.15.10.csv"))
compared <- c("ScreenR_CAMERA", "ScreenR_ROAST", "ScreenR_Zscore_mean",
    "ScreenR_Zscore_edgeR")
bind_rows(
    before %>%
        mutate(method = recode(method, ScreenR_Zscore = "ScreenR_Zscore_mean"),
            version = "1.15.9"),
    simulation %>% mutate(version = "1.15.10")
) %>%
    filter(method %in% compared, scenario != "S0_null", alpha == 0.05) %>%
    mutate(Method = factor(labels[method], levels = labels),
        version = factor(version, c("1.15.9", "1.15.10"))) %>%
    group_by(Method, version) %>%
    summarise(TPR = mean(TPR), .groups = "drop") %>%
    ggplot(aes(TPR, Method, fill = version)) +
    geom_col(position = position_dodge(0.8), width = 0.75) +
    scale_fill_manual(values = c("grey70", "#2166AC")) +
    scale_y_discrete(limits = rev) +
    labs(x = "True positive rate at 5% FDR", y = NULL, fill = "ScreenR") +
    theme_bw()

K562 essentiality screen

The data

k562_count_table is a subset of the genome-wide shRNA screen of Morgens et al. (2016): the plasmid library and two replicates after 14 days of growth of K562 cells. The shRNAs of the genes needed for growth are depleted. The reference genes of Hart et al. (k562_reference_genes) give the truth: the 681 core essential genes should be called, the 904 non-essential genes should not.

data(k562_count_table)
data(k562_annotation_table)
data(k562_reference_genes)
samples <- data.frame(
    Sample = c("Plasmid", "T14_A", "T14_B"),
    Day = c("Plasmid", "T14", "T14"),
    Treatment = "Untreated"
)
k562 <- create_screenr_object(
    table = k562_count_table, annotation = k562_annotation_table,
    groups = factor(c("Plasmid", "T14", "T14")), sample_table = samples
)
k562 <- compute_data_table(normalize_data(k562))
k562
#> class: screenr_object 
#> dim: 67358 3 
#> metadata(2): screenr_layout normalization
#> assays(1): counts
#> rownames(67358): 0_H01__01204 0_H01__01217 ... 9978_H02__24
#>   9978_H02_~_25
#> rowData names(4): Barcode Gene Gene_ID Class
#> colnames(3): Plasmid T14_A T14_B
#> colData names(5): Sample Day Treatment Group EffectiveLibSize
#> normalization: cpm (effective library sizes in colData(object)$EffectiveLibSize)

Running the ScreenR methods

The comparison is day 14 against the plasmid. fdr = 1 returns every gene, so that the methods can be evaluated at any threshold.

design <- create_design_matrix(k562)
contrast <- create_contrast(design, treatment = "T14", control = "Plasmid")
metrics <- compute_metrics(k562,
    control = "Plasmid", treatment = "T14", condition_column = "Day"
)
results <- list(
    `ScreenR_CAMERA` = find_camera_hit(k562, design, contrast,
        fdr = 1, number_barcode = 1),
    `ScreenR_FRY` = find_roast_hit(k562, design, contrast,
        method = "fry", fdr = 1, number_barcode = 1),
    `ScreenR_Zscore_mean` = find_gene_zscore_hit(metrics,
        fdr = 1, min_barcodes = 1, n_permutations = 20000, seed = 1),
    `ScreenR_Zscore_controls` = find_gene_zscore_hit(metrics,
        control_genes = "non-targeting", fdr = 1, min_barcodes = 1,
        n_permutations = 20000, seed = 1)
)
results <- lapply(results, function(x) select(x, Gene, PValue, FDR))

Adding the results of the other tools

The other tools were run on the same counts: mageck test for RRA, mageck mle with a design with intercept and treatment, BAGEL2 trained on the reference genes, gscreend and the two edgeR/limma workflows.

others <- bind_rows(
    read.csv(file.path(benchmark_dir, "mageck_k562.csv")),
    read.csv(file.path(benchmark_dir, "competitors_k562.csv"))
)
results <- c(split(select(others, -method), others$method), results)
names(results)
#>  [1] "BAGEL2"                  "edgeR_gene_QL"          
#>  [3] "gscreend"                "limma_voom_camera"      
#>  [5] "MAGeCK_MLE"              "MAGeCK_RRA"             
#>  [7] "ScreenR_CAMERA"          "ScreenR_FRY"            
#>  [9] "ScreenR_Zscore_mean"     "ScreenR_Zscore_controls"

Evaluation on the reference genes

evaluate <- function(result, alpha = 0.05) {
    # Genes are sorted by name, so that tied p-values are ordered the same
    # way for every method
    merged <- left_join(k562_reference_genes, result, by = "Gene") %>%
        mutate(PValue = coalesce(PValue, 1), FDR = coalesce(FDR, 1)) %>%
        arrange(Gene)
    essential <- merged$Class == "core essential"
    called <- merged$FDR <= alpha
    hit <- essential[order(merged$PValue)]
    precision <- cumsum(hit) / seq_along(hit)
    tibble(
        `Essential called` = sum(called & essential),
        `Non-essential called` = sum(called & !essential),
        Recall = mean(called[essential]),
        `Non-essential fraction` = sum(called & !essential) / max(1, sum(called)),
        AUPRC = sum(precision[hit]) / sum(hit)
    )
}
k562_evaluation <- bind_rows(lapply(results, evaluate), .id = "method") %>%
    mutate(Method = labels[method]) %>%
    select(Method, everything(), -method) %>%
    arrange(desc(AUPRC))
knitr::kable(k562_evaluation, digits = 3,
    caption = "K562: reference genes called at 5% FDR.")
K562: reference genes called at 5% FDR.
Method Essential called Non-essential called Recall Non-essential fraction AUPRC
gscreend 331 7 0.486 0.021 0.861
MAGeCK RRA 326 18 0.479 0.052 0.847
ScreenR Z-score (mean) 332 13 0.488 0.038 0.847
BAGEL2 350 18 0.514 0.049 0.847
limma-voom + camera 316 10 0.464 0.031 0.844
ScreenR CAMERA 312 17 0.458 0.052 0.824
MAGeCK MLE 417 130 0.612 0.238 0.809
edgeR gene-level QL 405 142 0.595 0.260 0.801
ScreenR Z-score (controls) 547 292 0.803 0.348 0.767
ScreenR FRY 0 0 0.000 0.000 0.757
precision_recall <- bind_rows(lapply(results, function(result) {
    merged <- left_join(k562_reference_genes, result, by = "Gene") %>%
        mutate(PValue = coalesce(PValue, 1)) %>%
        arrange(Gene)
    hit <- merged$Class[order(merged$PValue)] == "core essential"
    tibble(recall = cumsum(hit) / sum(hit),
        precision = cumsum(hit) / seq_along(hit))
}), .id = "method") %>%
    filter(method %in% main) %>%
    mutate(Method = factor(labels[method], levels = labels))
ggplot(precision_recall, aes(recall, precision, colour = Method)) +
    geom_line() +
    scale_colour_manual(values = colours) +
    coord_cartesian(ylim = c(0.4, 1)) +
    labs(x = "Recall (core essential genes)", y = "Precision", colour = NULL) +
    theme_bw()

The tools that control the FDR call between 310 and 350 of the 681 essential genes with few non-essential genes, and their rankings are close (AUPRC 0.82 to 0.86, gscreend best): on this screen the ScreenR Z-score test is comparable to MAGeCK RRA and BAGEL2. MAGeCK MLE and the edgeR gene-level test find more essential genes but also many non-essential ones (MAGeCK MLE skipped the genes with more than about 40 shRNAs, which count as not called here).

Two ScreenR results show the limits of some methods:

  • FRY calls no gene. It tests each gene on its own barcodes and relies on the variability between replicates, but the control group is a single plasmid sample. With one control sample use CAMERA or the Z-score test.
  • The non-targeting reference calls hundreds of non-essential genes. In shRNA screens the targeting shRNAs are usually somewhat depleted compared with the non-targeting ones, so every gene looks depleted against them. Before using control_genes, check that the controls behave like genes without effect:
metrics %>%
    left_join(distinct(k562_annotation_table, Gene, Class), by = "Gene") %>%
    filter(Class %in% c("non-targeting", "non-essential", "core essential")) %>%
    ggplot(aes(Log2FC, colour = Class)) +
    geom_density() +
    labs(x = "Barcode log2 fold change (day 14 vs plasmid)", colour = NULL) +
    theme_bw()

The non-targeting shRNAs (the reference) are shifted to the right of the non-essential genes, so they are not a good model of a gene without effect.

Checking the quality of the screen

The same reference genes measure the quality of a dropout screen before looking at any hit. compute_screen_quality() reports how well the essential genes are separated from the non-essential ones, and plot_reference_separation() shows it; hart_reference_genes has the full lists of Hart et al., usable with any human screen.

data(hart_reference_genes)
essential <- hart_reference_genes$Gene[
    hart_reference_genes$Class == "core essential"]
nonessential <- hart_reference_genes$Gene[
    hart_reference_genes$Class == "non-essential"]
compute_screen_quality(metrics, essential, nonessential)
#> # A tibble: 1 × 7
#>   NEssential NNonessential MeanEssential MeanNonessential  NNMD CohenD   AUC
#>        <int>         <int>         <dbl>            <dbl> <dbl>  <dbl> <dbl>
#> 1        660           890         -1.07          -0.0380 -2.39  -1.49 0.838
plot_reference_separation(metrics, essential, nonessential)

scale_by_reference() rescales the fold changes so that the non-essential genes are at 0 and the essential genes at -1, which makes screens of different strength comparable.

CRISPR-Cas9 screen

ScreenR also works with CRISPR-Cas9 screens: sgRNAs play the role of the shRNAs. As a test we used the HAP1 screen with the TKOv3 library (Hart et al. 2017; the example data of BAGEL2): about 71,000 sgRNAs, 4 for each of 18,000 genes, a T0 sample and three replicates after 18 days. The analysis compares day 18 with T0 and is evaluated on the core essential and non-essential genes of hart_reference_genes. The results of every method, restricted to the reference genes, are precomputed.

hap1 <- read.csv(file.path(benchmark_dir, "crispr_hap1.csv"))
hap1_results <- split(select(hap1, -method), hap1$method)
evaluate_crispr <- function(result, alpha = 0.05) {
    merged <- left_join(hart_reference_genes, result, by = "Gene") %>%
        filter(!is.na(PValue)) %>%
        arrange(Gene)
    essential <- merged$Class == "core essential"
    called <- merged$FDR <= alpha
    hit <- essential[order(merged$PValue)]
    precision <- cumsum(hit) / seq_along(hit)
    tibble(
        `Essential called` = sum(called & essential),
        `Non-essential called` = sum(called & !essential),
        Recall = mean(called[essential]),
        AUPRC = sum(precision[hit]) / sum(hit)
    )
}
bind_rows(lapply(hap1_results, evaluate_crispr), .id = "method") %>%
    mutate(Method = labels[method]) %>%
    select(Method, everything(), -method) %>%
    arrange(desc(AUPRC)) %>%
    knitr::kable(digits = 3,
        caption = "HAP1 CRISPR screen: reference genes called at 5% FDR.")
HAP1 CRISPR screen: reference genes called at 5% FDR.
Method Essential called Non-essential called Recall AUPRC
BAGEL2 638 33 0.988 0.998
ScreenR Z-score (mean) 396 0 0.613 0.998
ScreenR CAMERA 513 0 0.803 0.998
ScreenR Z-score (edgeR) 459 0 0.711 0.997
limma-voom + camera 487 0 0.754 0.997
gscreend 591 3 0.915 0.996
edgeR gene-level QL 539 1 0.834 0.995
MAGeCK RRA 362 0 0.560 0.994
ScreenR FRY 2 0 0.003 0.983

This screen has strong effects and every method ranks the essential genes almost perfectly. The ScreenR tests make no false calls but, with only 4 sgRNAs per gene, are more conservative at 5% FDR than gscreend and BAGEL2 (which is trained on the reference genes). As with the K562 plasmid, FRY finds nothing with a single T0 sample.

Three functions help with CRISPR screens:

  • barcodes_per_gene() counts the sgRNAs of each gene. The hit-finding functions warn when their threshold on the number of barcodes (number_barcode, min_barcodes) excludes many genes, which happens with the shRNA-oriented defaults and 4 sgRNAs per gene, and suggest a value.
  • compute_screen_quality() and scale_by_reference(), shown above.
  • correct_crispr_bias() corrects the copy-number and proximity bias of CRISPR-Cas9 screens (sgRNAs cutting amplified regions deplete the cells whatever their target) by calling the CRISPRcleanR package (Iorio et al. 2018), which must be installed separately because it is not on CRAN or Bioconductor (BiocManager::install("francescojm/CRISPRcleanR")).

ScreenR example dataset

The example dataset has no ground truth, so we only measure the agreement between the methods on the day 4 comparison of treated and control samples.

data(count_table)
data(annotation_table)
counts <- count_table
colnames(counts) <- c(
    "Barcode", "Time1", "Time2", "Time3_TRT_A", "Time3_TRT_B", "Time3_TRT_C",
    "Time3_CTRL_A", "Time3_CTRL_B", "Time3_CTRL_C",
    "Time4_TRT_A", "Time4_TRT_B", "Time4_TRT_C",
    "Time4_CTRL_A", "Time4_CTRL_B", "Time4_CTRL_C"
)
counts <- filter(counts, Barcode != "*")
groups <- factor(c(
    "T1/T2", "T1/T2",
    rep("Time3_TRT", 3), rep("Time3_CTRL", 3),
    rep("Time4_TRT", 3), rep("Time4_CTRL", 3)
))
object <- create_screenr_object(
    table = counts, annotation = annotation_table, groups = groups
)
object <- compute_data_table(normalize_data(remove_all_zero_row(object)))
design <- create_design_matrix(object)
contrast <- create_contrast(design, "Time4_TRT", "Time4_CTRL")
metrics <- compute_metrics(object,
    control = "CTRL", treatment = "TRT", day = "Time4"
)
example <- list(
    ScreenR_CAMERA = find_camera_hit(object, design, contrast,
        fdr = 1, number_barcode = 1),
    ScreenR_FRY = find_roast_hit(object, design, contrast,
        method = "fry", fdr = 1, number_barcode = 1),
    ScreenR_Zscore_mean = find_gene_zscore_hit(metrics,
        fdr = 1, min_barcodes = 1, n_permutations = 20000, seed = 1)
)
example <- lapply(example, function(x) select(x, Gene, PValue, FDR))
mageck <- read.csv(file.path(benchmark_dir, "mageck_screenr_dataset.csv"))
example <- c(split(select(mageck, -method), mageck$method), example)
genes <- Reduce(intersect, lapply(example, function(x) as.character(x$Gene)))
ranks <- sapply(example, function(x) {
    rank(x$PValue[match(genes, as.character(x$Gene))])
})
colnames(ranks) <- labels[colnames(ranks)]
as.data.frame(as.table(cor(ranks, method = "spearman"))) %>%
    ggplot(aes(Var1, Var2, fill = Freq)) +
    geom_tile(colour = "white") +
    geom_text(aes(label = sprintf("%.2f", Freq)), size = 3) +
    scale_fill_gradient(low = "white", high = "#2166AC", limits = c(0, 1)) +
    labs(x = NULL, y = NULL, fill = "Spearman") +
    theme_bw() +
    theme(axis.text.x = element_text(angle = 45, hjust = 1))

vapply(example, function(x) sum(x$FDR <= 0.05), integer(1))
#>          MAGeCK_MLE          MAGeCK_RRA      ScreenR_CAMERA         ScreenR_FRY 
#>                   4                   0                   0                   9 
#> ScreenR_Zscore_mean 
#>                   0

The treatment effect in the example dataset is weak and few genes pass 5% FDR. The rankings of the ScreenR methods agree well with MAGeCK MLE and less with MAGeCK RRA.

Recommendations

  • Use find_gene_zscore_hit() or find_camera_hit() to call hits: they controlled the FDR in every scenario, ranked the hits as well as the best tools and run in seconds. They are conservative at a fixed FDR, so look at the ranking (and validate the top genes) when few genes pass the cut-off. Use statistic = "median" when off-target effects are a concern.
  • Use find_roast_hit() (ROAST or FRY) only with replicates in both groups.
  • Keep the default reference of find_gene_zscore_hit() unless the control genes are shown to behave like genes without effect.
  • Do not use the legacy rule of find_zscore_hit() to call hits: about half of its hits are false.
  • For dropout screens, check the screen with compute_screen_quality() first; for CRISPR-Cas9 screens, consider correcting copy-number bias with correct_crispr_bias().

Literature

  • Li W, Xu H, Xiao T et al. (2014). MAGeCK enables robust identification of essential genes from genome-scale CRISPR/Cas9 knockout screens. Genome Biol 15:554.
  • Li W, Köster J, Xu H et al. (2015). Quality control, modeling, and visualization of CRISPR screens with MAGeCK-VISPR. Genome Biol 16:281.
  • Morgens DW, Deans RM, Li A, Bassik MC (2016). Systematic comparison of CRISPR/Cas9 and RNAi screens for essential genes. Nat Biotechnol 34:634-636.
  • Hart T, Brown KR, Sircoulomb F, Rottapel R, Moffat J (2014). Measuring error rates in genomic perturbation screens: gold standards for human functional genomics. Mol Syst Biol 10:733.
  • Hart T, Tong AHY, Chan K et al. (2017). Evaluation and design of genome-wide CRISPR/SpCas9 knockout screens. G3 7:2719-2727.
  • Kim E, Hart T (2021). Improved analysis of CRISPR fitness screens and reduced off-target effects with the BAGEL2 gene essentiality classifier. Genome Med 13:2.
  • Imkeller K, Ambrosi G, Boutros M, Huber W (2020). gscreend: modelling asymmetric count ratios in CRISPR screens to decrease experiment size and improve phenotype detection. Genome Biol 21:53.
  • Dai Z, Sheridan JM, Gearing LJ et al. (2014). edgeR: a versatile tool for the analysis of shRNA-seq and CRISPR-Cas9 genetic screens. F1000Research 3:95.
  • Iorio F, Behan FM, Goncalves E et al. (2018). Unsupervised correction of gene-independent cell responses to CRISPR-Cas9 targeting. BMC Genomics 19:604.

Session info

sessionInfo()
#> R version 4.6.1 (2026-06-24)
#> Platform: x86_64-pc-linux-gnu
#> Running under: Ubuntu 26.04.1 LTS
#> 
#> Matrix products: default
#> BLAS:   /usr/lib/x86_64-linux-gnu/openblas-pthread/libblas.so.3 
#> LAPACK: /usr/lib/x86_64-linux-gnu/openblas-pthread/libopenblasp-r0.3.32.so;  LAPACK version 3.12.0
#> 
#> locale:
#>  [1] LC_CTYPE=C.UTF-8       LC_NUMERIC=C           LC_TIME=C.UTF-8       
#>  [4] LC_COLLATE=C.UTF-8     LC_MONETARY=C.UTF-8    LC_MESSAGES=C.UTF-8   
#>  [7] LC_PAPER=C.UTF-8       LC_NAME=C              LC_ADDRESS=C          
#> [10] LC_TELEPHONE=C         LC_MEASUREMENT=C.UTF-8 LC_IDENTIFICATION=C   
#> 
#> time zone: UTC
#> tzcode source: system (glibc)
#> 
#> attached base packages:
#> [1] stats     graphics  grDevices utils     datasets  methods   base     
#> 
#> other attached packages:
#> [1] ggplot2_4.0.3    tidyr_1.3.2      dplyr_1.2.1      ScreenR_1.15.16 
#> [5] BiocStyle_2.40.0
#> 
#> loaded via a namespace (and not attached):
#>  [1] SummarizedExperiment_1.42.0 gtable_0.3.6               
#>  [3] xfun_0.62                   bslib_0.12.0               
#>  [5] htmlwidgets_1.6.4           Biobase_2.72.0             
#>  [7] lattice_0.22-9              vctrs_0.7.3                
#>  [9] tools_4.6.1                 generics_0.1.4             
#> [11] stats4_4.6.1                tibble_3.3.1               
#> [13] pkgconfig_2.0.3             Matrix_1.7-5               
#> [15] RColorBrewer_1.1-3          S7_0.2.2                   
#> [17] desc_1.4.3                  S4Vectors_0.50.3           
#> [19] lifecycle_1.0.5             compiler_4.6.1             
#> [21] farver_2.1.2                stringr_1.6.0              
#> [23] textshaping_1.0.5           statmod_1.5.2              
#> [25] Seqinfo_1.2.0               htmltools_0.5.9            
#> [27] sass_0.4.10                 yaml_2.3.12                
#> [29] pillar_1.11.1               pkgdown_2.2.1              
#> [31] jquerylib_0.1.4             DelayedArray_0.38.2        
#> [33] cachem_1.1.0                limma_3.68.5               
#> [35] abind_1.4-8                 tidyselect_1.2.1           
#> [37] locfit_1.5-9.12             digest_0.6.39              
#> [39] stringi_1.8.9               purrr_1.2.2                
#> [41] bookdown_0.48               splines_4.6.1              
#> [43] labeling_0.4.3              fastmap_1.2.0              
#> [45] grid_4.6.1                  cli_3.6.6                  
#> [47] SparseArray_1.12.3          magrittr_2.0.5             
#> [49] patchwork_1.3.2             S4Arrays_1.12.1            
#> [51] withr_3.0.3                 edgeR_4.10.7               
#> [53] scales_1.4.0                rmarkdown_2.32             
#> [55] XVector_0.52.0              matrixStats_1.5.0          
#> [57] otel_0.2.0                  ragg_1.5.2                 
#> [59] evaluate_1.0.5              knitr_1.52                 
#> [61] GenomicRanges_1.64.0        IRanges_2.46.0             
#> [63] rlang_1.3.0                 glue_1.8.1                 
#> [65] BiocManager_1.30.27         BiocGenerics_0.58.1        
#> [67] jsonlite_2.0.0              R6_2.6.1                   
#> [69] MatrixGenerics_1.24.0       systemfonts_1.3.2          
#> [71] fs_2.1.0