04 · MagPix / Luminex Multiplex

2×2 factorial cytokine analysis with FDR correction and four plot types

immunology
luminex
ANOVA
multiplex

Analyzes multiplex immunoassay (MagPix/Luminex) data from a factorial design (2×2 by default, e.g., disease × treatment). Handles left-censored values (< LoD) via LoD/√2 substitution and right-censored values (> ULoQ) explicitly, removes standard/blank/background wells, flags outliers (3×IQR), fits per-analyte two-way ANOVA, computes emmeans contrasts with BH-FDR applied within each model term, and produces a heatmap plus four per-analyte plot types (dot, bar, violin, box).

Overview

Item Details
Input Single wide-format CSV (rows = samples, columns = analytes + metadata)
Key packages tidyverse, emmeans, ggbeeswarm, ggpubr, scales
Statistics Two-way ANOVA per analyte · emmeans contrasts · BH/FDR
Output QC tables · ANOVA results CSV · Contrasts CSV · Heatmap · 4 plot types per analyte
Download template.Rmd
Tip

When to use this template: You have a MagPix/Luminex panel run on a 2×2 factorial experiment (e.g., disease status × treatment). Adapt FACTOR1_LEVELS and FACTOR2_LEVELS to your own factor names.

Note

Okabe-Ito palette is used throughout — colorblind-safe for all four groups.

Back to Gallery Open Template File

Code
## ── USER CONFIGURATION ──────────────────────────────────────────────────────
#
# DATA_FILE: Path to your MagPix/Luminex export CSV.
#   Required columns (in addition to analyte columns):
#   - "Location"     : well identifier (e.g., "A1")
#   - "Sample"       : sample name — used to derive Factor1 and Factor2
#   - "Original_ID"  : original sample ID (can be same as Sample)
#   - "Total Events" : bead event count (for QC)
#
DATA_FILE <- "data/luminex_data.csv"

# NON_ANALYTE_COLS: Columns that are NOT cytokine measurements.
#   All other columns will be treated as analytes.
NON_ANALYTE_COLS <- c("Location", "Sample", "Original_ID", "Total Events")

# FACTOR1_LEVELS / FACTOR2_LEVELS: Level labels for the two experimental
#   factors. Each label is matched as a *literal substring* (not a regex)
#   against the Sample column, so labels containing "+", "(" or "." are safe.
#   Element [1] is the reference / control level of that factor.
#
#   Example: Sample = "Disease_Drug_1"
#     "Disease" is found in Sample → Factor1 = "Disease"
#     "Drug"    is found in Sample → Factor2 = "Drug"
#
#   A sample matching *no* level of a factor is assigned NA and dropped
#   (with a count reported) rather than being folded into the control.
#
FACTOR1_LEVELS <- c("PBS", "Disease")     # [1] = reference / control level
FACTOR2_LEVELS <- c("Vehicle", "Drug")    # [1] = reference / control level

# NON_SAMPLE_PATTERNS: Literal substrings identifying non-biological wells that
#   Luminex exports routinely carry (standards, blanks, background). Rows whose
#   Sample matches any of these are removed before modelling.
NON_SAMPLE_PATTERNS <- c("Standard", "Blank", "Background", "Control Bead")

# EXCLUDE_OUTLIERS: Tukey 3xIQR outliers are always *flagged* and tabulated.
#   Set to TRUE to also drop them from the models; FALSE keeps them (default —
#   deleting Luminex wells on a distributional rule alone is rarely justified).
EXCLUDE_OUTLIERS <- FALSE

# OKABE_ITO: Colorblind-safe palette keyed by group label.
#   Names follow the "<Factor1>+<Factor2>" convention. Groups not listed here
#   are filled automatically from the Okabe-Ito sequence, so a design larger
#   than 2x2 still plots.
OKABE_ITO <- c(
  "PBS+Vehicle"      = "#0072B2",   # Blue
  "PBS+Drug"         = "#E69F00",   # Orange
  "Disease+Vehicle"  = "#009E73",   # Bluish green
  "Disease+Drug"     = "#D55E00"    # Vermilion
)

