Batch confounded with condition: can correction help?

batch effects
experimental design
edgeR
limma
RNA-seq
Batch confounded with condition in RNA-seq: when a batch term in the design can rescue the analysis, when it cannot, and why removeBatchEffect before DE fails.
Author

Pseudocount

Published

1 September 2026

Setup: packages and helper functions
suppressPackageStartupMessages({
  library(edgeR)
  library(limma)
  library(DESeq2)
  library(ggplot2)
})
stopifnot(packageVersion("edgeR") >= "4.0.0", packageVersion("limma") >= "3.58",
          packageVersion("DESeq2") >= "1.40", packageVersion("ggplot2") >= "3.4")
pct <- function(x, d = 0) sprintf("%.*f%%", d, 100 * x)
num <- function(x, d = 2) sprintf("%.*f", d, x)
int <- function(x) format(x, big.mark = ",", scientific = FALSE)
violet <- "#5a3fc0"; magenta <- "#d14fa6"; grey <- "#8a8797"
theme_pc <- theme_minimal(base_size = 12) +
  theme(plot.background = element_rect(fill = "white", colour = "white"),
        panel.grid.minor = element_blank(), legend.position = "bottom")

If batch and condition overlap in your RNA-seq experiment, can a batch correction step fix it? Only if batch and condition are not perfectly aligned (at least some batches hold both conditions), and only if the batch goes into the statistical model rather than being scrubbed out of the data beforehand. In the simulation below, a lopsided design analysed with limma and batch in the model keeps the false discovery proportion at 4.4%. Removing the batch first with limma::removeBatchEffect() and then testing the corrected values as if they were raw, same data and same limma test, pushes it to 34%. The honest analysis still pays for the lopsided layout in power: with edgeR, 68% of true changes are found, against 91% in a balanced layout. When batch and condition coincide completely, nothing can separate them.

First, two terms. A batch is any group of samples processed together, such as the same RNA extraction day or the same sequencing run. Two factors are confounded when they change together, so that the data cannot say which of them caused a difference.

The setup

Each simulated experiment has twelve samples: six controls and six treated, processed in two batches of six. Only the allocation of samples to batches changes between the three designs.

mk <- function(cond) data.frame(
  batch = factor(rep(c("A", "B"), each = 6)),
  cond  = factor(cond, levels = c("ctrl", "trt")))
designs <- list(
  balanced = mk(rep(c("ctrl", "trt", "ctrl", "trt"), each = 3)),
  partial  = mk(c(rep("ctrl", 5), "trt", "ctrl", rep("trt", 5))),
  full     = mk(rep(c("ctrl", "trt"), each = 6)))
lapply(designs, function(d) table(batch = d$batch, condition = d$cond))
$balanced
     condition
batch ctrl trt
    A    3   3
    B    3   3

$partial
     condition
batch ctrl trt
    A    5   1
    B    1   5

$full
     condition
batch ctrl trt
    A    6   0
    B    0   6
lay <- do.call(rbind, lapply(names(designs), function(nm) {
  d <- designs[[nm]]
  data.frame(design = nm, x = seq_len(12) + (d$batch == "B") * 0.8, cond = d$cond)
}))
lay$design <- factor(lay$design, levels = rev(names(designs)),
                     labels = rev(c("balanced", "partially confounded", "fully confounded")))
ggplot(lay, aes(x, design, fill = cond)) +
  geom_tile(width = 0.9, height = 0.7, colour = "white") +
  annotate("text", x = c(3.5, 10.3), y = 3.65, label = c("batch A", "batch B"),
           colour = "grey30", size = 3.8) +
  scale_fill_manual(values = c(ctrl = grey, trt = violet), name = "condition") +
  coord_cartesian(ylim = c(0.6, 3.8)) +
  labs(x = NULL, y = NULL) + theme_pc +
  theme(axis.text.x = element_blank(), panel.grid = element_blank())
Three rows of twelve tiles, split into batch A and batch B. In the balanced row each batch holds three control and three treated samples. In the partially confounded row batch A holds five controls and one treated sample and batch B the reverse. In the fully confounded row batch A is all control and batch B is all treated.
Figure 1: The three sample layouts. Each row is one experiment of twelve samples split over two batches; colour shows the condition.

The counts are negative binomial, the usual model for RNA-seq read counts, for 3,000 genes. The first 300 genes have a true change between conditions, a log2 fold change of plus or minus 1; the rest have none. Every gene also gets a batch shift, drawn once per gene from a normal distribution with a standard deviation of 0.7 on the log2 scale, so batch B moves some genes up and others down. Library sizes differ a little between samples.

