03 · Multi-Condition ANOVA

Welch’s one-way ANOVA with planned contrasts and Holm correction

infection
statistics
ANOVA

Compares multiple cell types or treatment conditions across one or more viral/experimental conditions. Uses Welch’s ANOVA (robust to unequal variances) on log₁₀-transformed data, with planned pairwise contrasts against a reference group and Holm multiple-testing correction.

Overview

Item Details
Input Wide-format CSV per condition (columns = cell types, rows = replicates)
Key packages tidyverse, car, broom
Statistics Shapiro-Wilk, Bartlett, Levene, Welch one-way ANOVA, Welch t-tests
Correction Holm (FWER) by default; any p.adjust() method via P_ADJUST_METHOD
Output Diagnostic table · ANOVA result · Contrast table with fold-changes
Download template.Rmd
Tip

When to use this template: You have measurements (e.g., viral titres, protein levels) across several cell types or treatment groups, and you need to test whether a reference group differs from the others, accounting for potential heteroscedasticity.

Back to Gallery Open Template File

Note

Analytical Purpose: Performs multi-condition Welch’s One-Way ANOVA with planned contrasts (reference group vs. all experimental conditions) and a configurable multiplicity correction (P_ADJUST_METHOD, Holm-Bonferroni by default). Designed for viral titres and NGS read count comparisons where heteroscedasticity is common.

Code
## ── USER CONFIGURATION ──────────────────────────────────────────────────────
#
# CONDITION_FILES: Named character vector of CSV files to load.
#   Names become the "condition" label in all outputs.
#   Each CSV must be wide-format: one column per cell type / sample group,
#   with replicate runs stored as "CellType.1", "CellType.2" … suffix columns.
#
CONDITION_FILES <- c(
  "Condition_A" = "data/Condition_A.csv",
  "Condition_B" = "data/Condition_B.csv",
  "Condition_C" = "data/Condition_C.csv"
)

# FOCAL_CONDITION: Which condition to run the planned-contrast analysis on.
#   Must match one of the names() of CONDITION_FILES exactly.
FOCAL_CONDITION <- "Condition_A"

# REFERENCE_GROUP: The cell type / sample group used as the reference in
#   planned comparisons (i.e., all other groups are tested against this one).
REFERENCE_GROUP <- "CellType_A"

# LOG_TRANSFORM: Apply log10 before analysis? Almost always TRUE for viral
#   titres and NGS read-count data.
LOG_TRANSFORM <- TRUE

# PSEUDOCOUNT: Added to every value before log10 so that true zeros (below the
#   limit of detection, or zero read counts) do not become -Inf.
#   -Inf is NOT NA: it slips through every is.na() guard and then poisons
#   shapiro.test() (which errors), oneway.test() and t.test().
#   Choose it on the scale of your data: 1 for read counts, or the assay's
#   limit of detection for titres. Set to 0 only if you are certain no value
#   can ever be zero — the template checks and stops if one is.
PSEUDOCOUNT <- 1

# ALPHA: Significance threshold for all tests.
ALPHA <- 0.05

# P_ADJUST_METHOD: Multiplicity correction for the planned contrasts.
#   Any method accepted by p.adjust() ("holm", "BH", "bonferroni", ...).
P_ADJUST_METHOD <- "holm"

## ────────────────────────────────────────────────────────────────────────────
Code
library(tidyverse)
library(car)
library(broom)
library(knitr)

Load Data

Code
# ── Input validation ─────────────────────────────────────────────────────────
missing_files <- CONDITION_FILES[!file.exists(CONDITION_FILES)]
if (length(missing_files) > 0) {
  stop("CONDITION_FILES not found: ",
       paste(sQuote(missing_files), collapse = ", "),
       ". Run `Rscript data/simulate_data.R` from the template directory to ",
       "regenerate the demo data, or point CONDITION_FILES at your own CSVs.")
}
if (!FOCAL_CONDITION %in% names(CONDITION_FILES)) {
  stop("FOCAL_CONDITION (", sQuote(FOCAL_CONDITION), ") is not one of ",
       "names(CONDITION_FILES): ",
       paste(sQuote(names(CONDITION_FILES)), collapse = ", "))
}
stopifnot(PSEUDOCOUNT >= 0, P_ADJUST_METHOD %in% p.adjust.methods)