# CONTRAST_ORDER / CONTRAST_LABELS: Define which emmeans contrasts appear in
#   the heatmap (order matters for the y-axis) and their display labels.
#   Adjust if your Factor levels produce different contrast names.
CONTRAST_ORDER <- c(
  paste0(FACTOR2_LEVELS[2], " - ", FACTOR2_LEVELS[1], ", ", FACTOR1_LEVELS[1]),
  paste0(FACTOR2_LEVELS[2], " - ", FACTOR2_LEVELS[1], ", ", FACTOR1_LEVELS[2]),
  paste0(FACTOR1_LEVELS[2], " - ", FACTOR1_LEVELS[1], ", ", FACTOR2_LEVELS[1]),
  paste0(FACTOR1_LEVELS[2], " - ", FACTOR1_LEVELS[1], ", ", FACTOR2_LEVELS[2])
)
CONTRAST_LABELS <- c(
  paste0("Drug (", FACTOR1_LEVELS[1], ")"),
  paste0("Drug (", FACTOR1_LEVELS[2], ")"),
  paste0(FACTOR1_LEVELS[2], " (", FACTOR2_LEVELS[1], ")"),
  paste0(FACTOR1_LEVELS[2], " (", FACTOR2_LEVELS[2], ")")
)

# OUTPUT_DIR: Folder where per-analyte plots are saved.
OUTPUT_DIR <- "Plots"

## ────────────────────────────────────────────────────────────────────────────

MagPix / Luminex Best Practices

  • Bead counts: ≥35 per analyte (≥50 ideal). Re-export with bead counts if missing.
  • Left-censoring (“< LoD”): Do not treat as zero. Replace with LoD/√2.
  • Right-censoring (“> ULoQ”): Equally important and usually ignored. parse_number("> 10000") returns 10000, which enters the model as an exact measurement. Here such values are substituted at the ULoQ itself and flagged; the resulting effect estimates are attenuated (conservative), which is stated wherever the censoring rate is non-trivial.
  • QC/outliers: Flag wells using Tukey 3×IQR on the log10 scale. Flagging is not the same as excluding — this template flags and tabulates them, and only drops them if EXCLUDE_OUTLIERS is set to TRUE.
  • Non-sample wells: Standards, blanks and background rows must be removed before modelling, not silently folded into the control group.
  • Design: factorial (2×2 by default) — model on log10(concentration), test main effects and interaction. Level counts are derived from the config, so a 2×3 design works unchanged.
  • Multiple analytes: Correct FDR within each model term, across analytes (BH). The intercept is not a hypothesis of interest and is excluded from the correction family. Report fold-changes as 10^β.

Setup

Code
library(tidyverse)
library(stringr)
library(broom)
library(emmeans)
library(ggpubr)
library(ggbeeswarm)
library(scales)
library(forcats)

Load Data

Code
raw <- read_csv(DATA_FILE, show_col_types = FALSE)

analyte_cols <- setdiff(names(raw), NON_ANALYTE_COLS)
raw <- raw %>% mutate(across(all_of(analyte_cols), as.character))

n_wells_in <- nrow(raw)

# ── 1. Remove non-biological wells (standards, blanks, background) ────────────
# Matched as literal substrings, case-insensitively, so a pattern containing
# regex metacharacters is safe.
if (length(NON_SAMPLE_PATTERNS) > 0) {
  is_non_sample <- Reduce(
    `|`,
    lapply(NON_SAMPLE_PATTERNS,
           function(p) str_detect(raw$Sample, fixed(p, ignore_case = TRUE)))
  )
} else {
  is_non_sample <- rep(FALSE, nrow(raw))
}
n_non_sample <- sum(is_non_sample)
raw <- raw[!is_non_sample, , drop = FALSE]

