Log2 fold change cut-off after padj: what is the FDR?

DESeq2
edgeR
limma
FDR
RNA-seq
Is padj < 0.05 plus a log2 fold change cutoff a test of a 2-fold change? A simulation against lfcThreshold in DESeq2, glmTreat in edgeR and treat in limma.
Author

Pseudocount

Published

27 September 2026

Setup: packages and helper functions
suppressPackageStartupMessages({
  library(DESeq2)
  library(apeglm)
  library(edgeR)
  library(limma)
  library(ggplot2)
})
stopifnot(packageVersion("DESeq2") >= "1.40", packageVersion("apeglm") >= "1.20",
          packageVersion("edgeR") >= "4.0.0", packageVersion("limma") >= "3.56",
          packageVersion("ggplot2") >= "3.4.0")

pct <- function(x, d = 1) sprintf(paste0("%.", d, "f%%"), 100 * x)
num <- function(x) format(round(x), big.mark = ",", scientific = FALSE, trim = TRUE)
dec <- function(x, d = 2) sprintf(paste0("%.", d, "f"), x)
violet <- "#5a3fc0"; magenta <- "#d14fa6"; grey <- "#8e8c9c"; dark <- "#5f5c70"
theme_set(theme_minimal(base_size = 12) +
  theme(panel.grid.minor = element_blank(), strip.text = element_text(face = "bold"),
        plot.background = element_rect(fill = "white", colour = "white")))

thr <- 1          # the size claim: |true log2 fold change| > 1, a 2-fold change
alpha <- 0.05     # FDR level for every list in this post

# score one list of calls against the truth; a call is false for the size claim when the
# true change is at or below the threshold t, or when the estimate has the wrong sign
score <- function(call, est, true_lfc, t = thr) {
  call[is.na(call)] <- FALSE
  right <- abs(true_lfc) > t & sign(est) == sign(true_lfc)
  right[is.na(right)] <- FALSE
  c(n_call = sum(call),
    fdp = if (any(call)) mean(!right[call]) else 0,             # false for the size claim
    fdp_zero = if (any(call)) mean(true_lfc[call] == 0) else 0, # false for "it changed"
    power = sum(call & right) / sum(abs(true_lfc) > t))         # share of big changes found
}

# DESeq2's two threshold tests for |LFC| > thr, from the Wald estimate and its standard error
p_thr_2014 <- function(b, se) pmin(1, 2 * pnorm((abs(b) - thr) / se, lower.tail = FALSE))
p_thr_new <- function(b, se) pnorm(-abs(b) + thr, sd = se) + pnorm(-abs(b) - thr, sd = se)
# the p-values then go through DESeq2's own independent filtering and BH, as in results()
deseq_adjust <- function(res, p) {
  res$pvalue <- ifelse(is.na(res$pvalue), NA_real_, p)   # keep results()'s missing values
  DESeq2:::pvalueAdjustment(res, independentFiltering = TRUE, alpha = alpha,
                            pAdjustMethod = "BH")$padj
}

# defaults quoted in the text, read from the installed packages
res_thr_default <- eval(formals(DESeq2::results)$lfcThreshold)
res_alt_default <- eval(formals(DESeq2::results)$altHypothesis)[1]
deseq_version <- as.character(packageVersion("DESeq2"))
has_2014 <- "greaterAbs2014" %in% eval(formals(DESeq2::results)$altHypothesis)   # DESeq2 >= 1.44
treat_null_default <- eval(formals(edgeR::glmTreat)$null)
treat_lfc_default <- eval(formals(edgeR::glmTreat)$lfc)
limma_fc_default <- eval(formals(limma::treat)$fc)

You ran DESeq2, kept the genes with padj < 0.05, and then kept those with an absolute log2 fold change above 1. The list now reads as “genes that changed more than 2-fold, at 5% FDR”. Is that what it is? No. The adjusted p-value tested whether the change is zero; the cut-off was applied to an estimate afterwards, and nothing controls how many of the genes that pass it truly changed less than 2-fold. In the simulation below, with 3 samples per group, 11.8% of the genes on such a list had a true change of 2-fold or less, for a nominal 5%. Testing against the threshold with results(dds, lfcThreshold = 1) (DESeq2 1.44 or later) kept that share at 0.6%, and paid for it in genes found: it recovered 9.7% of the genes that truly changed more than 2-fold, against 65.8% for the cut.

The setup

Each simulated dataset has 2,500 genes measured in two groups, with 3, 5 or 10 samples per group. Counts are drawn from a negative binomial distribution (the usual model for RNA-seq counts, in which the variance grows faster than the mean), with mean counts spread over several orders of magnitude and a larger dispersion, the biological part of the variance, for weakly expressed genes.

The true changes are the assumption that matters. 40% of genes change, and the size of each change, as an absolute log2 fold change, is spread evenly between 0 and 2; the sign is random. So among the genes that change, half change by less than 2-fold and half by more, and there is as much true change just below the cut-off as just above it. A call counts as false when the gene’s true absolute log2 fold change is 1 or less. That is the claim a list filtered at 1 makes, and it is stricter than “the gene changed”. A later section varies how many of the changes sit below the cut-off, because that share decides how bad the problem is.

For each sample size I simulate 4 datasets and report averages over them, since the false discovery rate (FDR) is itself an average over experiments; the table at the end also gives the range over the datasets. The datasets of one sample size are rows of one count matrix, so they share their samples’ sequencing depths (size factors).

n_values <- c(3, 5, 10)      # samples per group
n_genes <- 2500              # genes per dataset
n_exp <- 4                   # datasets per sample size
p_change <- 0.4              # share of genes with a true change
lfc_max <- 2                 # |true log2 fold change| of changed genes ~ Uniform(0, lfc_max)