clean_one <- function(path, condition_label) {
  raw <- read_csv(path, show_col_types = FALSE) %>%
    select(-matches("^Unnamed"))                           # drop blank index cols

  if (ncol(raw) == 0) {
    stop("No usable columns in ", sQuote(path),
         " — expected one column per cell type / sample group.")
  }

  long <- raw %>%
    pivot_longer(
      everything(),
      names_to  = "cell_type_raw",
      values_to = "value"
    ) %>%
    filter(!is.na(value))

  if (!is.numeric(long$value)) {
    stop("Non-numeric values in ", sQuote(path),
         ". Every column must hold measurements only (no text, no units).")
  }
  if (any(long$value < 0)) {
    stop("Negative values in ", sQuote(path),
         " — these cannot be log-transformed. Inspect the file before proceeding.")
  }
  # log10(0) is -Inf, and -Inf is not NA, so it passes every is.na() guard and
  # then breaks shapiro.test()/oneway.test()/t.test(). Refuse rather than let
  # that happen silently.
  if (LOG_TRANSFORM && PSEUDOCOUNT == 0 && any(long$value == 0)) {
    stop(sum(long$value == 0), " zero value(s) in ", sQuote(path),
         " with LOG_TRANSFORM = TRUE and PSEUDOCOUNT = 0. ",
         "Set PSEUDOCOUNT to a positive number (e.g. 1, or your limit of detection).")
  }

  long %>%
    mutate(
      cell_type = str_remove(cell_type_raw, "\\.[0-9]+$") %>%   # strip ".1"
                  str_remove("\\.+$"),                           # strip trailing dots
      replicate = if_else(
        str_detect(cell_type_raw, "\\.[0-9]+$"),
        as.integer(str_extract(cell_type_raw, "\\d+$")) + 1L,
        1L
      ),
      condition   = condition_label,
      log_value   = if (LOG_TRANSFORM) log10(value + PSEUDOCOUNT) else value
    ) %>%
    select(condition, cell_type, replicate, value, log_value)
}

all_data <- imap_dfr(CONDITION_FILES, ~ clean_one(.x, .y))

# Nothing downstream can recover from a non-finite response.
stopifnot(all(is.finite(all_data$log_value)))

if (!REFERENCE_GROUP %in% all_data$cell_type[all_data$condition == FOCAL_CONDITION]) {
  stop("REFERENCE_GROUP (", sQuote(REFERENCE_GROUP), ") is not present in ",
       FOCAL_CONDITION, ". Available groups: ",
       paste(sQuote(sort(unique(all_data$cell_type[all_data$condition == FOCAL_CONDITION]))),
             collapse = ", "))
}

all_data %>%
  head(15) %>%
  knitr::kable(
    caption     = "Preview of Processed Condition Data (First 15 Rows)",
    col.names   = c("Condition", "Cell Type", "Replicate", "Raw Value", "Log10 Value"),
    digits      = 3,
    format.args = list(big.mark = ",")
  )
Preview of Processed Condition Data (First 15 Rows)
Condition Cell Type Replicate Raw Value Log10 Value
Condition_A CellType_A 1 9,546,308.314 6.980
Condition_A CellType_B 1 26,393.799 4.422
Condition_A CellType_C 1 21,332.055 4.329
Condition_A CellType_D 1 119,687.850 5.078
Condition_A CellType_A 2 1,032,520.670 6.014
Condition_A CellType_B 2 26,459.561 4.423
Condition_A CellType_C 2 882.748 2.946
Condition_A CellType_D 2 29,960.016 4.477
Condition_A CellType_A 1 2,006,114.145 6.302
Condition_A CellType_B 1 21,953.002 4.342
Condition_A CellType_C 1 5,846.142 3.767
Condition_A CellType_D 1 360,333.283 5.557
Condition_A CellType_A 2 2,525,945.242 6.402
Condition_A CellType_B 2 12,604.089 4.101
Condition_A CellType_C 2 18,282.672 4.262

Assumption Checks

Code
# Shapiro-Wilk must be applied to the WITHIN-GROUP deviations, not to the
# pooled values. Pooling several cell types with different means produces a
# mixture distribution that Shapiro-Wilk will reject even when every individual
# group is perfectly normal — the normality assumption of ANOVA is about the
# residuals, so that is what is tested here.
shapiro_or_na <- function(x) {
  x <- x[is.finite(x)]
  if (length(x) < 3 || stats::sd(x) == 0) return(NA_real_)
  stats::shapiro.test(x)$p.value
}

diagnostics <- all_data %>%
  group_by(condition) %>%
  summarise(
    shapiro_raw_resid = shapiro_or_na(residuals(lm(value ~ cell_type))),
    shapiro_log_resid = shapiro_or_na(residuals(lm(log_value ~ cell_type))),
    bartlett_log = bartlett.test(log_value ~ cell_type)$p.value,
    levene_log   = car::leveneTest(
      log_value ~ factor(cell_type),
      center = median
    )[["Pr(>F)"]][1],
    .groups = "drop"
  )