# ── 2. Long format, censoring handling, factor assignment ────────────────────
# Left-censored  ("< x") → substitute LoD/sqrt(2).
# Right-censored ("> x") → substitute the ULoQ itself; the estimate is then a
#   conservative lower bound rather than a point measurement.
# A sample matching no level of a factor gets NA (never the control level).
# For a factor with more than two levels, add one `str_detect(...) ~ level`
# line per extra level, most specific first.
dat_long <- raw %>%
  pivot_longer(all_of(analyte_cols), names_to = "Analyte", values_to = "raw_value") %>%
  mutate(
    below_lod   = str_detect(raw_value, "^\\s*<"),
    above_uloq  = str_detect(raw_value, "^\\s*>"),
    censored    = below_lod | above_uloq,
    lod         = if_else(below_lod,  parse_number(raw_value), NA_real_),
    uloq        = if_else(above_uloq, parse_number(raw_value), NA_real_),
    value       = case_when(
      below_lod  ~ lod / sqrt(2),
      above_uloq ~ uloq,
      TRUE       ~ parse_number(raw_value)
    ),
    log10_value = log10(value),
    Factor1     = case_when(
      str_detect(Sample, fixed(FACTOR1_LEVELS[2])) ~ FACTOR1_LEVELS[2],
      str_detect(Sample, fixed(FACTOR1_LEVELS[1])) ~ FACTOR1_LEVELS[1],
      TRUE                                         ~ NA_character_
    ),
    Factor2     = case_when(
      str_detect(Sample, fixed(FACTOR2_LEVELS[2])) ~ FACTOR2_LEVELS[2],
      str_detect(Sample, fixed(FACTOR2_LEVELS[1])) ~ FACTOR2_LEVELS[1],
      TRUE                                         ~ NA_character_
    )
  )

# ── 3. Drop rows whose design assignment is unknown, and say how many ────────
unassigned <- dat_long %>% filter(is.na(Factor1) | is.na(Factor2))
n_unassigned_rows    <- nrow(unassigned)
unassigned_samples   <- sort(unique(unassigned$Sample))

dat_long <- dat_long %>%
  filter(!is.na(Factor1), !is.na(Factor2)) %>%
  mutate(
    Factor1 = factor(Factor1, levels = FACTOR1_LEVELS),
    Factor2 = factor(Factor2, levels = FACTOR2_LEVELS),
    Group   = factor(
      paste0(Factor1, "+", Factor2),
      # Derived from the configured level counts — works for any n x m design.
      levels = paste0(
        rep(FACTOR1_LEVELS, each  = length(FACTOR2_LEVELS)),
        "+",
        rep(FACTOR2_LEVELS, times = length(FACTOR1_LEVELS))
      )
    )
  )

stopifnot(nrow(dat_long) > 0)

cat(sprintf(
  paste0("Wells read: %d\n",
         "  removed as non-sample (standard/blank/background): %d\n",
         "Measurement rows after reshaping: %d\n",
         "  removed — Sample matched no level of Factor1/Factor2: %d (%d sample%s)\n",
         "Rows retained for analysis: %d\n"),
  n_wells_in, n_non_sample,
  n_unassigned_rows + nrow(dat_long),
  n_unassigned_rows, length(unassigned_samples),
  if (length(unassigned_samples) == 1) "" else "s",
  nrow(dat_long)
))
Wells read: 24
  removed as non-sample (standard/blank/background): 4
Measurement rows after reshaping: 160
  removed — Sample matched no level of Factor1/Factor2: 0 (0 samples)
Rows retained for analysis: 160
Code
if (length(unassigned_samples) > 0) {
  cat("Unassigned samples: ", paste(unassigned_samples, collapse = ", "), "\n", sep = "")
}

QC

Censoring Rate per Analyte

Code
qc_censor <- dat_long %>%
  group_by(Analyte) %>%
  summarise(
    n            = n(),
    below_n      = sum(below_lod),
    below_pct    = 100 * below_n / n,
    above_n      = sum(above_uloq),
    above_pct    = 100 * above_n / n,
    total_pct    = 100 * sum(censored) / n,
    .groups      = "drop"
  ) %>%
  arrange(desc(total_pct))

qc_censor %>%
  knitr::kable(
    caption   = "Censoring Rate per Analyte (Below LoD and Above ULoQ)",
    col.names = c("Analyte", "Total Samples",
                  "< LoD Count", "< LoD %",
                  "> ULoQ Count", "> ULoQ %",
                  "Any Censoring %"),
    digits    = 1
  )