simulate <- function(n_rows, n_per, lfc_max) {
  mu0 <- exp(rnorm(n_rows, log(40), 1.6))                              # mean count
  phi <- (0.04 + 0.2 / sqrt(mu0 + 1)) * exp(rnorm(n_rows, 0, 0.3))    # dispersion
  changed <- runif(n_rows) < p_change
  lfc <- ifelse(changed, sample(c(-1, 1), n_rows, TRUE) * runif(n_rows, 0, lfc_max), 0)
  grp <- rep(0:1, each = n_per)
  size_factor <- exp(rnorm(2 * n_per, 0, 0.15))
  mu <- outer(mu0, size_factor) * 2^outer(lfc, grp)
  counts <- matrix(as.integer(rnbinom(length(mu), mu = mu, size = 1 / phi)), nrow = n_rows)
  list(counts = counts, lfc = lfc)
}

Each dataset goes through three packages, each run the way its documentation describes. DESeq2 runs DESeq() and results() (Love, Huber and Anders 2014), plus lfcShrink() with the apeglm prior (Zhu, Ibrahim and Love 2019). To save time, the datasets of one sample size are stacked and run through DESeq() together (they share the fitted dispersion trend), and each is then passed to results() and lfcShrink(), filtered, corrected and scored on its own. edgeR runs its quasi-likelihood recipe: filterByExpr(), normLibSizes(), glmQLFit() with legacy = FALSE set explicitly (so that older and newer edgeR releases fit the same model), then either glmQLFTest() or glmTreat(). limma runs voom on the same filtered and normalised counts, then lmFit() and treat(). DESeq2’s threshold test changed between versions (more on this below), so the chunk computes both versions of it by hand from the Wald estimate and its standard error, and passes the p-values through DESeq2’s own independent filtering and correction, the internal function results() uses. It also checks the hand computation against results().

# all the ways of calling "more than 2-fold" genes in one dataset
analyse <- function(dds_e, counts, group, design) {
  r0 <- results(dds_e, alpha = alpha)                                  # H0: LFC = 0
  b <- r0$log2FoldChange; se <- r0$lfcSE
  p_old <- p_thr_2014(b, se); p_new <- p_thr_new(b, se)
  # what results() itself returns for "greaterAbs" in the installed DESeq2 (checked below)
  rt <- results(dds_e, lfcThreshold = thr, altHypothesis = "greaterAbs", alpha = alpha)
  sh <- suppressMessages(                                              # prints a note on FSOS
    lfcShrink(dds_e, coef = 2, type = "apeglm", lfcThreshold = thr, quiet = TRUE))

  y <- DGEList(counts, group = group)
  keep <- filterByExpr(y, group = group)
  y <- normLibSizes(y[keep, , keep.lib.sizes = FALSE])
  fit <- glmQLFit(y, design, robust = TRUE, legacy = FALSE)
  qlf <- glmQLFTest(fit, coef = 2)$table
  tr1 <- glmTreat(fit, coef = 2, lfc = thr)$table
  tr1w <- glmTreat(fit, coef = 2, lfc = thr, null = "worst.case")$table
  tr15 <- glmTreat(fit, coef = 2, lfc = log2(1.5))$table
  v <- voom(y, design)
  lf <- lmFit(v, design)
  lt1 <- topTreat(treat(lf, lfc = thr), coef = 2, number = nrow(lf), sort.by = "none")
  lt15 <- topTreat(treat(lf, lfc = log2(1.5)), coef = 2, number = nrow(lf), sort.by = "none")

  full <- function(x) { out <- rep(NA_real_, length(keep)); out[keep] <- x; out }   # back to all genes
  bh <- function(p) full(p.adjust(p, "BH"))
  padj_new <- deseq_adjust(r0, p_new)
  # each entry: the calls, the estimate whose sign is claimed, the threshold of the claim
  calls <- list(
    deseq_zero  = list(r0$padj < alpha, b),
    deseq_post  = list(r0$padj < alpha & abs(b) > thr, b),
    deseq_ape   = list(r0$padj < alpha & abs(sh$log2FoldChange) > thr, sh$log2FoldChange),
    edger_post  = list(bh(qlf$PValue) < alpha & full(abs(qlf$logFC)) > thr, full(qlf$logFC)),
    deseq_thr   = list(padj_new < alpha, b),
    deseq_2014  = list(deseq_adjust(r0, p_old) < alpha, b),
    edger_thr   = list(bh(tr1$PValue) < alpha, full(tr1$logFC)),
    edger_wc    = list(bh(tr1w$PValue) < alpha, full(tr1w$logFC)),
    limma_thr   = list(full(lt1$adj.P.Val) < alpha, full(lt1$logFC)),
    edger_small = list(bh(tr15$PValue) < alpha & full(abs(tr15$logFC)) > thr, full(tr15$logFC)),
    limma_small = list(full(lt15$adj.P.Val) < alpha & full(abs(lt15$logFC)) > thr, full(lt15$logFC)),
    ape_sval    = list(sh$svalue < alpha, sh$log2FoldChange),
    edger_own15 = list(bh(tr15$PValue) < alpha, full(tr15$logFC), log2(1.5)),
    limma_own15 = list(full(lt15$adj.P.Val) < alpha, full(lt15$logFC), log2(1.5)))
  list(calls = calls,
       est = data.frame(mle = b, se = se, padj = r0$padj),
       # does the formula above reproduce results()? compare with the method it has as greaterAbs
       check = max(abs(rt$padj - deseq_adjust(r0, if (has_2014) p_new else p_old)), na.rm = TRUE),
       p_one = c(old = mean(p_old[!is.na(r0$pvalue)] == 1), new = mean(p_new[!is.na(r0$pvalue)] == 1)),
       filtered = mean(!keep))
}

methods <- c("deseq_post", "deseq_ape", "edger_post", "deseq_thr", "deseq_2014", "edger_thr",
             "edger_wc", "limma_thr", "edger_small", "limma_small", "ape_sval")