diagnostics %>%
  knitr::kable(
    caption     = "Normality (Shapiro-Wilk on model residuals) and Homogeneity of Variance (Bartlett & Levene) Diagnostics",
    col.names   = c("Condition", "Shapiro (Raw residuals) p", "Shapiro (Log10 residuals) p", "Bartlett (Log10) p", "Levene (Log10) p"),
    digits      = 4
  )
Normality (Shapiro-Wilk on model residuals) and Homogeneity of Variance (Bartlett & Levene) Diagnostics
Condition Shapiro (Raw residuals) p Shapiro (Log10 residuals) p Bartlett (Log10) p Levene (Log10) p
Condition_A 0e+00 0.1320 0.6758 0.5134
Condition_B 0e+00 0.6508 0.6765 0.8352
Condition_C 3e-04 0.0515 0.0264 0.3262
Code
# Per cell type as well, so a single badly behaved group is visible rather than
# averaged away. NA = fewer than 3 observations or zero variance.
all_data %>%
  group_by(condition, cell_type) %>%
  summarise(
    n           = dplyr::n(),
    shapiro_log = shapiro_or_na(log_value),
    .groups     = "drop"
  ) %>%
  knitr::kable(
    caption   = "Per-Group Shapiro-Wilk on Log10 Values (NA = n < 3 or zero variance)",
    col.names = c("Condition", "Cell Type", "n", "Shapiro (Log10) p"),
    digits    = 4
  )
Per-Group Shapiro-Wilk on Log10 Values (NA = n < 3 or zero variance)
Condition Cell Type n Shapiro (Log10) p
Condition_A CellType_A 6 0.9406
Condition_A CellType_B 6 0.0200
Condition_A CellType_C 6 0.4323
Condition_A CellType_D 6 0.8004
Condition_B CellType_A 6 0.4489
Condition_B CellType_B 6 0.4146
Condition_B CellType_C 6 0.1594
Condition_B CellType_D 6 0.8947
Condition_C CellType_A 6 0.2983
Condition_C CellType_B 6 0.0877
Condition_C CellType_C 6 0.5808
Condition_C CellType_D 6 0.1412

Interpretation Guide

  • Raw data from viral infections are typically log-normally distributed — expect low Shapiro-Wilk p-values on the raw-scale residuals.
  • Log₁₀ transform symmetrises the distribution and stabilises variance. Check shapiro_log_resid > 0.05.
  • Both Shapiro columns are computed on residuals from the cell-type means within each condition, which is the quantity ANOVA actually assumes to be normal. Testing the pooled values instead would reject normality simply because the groups have different means.
  • Bartlett / Levene tests flag heteroscedasticity. If these are significant for your focal condition, Welch’s ANOVA (used below) is the correct choice.

Why Welch’s One-Way ANOVA on log₁₀ data?

  1. Handles heteroscedasticity — group-specific variances are allowed.
  2. Tolerates moderate non-normality — log transform already reduces most skew.
  3. More powerful than Kruskal-Wallis at typical n = 3–6 per group.
  4. Pairs with Holm correction for planned comparisons (controls family-wise error rate without being overly conservative).

Focal Condition Analysis

Code
focal <- all_data %>%
  filter(condition == FOCAL_CONDITION, !is.na(log_value)) %>%
  mutate(cell_type = factor(cell_type))

welch_anova <- oneway.test(log_value ~ cell_type,
                           data      = focal,
                           var.equal = FALSE)

welch_tbl <- tibble(
  Condition      = FOCAL_CONDITION,
  `F Statistic`  = unname(welch_anova$statistic),
  `Num df`       = unname(welch_anova$parameter["num df"]),
  `Denom df`     = unname(welch_anova$parameter["denom df"]),
  `p-value`      = unname(welch_anova$p.value)
)

welch_tbl %>%
  knitr::kable(
    caption = paste("Welch's One-Way ANOVA Test Result —", FOCAL_CONDITION),
    digits  = 4
  )
Welch’s One-Way ANOVA Test Result — Condition_A
Condition F Statistic Num df Denom df p-value
Condition_A 46.2156 3 10.8589 0

Planned Contrasts: Reference Group vs All Others

Code
other_groups <- levels(focal$cell_type)[levels(focal$cell_type) != REFERENCE_GROUP]

if (length(other_groups) == 0) {
  stop("No groups to compare against REFERENCE_GROUP (", sQuote(REFERENCE_GROUP),
       ") in ", FOCAL_CONDITION, ".")
}