Censoring Rate per Analyte (Below LoD and Above ULoQ)
Analyte Total Samples < LoD Count < LoD % > ULoQ Count > ULoQ % Any Censoring %
IFN-gamma 20 4 20 0 0 20
CXCL10 20 0 0 2 10 10
IL-6 20 0 0 2 10 10
TNF-alpha 20 2 10 0 0 10
CCL2 20 0 0 1 5 5
CCL5 20 0 0 0 0 0
IL-10 20 0 0 0 0 0
IL-1beta 20 0 0 0 0 0

Guidance: Analytes with >50% censoring warrant Tobit regression as a sensitivity check. The standard substitution (LoD/√2) used here is appropriate for <30% left-censoring. Right-censored values are substituted at the ULoQ, which attenuates any effect involving them — treat those estimates as conservative lower bounds, and re-run the affected analytes at a higher dilution rather than reporting the substituted value as a measurement.

Note: 3 analyte(s) contain values above the upper limit of quantification (max 10.0%: CXCL10). Their effect estimates are attenuated.

Outliers (Tukey 3×IQR on log10 scale)

Code
fences <- dat_long %>%
  group_by(Analyte) %>%
  summarise(
    q1    = quantile(log10_value, 0.25, na.rm = TRUE),
    q3    = quantile(log10_value, 0.75, na.rm = TRUE),
    iqr   = IQR(log10_value, na.rm = TRUE),
    lower = q1 - 3 * iqr,
    upper = q3 + 3 * iqr,
    .groups = "drop"
  )

dat_long <- dat_long %>%
  left_join(fences, by = "Analyte") %>%
  mutate(
    Outlier = if_else(
      !is.na(log10_value) & (log10_value < lower | log10_value > upper),
      "Yes", "No"
    )
  ) %>%
  select(-q1, -q3, -iqr, -lower, -upper)

outlier_tbl <- dat_long %>%
  filter(Outlier == "Yes") %>%
  count(Analyte, name = "outlier_n") %>%
  arrange(desc(outlier_n))

outlier_tbl %>%
  knitr::kable(
    caption   = "Wells Flagged as Outliers (Tukey 3xIQR on log10 scale)",
    col.names = c("Analyte", "Flagged Wells")
  )
Wells Flagged as Outliers (Tukey 3xIQR on log10 scale)
Analyte Flagged Wells
Code
# Flagging and excluding are separate decisions — EXCLUDE_OUTLIERS controls the
# second one, so the code and the text above cannot drift apart.
n_outliers <- sum(dat_long$Outlier == "Yes")
if (isTRUE(EXCLUDE_OUTLIERS)) {
  dat_long <- dat_long %>% filter(Outlier != "Yes")
  cat(sprintf("EXCLUDE_OUTLIERS = TRUE: %d flagged well-measurements removed from all models.\n",
              n_outliers))
} else {
  cat(sprintf("EXCLUDE_OUTLIERS = FALSE: %d flagged well-measurements are reported above but RETAINED in all models.\n",
              n_outliers))
}
EXCLUDE_OUTLIERS = FALSE: 0 flagged well-measurements are reported above but RETAINED in all models.

Two-Way ANOVA (per Analyte)

Code
effect_map <- c(
  setNames(
    paste0(FACTOR1_LEVELS[2], " vs ", FACTOR1_LEVELS[1]),
    paste0("Factor1", FACTOR1_LEVELS[2])
  ),
  setNames(
    paste0(FACTOR2_LEVELS[2], " vs ", FACTOR2_LEVELS[1]),
    paste0("Factor2", FACTOR2_LEVELS[2])
  ),
  setNames(
    "Interaction",
    paste0("Factor1", FACTOR1_LEVELS[2], ":Factor2", FACTOR2_LEVELS[2])
  )
)