# simulate n_exp datasets, run DESeq() once on the stack, analyse and score each dataset
run_setting <- function(n_per, lfc_max, which = methods) {
  group <- factor(rep(c("A", "B"), each = n_per))
  design <- model.matrix(~ group)
  sim <- simulate(n_genes * n_exp, n_per, lfc_max)
  dds <- DESeqDataSetFromMatrix(sim$counts, data.frame(group = group), ~ group)
  dds <- DESeq(dds, quiet = TRUE)
  lapply(seq_len(n_exp), function(e) {
    i <- (e - 1) * n_genes + seq_len(n_genes)
    a <- analyse(dds[i, ], sim$counts[i, ], group, design)
    sc <- t(sapply(a$calls, function(x)
      score(x[[1]], x[[2]], sim$lfc[i], if (length(x) > 2) x[[3]] else thr)))
    list(scores = sc[unique(c(which, "deseq_zero", "edger_own15", "limma_own15")), ],
         est = cbind(a$est, true = sim$lfc[i], called = a$calls$deseq_post[[1]] %in% TRUE),
         check = a$check, p_one = a$p_one, filtered = a$filtered)
  })
}
set.seed(2026)
grid <- lapply(n_values, run_setting, lfc_max = lfc_max)
names(grid) <- n_values
avg <- lapply(grid, function(g) Reduce(`+`, lapply(g, `[[`, "scores")) / n_exp)
grid_fdp <- function(m, k) avg[[k]][m, "fdp"]
grid_pow <- function(m, k) avg[[k]][m, "power"]
grid_zero <- function(m, k) avg[[k]][m, "fdp_zero"]
grid_calls <- function(m, k) avg[[k]][m, "n_call"]
fdp_each <- function(m) unlist(lapply(grid, function(g) sapply(g, function(x) x$scores[m, "fdp"])))
fdp_range <- function(m, k) range(sapply(grid[[k]], function(x) x$scores[m, "fdp"]))
n_sets <- length(fdp_each("deseq_post"))
post_above <- sum(fdp_each("deseq_post") > alpha)
ape_above <- sum(fdp_each("deseq_ape") > alpha)
thr_tests <- c("deseq_thr", "deseq_2014", "edger_thr", "edger_wc", "limma_thr")
thr_max <- max(sapply(thr_tests, function(m) max(fdp_each(m))))
own15_max <- max(avg[[1]][c("edger_own15", "limma_own15"), "fdp"],
                 avg[[2]][c("edger_own15", "limma_own15"), "fdp"],
                 avg[[3]][c("edger_own15", "limma_own15"), "fdp"])
filtered_share <- mean(unlist(lapply(grid, function(g) sapply(g, `[[`, "filtered"))))
p_one_share <- rowMeans(do.call(cbind, lapply(grid, function(g) sapply(g, `[[`, "p_one"))))
check_max <- max(unlist(lapply(grid, function(g) sapply(g, `[[`, "check"))))
share_band <- p_change * thr / lfc_max            # genes with 0 < |true LFC| <= 1
share_zero_inside <- (1 - p_change) / (1 - p_change + share_band)
est5 <- grid[["5"]][[1]]$est
# where the false calls of the post hoc cut sit: true |LFC| of false calls
false_true <- abs(est5$true[est5$called & abs(est5$true) <= thr])
false_true_q <- quantile(false_true, 0.25)
false_se <- median(est5$se[est5$called & abs(est5$true) <= thr])   # their standard errors

The usual way: padj, then a cut

The first figure shows one dataset with 5 samples per group. Every point is a gene with padj < 0.05 from the standard DESeq2 test against zero, placed by its true log2 fold change (horizontal) and its estimate (vertical). The genes kept by the cut are the ones outside the horizontal lines. The false ones, for the size claim, are those kept although their true change lies between the vertical lines.

d <- est5[!is.na(est5$padj) & est5$padj < alpha, ]
d$class <- ifelse(!d$called, "removed by the cut",
                  ifelse(abs(d$true) > thr, "kept, true change > 2-fold",
                         "kept, true change <= 2-fold"))
d$class <- factor(d$class, levels = c("kept, true change > 2-fold",
                                      "kept, true change <= 2-fold", "removed by the cut"))
ggplot(d, aes(true, mle, colour = class)) +
  geom_vline(xintercept = c(-thr, thr), linetype = "dashed", colour = grey) +
  geom_hline(yintercept = c(-thr, thr), linetype = "dashed", colour = grey) +
  geom_point(size = 1.4, alpha = 0.8) +
  scale_colour_manual(values = setNames(c(violet, magenta, "#c9c7d4"), levels(d$class)),
                      name = NULL) +
  coord_cartesian(ylim = c(-3.5, 3.5)) +
  labs(x = "true log2 fold change", y = "estimated log2 fold change (DESeq2)") +
  theme(legend.position = "bottom", legend.direction = "vertical")
Scatter plot of estimated against true log2 fold change. Points lie along the diagonal. Dashed vertical lines at minus 1 and plus 1 mark the true 2-fold boundary and dashed horizontal lines mark the cut on the estimate. Violet points sit beyond both boundaries, grey points sit between the horizontal lines, and a band of pink points sits just inside the vertical lines but beyond the horizontal ones, where the estimate overshoots a true change slightly below 2-fold; a few pink points sit at a true change of zero.
Figure 1: True against estimated log2 fold change in one simulated dataset, five samples per group, for genes with padj below 0.05 in the DESeq2 test against zero. Pink: kept by the cut on the estimate although the true change is 2-fold or less. Violet: kept and truly larger. Grey: significant but removed by the cut.