Two assumptions carry the argument. The batch effect adds to the condition effect on the log scale (it is the same in control and treated samples), and the batch labels are known. These are exactly the assumptions of a ~ batch + condition model, so the model is given its best chance. The simulation is repeated 6 times; within each repeat, the three designs share the same genes and differ only in which samples sit in which batch.

G <- 3000
n_de <- G / 10
true_lfc <- 1                                          # log2 fold change of the DE genes
batch_sd <- 0.7                                        # spread of per-gene log2 batch effects
sim_genes <- function() {
  mu0 <- exp(rnorm(G, log(150), 1.3))                 # baseline mean count
  list(mu0 = mu0,
       phi = 0.03 + 0.3 / sqrt(mu0),                  # NB dispersion, higher for low counts
       lfc = c(sample(c(-1, 1) * true_lfc, n_de, TRUE), rep(0, G - n_de)),
       bfx = rnorm(G, 0, batch_sd))                    # log2 batch B effect, every gene
}
sim_counts <- function(g, d) {
  lf <- outer(g$lfc, d$cond == "trt") + outer(g$bfx, d$batch == "B")
  mu <- g$mu0 * 2^lf * rep(runif(12, 0.8, 1.25), each = G)
  m <- matrix(rnbinom(G * 12, mu = mu, size = 1 / g$phi), G, 12)
  storage.mode(m) <- "integer"
  m
}
is_de <- seq_len(G) <= n_de

Full confounding is not a statistics problem

In the fully confounded design, the column of the design matrix that marks batch B is identical to the column that marks the treated samples. The matrix has three columns but only two independent ones, and no fitting procedure can split one number into two.

X_full <- model.matrix(~ batch + cond, designs$full)
c(columns = ncol(X_full), rank = qr(X_full)$rank)
columns    rank 
      3       2 
nonEstimable(X_full)
[1] "condtrt"
set.seed(11)
cts <- sim_counts(sim_genes(), designs$full)
deseq_msg <- tryCatch(
  DESeqDataSetFromMatrix(cts, designs$full, design = ~ batch + cond),
  error = function(e) conditionMessage(e))
cat(deseq_msg)
the model matrix is not full rank, so the model cannot be fit as specified.
  One or more variables or interaction terms in the design formula are linear
  combinations of the others and must be removed.

  Please read the vignette section 'Model matrix not full rank':

  vignette('DESeq2')
edger_msg <- tryCatch({
  y <- normLibSizes(DGEList(cts))
  estimateDisp(y, X_full)
}, error = function(e) conditionMessage(e))
cat(edger_msg)
Design matrix not of full rank.  The following coefficients not estimable:
 condtrt
# removeBatchEffect without group: what is left of the condition difference?
lc_full <- cpm(DGEList(cts), log = TRUE, prior.count = 3)
rb_full <- removeBatchEffect(lc_full, batch = designs$full$batch,
                             design = matrix(1, ncol(lc_full), 1))
max_diff_full <- max(abs(rowMeans(rb_full[, designs$full$cond == "trt"]) -
                         rowMeans(rb_full[, designs$full$cond == "ctrl"])))
max_diff_full
[1] 7.105427e-15
# ... and with group: the warning is caught here so that it can be printed
caught <- character(0)
rb_group <- withCallingHandlers(
  removeBatchEffect(lc_full, batch = designs$full$batch, group = designs$full$cond),
  warning = function(w) {
    caught <<- c(caught, conditionMessage(w)); invokeRestart("muffleWarning")
  })
Coefficients not estimable: batch1 
caught
[1] "Partial NA coefficients for 3000 probe(s)"
max_change_group <- max(abs(rb_group - lc_full))
max_change_group
[1] 0

Both packages refuse, and they are right to. DESeq2 stops as soon as the data object is built, with a message that the model matrix is not full rank; edgeR stops at dispersion estimation and names the coefficient it cannot estimate. limma’s nonEstimable() gives the same answer before any data are involved.

The tempting workarounds do not help. Dropping batch and fitting ~ condition runs without complaint, but the “condition” coefficient is now condition plus batch: in the simulation, 86% of the genes called at 5% FDR (false discovery rate) have no true condition effect (Figure 2). Running removeBatchEffect() on the data first is no better. Without the group argument, it removes the batch B shift, which is also the whole treatment difference: the largest remaining difference between condition means is 7.1e-15 log2 units, zero up to rounding error. With group supplied, the function reports that the batch coefficient cannot be estimated, issues a warning (caught and printed above), and returns the data unchanged: the largest change to any value is 0. Testing that output with ~ condition is the first workaround again. Batch effects are widespread in high-throughput data, and when they line up with the biological groups they lead to wrong conclusions (Leek et al. 2010).