# The correction family is "this model term, across all analytes". The
# intercept tests "mean log10 concentration = 0", which is never a hypothesis of
# interest, is always astronomically significant, and would both inflate m and
# occupy the top ranks — so it is dropped before correcting.
res_lm <- dat_long %>%
  group_by(Analyte) %>%
  group_modify(~ {
    m <- lm(log10_value ~ Factor1 * Factor2, data = .x)
    broom::tidy(m)
  }) %>%
  ungroup() %>%
  filter(term != "(Intercept)") %>%
  group_by(term) %>%
  mutate(adj.p = p.adjust(p.value, method = "BH")) %>%
  ungroup() %>%
  mutate(
    effect      = coalesce(unname(effect_map[term]), term),
    fold_change = 10^estimate
  ) %>%
  arrange(Analyte, effect)

res_lm %>%
  head(20) %>%
  select(Analyte, term, estimate, std.error, statistic, p.value, effect, adj.p, fold_change) %>%
  knitr::kable(
    caption     = "Two-Way ANOVA Model Summary (First 20 Effects; intercepts excluded, BH within each term)",
    col.names   = c("Analyte", "Term", "Estimate", "Std Error", "Statistic", "Raw p", "Effect", "FDR adj. p", "Fold Change"),
    digits      = 4
  )
Two-Way ANOVA Model Summary (First 20 Effects; intercepts excluded, BH within each term)
Analyte Term Estimate Std Error Statistic Raw p Effect FDR adj. p Fold Change
CCL2 Factor1Disease 0.6674 0.1616 4.1302 0.0008 Disease vs PBS 0.0008 4.6499
CCL2 Factor2Drug 0.2609 0.1616 1.6142 0.1260 Drug vs Vehicle 0.3719 1.8233
CCL2 Factor1Disease:Factor2Drug -0.2164 0.2285 -0.9467 0.3579 Interaction 0.4090 0.6076
CCL5 Factor1Disease 0.5517 0.1219 4.5264 0.0003 Disease vs PBS 0.0004 3.5624
CCL5 Factor2Drug 0.2169 0.1219 1.7797 0.0941 Drug vs Vehicle 0.3719 1.6479
CCL5 Factor1Disease:Factor2Drug -0.3907 0.1724 -2.2663 0.0377 Interaction 0.0602 0.4068
CXCL10 Factor1Disease 1.6737 0.1221 13.7063 0.0000 Disease vs PBS 0.0000 47.1688
CXCL10 Factor2Drug -0.0911 0.1221 -0.7464 0.4663 Drug vs Vehicle 0.5329 0.8107
CXCL10 Factor1Disease:Factor2Drug -0.4163 0.1727 -2.4108 0.0283 Interaction 0.0566 0.3834
IFN-gamma Factor1Disease 2.0299 0.1638 12.3950 0.0000 Disease vs PBS 0.0000 107.1291
IFN-gamma Factor2Drug 0.2095 0.1638 1.2791 0.2191 Drug vs Vehicle 0.4382 1.6198
IFN-gamma Factor1Disease:Factor2Drug -0.6889 0.2316 -2.9744 0.0089 Interaction 0.0319 0.2047
IL-10 Factor1Disease 1.0434 0.1592 6.5536 0.0000 Disease vs PBS 0.0000 11.0518
IL-10 Factor2Drug 0.2476 0.1592 1.5552 0.1395 Drug vs Vehicle 0.3719 1.7685
IL-10 Factor1Disease:Factor2Drug -0.6885 0.2252 -3.0577 0.0075 Interaction 0.0319 0.2049
IL-1beta Factor1Disease 0.9809 0.1112 8.8216 0.0000 Disease vs PBS 0.0000 9.5688
IL-1beta Factor2Drug 0.0054 0.1112 0.0483 0.9621 Drug vs Vehicle 0.9621 1.0124
IL-1beta Factor1Disease:Factor2Drug -0.4457 0.1572 -2.8348 0.0120 Interaction 0.0319 0.3583
IL-6 Factor1Disease 1.4382 0.1622 8.8678 0.0000 Disease vs PBS 0.0000 27.4264
IL-6 Factor2Drug -0.1485 0.1622 -0.9155 0.3735 Drug vs Vehicle 0.5329 0.7104
Code
readr::write_csv(res_lm, file.path(OUTPUT_DIR, "model_LM_results.csv"))