The pink points are the problem. Nearly all of them are genes that did change, just by less than 2-fold, and whose estimate landed above the cut by chance. Three quarters of them had a true absolute log2 fold change above 0.65, so these are not wild errors: they are near misses on the wrong side of a line that the estimate cannot place precisely. The median standard error of their estimated log2 fold changes was 0.28, the same order as their distance from the cut-off.

Averaged over the datasets, the share of false calls for the size claim was 11.8%, 11.3% and 8.5% at 3, 5 and 10 samples per group, above the nominal 5% in every one of the 12 datasets. The same cut on edgeR’s quasi-likelihood results gave 13.7%, 11.4% and 7.8%, so this is not about one package.

The list is not wrong for the question its adjusted p-value answered. Counting a call as false only when the gene did not change at all, the plain padj < 0.05 list from DESeq2, before any cut, had a false share of 1.8%, 2.1% and 3.3% at the three sample sizes, under 5%. The trouble is only with the added claim about size. This is the flip side of the result in the post on independent filtering. There, the smallest true change was a log2 fold change of 0.5 and a call counted as false only if the gene had not changed at all, so a cut after the correction looked harmless. Here a call is false when the change is 2-fold or less, and 20% of all genes sit between zero and that cut-off.

Shrunken fold changes help a little

A common suggestion is to cut on shrunken fold changes instead, because shrinkage pulls noisy estimates towards zero (the post on log fold changes shows what shrinkage does to low-count genes). With apeglm’s estimates in place of the maximum likelihood ones, the false share fell to 8.1%, 8.4% and 6.1%, still above the nominal level in 11 of the 12 datasets. Shrinkage makes the estimates better; it does not turn a cut into a test.

Testing against the threshold

All three packages can test the size claim directly. The null hypothesis becomes “the absolute log2 fold change is at most the threshold” instead of “it is zero”, and the adjusted p-values then refer to that claim.

  • In DESeq2, results(dds, lfcThreshold = 1, altHypothesis = "greaterAbs"). The default lfcThreshold is 0, and "greaterAbs", absolute change greater than the threshold, is the default altHypothesis.
  • In edgeR, glmTreat(fit, coef = 2, lfc = 1) on a quasi-likelihood fit, based on the TREAT method of McCarthy and Smyth (2009).
  • In limma, treat(fit, lfc = 1) followed by topTreat().

What "greaterAbs" computes depends on your DESeq2. Before version 1.44 its p-value was twice the one-sided tail beyond the threshold, so every gene whose estimate lies inside the threshold got a p-value of 1. According to the DESeq2 NEWS file, version 1.44 replaced it with a method that “has more power than the original 2014-2023 method”: the p-value is now the sum of the two tails beyond plus and minus the threshold, the same construction limma’s treat() uses. The old formula is still available as altHypothesis = "greaterAbs2014", and version 1.50 adds a further option, "greaterAbsUPSHOT", which is not tested here. Both formulas appear below, labelled by name. This page was knitted with DESeq2 1.50.2, whose "greaterAbs" is the newer formula; the hand-computed adjusted p-values matched those of results() for it exactly.

show <- c(deseq_post = "padj + cut (DESeq2 MLE)", deseq_ape = "padj + cut (apeglm)",
          deseq_thr = "DESeq2 greaterAbs (1.44+)", deseq_2014 = "DESeq2 greaterAbs2014",
          edger_thr = "edgeR glmTreat", ape_sval = "apeglm s-value")
fp <- do.call(rbind, lapply(seq_along(n_values), function(k) data.frame(
  n = n_values[k], method = factor(unname(show), levels = show),
  value = unname(c(avg[[k]][names(show), "fdp"], avg[[k]][names(show), "power"])),
  measure = rep(c("realised FDR, size claim", "power, true change > 2-fold"), each = length(show)))))
fp$measure <- factor(fp$measure, levels = c("realised FDR, size claim", "power, true change > 2-fold"))
cols <- setNames(c(magenta, "#e89bd0", violet, "#a99be6", "#2f8f83", dark), show)
ggplot(fp, aes(n, value, colour = method, shape = method)) +
  geom_hline(data = data.frame(measure = factor(levels(fp$measure)[1], levels = levels(fp$measure)),
                               y = alpha), aes(yintercept = y),
             linetype = "dashed", colour = "#c9c7d4") +
  geom_line(linewidth = 0.8) + geom_point(size = 2.2) +
  facet_wrap(~ measure, scales = "free_y") +
  scale_colour_manual(values = cols, name = NULL) +
  scale_shape_manual(values = c(16, 16, 17, 2, 15, 1), name = NULL) +
  scale_x_continuous(breaks = n_values) +
  scale_y_continuous(labels = function(x) paste0(100 * x, "%")) +
  labs(x = "samples per group", y = NULL) +
  theme(legend.position = "bottom") +
  guides(colour = guide_legend(nrow = 2))
Two line charts against samples per group. Left, realised FDR: the two cut-off lines sit above a dashed line at the nominal level at every sample size, the two DESeq2 threshold lines and the glmTreat line sit on or just above zero, and the s-value line sits between zero and the dashed line. Right, power: the cut-off lines are highest, the s-value line is next, and the threshold tests are lowest, with the current DESeq2 formula above the 2014 one; all rise with sample size.
Figure 2: Realised FDR for the size claim (left) and share of the truly larger than 2-fold changes found (right), by samples per group, averaged over the simulated datasets. The dashed line on the left is the nominal level. limma treat is in the table at the end.

No threshold test in the table at the end had a false share above 3.2% in any of the 12 datasets. They are conservative because the p-value is computed for the least favourable case, a true change sitting exactly on the threshold (edgeR’s default interval null softens this, see below), while in this simulation 75% of the genes inside the threshold did not change at all. DESeq2’s 2014 formula, which doubles a one-sided tail, is more conservative still. The cost is power. At 3 samples per group, DESeq2’s current threshold test found 9.7% of the genes that truly changed more than 2-fold (the 2014 formula 7.1%), against 65.8% for the cut. At 10 samples per group the figures were 41.4% (37.0%) and 89.1%.