Partial confounding: put batch in the model

In the other two designs every batch holds both conditions, so the model can estimate the batch shift from within-condition comparisons and the condition effect from within-batch comparisons. The analysis is edgeR’s quasi-likelihood pipeline (Chen et al. 2016) in its current form. Filtering uses filterByExpr(), which according to its help page implements the filtering that paper describes informally, and normalisation uses normLibSizes() (a newer name for calcNormFactors()). glmQLFit() is called with robust = TRUE, as in the paper, and with legacy = FALSE, the new quasi-likelihood method that is the default from edgeR 4.2 onwards; in that mode glmQLFit() estimates the negative binomial dispersion itself, so no separate estimateDisp() step is needed. glmQLFTest() then tests the condition coefficient.

run_edger <- function(cts, X) {
  y <- DGEList(cts)
  keep <- filterByExpr(y, X)
  y <- normLibSizes(y[keep, , keep.lib.sizes = FALSE])
  fit <- glmQLFit(y, X, robust = TRUE, legacy = FALSE)
  res <- glmQLFTest(fit, coef = "condtrt")
  list(p = res$table$PValue, lfc = res$table$logFC, keep = keep)
}
score <- function(p, de) {
  called <- p.adjust(p, "BH") < 0.05
  c(fpr   = mean(p[!de] < 0.05),                         # nulls with raw p < 0.05
    fdp   = if (any(called)) mean(!de[called]) else 0,   # false discovery proportion
    power = mean(called[de]))                            # true DE genes called
}

The loop below runs every analysis in the post on each simulated experiment: the edgeR fits for this section, and the limma fits used in the next one.

n_rep <- 6
runs1 <- list(
  c("balanced", "~ batch + cond"), c("partial", "~ batch + cond"),
  c("partial", "~ cond"), c("full", "~ cond"))
set.seed(2026)
res1 <- list(); res2 <- list(); nullp <- list()
for (r in seq_len(n_rep)) {
  g <- sim_genes()
  cts_by <- lapply(designs, function(d) sim_counts(g, d))
  for (k in runs1) {
    d <- designs[[k[1]]]
    e <- run_edger(cts_by[[k[1]]], model.matrix(as.formula(k[2]), d))
    res1[[length(res1) + 1]] <- data.frame(rep = r, design = k[1], model = k[2],
                                           t(score(e$p, is_de[e$keep])))
  }
  # next section: limma-trend on log-CPM, batch in the model vs removeBatchEffect first
  for (nm in c("balanced", "partial")) {
    d <- designs[[nm]]
    X2 <- model.matrix(~ batch + cond, d); X1 <- model.matrix(~ cond, d)
    y <- DGEList(cts_by[[nm]])
    keep <- filterByExpr(y, X2)
    y <- normLibSizes(y[keep, , keep.lib.sizes = FALSE])
    lc <- cpm(y, log = TRUE, prior.count = 3)
    fits <- list(
      "batch in model" = eBayes(lmFit(lc, X2), trend = TRUE),
      "removeBatchEffect with group" = eBayes(lmFit(
        removeBatchEffect(lc, batch = d$batch, group = d$cond), X1), trend = TRUE),
      "removeBatchEffect without group" = eBayes(lmFit(
        removeBatchEffect(lc, batch = d$batch, design = matrix(1, ncol(lc), 1)),
        X1), trend = TRUE))
    for (m in names(fits)) {
      p <- fits[[m]]$p.value[, "condtrt"]
      lfc <- fits[[m]]$coefficients[, "condtrt"]
      de <- is_de[keep]
      res2[[length(res2) + 1]] <- data.frame(rep = r, design = nm, method = m,
        t(score(p, de)), lfc_de = median(abs(lfc[de])))
      if (nm == "partial") nullp[[length(nullp) + 1]] <- data.frame(method = m, p = p[!de])
    }
  }
}
res1 <- do.call(rbind, res1); res2 <- do.call(rbind, res2); nullp <- do.call(rbind, nullp)
agg1 <- aggregate(cbind(fpr, fdp, power) ~ design + model, res1, mean)
agg2 <- aggregate(cbind(fpr, fdp, power, lfc_de) ~ design + method, res2, mean)
a1 <- function(d, m, v) agg1[agg1$design == d & agg1$model == m, v]
pow_bal  <- a1("balanced", "~ batch + cond", "power")
pow_par  <- a1("partial", "~ batch + cond", "power")
fdp_bal  <- a1("balanced", "~ batch + cond", "fdp")
fdp_par  <- a1("partial", "~ batch + cond", "fdp")
fdp_par_nob <- a1("partial", "~ cond", "fdp")
fdp_full <- a1("full", "~ cond", "fdp")
# precision of the condition coefficient: diagonal of (X'X)^-1, in units of sigma^2
vf <- sapply(designs[1:2], function(d) {
  X <- model.matrix(~ batch + cond, d); solve(crossprod(X))["condtrt", "condtrt"] })