Post-Hoc Contrasts (emmeans)

Code
group_levels <- levels(dat_long$Group)

emm_contrasts <- dat_long %>%
  group_by(Analyte) %>%
  group_modify(~ {
    m   <- lm(log10_value ~ Factor1 * Factor2, data = .x)
    emm <- emmeans(m, ~ Factor1 * Factor2)

    drug_effects    <- contrast(emm, method = "revpairwise", by = "Factor1", infer = TRUE)
    disease_effects <- contrast(emm, method = "revpairwise", by = "Factor2", infer = TRUE)
    interaction_eff <- contrast(emm, interaction = c("revpairwise", "revpairwise"), infer = TRUE)

    s <- bind_rows(
      as.data.frame(drug_effects),
      as.data.frame(disease_effects),
      as.data.frame(interaction_eff)
    )
    s_tidy <- s %>%
      mutate(
        unique_contrast = case_when(
          !is.na(Factor1) ~ paste(contrast, Factor1, sep = ", "),
          !is.na(Factor2) ~ paste(contrast, Factor2, sep = ", "),
          TRUE            ~ contrast
        )
      )

    tibble(
      contrast       = s_tidy$unique_contrast,
      estimate_log10 = s_tidy$estimate,
      SE             = s_tidy$SE,
      df             = s_tidy$df,
      t              = s_tidy$t.ratio,
      p              = s_tidy$p.value,
      fold_change    = 10^s_tidy$estimate,
      lower_FC       = 10^s_tidy$lower.CL,
      upper_FC       = 10^s_tidy$upper.CL
    )
  }) %>%
  ungroup() %>%
  # Same family definition as the model table above: correct across analytes
  # within each contrast, not across every analyte-by-contrast cell at once.
  group_by(contrast) %>%
  mutate(p_adj = p.adjust(p, method = "BH")) %>%
  ungroup() %>%
  arrange(contrast, p_adj)

emm_contrasts %>%
  head(20) %>%
  knitr::kable(
    caption     = "emmeans Post-Hoc Contrasts & Fold Changes (First 20 Comparisons)",
    col.names   = c("Analyte", "Contrast", "Log10 Est.", "SE", "df", "t ratio", "Raw p", "Fold Change", "Lower FC", "Upper FC", "FDR adj. p"),
    digits      = 4
  )
emmeans Post-Hoc Contrasts & Fold Changes (First 20 Comparisons)
Analyte Contrast Log10 Est. SE df t ratio Raw p Fold Change Lower FC Upper FC FDR adj. p
CXCL10 Disease - PBS, Drug 1.2573 0.1221 16 10.2969 0.0000 18.0857 9.9650 32.8242 0.0000
IFN-gamma Disease - PBS, Drug 1.3410 0.1638 16 8.1885 0.0000 21.9290 9.8593 48.7743 0.0000
TNF-alpha Disease - PBS, Drug 1.3237 0.1612 16 8.2094 0.0000 21.0715 9.5914 46.2926 0.0000
IL-6 Disease - PBS, Drug 1.2799 0.1622 16 7.8919 0.0000 19.0501 8.6317 42.0435 0.0000
IL-1beta Disease - PBS, Drug 0.5351 0.1112 16 4.8126 0.0002 3.4285 1.9925 5.8995 0.0003
CCL2 Disease - PBS, Drug 0.4511 0.1616 16 2.7913 0.0131 2.8254 1.2838 6.2181 0.0174
IL-10 Disease - PBS, Drug 0.3549 0.1592 16 2.2293 0.0405 2.2643 1.0409 4.9256 0.0463
CCL5 Disease - PBS, Drug 0.1611 0.1219 16 1.3214 0.2049 1.4490 0.7992 2.6271 0.2049
CXCL10 Disease - PBS, Vehicle 1.6737 0.1221 16 13.7063 0.0000 47.1688 25.9894 85.6078 0.0000
IFN-gamma Disease - PBS, Vehicle 2.0299 0.1638 16 12.3950 0.0000 107.1291 48.1653 238.2762 0.0000
TNF-alpha Disease - PBS, Vehicle 1.6160 0.1612 16 10.0225 0.0000 41.3084 18.8028 90.7516 0.0000
IL-1beta Disease - PBS, Vehicle 0.9809 0.1112 16 8.8216 0.0000 9.5688 5.5610 16.4652 0.0000
IL-6 Disease - PBS, Vehicle 1.4382 0.1622 16 8.8678 0.0000 27.4264 12.4270 60.5302 0.0000
IL-10 Disease - PBS, Vehicle 1.0434 0.1592 16 6.5536 0.0000 11.0518 5.0805 24.0413 0.0000
CCL5 Disease - PBS, Vehicle 0.5517 0.1219 16 4.5264 0.0003 3.5624 1.9649 6.4586 0.0004
CCL2 Disease - PBS, Vehicle 0.6674 0.1616 16 4.1302 0.0008 4.6499 2.1128 10.2335 0.0008
CXCL10 Drug - Vehicle, Disease -0.5075 0.1221 16 -4.1558 0.0007 0.3108 0.1713 0.5642 0.0045
IL-1beta Drug - Vehicle, Disease -0.4404 0.1112 16 -3.9606 0.0011 0.3628 0.2108 0.6242 0.0045
IFN-gamma Drug - Vehicle, Disease -0.4794 0.1638 16 -2.9274 0.0099 0.3316 0.1491 0.7375 0.0263
IL-10 Drug - Vehicle, Disease -0.4409 0.1592 16 -2.7691 0.0137 0.3623 0.1666 0.7882 0.0274
Code
readr::write_csv(emm_contrasts, file.path(OUTPUT_DIR, "emmeans_contrasts_foldchanges.csv"))