edgeR’s glmTreat() found more (20.4% and 47.2%), but that comparison mixes two kinds of null hypothesis. Its default, null = "interval", is described in its help page as somewhat less conservative than null = "worst.case", which the page calls closely analogous to limma’s treat(). With worst.case, glmTreat() found 6.7% and 38.0%, near limma’s 4.6% and 36.7%. So most of the gap is the choice of null, not the count model.

A gene needs an estimate well above the threshold to pass, because the test has to rule out every true value at or below it. This is why people who switch to lfcThreshold = 1 see their list shrink and conclude the tool is broken. The shorter list is the honest answer to the stricter question with this many samples.

What the edgeR and limma authors recommend

Both help pages warn that their threshold is not a fold-change cut-off, and both advise against thresholds as large as the one tested above. The treat() page says the threshold “should be chosen as a small value below which results should be ignored rather than as a target fold-change”, that “larger thresholds are usually overly conservative and counter productive”, and that it should be small enough that a worthwhile number of genes remain. The glmTreat() page recommends modest values such as log2(1.2) or log2(1.5), which “will usually cause most differentially expressed genes to have estimated fold-changes of 2-fold or greater, depending on the sample size and precision of the experiment”. The default is lfc = log2(1.2) in edgeR and fc = 1.2 in limma. The powers measured above, down to 4.6% for limma at 3 samples per group, are what the authors are warning about.

So there are two honest routes. If your claim really is “more than 2-fold”, testing at lfc = 1 is what the claim needs, and the short list is its price. The authors’ route is to test at a smaller threshold and scale the claim to match: tested against their own claim, a change larger than 1.5-fold, glmTreat() and treat() at log2(1.5) had false shares of at most 0.8% at every sample size (averaged over the datasets). What does not work is to test at log2(1.5) and then report the genes above 2-fold as a 2-fold result. Measured against the 2-fold claim, that list had a false share of 6.5%, 8.5% and 7.4% for edgeR, and 2.7%, 3.5% and 5.5% for limma, with 54.8% and 35.6% of the large changes found at 3 samples per group. Nothing in the procedure ties these numbers to 5%: they depend on the sample size, the package and, as the next section shows for the plain cut, on how many genes changed a little. As the help page says, the 2-fold estimates are a useful way to rank the list; they are not an FDR statement about 2-fold changes.

A middle route: s-values from apeglm

lfcShrink() with type = "apeglm" and lfcThreshold = 1 returns, in place of p-values, an s-value for each gene. The DESeq2 vignette describes it as an estimate of the “false sign or small” rate: among genes with an s-value at or below yours, the share whose true change has the wrong sign or is smaller than the threshold. That is the size claim. Calling genes with an s-value below 0.05, the false share was 2.8%, 2.7% and 3.0%, and the power 38.0%, 56.8% and 75.4%: between the threshold tests and the cut. The catch is that an s-value is an estimate from a fitted prior, not a test with a guarantee; the vignette notes that it is accurate only when the prior matches the real spread of effect sizes. The next section checks it in less friendly settings.

How much depends on the genes just below the cut

The false share of the post hoc cut depends on how many genes have a true change just below the cut-off, something you cannot see in your own data. The next chunk repeats the 5 samples per group case with the size of the changes spread up to 1.5 instead of 2 (more of them below 2-fold) and up to 3 (fewer).

lfc_alt <- c(1.5, 3)
set.seed(2027)
sens_methods <- c("deseq_post", "deseq_ape", "deseq_thr", "ape_sval")
sens <- lapply(lfc_alt, function(m) run_setting(n_values[2], m, which = sens_methods))
sens_avg <- lapply(sens, function(g) (Reduce(`+`, lapply(g, `[[`, "scores")) / n_exp)[sens_methods, ])
sens_tab <- rbind(sens_avg[[1]], avg[["5"]][sens_methods, ], sens_avg[[2]])
sens_tab <- data.frame(sens_tab, method = rownames(sens_tab),
                       lfc_max = rep(c(lfc_alt[1], lfc_max, lfc_alt[2]), each = length(sens_methods)))
sens_tab$below <- 1 / sens_tab$lfc_max          # share of changed genes at or below 2-fold
sf <- function(m, s) sens_tab[sens_tab$method == m & sens_tab$lfc_max == s, "fdp"]
sp <- function(m, s) sens_tab[sens_tab$method == m & sens_tab$lfc_max == s, "power"]
lab <- c(deseq_post = "padj + cut (DESeq2 MLE)", deseq_ape = "padj + cut (apeglm)",
         deseq_thr = "DESeq2 greaterAbs (1.44+)", ape_sval = "apeglm s-value")
sens_tab$label <- factor(lab[sens_tab$method], levels = lab)
ggplot(sens_tab, aes(below, fdp, colour = label, shape = label)) +
  geom_hline(yintercept = alpha, linetype = "dashed", colour = "#c9c7d4") +
  geom_line(linewidth = 0.8) + geom_point(size = 2.4) +
  scale_colour_manual(values = setNames(c(magenta, "#e89bd0", violet, dark), lab), name = NULL) +
  scale_shape_manual(values = c(16, 16, 17, 1), name = NULL) +
  scale_x_continuous(breaks = sort(unique(sens_tab$below)),
                     labels = function(x) paste0(round(100 * x), "%")) +
  scale_y_continuous(labels = function(x) paste0(100 * x, "%")) +
  labs(x = "share of changed genes with a true change of 2-fold or less", y = "realised FDR") +
  theme(legend.position = "bottom") +
  guides(colour = guide_legend(nrow = 2))