se_ratio <- sqrt(vf["partial"] / vf["balanced"])
n_equiv  <- 2 / vf["partial"]            # per-group n of an unconfounded design with equal precision
agg1
    design          model        fpr        fdp     power
1 balanced ~ batch + cond 0.05025914 0.03471029 0.9057948
2  partial ~ batch + cond 0.04960208 0.04436586 0.6817737
3     full         ~ cond 0.56486524 0.85723246 0.7711475
4  partial         ~ cond 0.29171683 0.60200705 0.7002244
lab1 <- c("balanced\n~ batch + cond", "partially confounded\n~ batch + cond",
          "partially confounded\n~ cond", "fully confounded\n~ cond")
nice <- c(balanced = "balanced", partial = "partially confounded", full = "fully confounded")
res1$analysis <- factor(paste0(nice[res1$design], "\n", res1$model), levels = rev(lab1))
long1 <- rbind(data.frame(res1[, c("analysis", "rep")], metric = "power", value = res1$power),
               data.frame(res1[, c("analysis", "rep")], metric = "false discovery proportion",
                          value = res1$fdp))
long1$metric <- factor(long1$metric, levels = c("power", "false discovery proportion"))
long1$ok <- grepl("batch", long1$analysis)
ggplot(long1, aes(value, analysis, colour = ok)) +
  geom_vline(data = data.frame(metric = factor("false discovery proportion",
             levels = levels(long1$metric)), x = 0.05),
             aes(xintercept = x), linetype = "dashed", colour = "grey40") +
  geom_blank(data = data.frame(metric = factor("power", levels = levels(long1$metric)),
                              value = c(0, 1), analysis = levels(long1$analysis)[1], ok = TRUE)) +
  geom_point(size = 2.4, alpha = 0.8, position = position_jitter(height = 0.12, seed = 1)) +
  facet_wrap(~ metric, scales = "free_x") +
  scale_colour_manual(values = c(`TRUE` = violet, `FALSE` = magenta), guide = "none") +
  labs(x = NULL, y = NULL) + theme_pc + theme(panel.spacing = unit(1.5, "lines"))
Two dot plots with four analyses each. With batch in the model, the balanced design has the highest power and the partially confounded design lower power, and both keep the false discovery proportion near the dashed 5% line. Leaving batch out in the partially confounded design, or analysing the fully confounded design with condition only, gives false discovery proportions far above the line.
Figure 2: Power (left) and false discovery proportion (right) for edgeR quasi-likelihood tests at a 5% FDR cut-off. Each dot is one simulated experiment; the dashed line marks the nominal 5%.

With ~ batch + condition, both designs keep the false discovery proportion close to the 5% target on average (3.5% balanced, 4.4% partially confounded). The price of the lopsided layout is power: 91% of the truly changed genes are found in the balanced design and 68% in the partially confounded one, from the same number of samples.

The loss follows from the design alone. The variance of the condition estimate is the residual variance times a factor that depends only on the design matrix (the matching diagonal entry of (X⊤X)−1(X^\top X)^{-1}). That factor is 0.33 for the balanced design and 0.60 for the partial one, so the standard error is 1.34 times larger. The only information about the treatment effect comes from comparisons inside a batch, and in batch A that means five controls against a single treated sample. Twelve samples laid out this way estimate the condition effect about as precisely as an unconfounded design with 3.3 samples per group.

Leaving batch out of the partial design is worse than a loss of power. Now the batch shift leaks into the condition estimate, and 60% of the calls are false.

Correcting first, testing later