Visualization

Effect Size Heatmap

Code
heatmap_data <- emm_contrasts %>%
  filter(!is.na(contrast)) %>%
  mutate(
    log2_FC      = log2(fold_change),
    log2_FC_plot = if_else(p_adj < 0.05, log2_FC, 0),
    Analyte      = factor(Analyte, levels = sort(unique(Analyte))),
    contrast_disp = factor(contrast,
                           levels = CONTRAST_ORDER,
                           labels = CONTRAST_LABELS)
  ) %>%
  filter(!is.na(contrast_disp))

lim <- max(abs(heatmap_data$log2_FC_plot), na.rm = TRUE)
lim <- max(lim, 1)

p_heat <- ggplot(heatmap_data,
                 aes(x = contrast_disp, y = Analyte, fill = log2_FC_plot)) +
  geom_tile(color = "grey85", linewidth = 0.2) +
  scale_fill_gradient2(
    low      = "#3B4CC0",
    mid      = "white",
    high     = "#D55E00",
    midpoint = 0,
    limits   = c(-lim, lim),
    breaks   = pretty_breaks(n = 5),
    labels   = label_number(accuracy = 0.1)
  ) +
  scale_y_discrete(expand = expansion(mult = c(0.01, 0.05))) +
  labs(
    x       = NULL,
    y       = NULL,
    fill    = "Log\u2082 Fold Change",
    title   = "Analyte-wise Effects of Treatments",
    caption = "Non-significant tiles masked to 0 (FDR < 0.05)"
  ) +
  theme_bw(base_size = 13, base_family = "sans") +
  theme(
    legend.position  = "right",
    legend.direction = "vertical",
    legend.title     = element_text(size = 11),
    legend.key.width = unit(12, "pt"),
    axis.text.x      = element_text(angle = 30, hjust = 1, face = "bold"),
    axis.text.y      = element_text(size = 11, lineheight = 1.3, face = "bold"),
    axis.ticks       = element_blank(),
    axis.line        = element_blank(),
    plot.title       = element_text(size = 14, face = "bold", margin = margin(b = 6)),
    panel.grid.major = element_blank(),
    panel.grid.minor = element_blank()
  )

ggsave(file.path(OUTPUT_DIR, "Heatmaps", "heatmap.tiff"),
       plot = p_heat, width = 6, height = 4, dpi = 600,
       device = "tiff", compression = "lzw")
print(p_heat)