Line chart with four lines of realised FDR against the share of changes that are 2-fold or less. The two cut-off lines rise steeply from left to right and end far above a dashed line at the nominal level. The s-value line rises slowly towards the dashed line. The DESeq2 threshold-test line stays near zero.
Figure 3: Realised FDR for the size claim with five samples per group, against the share of changed genes whose true change is 2-fold or less, averaged over the simulated datasets. The dashed line is the nominal level.

With two thirds of the changes below 2-fold, the cut’s false share rose to 24.6% (17.9% with apeglm). With one third, it came down to 5.9% (4.2%), close to the nominal level: when fewer genes change by a little, a cut does less harm. DESeq2’s threshold test (the current formula) made no false calls for the size claim in either setting, but with two thirds of the changes below 2-fold it called only 3 genes per dataset on average and found 1.0% of the larger changes, so that result rests on very few calls (see the table). The s-value’s false share also rose with the share of small changes, from 2.4% to 4.9%, which puts it close to the nominal level in the least friendly setting; with a spread of effect sizes that its prior fits worse, it could go above.

sens_lab <- c(deseq_post = "padj + |MLE LFC| > 1", deseq_ape = "padj + |apeglm LFC| > 1",
              deseq_thr = "lfcThreshold = 1, greaterAbs (1.44+)", ape_sval = "apeglm s-value < 0.05")
knitr::kable(data.frame(
  `changes at or below 2-fold` = pct(sens_tab$below, 0),
  analysis = sens_lab[sens_tab$method],
  `genes called` = num(sens_tab$n_call),
  `realised FDR, size claim` = pct(sens_tab$fdp),
  `power, change > 2-fold` = pct(sens_tab$power),
  check.names = FALSE, row.names = NULL), align = "rlrrr")
changes at or below 2-fold analysis genes called realised FDR, size claim power, change > 2-fold
67% padj + |MLE LFC| > 1 294 24.6% 67.1%
67% padj + |apeglm LFC| > 1 216 17.9% 53.6%
67% lfcThreshold = 1, greaterAbs (1.44+) 3 0.0% 1.0%
67% apeglm s-value < 0.05 86 4.9% 24.6%
50% padj + |MLE LFC| > 1 454 11.3% 79.9%
50% padj + |apeglm LFC| > 1 412 8.4% 74.9%
50% lfcThreshold = 1, greaterAbs (1.44+) 117 0.0% 23.2%
50% apeglm s-value < 0.05 294 2.7% 56.8%
33% padj + |MLE LFC| > 1 614 5.9% 87.1%
33% padj + |apeglm LFC| > 1 588 4.2% 84.7%
33% lfcThreshold = 1, greaterAbs (1.44+) 364 0.0% 54.8%
33% apeglm s-value < 0.05 556 2.4% 81.7%

So the error of the post hoc cut is not a fixed penalty you could correct for. It grows with the share of genes that changed a little, which is what the experiment was meant to find out.

The same check in Python

py <- read.csv("lfc_threshold_check-results.csv", stringsAsFactors = FALSE)
pyv <- setNames(py$value, py$name)
py_num <- function(k) as.numeric(pyv[[k]])

PyDESeq2 (Muzellec et al. 2023) has a threshold test too. In version 0.5.4, DeseqStats() takes lfc_null, the threshold on the log2 scale, and alt_hypothesis; the script checks that both names exist in the installed version. Its "greaterAbs" p-value, in the package source, is the 2014 formula (twice the one-sided tail), so it matches greaterAbs2014 in current DESeq2, not DESeq2’s current "greaterAbs". Set both: with alt_hypothesis left at its default of None, the Wald test asks whether the fold change equals lfc_null, not whether it is larger in absolute value. The script simulates 3 datasets of 2,500 genes with 5 samples per group the same way as above, with numpy’s random generator, so the draws differ from the R run. The cut after padj gave a false share of 11.9% for the size claim (power 78.8%); the threshold test gave 0.4% (power 17.1%), in line with the greaterAbs2014 row of the R table at 5 samples per group.

# PyDESeq2: a fold-change cut-off after padj versus a test against the threshold.
# Simulates negative binomial counts the same way as the R code in the post (with
# numpy's random generator, so the datasets are not identical to the R ones), runs
# PyDESeq2 once per dataset and scores two ways of calling "more than 2-fold" genes.
import csv
import importlib.metadata as md
import inspect

import numpy as np
import pandas as pd
from pydeseq2.dds import DeseqDataSet
from pydeseq2.ds import DeseqStats

n_per, n_genes, n_exp = 5, 2500, 3      # samples per group, genes per dataset, datasets
p_change, lfc_max = 0.4, 2.0            # share of genes that change; |true LFC| ~ U(0, lfc_max)
threshold, alpha = 1.0, 0.05            # size claim |log2 FC| > 1; FDR level

# the argument names used below, read from the installed version
stats_args = inspect.signature(DeseqStats.__init__).parameters
assert "lfc_null" in stats_args and "alt_hypothesis" in stats_args

rng = np.random.default_rng(2026)


def simulate():
    mu0 = np.exp(rng.normal(np.log(40), 1.6, n_genes))
    phi = (0.04 + 0.2 / np.sqrt(mu0 + 1)) * np.exp(rng.normal(0, 0.3, n_genes))
    changed = rng.random(n_genes) < p_change
    lfc = np.where(changed, rng.choice([-1, 1], n_genes) * rng.uniform(0, lfc_max, n_genes), 0)
    group = np.repeat([0, 1], n_per)
    sf = np.exp(rng.normal(0, 0.15, 2 * n_per))
    mu = mu0[:, None] * sf[None, :] * 2.0 ** (lfc[:, None] * group[None, :])
    size = 1 / phi[:, None]
    counts = rng.negative_binomial(size, size / (size + mu))   # mean mu, dispersion phi
    return counts.T, lfc, group


def score(call, lfc):
    big = np.abs(lfc) > threshold
    n = int(call.sum())
    fdp = float(np.mean(~big[call])) if n else 0.0
    return n, fdp, float((call & big).sum() / big.sum())