A common shortcut is to “clean” the data once and then run the differential expression (DE) test on the result: compute log-CPM values (log counts per million), remove the batch with limma::removeBatchEffect(), and pass the corrected matrix to lmFit() with ~ condition as if it were ordinary data. The comparison uses limma (Ritchie et al. 2015) in its limma-trend form: lmFit() on the log-CPM values, then eBayes(trend = TRUE). The eBayes() help page recommends limma-trend for RNA-seq log-CPM values when library sizes are reasonably consistent, as they are here. Batch is either in the design matrix or removed beforehand. removeBatchEffect() is run two ways. With group = condition, the function fits batch and condition together and removes only the batch part, which is how its help page says to protect the effect of interest. Without group, the batch is estimated on its own.

a2 <- function(d, m, v) agg2[agg2$design == d & agg2$method == m, v]
m0 <- "batch in model"; mg <- "removeBatchEffect with group"; mn <- "removeBatchEffect without group"
fpr_model_par <- a2("partial", m0, "fpr"); fpr_g_par <- a2("partial", mg, "fpr")
fpr_n_par <- a2("partial", mn, "fpr")
fdp_model_par <- a2("partial", m0, "fdp"); fdp_g_par <- a2("partial", mg, "fdp")
pow_model_par <- a2("partial", m0, "power"); pow_g_par <- a2("partial", mg, "power")
pow_n_par <- a2("partial", mn, "power")
lfc_model_par <- a2("partial", m0, "lfc_de"); lfc_n_par <- a2("partial", mn, "lfc_de")
fpr_model_bal <- a2("balanced", m0, "fpr"); fpr_g_bal <- a2("balanced", mg, "fpr")
fpr_n_bal <- a2("balanced", mn, "fpr")
# with group: the naive fit uses (X1'X1)^-1 instead of the full-model variance factor
X1p <- model.matrix(~ cond, designs$partial)
t_inflate <- sqrt(vf["partial"] / solve(crossprod(X1p))["condtrt", "condtrt"])
# without group: share of the condition contrast left after regressing on batch alone
gp <- as.numeric(designs$partial$cond == "trt")
keep_share <- sum(resid(lm(gp ~ designs$partial$batch))^2) / sum((gp - mean(gp))^2)
# check on the last partial experiment: same estimate, one residual df more, smaller SE
lc_p <- cpm(normLibSizes(DGEList(cts_by$partial)), log = TRUE, prior.count = 3)
X2p <- model.matrix(~ batch + cond, designs$partial)
fit_full  <- lmFit(lc_p, X2p)
fit_naive <- lmFit(removeBatchEffect(lc_p, batch = designs$partial$batch,
                                     group = designs$partial$cond), X1p)
est_diff <- max(abs(fit_full$coefficients[, "condtrt"] - fit_naive$coefficients[, "condtrt"]))
df_full <- fit_full$df.residual[1]; df_naive <- fit_naive$df.residual[1]
agg2
    design                          method         fpr        fdp      power
1 balanced                  batch in model 0.047814449 0.02823014 0.90183235
2  partial                  batch in model 0.049665731 0.04375510 0.66448768
3 balanced    removeBatchEffect with group 0.060334592 0.04617665 0.91649528
4  partial    removeBatchEffect with group 0.162727008 0.34317329 0.89826435
5 balanced removeBatchEffect without group 0.060334592 0.04617665 0.91649528
6  partial removeBatchEffect without group 0.007460888 0.00000000 0.07046143
     lfc_de
1 0.9764442
2 0.9710172
3 0.9764442
4 0.9710172
5 0.9764442
6 0.5394540
c(t_inflate = t_inflate, keep_share = keep_share, est_diff = est_diff,
  df_full = df_full, df_naive = df_naive)
t_inflate.partial        keep_share          est_diff           df_full 
     1.341641e+00      5.555556e-01      3.774758e-15      9.000000e+00 
         df_naive 
     1.000000e+01 
nullp$method <- factor(nullp$method, levels = c(m0, mg, mn),
                       labels = c("batch in model", "removeBatchEffect\nwith group",
                                  "removeBatchEffect\nwithout group"))
ggplot(nullp, aes(p, fill = method == "batch in model")) +
  geom_histogram(breaks = seq(0, 1, 0.05), colour = "white", linewidth = 0.2) +
  facet_wrap(~ method, nrow = 1) +
  scale_fill_manual(values = c(`TRUE` = violet, `FALSE` = magenta), guide = "none") +
  labs(x = "p-value", y = "null genes") + theme_pc