contrasts <- map_df(other_groups, function(grp) {
  x_ref <- focal$log_value[focal$cell_type == REFERENCE_GROUP]
  x_grp <- focal$log_value[focal$cell_type == grp]
  # t.test() errors with fewer than 2 observations per arm; report NA instead
  # of killing the render.
  t_out <- if (length(x_ref) >= 2 && length(x_grp) >= 2) {
    tryCatch(t.test(x_ref, x_grp, var.equal = FALSE), error = function(e) NULL)
  } else NULL

  tibble(
    Comparison  = paste(REFERENCE_GROUP, "vs", grp),
    n_ref       = length(x_ref),
    n_grp       = length(x_grp),
    Diff        = if (is.null(t_out)) NA_real_
                  else unname(t_out$estimate[1] - t_out$estimate[2]),
    p_raw       = if (is.null(t_out)) NA_real_ else t_out$p.value
  )
}) %>%
  mutate(
    p_adj = p.adjust(p_raw, method = P_ADJUST_METHOD),
    # Only a log10 difference can be exponentiated back to a fold change.
    # With LOG_TRANSFORM = FALSE, Diff is already a difference on the raw
    # scale, so 10^ would be meaningless.
    effect_size = if (LOG_TRANSFORM) round(10^(-Diff), 2) else round(-Diff, 2),
    significant = case_when(
      is.na(p_adj)   ~ "n/a",
      p_adj < ALPHA  ~ "Yes",
      TRUE           ~ "No"
    )
  ) %>%
  arrange(p_adj)

contrasts %>%
  knitr::kable(
    caption   = paste0("Planned Contrasts vs Reference Group (", REFERENCE_GROUP,
                       ") with ", P_ADJUST_METHOD, " FWER/FDR correction"),
    col.names = c("Comparison", "n (ref)", "n (group)",
                  if (LOG_TRANSFORM) "Log10 Diff" else "Raw Diff",
                  "Raw p-value",
                  paste0(P_ADJUST_METHOD, " Adj. p"),
                  if (LOG_TRANSFORM) "Fold Change" else "Difference (group - ref)",
                  "Significant"),
    digits    = 4
  )
Planned Contrasts vs Reference Group (CellType_A) with holm FWER/FDR correction
Comparison n (ref) n (group) Log10 Diff Raw p-value holm Adj. p Fold Change Significant
CellType_A vs CellType_B 6 6 2.3432 0.000 0.000 0.00 Yes
CellType_A vs CellType_C 6 6 2.5459 0.000 0.000 0.00 Yes
CellType_A vs CellType_D 6 6 1.2004 0.001 0.001 0.06 Yes

Visualization

Code
focal_summary <- focal %>%
  group_by(cell_type) %>%
  summarise(
    mean_val = mean(value, na.rm = TRUE),
    sd_val   = sd(value, na.rm = TRUE),
    n        = n(),
    se_val   = sd_val / sqrt(n),
    .groups  = "drop"
  )

p_focal <- ggplot(focal_summary, aes(x = cell_type, y = mean_val, fill = cell_type)) +
  geom_col(width = 0.65, color = "black", linewidth = 0.5, alpha = 0.85) +
  geom_errorbar(aes(ymin = pmax(0, mean_val - se_val), ymax = mean_val + se_val),
                width = 0.2, linewidth = 0.6) +
  geom_jitter(data = focal, aes(x = cell_type, y = value),
              width = 0.15, size = 2, alpha = 0.7, color = "grey20", inherit.aes = FALSE) +
  scale_fill_brewer(palette = "Set2", guide = "none") +
  labs(
    title    = paste("Cell Type Comparison —", FOCAL_CONDITION),
    subtitle = paste("Reference:", REFERENCE_GROUP, "| Error bars represent Mean ± 1 SE"),
    x        = "Cell Type / Treatment Group",
    y        = ifelse(LOG_TRANSFORM, "Titer / Expression (linear scale)", "Response Value")
  ) +
  theme_bw(base_size = 11) +
  theme(
    plot.title       = element_text(face = "bold", size = 12),
    plot.subtitle    = element_text(size = 10, color = "grey30"),
    axis.title       = element_text(face = "bold"),
    axis.text.x      = element_text(angle = 25, hjust = 1),
    panel.grid.major = element_blank(),
    panel.grid.minor = element_blank()
  )

print(p_focal)

Summary Note

Code
cat(sprintf(
  "Focal condition: %s\nReference group: %s\nSignificant contrasts (%s-adjusted p < %.2f): %d / %d\n",
  FOCAL_CONDITION,
  REFERENCE_GROUP,
  P_ADJUST_METHOD,
  ALPHA,
  sum(contrasts$significant == "Yes"),
  nrow(contrasts)
))
Focal condition: Condition_A
Reference group: CellType_A
Significant contrasts (holm-adjusted p < 0.05): 3 / 3