rows = {"post": [], "test": []}
for e in range(n_exp):
    counts, lfc, group = simulate()
    genes = [f"g{i}" for i in range(n_genes)]
    meta = pd.DataFrame({"condition": np.where(group == 1, "B", "A")},
                        index=[f"s{i}" for i in range(2 * n_per)])
    dds = DeseqDataSet(counts=pd.DataFrame(counts, index=meta.index, columns=genes),
                       metadata=meta, design="~condition", quiet=True, n_cpus=1)
    dds.deseq2()
    # the usual route: test against zero, then cut on the estimated fold change
    st0 = DeseqStats(dds, contrast=["condition", "B", "A"], alpha=alpha, quiet=True, n_cpus=1)
    st0.summary()
    r0 = st0.results_df
    post = ((r0["padj"] < alpha) & (r0["log2FoldChange"].abs() > threshold)).to_numpy()
    # the test against the threshold: lfc_null is on the log2 scale
    st1 = DeseqStats(dds, contrast=["condition", "B", "A"], alpha=alpha, quiet=True, n_cpus=1,
                     lfc_null=threshold, alt_hypothesis="greaterAbs")
    st1.summary()
    test = (st1.results_df["padj"] < alpha).to_numpy()
    rows["post"].append(score(post, lfc))
    rows["test"].append(score(test, lfc))

out = [("n_per_group", n_per), ("n_genes", n_genes), ("n_datasets", n_exp)]
for k, v in rows.items():
    a = np.array(v)
    out += [(f"calls_{k}", a[:, 0].mean()), (f"fdr_{k}", a[:, 1].mean()),
            (f"fdr_{k}_max", a[:, 1].max()), (f"power_{k}", a[:, 2].mean())]
out += [("pydeseq2_version", md.version("pydeseq2")), ("numpy_version", md.version("numpy"))]

with open("lfc_threshold_check-results.csv", "w", newline="") as f:
    w = csv.writer(f)
    w.writerow(["name", "value"])
    w.writerows(out)

What to do in practice

Decide before looking at the results whether the size of the change is part of your claim.

If it is, put the threshold into the test and state the threshold with the list. The edgeR and limma authors’ advice is to use a modest threshold, such as log2(1.2) or log2(1.5), and make the claim at that size (“changed by more than 1.5-fold”); sorting that list by fold change is fine, as long as a 2-fold subset is described as a ranking choice, not a result. If the claim has to be “more than 2-fold”, test at 1 and expect a short list, longer with more samples. In DESeq2, name the altHypothesis and the DESeq2 version in your methods, since "greaterAbs" changed in 1.44.

If the size is not part of your claim, test against zero and report the genes at your FDR. You can still sort or filter that list by fold change for follow-up, but say that the fold-change filter is a choice of which genes to look at, not a second statistical result.

# DESeq2: genes whose absolute log2 fold change is greater than 1, at 5% FDR
# ("greaterAbs" is the newer formula from DESeq2 1.44; "greaterAbs2014" gives the old one)
res_thr <- results(dds, lfcThreshold = 1, altHypothesis = "greaterAbs", alpha = 0.05)
summary(res_thr)

# DESeq2 + apeglm: shrunken estimates and s-values for the same claim
res_ape <- lfcShrink(dds, coef = 2, type = "apeglm", lfcThreshold = 1)
subset(res_ape, svalue < 0.05)

# edgeR: quasi-likelihood fit, then a test against a modest threshold (claim: > 1.5-fold)
keep <- filterByExpr(y, group = group)
y <- normLibSizes(y[keep, , keep.lib.sizes = FALSE])
fit <- glmQLFit(y, design, robust = TRUE, legacy = FALSE)
tr <- glmTreat(fit, coef = 2, lfc = log2(1.5))      # lfc = 1 for a 2-fold claim
topTags(tr, p.value = 0.05, n = nrow(y))

# limma-voom, same claim
v <- voom(y, design)
fit_l <- lmFit(v, design)
topTreat(treat(fit_l, lfc = log2(1.5)), coef = 2, p.value = 0.05, number = nrow(v))

Three smaller points. In this simulation edgeR’s filterByExpr() removed 19% of genes before testing, and those count as missed in the power column of the table below; DESeq2 filters differently, inside results(). And the threshold tests inherit the usual limits of the underlying test, so a gene with very few reads and an extreme estimate can still pass them; expression filtering matters as much as before. Finally, a threshold test changes the p-value histogram. With DESeq2’s 2014 formula every gene whose estimate lies inside the threshold gets a p-value of exactly 1 (78% of genes here); with the current formula they all get a p-value of at least 0.5, since the tail beyond the nearer boundary alone is at least a half. The shapes described in the post on p-value histograms are for tests against zero and do not apply to either.

All the analyses side by side, averaged over the 4 datasets per sample size:

lab_all <- c(deseq_post = "DESeq2, padj + |MLE LFC| > 1",
             deseq_ape = "DESeq2, padj + |apeglm LFC| > 1",
             edger_post = "edgeR QL, FDR + |logFC| > 1",
             deseq_thr = "DESeq2, lfcThreshold = 1, greaterAbs (1.44+)",
             deseq_2014 = "DESeq2, lfcThreshold = 1, greaterAbs2014",
             edger_thr = "edgeR, glmTreat lfc = 1",
             edger_wc = "edgeR, glmTreat lfc = 1, worst.case",
             limma_thr = "limma, treat lfc = 1",
             edger_small = "edgeR, glmTreat lfc = log2(1.5) + |logFC| > 1",
             limma_small = "limma, treat lfc = log2(1.5) + |logFC| > 1",
             ape_sval = "apeglm s-value < 0.05 (lfcThreshold = 1)")