Three histograms of null p-values. With batch in the limma model the histogram is flat. After removeBatchEffect with the group argument the histogram has a tall spike near zero. After removeBatchEffect without the group argument the histogram is tilted towards one, with few small p-values.
Figure 3: P-values of genes with no true condition effect in the partially confounded design, pooled over all simulated experiments. A correct test gives a flat histogram.

In the partially confounded design the two shortcuts fail in opposite directions. With batch in the model, 5.0% of the genes without a true effect have p < 0.05, as a valid test should. After removeBatchEffect() with group, that share is 16.3%, and 34% of the genes called at 5% FDR are false. The same analysis appears to find 90% of the true changes, against 66% with batch in the model; that extra power is bought with the inflated error rate, not with information.

The reason is bookkeeping. After the correction, the condition estimate is the same number the full model would give (the largest difference over all genes in the last simulated experiment is 3.8e-15), but the second fit does not know that a batch effect was estimated from these twelve samples. It computes the standard error of a plain six-against-six comparison, which is too small by the factor of 1.34 from the previous section, and it counts 10 residual degrees of freedom where the full model has 9. Each t-statistic grows by that factor, and a little more for the extra degree of freedom, so in the histogram the null p-values pile up near zero. Nygaard et al. (2016) describe this effect for batch adjustment methods that retain group differences when groups are unevenly spread across batches.

Without group, the failure is loss of signal. The batch estimate is simply the difference between batch means, and with five treated samples in batch B, part of that difference is the treatment. Subtracting it leaves 56% of the true condition difference; the median estimated absolute log2 fold change of the truly changed genes drops to 0.54, against 0.97 with batch in the model (the truth is 1). Only 7% of true changes are found, and just 0.7% of null genes reach p < 0.05.

In the balanced design, the two versions of removeBatchEffect() give identical results, because batch and condition are unrelated, and the damage is smaller: 6.0% of null genes reach p < 0.05, against 4.8% with batch in the model. The excess comes from the extra degree of freedom alone. Balance limits the harm of the shortcut but does not make it correct.

The limma documentation says as much. The help page for removeBatchEffect() (limma 3.66.0, the version used here) describes it as a tool for plotting and exploration, such as PCA, MDS plots or heatmaps. Its note states that it is not meant to prepare data for linear modelling with lmFit(), and that batch factors should go into the linear model so that the standard errors are assessed correctly.

What to do in practice

Fix the layout before the samples are processed. Spread every condition over every batch, as evenly as the sample numbers allow. If extraction or library preparation has to happen over several days, each day should include samples from each group. Check the layout from the sample sheet before sequencing:

table(samples$batch, samples$condition)          # every cell should be non-zero
X <- model.matrix(~ batch + condition, samples)
limma::nonEstimable(X)                           # NULL means every coefficient can be estimated

Analyse with batch in the design formula, in whichever package you use:

dds <- DESeqDataSetFromMatrix(counts, samples, design = ~ batch + condition)   # DESeq2
design <- model.matrix(~ batch + condition, samples)                            # edgeR, limma

Use removeBatchEffect() for pictures only, with the design so that the condition difference stays visible, and keep the uncorrected counts for testing:

y <- edgeR::normLibSizes(edgeR::DGEList(counts))
logcpm <- edgeR::cpm(y, log = TRUE, prior.count = 3)
design_cond <- model.matrix(~ condition, samples)
limma::plotMDS(limma::removeBatchEffect(logcpm, batch = samples$batch, design = design_cond))

If the design is already fully confounded, no software step can recover the condition effect; the honest report says so. Adding new samples of each condition to the other batch turns it into a partially confounded design, which the model can analyse. Re-processing the same biological samples does not count as new samples: repeated libraries of one sample are technical replicates and must be handled as such (for example summed with edgeR::sumTechReps()), not added as independent replicates.

References

Leek JT, Scharpf RB, Corrada Bravo H et al. 2010 Nature Reviews Genetics 11(10):733-739 (doi:10.1038/nrg2825)

Nygaard V, Rodland EA, Hovig E 2016 Biostatistics 17(1):29-39 (doi:10.1093/biostatistics/kxv027)

Ritchie ME, Phipson B, Wu D et al. 2015 Nucleic Acids Research 43(7):e47 (doi:10.1093/nar/gkv007)

Chen Y, Lun ATL, Smyth GK 2016 F1000Research 5:1438 (doi:10.12688/f1000research.8987.2)

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

R version 4.5.3 (2026-03-11)
Bioconductor 3.22
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