knitr::kable(do.call(rbind, lapply(seq_along(n_values), function(k) data.frame(
  `per group` = n_values[k], analysis = lab_all[methods],
  `genes called` = num(avg[[k]][methods, "n_call"]),
  `realised FDR, size claim` = pct(avg[[k]][methods, "fdp"]),
  `range over datasets` = sapply(methods, function(m)
    paste(pct(fdp_range(m, k)), collapse = " to ")),
  `power, change > 2-fold` = pct(avg[[k]][methods, "power"]),
  check.names = FALSE, row.names = NULL))), align = "rlrrrr")
per group analysis genes called realised FDR, size claim range over datasets power, change > 2-fold
3 DESeq2, padj + |MLE LFC| > 1 383 11.8% 10.5% to 13.3% 65.8%
3 DESeq2, padj + |apeglm LFC| > 1 338 8.1% 7.6% to 8.7% 60.5%
3 edgeR QL, FDR + |logFC| > 1 405 13.7% 11.9% to 14.7% 68.1%
3 DESeq2, lfcThreshold = 1, greaterAbs (1.44+) 50 0.6% 0.0% to 2.3% 9.7%
3 DESeq2, lfcThreshold = 1, greaterAbs2014 36 0.0% 0.0% to 0.0% 7.1%
3 edgeR, glmTreat lfc = 1 105 0.8% 0.0% to 2.2% 20.4%
3 edgeR, glmTreat lfc = 1, worst.case 34 0.8% 0.0% to 3.2% 6.7%
3 limma, treat lfc = 1 23 0.0% 0.0% to 0.0% 4.6%
3 edgeR, glmTreat lfc = log2(1.5) + |logFC| > 1 300 6.5% 5.4% to 7.9% 54.8%
3 limma, treat lfc = log2(1.5) + |logFC| > 1 187 2.7% 1.9% to 3.5% 35.6%
3 apeglm s-value < 0.05 (lfcThreshold = 1) 200 2.8% 1.4% to 4.0% 38.0%
5 DESeq2, padj + |MLE LFC| > 1 454 11.3% 10.7% to 12.5% 79.9%
5 DESeq2, padj + |apeglm LFC| > 1 412 8.4% 6.9% to 9.3% 74.9%
5 edgeR QL, FDR + |logFC| > 1 407 11.4% 10.8% to 13.1% 71.6%
5 DESeq2, lfcThreshold = 1, greaterAbs (1.44+) 117 0.0% 0.0% to 0.0% 23.2%
5 DESeq2, lfcThreshold = 1, greaterAbs2014 93 0.0% 0.0% to 0.0% 18.4%
5 edgeR, glmTreat lfc = 1 172 0.6% 0.0% to 1.2% 33.9%
5 edgeR, glmTreat lfc = 1, worst.case 108 0.0% 0.0% to 0.0% 21.4%
5 limma, treat lfc = 1 94 0.0% 0.0% to 0.0% 18.7%
5 edgeR, glmTreat lfc = log2(1.5) + |logFC| > 1 369 8.5% 7.7% to 9.1% 66.9%
5 limma, treat lfc = log2(1.5) + |logFC| > 1 280 3.5% 2.6% to 4.1% 53.6%
5 apeglm s-value < 0.05 (lfcThreshold = 1) 294 2.7% 2.0% to 3.1% 56.8%
10 DESeq2, padj + |MLE LFC| > 1 474 8.5% 6.8% to 10.1% 89.1%
10 DESeq2, padj + |apeglm LFC| > 1 446 6.1% 4.4% to 7.9% 85.9%
10 edgeR QL, FDR + |logFC| > 1 386 7.8% 5.5% to 9.4% 73.2%
10 DESeq2, lfcThreshold = 1, greaterAbs (1.44+) 202 0.0% 0.0% to 0.0% 41.4%
10 DESeq2, lfcThreshold = 1, greaterAbs2014 180 0.0% 0.0% to 0.0% 37.0%
10 edgeR, glmTreat lfc = 1 230 0.2% 0.0% to 1.0% 47.2%
10 edgeR, glmTreat lfc = 1, worst.case 185 0.0% 0.0% to 0.0% 38.0%
10 limma, treat lfc = 1 179 0.0% 0.0% to 0.0% 36.7%
10 edgeR, glmTreat lfc = log2(1.5) + |logFC| > 1 381 7.4% 5.1% to 9.2% 72.6%
10 limma, treat lfc = log2(1.5) + |logFC| > 1 354 5.5% 3.1% to 7.4% 68.9%
10 apeglm s-value < 0.05 (lfcThreshold = 1) 378 3.0% 2.0% to 3.9% 75.4%

References

Love MI, Huber W, Anders S 2014 Genome Biology 15(12):550 (doi:10.1186/s13059-014-0550-8)

Zhu A, Ibrahim JG, Love MI 2019 Bioinformatics 35(12):2084-2092 (doi:10.1093/bioinformatics/bty895)

McCarthy DJ, Smyth GK 2009 Bioinformatics 25(6):765-771 (doi:10.1093/bioinformatics/btp053)

Muzellec B, Telenczuk M, Cabeli V et al. 2023 Bioinformatics 39(9):btad547 (doi:10.1093/bioinformatics/btad547)

The numbers on this page were computed with the versions below.

R version 4.5.3 (2026-03-11)
Bioconductor 3.22
apeglm 1.32.0, Biobase 2.70.0, BiocGenerics 0.56.0, DESeq2 1.50.2,
edgeR 4.8.2, generics 0.1.4, GenomicRanges 1.62.1, ggplot2 4.0.3,
IRanges 2.44.0, limma 3.66.0, MatrixGenerics 1.22.0, matrixStats 1.5.0,
S4Vectors 0.48.1, Seqinfo 1.0.0, SummarizedExperiment 1.40.0
Python part: pydeseq2 0.5.4, numpy 2.4.4