Every methylation array is run somewhere on a physical chip, at some position on that chip, on some plate, on some day. These technical factors leave systematic fingerprints in the data, known as “batch effects”. If a batch factor happens to line up with your exposure of interest, it manufactures false positives. Even when it doesn’t, it inflates noise and costs power. This notebook shows how to detect and quantify batch structure with PCA, how to tell a batch effect apart from a biological one, and — importantly — how to decide whether and how to correct.

NoteWhat this chapter needs

This chapter starts from data/03_grs_filtered.rds, written by chapter 03. If you did not run that chapter, fetch the published checkpoint instead:

Terminal
./get_data.sh D_filtered
Code
library(minfi)   # getBeta() on the filtered GenomicRatioSet
library(sva)     # ComBat(), num.sv(), sva()
library(limma)   # lmFit()/eBayes(), used in the k-selection sweep
library(ggplot2) # every figure in this chapter

# num.sv()'s permutation test and sva()'s iterative reweighting are both
# stochastic, so the seed is what makes the surrogate variables -- and every
# number derived from them below -- reproducible from one render to the next.
set.seed(42)

# The probe-filtered, funnorm-normalized object written by chapter 03:
# 756,273 EWAS-ready CpGs x 96 QC-passing arrays.
grs <- readRDS("data/03_grs_filtered.rds")

# The reference-based cell proportions written by chapter 04 (96 x 6). This one
# is small enough to live in the repository, so it needs no download.
props <- readRDS("data/04_cc_full.rds")$prop

# The sample sheet rides along on the object, so the phenotype table comes back
# out of it rather than being read from the CSV again.
md <- as.data.frame(pData(grs))

# Everything below indexes samples three different ways -- array columns, rows
# of `md`, rows of `props` -- so align them once, here, and assert it. A silent
# mis-alignment would produce plausible-looking nonsense in every figure.
props <- props[md$sample_id, , drop = FALSE]
stopifnot(identical(colnames(grs), md$sample_id),
          identical(rownames(props), md$sample_id))

# The 87 samples an association model can actually use: PTSD status is NA for
# nine arrays (see chapter 01), and a model containing the exposure drops them.
# The PCA diagnostic in section 2 deliberately keeps all 96.
keep  <- !(is.na(md$ptsd) | md$ptsd == "") & !is.na(md$sex) &
         !is.na(md$age) & complete.cases(props)
mdk   <- md[keep, ]
propk <- props[keep, , drop = FALSE]

# Control is the reference level: the PTSD coefficient is then "Case relative to
# Control", which is the direction the whole tutorial reports.
mdk$ptsd <- factor(mdk$ptsd, levels = c("Control", "Case"))

cat("arrays:", ncol(grs), "| probes:", nrow(grs),
    "| arrays with modelable phenotype:", sum(keep), "\n")
arrays: 96 | probes: 756273 | arrays with modelable phenotype: 87 
NoteWhere 96 becomes 87

Two sample counts appear in this chapter and it is worth being clear about which is which.

The PCA diagnostics in §2 run on all 96 QC-passing arrays. They describe technical structure in the arrays and take no model, so the exposure never enters and there is no reason to exclude anything.

Batch effect adjustment runs on 87 — the stratified ComBat, and SVA. For each of these analyses, it’s necessary to provide which variables should be protected which includes the phenotype of interest. There are nine samples with no recorded PTSD status, therefore these are excluded.

The Smoking proxy is also run on 87 samples, but for the reason of capturing the variance of the sample set that the EWAS will actually be performed on.

1. The sources of technical variation on an array

The usual suspects, in rough order of how often they matter:

  • Chip / BeadChip (Sentrix ID). Eight samples share one EPIC chip. Samples on the same chip are processed together and tend to be more similar to each other than to samples on other chips — the strongest and most common array batch factor.
  • Array position (Sentrix position). Where a sample sits on the chip. The EPIC v1 BeadChip is an 8 × 1 grid, so the positions are R01C01 through R08C01 and there are exactly eight of them. Position effects are subtler than chip effects but real and reproducible — edge rows in particular can differ systematically.
  • Plate. Chips are processed in plates. A full plate holds 96 arrays — twelve chips of eight — and captures reagent lots, enzyme batches, and the handling session, all upstream of any individual chip.
  • Well. The sample’s position in the 96-well DNA plate, labeled A01H12. Well captures pipetting variation, bisulfite-conversion efficiency, reagent gradients across the plate, and plate-edge effects (evaporation and thermal gradients hit the outer wells hardest). Well is related to but distinct from Sentrix position: under a standard plate map, well is essentially the (chip, position) pair taken jointly, so within a single plate, adjusting for chip and position largely covers well. Across plates they separate — two samples can both sit at R03C01 on plates processed weeks apart.
  • Scan date / scanner. When and on which machine the chip was imaged.

These are recorded in the IDAT filenames and sample sheet (see Setup). Here we have chip (slide) and position (array_pos); the Grady deposit doesn’t give explicit plate, well, or scan date.

2. Detecting structure with PCA

Principal component analysis is a commonly used method to assess for technical variation. Each principal component is an axis of coordinated methylation variation across samples; we then ask what each PC is associated with by regressing it on every candidate variable — technical and biological alike — and reading off the \(R^2\). A PC that has a high \(R^2\) with a technical factor is a batch effect.

Code
# PCA, ComBat and SVA all work on M-values rather than betas: log2(beta/(1-beta))
# is unbounded and roughly homoscedastic, so a linear model is not fighting the
# floor and ceiling of the 0-1 beta scale.
#
# Cost: getBeta() materializes a 756,273 x 96 numeric matrix (~580 MB) and M is a
# second one, so this chunk peaks near 1.2 GB. `beta` is dropped as soon as the
# M-values exist; only the handful of panel probes needed in section 5 are read
# back off `grs` later.
beta <- getBeta(grs)
M    <- log2(beta / (1 - beta))
M[!is.finite(M)] <- NA          # beta exactly 0 or 1 gives -Inf/+Inf
rm(beta); invisible(gc())
cat("M-value matrix:", paste(dim(M), collapse = " x "),
    "| probes with a non-finite value:", sum(rowSums(is.na(M)) > 0), "\n")
M-value matrix: 756273 x 96 | probes with a non-finite value: 3 
Code
# Rank probes by variance and keep the top 20,000. PCA on all 756,273 probes
# would take minutes and give the same picture: the leading components are driven
# by the most variable probes, and this diagnostic only needs to identify what
# those components track. The SVA in section 6 uses every probe.
v    <- apply(M, 1, var, na.rm = TRUE)
top  <- names(sort(v, decreasing = TRUE))[1:20000]
Mtop <- M[top, ]
Mtop <- Mtop[complete.cases(Mtop), ]   # prcomp() cannot take an NA

# center but do not scale: every row is already on the same M-value scale, and
# scaling would give the least variable of the 20,000 probes equal say.
pr  <- prcomp(t(Mtop), center = TRUE, scale. = FALSE)
pcs <- pr$x[, 1:10]
pve <- (pr$sdev^2 / sum(pr$sdev^2))[1:10]   # fraction of variance per PC
cat("PCA input:", nrow(Mtop), "probes | PC1-PC3 variance:",
    paste(sprintf("%.1f%%", 100 * pve[1:3]), collapse = " "), "\n")
PCA input: 19999 probes | PC1-PC3 variance: 15.4% 4.6% 3.8% 
Code
# One row per candidate explanatory variable, technical and biological together
# -- the point of the diagnostic is that they compete on the same axes.
# Position is collapsed to its row (R01..R08 -> "R01"), because on an 8 x 1 EPIC
# chip the column is constant and adds nothing.
slide  <- factor(as.character(md$slide))
posrow <- substr(md$array_pos, 1, 3)
vars <- list(chip_slide = slide, array_position = factor(posrow),
             PTSD = factor(md$ptsd), sex = factor(md$sex), age = md$age,
             Neu = props[, "Neu"], CD4T = props[, "CD4T"], CD8T = props[, "CD8T"])

# R^2 of each PC regressed on each variable: a factor enters as its full set of
# dummies, so a many-level factor like chip is compared against the others with
# that caveat in mind (see take-away 2 below).
pc_r2 <- t(sapply(vars, function(x)
  sapply(1:10, function(j) summary(lm(pcs[, j] ~ x))$r.squared)))
colnames(pc_r2) <- paste0("PC", 1:10)
round(pc_r2, 2)
                PC1  PC2  PC3  PC4  PC5  PC6  PC7  PC8  PC9 PC10
chip_slide     0.06 0.26 0.66 0.43 0.69 0.23 0.20 0.20 0.33 0.23
array_position 0.15 0.04 0.03 0.04 0.03 0.08 0.09 0.05 0.14 0.08
PTSD           0.00 0.00 0.00 0.00 0.00 0.02 0.00 0.01 0.01 0.03
sex            0.01 0.00 0.25 0.00 0.51 0.00 0.03 0.00 0.05 0.03
age            0.06 0.32 0.02 0.00 0.01 0.00 0.00 0.00 0.01 0.01
Neu            0.79 0.11 0.01 0.05 0.00 0.00 0.00 0.00 0.00 0.00
CD4T           0.14 0.32 0.00 0.09 0.00 0.01 0.00 0.00 0.00 0.01
CD8T           0.66 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.02 0.00
Code
# Long form for geom_tile, with display labels instead of the variable names
# used in the matrix.
nice <- c(chip_slide = "Chip (slide)", array_position = "Array position",
          PTSD = "PTSD", sex = "Sex", age = "Age",
          Neu = "Neutrophil", CD4T = "CD4 T", CD8T = "CD8 T")
hm <- as.data.frame(as.table(pc_r2))
names(hm) <- c("variable", "PC", "r2")
# rev() so the first variable in `nice` ends up at the top of the y axis
hm$variable <- factor(nice[as.character(hm$variable)], levels = rev(unname(nice)))
hm$PC <- factor(hm$PC, levels = paste0("PC", 1:10))

p_hm <- ggplot(hm, aes(PC, variable, fill = r2)) +
  geom_tile(color = "white", linewidth = 0.7) +
  # white numbers on the dark tiles, dark on the pale ones
  geom_text(aes(label = sub("^0", "", sprintf("%.2f", r2)), color = r2 > 0.45),
            size = 2.9, show.legend = FALSE) +
  scale_color_manual(values = c(`TRUE` = "white", `FALSE` = "#3A362F")) +   # gray_dark
  scale_fill_gradient(low = "#F4F2EE", high = "#0F3D43", limits = c(0, 1),   # teal_dark
                      name = expression(R^2)) +
  # each PC's share of variance printed under its label, so the heatmap says how
  # much of the data each column actually represents
  scale_x_discrete(position = "top", labels = function(x)
    sprintf("%s\n%.1f%%", x, 100 * pve[match(x, paste0("PC", 1:10))])) +
  labs(x = NULL, y = NULL,
       title = "What the leading principal components are made of",
       subtitle = "Cell composition owns PC1; chip and sex are entangled on PC5; PTSD is nowhere",
       caption = "Percentages under each label are the variance that component explains.") +
  theme(panel.grid = element_blank(), panel.border = element_blank(),
        axis.text.x.top = element_text(color = "#6E675B", lineheight = 1.15))  # gray
p_hm

Association (R²) between the top 10 principal components and each candidate variable. Computed on the 20,000 most variable probes — a subset used here only to keep this diagnostic fast; the SVA below runs on the full filtered probe set. Cell-composition variables (Neu, CD8T) dominate PC1; age and CD4T explain PC2; sex and chip are entangled on PC5; chip smears across many PCs. PTSD — the exposure of interest — is essentially absent, the honest reality of a subtle epigenetic effect.
Code
# The repository tracks a copy of this figure; rewrite it here so the file on
# disk is always the one the code above just produced.
ggsave("data/05_pc_heatmap.png", plot = p_hm, width = 9.6, height = 4.6, dpi = 200)

Key take-aways from the heatmap:

  1. The largest axis of variation is biology you already modeled — cell composition. PC1 (15% of variance) is almost entirely neutrophil fraction (\(R^2\) = 0.79) and CD8T (\(R^2\) = 0.66). This is why cell composition came first: if you skip it, your biggest “batch effect” is actually unmodeled cell mixture.
  2. Chip is a real technical effect, associating with several PCs (peaking at \(R^2\) = 0.69). Some of this magnitude is inflated because slide has many levels (11 degrees of freedom soak up variance mechanically), so we interpret chip effects with the adjusted \(R^2\) and with the confounding check below, not the raw number alone.
  3. PTSD doesn’t explain much. The exposure explains almost none of the leading variance. This is not a cause for alarm. Real epigenetic effects of a psychiatric phenotype are small and spread across many probes, making them practically invisible at the whole-genome PCA level. The association test in analysis is designed to find these signals.
Code
sc <- data.frame(PC1 = pcs[, 1], PC2 = pcs[, 2], Neu = 100 * props[, "Neu"])

p_sc <- ggplot(sc, aes(PC1, PC2, color = Neu)) +
  geom_point(size = 2.7, alpha = 0.92) +
  # a single-hue ramp: neutrophil fraction is a quantity, not a category, so pale
  # to teal reads as "more" without implying groups
  scale_color_gradient(low = "#DCE9EA", high = "#0F3D43",              # teal_dark
                        name = "Neutrophil\nfraction",
                        labels = function(x) paste0(x, "%")) +
  # variance explained goes in the axis titles, so the scatter is readable alone
  labs(x = sprintf("PC1 (%.1f%% of variance)", 100 * pve[1]),
       y = sprintf("PC2 (%.1f%%)", 100 * pve[2]),
       title = "PC1 of a blood methylome is immune-cell mixture",
       subtitle = "Each point is one array, colored by its estimated neutrophil fraction")
p_sc

Samples in PC1–PC2 space, colored by estimated neutrophil fraction. The leading component is a smooth gradient in cell composition — a textbook illustration that PC1 of a blood methylome is usually immune-cell mixture, not the phenotype under study.
Code
ggsave("data/05_pc_scatter_neu.png", plot = p_sc, width = 7.4, height = 5, dpi = 200)

3. The crucial step: is the batch factor confounded?

Detecting a batch effect is not enough. Before choosing a correction you must ask whether the technical factor is confounded with something you care about, because that determines which corrections are even possible. The diagnostic is a simple cross-tabulation (Cramér’s V for two categorical variables):

Code
# Cramer's V rescales a chi-squared statistic to [0, 1] so that two tables of
# different sizes are comparable: 0 is independence, 1 means one variable is a
# relabelling of the other. chisq.test() warns about small expected counts in a
# table this sparse, which is expected here and not informative, so it is silenced.
cramV <- function(a, b){
  t <- table(a, b); if (any(dim(t) < 2)) return(NA)
  chi <- suppressWarnings(chisq.test(t)$statistic)
  as.numeric(sqrt((chi / sum(t)) / (min(dim(t)) - 1)))
}
# All 96 arrays: this asks how the design was built, not how a model behaves.
conf <- data.frame(
  pair = c("chip × PTSD", "chip × sex", "position × PTSD", "position × sex"),
  cramers_V = round(c(
    cramV(md$slide, md$ptsd), cramV(md$slide, md$sex),
    cramV(substr(md$array_pos,1,3), md$ptsd),
    cramV(substr(md$array_pos,1,3), md$sex)), 2))
knitr::kable(conf, caption = "Confounding between technical factors and biological variables (Cramér's V; 0 = independent, 1 = perfectly confounded).")
Confounding between technical factors and biological variables (Cramér’s V; 0 = independent, 1 = perfectly confounded).
pair cramers_V
chip × PTSD 0.24
chip × sex 0.93
position × PTSD 0.27
position × sex 0.10

This results of this can greatly impact how you proceed with correcting for batch effects. In this case:

  • chip × PTSD = 0.24 — low. The exposure is well mixed across chips (all 12 chips in the subset carry both cases and controls, by design of the subset selection). Chip effects can therefore be adjusted without harming the PTSD contrast.
  • chip × sex = 0.93 — nearly total. In the Grady Trauma Project, samples were plated by sex: 135 of the 137 chips in the full study are single-sex, and our subset faithfully reproduces this (10 of 12 chips are single-sex). Chip and sex are essentially the same variable.
ImportantWhy the chip × sex confound matters

Sex is a strong source of variation in methylation data, so as a rule sex should be accounted for in the model design. Here chip is collinear with sex. If you put both chip and sex in the design as fixed effects, the model cannot separate them and the sex coefficient and the chip coefficients become unidentifiable.(Leek et al. 2010).

4. Choosing a correction strategy

The correction methods, compared

ComBat (Johnson et al. 2007) is the most widely used named-batch corrector. For a batch factor you recorded (plate, chip, scan date), it fits a location-and-scale model per batch and uses empirical Bayes to shrink each batch’s mean and variance toward the global estimate — borrowing strength across probes so small batches are corrected stably. You protect the biology by passing a model matrix of covariates to keep (mod), and ComBat removes only the batch-associated shift:

library(sva)
mod <- model.matrix(~ ptsd + sex + age + Neu + CD4T + CD8T + Bcell + Mono, data = md)
M_combat <- ComBat(dat = M, batch = md$plate, mod = mod)   # M = M-value matrix

ComBat is fast, matrix-native, and the default in many pipelines. But it applies a global, parametric adjustment: every sample in a batch is shifted by the same estimated location/scale, under the assumption that the batch effect is a single additive+multiplicative offset shared by all probes in that batch. When that assumption doesn’t hold, the global shift can introduce distortion, and because it also alters the variance structure it can inflate false positives if the design is unbalanced. This is the core reason some analysts prefer to leave the data untouched and instead model the batch (as a covariate or via surrogate variables), rather than overwrite the matrix.

NoteAn alternative philosophy: distribution-aware correction (BeCLEAR)

Not everyone accepts the global-shift assumption. Methods such as BeCLEAR take a different tack: rather than shifting every sample by one batch-level offset, they detect batch effects by comparing distributions — using the Kolmogorov–Smirnov test and median differences between batches at each probe — and correct only where a distributional difference is actually present. The appeal is that it does not force a uniform parametric adjustment on probes that don’t need one.

However, BeCLEAR was not designed to scale to large EPIC datasets — the per-probe, per-batch distributional testing is computationally heavy — and it is far less widely validated and supported than ComBat or SVA. For a teaching pipeline, and for most production EWAS, we stay with the standard tools; but it is worth knowing that the “global adjustment” of ComBat is a choice, not a law, and that distribution-aware alternatives exist for cases where it matters.

SVA and RUV — latent-variable correction:

  • SVA (Leek et al. 2012) discovers unnamed structure and returns surrogate variables you add as covariates. Use it for unknown or unmeasured technical or even biological variation (such as smoking) which can cause systemic methylation changes that confound the association testing.
  • RUV (Gagnon-Bartsch and Speed 2012) uses negative-control probes (or replicate samples) to estimate unwanted variation. A good choice when reliable control probes exist.

Latent variable methods are not mutually exclusive with ComBat: a common recipe is ComBat for a clean, recorded, un-confounded plate effect, then SVA for whatever residual structure remains. That is close to what we do here — with one modification forced by the chip × sex confound, described next.

The design we actually use: ComBat within sex strata

The confounding between Chip and sex adds a layer of complexity to adjusting for batch effects in the Grady Trauma Project. While this particular design flaw may not be common, it highlights the importance of looking critically at the relationship between technical and biological variables relevant to your analysis. Another dataset may not have confounding between chip and sex, but may have confounding by something else, or may not have any confounding at all! What matters is that you check for it so that you can address it appropriately.

Here, chip is a known variable, so ideally we would use ComBat to adjust for this. However, because of the collinearity with sex, correcting chip across the whole cohort would partly remove biological signal from sex. Passing sex in ComBat’s mod is supposed to ‘protect’ it, but this only works when the batch and the covariate are not confounded.

There are a number of ways this could be handled, but the way we chose and recommend is to stratify the correction. Within the female samples alone, chip is no longer confounded with sex — there is only one sex present — so chip is a clean batch factor, and the same holds within the males. So we run ComBat on slide separately in each sex stratum and then recombine:

## ComBat on chip, separately within each sex stratum, then recombine.
## Within a stratum, chip is no longer confounded with sex.
##
## This is the call; it is *run* in section 6, not here, because `smoke` -- the
## methylation-derived smoking covariate ComBat has to protect -- is not built
## until section 5. Nothing about the design changes in between.
M_adj <- M
for (s in c("F", "M")) {
  i <- which(md$sex == s)
  mod_s <- model.matrix(~ ptsd + age + smoke + Neu + NK + CD4T + CD8T + Bcell + Mono,
                        data = md[i, ])
  M_adj[, i] <- sva::ComBat(dat = M[, i], batch = factor(as.character(md$slide[i])),
                            mod = mod_s, mean.only = TRUE, par.prior = TRUE)
}

Two choices in that call are worth explaining:

  • mean.only = TRUE corrects each chip’s mean but not its variance. ComBat’s default is to adjust both, and this is normally fine. In this specific case, there are two chips which have males and females present. When we split by sex to do the stratified ComBat adjustment, we end up with a problematic splitting of the sexes on one of the chips.

    Code
    ## The 87 modeled samples, split the way ComBat will split them. `slide` is the
    ## Sentrix barcode of the chip, so it is the batch label itself; splitting it by
    ## sex shows the batch sizes each stratum's ComBat call will actually see.
    for (s in c("F", "M")) {
      tb <- table(as.character(mdk$slide)[mdk$sex == s])
      cat(sprintf("%s stratum: n = %2d across %d chips | chip sizes: %s\n",
                  s, sum(tb), length(tb), paste(sort(as.vector(tb)), collapse = ", ")))
    }
    F stratum: n = 45 across 7 chips | chip sizes: 3, 6, 7, 7, 7, 7, 8
    M stratum: n = 42 across 7 chips | chip sizes: 1, 5, 6, 7, 7, 8, 8
    Code
    ## A chip contributing one sample to a stratum is a batch of size one, which is
    ## the situation that forces the mean.only choice discussed above.
    cat("\nchips contributing a single sample to a stratum:\n")
    
    chips contributing a single sample to a stratum:
    Code
    for (s in c("F", "M")) {
      tb <- table(as.character(mdk$slide)[mdk$sex == s])
      if (any(tb == 1)) cat(sprintf("  %s: %s\n", s, paste(names(tb)[tb == 1], collapse = ", ")))
    }
      M: 201114400024

    Chip 201114400024 carries 6 females and 1 male. In the male stratum that chip is a batch of size one, and a batch of one has no within-batch variance to estimate — the scale parameter for that chip is unidentifiable. sva::ComBat checks for exactly this and switches to mean.only = T from its default of mean.only = F. So, if we did not explicitly specify mean.only = T, the female arm would end up being corrected differently from the male arm.

    Note where that singleton came from. Across all 96 arrays this chip carries 6 females and 2 males; one of those two males is among the nine samples with no PTSD status (chapter 01), so it leaves the modeled set and the chip drops to a single male. The incomplete phenotype did not just cost nine samples — it changed which batch corrections are estimable. Missing metadata propagates into methods choices in ways that are easy to miss if you only look at the final n.

  • par.prior = TRUE uses the parametric empirical-Bayes prior. It is the stable choice at this sample size; the non-parametric alternative needs more samples per batch than we have.

The correction works: chip explains R² ≈ 0.20 (female) and 0.14 (male) of the leading residual structure before using ComBat and is reduced after, and PTSD retains R² ≈ 0.01 in both strata — the batch signal comes down without affecting the exposure.

Array position

Chip position (array_pos) is the second measured technical factor, and it is not confounded with anything biological here. While in theory we could use ComBat to adjust for this as well in the chip-adjusted-recombined dataset, in practice this is not the best choice. The array_pos only has 8 levels, so it’s cost in degrees of freedom (7) in modeling array_pos as a covariate is acceptable for the sample size we are testing in.

5. Constructing a smoking proxy when smoking is not recorded

Smoking is the single largest environmental driver of the blood methylome (Joehanes et al. 2016; Zeilinger et al. 2013), and it is comorbid with PTSD. But, GSE132203’s public phenotype table has no smoking variable — a very common situation with GEO datasets. Fortunately, this is something we can address with the methylation data itself.

We can take CpGs that the literature has established as smoking-associated, and summarize their methylation into a single continuous score. We use a 20-CpG panel of loci replicated across the large smoking EWAS (Joehanes et al. 2016; Zeilinger et al. 2013; Shenker et al. 2013; Elliott et al. 2014), anchored on cg05575921 in AHRR — the most reproducible smoking-associated CpG in blood:

Code
# The panel, grouped by locus so the provenance of each CpG stays visible. These
# are the loci that replicate across the smoking EWAS cited above; grouping also
# makes it obvious that the score is not driven by a single gene.
panel <- list(
  AHRR      = c("cg05575921","cg21161138","cg23576855","cg25648203","cg26703534","cg11902777"),
  F2RL3     = c("cg03636183","cg21911711"),
  GPR15     = c("cg19859270"),
  `2q37.1`  = c("cg05951221","cg21566642","cg01940273","cg03329539","cg06126421"),
  GFI1      = c("cg09935388","cg12876356","cg18146737","cg06338710"),
  MYO1G     = c("cg12803068","cg22132788","cg04180046","cg19089201"),
  PRSS23    = c("cg14391737"),
  RARA      = c("cg17739917")
)
# Not every published CpG survives chapter 03's probe filtering, so the panel is
# intersected with the probes actually present. `panel_probes` is what the score
# is built from -- and, below, what has to come out of the tested set.
pv <- unlist(panel, use.names = FALSE)
panel_probes <- pv[pv %in% rownames(M)]

cat("Panel CpGs (n =", length(panel_probes), "):\n")
Panel CpGs (n = 20 ):
Code
cat(strwrap(paste(panel_probes, collapse = ", "), width = 78), sep = "\n")
cg05575921, cg21161138, cg25648203, cg26703534, cg11902777, cg03636183,
cg21911711, cg19859270, cg21566642, cg01940273, cg03329539, cg09935388,
cg12876356, cg18146737, cg06338710, cg12803068, cg04180046, cg19089201,
cg14391737, cg17739917
Code
cat("published CpGs dropped by probe filtering:",
    paste(setdiff(pv, panel_probes), collapse = ", "), "\n")
published CpGs dropped by probe filtering: cg23576855, cg05951221, cg06126421, cg22132788 
Code
## PC1 of the replicated smoking panel, oriented so that higher = heavier exposure.
## Built on the 87 modeled arrays, so the score describes the variance of the set
## the EWAS is actually fit on.
##
## The panel betas are read back off `grs` rather than taken from `M`: 20 rows of
## a 756,273-row object is a cheap subset, and the score is defined on the beta
## scale.
Bp <- getBeta(grs[panel_probes, keep])

## Each probe is z-scored across samples first, so that a probe with a wide beta
## range does not dominate PC1 purely because of its scale. scale. = FALSE in
## prcomp() then, because the scaling has already happened.
Z  <- t(scale(t(Bp)))
Z  <- Z[rowSums(is.na(Z)) == 0, , drop = FALSE]
pc <- prcomp(t(Z), center = TRUE, scale. = FALSE)
smoke     <- pc$x[, 1]
panel_pve <- pc$sdev^2 / sum(pc$sdev^2)

## AHRR cg05575921 is hypomethylated in smokers, so flip the sign if needed
## to make the score increase with exposure rather than decrease.
if (cor(smoke, Bp["cg05575921", ], use = "complete.obs") > 0) smoke <- -smoke
## Standardized, so the proxy enters every model below in units of one SD.
smoke <- as.numeric(scale(smoke))

cat("panel PC1 variance explained:", sprintf("%.1f%%", 100 * panel_pve[1]), "\n")
panel PC1 variance explained: 52.9% 
Code
cat("correlation of the score with the AHRR anchor:",
    round(cor(smoke, Bp["cg05575921", ], use = "complete.obs"), 3),
    "(negative = higher score means less AHRR methylation)\n")
correlation of the score with the AHRR anchor: -0.908 (negative = higher score means less AHRR methylation)

PC1 captures 53% of the variance across the panel, consistent with the panel behaving as one coherent axis rather than 20 independent probes.

Code
# `Mk` is the matrix everything downstream is estimated on: the 87 modeled arrays,
# with the proxy's own panel probes removed and any probe that is non-finite in
# *any* of those 87 samples dropped -- ComBat and sva() both refuse an Inf.
excl <- rownames(M) %in% panel_probes
Mk   <- M[!excl, keep, drop = FALSE]
Mk   <- Mk[rowSums(!is.finite(Mk)) == 0, , drop = FALSE]

excluded_n    <- sum(excl)
tested_probes <- nrow(Mk)
cat("excluded panel probes:", excluded_n,
    "| non-finite probes dropped:", nrow(M) - excluded_n - tested_probes,
    "| probes carried into modeling:", format(tested_probes, big.mark = ","), "\n")
excluded panel probes: 20 | non-finite probes dropped: 2 | probes carried into modeling: 756,251 
Code
# The full 96-sample matrix is no longer needed; `top` keeps the variable-probe
# names for the before/after diagnostic in section 6.
rm(M); invisible(gc())

The panel probes must be excluded from the EWAS

A proxy built from methylation is a linear combination of methylation. If those same 20 CpGs stay in the tested probe set, you are regressing each of them partly on itself — guaranteed significance that means nothing. We therefore drop all 20 panel probes before testing. Together with two probes whose M-values are non-finite in this 87-sample subset (cg17759086, cg01801182 — β pinned at 0 or 1, so log₂(β/(1−β)) is ±∞), that is why the EWAS reports 756,251 CpGs rather than the 756,273 that survived filtering in chapter 03.

The same logic applies to any methylation-derived covariate — epigenetic age, cell proportions estimated from the same array, an EpiSmokEr score (Bollepalli et al. 2019). Know which probes went into it, and take them out.

NoteA proxy is not the phenotype

This score is a measurement of methylation that correlates with smoking, not a measurement of smoking. There are two characteristics of this proxy score worth stating. First, it is a continuous variable vs a categorical. A score doesn’t distinguish “current smoker” from “recently quit heavy smoker”. It is possible to convert this continuous scale into categories, and some tools that do return ‘smoker’ vs ‘non-smoker’, if this would be preferred to a continuous scale. Second, some smoking-associated CpGs act partly through shifts in cell composition (Bauer et al. 2015), so the proxy and the cell-proportion covariates are not fully independent. If real smoking data exists (and is reliable), use it; a proxy is what you do when it doesn’t.

6. Surrogate Variable Analysis, done correctly

After doing ComBat for chip, adding array_pos as a covariate, and creating the smoking proxy, we then use SVA to create surrogate variables for any additional unknown/unmeasured variation. For example, we know that plate and well can cause technical variation, but these were not reported in the Grady Trauma Project phenotypes, so we can use SVA to hopefully capture any remaining technical variation from these types of batch effects if they are present. SVA is run on M-values with a full model (everything you want to keep) and a null model (everything except the exposure). We ‘protect’ PTSD, sex, age, the smoking proxy, array position, and the six estimated cell-type proportions:

Code
# The stratified ComBat of section 4, run now that `smoke` exists. About 57
# seconds for the two strata on this matrix.
#
# droplevels() matters twice over: a stratum carries only some of the 12 chips, and
# ComBat cannot estimate a location for a batch level with no samples in it.
sexk    <- factor(mdk$sex)
slidek  <- droplevels(factor(as.character(mdk$slide)))
cellk   <- as.data.frame(propk)[, c("Neu", "NK", "CD4T", "CD8T", "Bcell", "Mono")]

Mcb <- Mk
for (s in levels(sexk)) {
  i  <- which(sexk == s)
  bs <- droplevels(slidek[i])
  # sex is constant inside a stratum, so it is not -- and cannot be -- in cb_mod.
  # Everything else we intend to keep is passed here so ComBat does not remove it
  # along with the chip shift.
  d  <- cbind(cellk[i, , drop = FALSE],
              ptsd = mdk$ptsd[i], age = as.numeric(mdk$age)[i], smoke = smoke[i])
  cb_mod <- model.matrix(~ ptsd + age + smoke + Neu + NK + CD4T + CD8T + Bcell + Mono,
                         data = d)
  cat(sprintf("%s stratum: n = %d | chips = %d | smallest chip = %d\n",
              s, length(i), nlevels(bs), min(table(bs))))
  Mcb[, i] <- ComBat(dat = Mk[, i, drop = FALSE], batch = bs, mod = cb_mod,
                     par.prior = TRUE, mean.only = TRUE, prior.plots = FALSE)
}
F stratum: n = 45 | chips = 7 | smallest chip = 3
M stratum: n = 42 | chips = 7 | smallest chip = 1
Code
# Did the correction do what it was supposed to, and did sex survive it?
#
# Per-probe R^2 against a factor, for a whole matrix in one call: lm.fit() on the
# transposed matrix fits all probes simultaneously, which is the difference
# between seconds and an hour of looping lm() over 20,000 probes.
mean_r2 <- function(X, f) {
  Y   <- t(X)
  rss <- colSums(resid(lm.fit(model.matrix(~ f), Y))^2)
  tss <- colSums(sweep(Y, 2, colMeans(Y))^2)
  mean(1 - rss / tss)
}

# Judge the correction on the same 20,000 variable probes the PCA used -- that is
# where batch structure is visible in the first place.
diag_probes <- intersect(top, rownames(Mk))
report <- function(label, f)
  cat(sprintf("mean per-probe R2 vs %-22s %.4f -> %.4f\n", label,
              mean_r2(Mk[diag_probes, ], f), mean_r2(Mcb[diag_probes, ], f)))

report("chip (should fall)", slidek)
mean per-probe R2 vs chip (should fall)     0.1529 -> 0.1095
Code
report("sex (must survive)", sexk)          # ComBat was never told about sex
mean per-probe R2 vs sex (must survive)     0.0238 -> 0.0271
Code
report("PTSD (must survive)", mdk$ptsd)     # the exposure must be untouched
mean per-probe R2 vs PTSD (must survive)    0.0086 -> 0.0094
Code
# Array position enters as a fixed covariate rather than being ComBat-ed: all
# eight levels are present, unconfounded, and cost only 7 degrees of freedom.
pos <- factor(as.character(mdk$array_pos))
mdm <- cbind(cellk, ptsd = mdk$ptsd, sex = sexk, age = as.numeric(mdk$age),
             smoke = smoke, pos = pos)

mod  <- model.matrix(~ ptsd + sex + age + smoke + pos + Neu + NK + CD4T + CD8T + Bcell + Mono, data = mdm)
mod0 <- model.matrix(~        sex + age + smoke + pos + Neu + NK + CD4T + CD8T + Bcell + Mono, data = mdm)
stopifnot(qr(mod)$rank == ncol(mod))       # a rank-deficient full model makes sva() meaningless

# num.sv()'s Buja-Eyuboglu test on the uncorrected matrix: how many dimensions of
# structure are *detectable* before any correction. This and the sva() call below
# are the expensive pair in the chapter -- together roughly 5 minutes, peaking
# near 8.9 GB, because both work on the full 756,251 x 87 matrix.
n_sv_be <- num.sv(Mk, mod, method = "be")

# We fit 6, not n_sv_be -- see the k-selection sweep below for why. The sweep
# compared these candidates; 6 is the one adopted.
k_candidates <- c(6, 8, 10, 15)
k_selected   <- 6
svobj <- sva(Mcb, mod, mod0, n.sv = k_selected)
Number of significant surrogate variables is:  6 
Iteration (out of 5 ):1  2  3  4  5  
Code
SV <- svobj$sv                            # add these as covariates in the EWAS
colnames(SV) <- paste0("SV", seq_len(ncol(SV)))
cat("num.sv (Buja-Eyuboglu):", n_sv_be, "| surrogate variables carried forward:",
    ncol(SV), "\n")
num.sv (Buja-Eyuboglu): 14 | surrogate variables carried forward: 6 

Note what is in the null model as well as the full one. Anything you put in mod0 is variation SVA is told not to bother capturing, because you are already modeling it explicitly.

Note that SVA runs on the full filtered probe set, not the 20,000-probe subset used for the PCA diagnostic above. The PCA subset exists only to make the variable-association heatmap legible; the surrogate variables that go into the model are estimated from every probe that survived filtering.

How many surrogate variables? (choosing k)

num.sv’s Buja–Eyuboglu permutation test suggests 14 SVs here. We fit 6. num.sv answers how many dimensions of residual structure are statistically detectable — and with 756,251 probes, plenty are detectable, but are also tiny. What we need to know is how many are worth a degree of freedom in modeling, and at n = 87, degrees of freedom are a constraint. So, we swept k over 6, 8, 10, 15 and judged each fit on two things: calibration (genomic inflation λ, which should sit near 1) and precision (the median standard error of the PTSD coefficient, which should fall if the SVs are removing real noise).

Code
# This is the sweep behind data/05_k_lambda_sweep.csv, and it runs at render.
# Each candidate k gets its own sva() fit plus a full-probe limma fit, but n.sv is
# supplied here, so each fit skips the Buja-Eyuboglu estimation that num.sv() does
# above -- which is what makes the four candidates together cost about two and a
# half minutes, rather than the several minutes the single estimated fit above
# took on its own. The peak is comparable to that fit's (~9 GB). The small table
# it writes is committed to the repository, and the chunk below reads it back, so
# the figure and the text quote one set of numbers.

# Genomic inflation: the median observed chi-squared over its null expectation.
# 1 is calibrated, > 1 inflated, < 1 deflated.
lam <- function(p) median(qchisq(1 - p, 1), na.rm = TRUE) / qchisq(0.5, 1)
coefn <- "ptsdCase"          # the PTSD contrast, with Control as the reference level

# k = 0 is the reference point for every SE comparison: the same design without
# any surrogate variables.
fit0 <- eBayes(lmFit(Mcb, mod))
tt0  <- limma::topTable(fit0, coef = coefn, number = Inf, sort.by = "none")
se0  <- sqrt(fit0$s2.post) * fit0$stdev.unscaled[, coefn]

rows <- list(data.frame(
  k = 0, npar = ncol(mod), resid_df = nrow(mdk) - ncol(mod),
  lambda = lam(tt0$P.Value), med_se = median(se0), se_ratio = 1, frac_gain = NA,
  n_p1e5 = sum(tt0$P.Value < 1e-5), n_fdr = sum(tt0$adj.P.Val < 0.05),
  n_bonf = sum(tt0$P.Value < 0.05 / nrow(tt0)),
  r2_slide = NA, r2_smoke_sv = NA, max_r2_sv_ptsd = NA, strata_full_rank = TRUE,
  df_F = 45 - (ncol(mod) - 1), df_M = 42 - (ncol(mod) - 1)))

for (K in k_candidates) {
  set.seed(42)                                  # same seed at every k
  SVk <- sva(Mcb, mod, mod0, n.sv = K)$sv       # SVA's native order, first K
  colnames(SVk) <- paste0("SV", seq_len(K))
  dm  <- cbind(mod, SVk)
  fit <- eBayes(lmFit(Mcb, dm))
  tt  <- limma::topTable(fit, coef = coefn, number = Inf, sort.by = "none")
  se  <- sqrt(fit$s2.post) * fit$stdev.unscaled[, coefn]

  # What chip structure is still left once this design has been fitted: residuals
  # first, then how much of them chip explains.
  R <- t(resid(lm.fit(dm, t(Mcb[diag_probes, ]))))

  # The stratified fits in chapter 07 drop sex and refit per stratum, so check
  # that the design is still full rank there before adopting a k.
  full_rank <- all(sapply(levels(sexk), function(lv) {
    i  <- which(sexk == lv)
    ds <- cbind(model.matrix(~ ptsd + age + smoke + pos + Neu + NK + CD4T + CD8T +
                               Bcell + Mono, data = mdm)[i, , drop = FALSE],
                SVk[i, , drop = FALSE])
    qr(ds)$rank == ncol(ds)
  }))

  rows[[length(rows) + 1]] <- data.frame(
    k = K, npar = ncol(dm), resid_df = nrow(mdk) - ncol(dm),
    lambda = lam(tt$P.Value), med_se = median(se),
    se_ratio = median(se / se0),
    # fraction of probes whose SE improved relative to k = 0
    frac_gain = mean(se < se0),
    n_p1e5 = sum(tt$P.Value < 1e-5), n_fdr = sum(tt$adj.P.Val < 0.05),
    n_bonf = sum(tt$P.Value < 0.05 / nrow(tt)),
    r2_slide = mean_r2(R, slidek),
    # how much of the named smoking covariate the SVs have re-absorbed
    r2_smoke_sv = summary(lm(smoke ~ SVk))$r.squared,
    max_r2_sv_ptsd = max(sapply(seq_len(K), function(j)
      summary(lm(SVk[, j] ~ mdk$ptsd))$r.squared)),
    strata_full_rank = full_rank,
    # residual df left in each sex stratum, which is the real cost of large k
    df_F = 45 - (ncol(dm) - 1), df_M = 42 - (ncol(dm) - 1))
}
Number of significant surrogate variables is:  6 
Iteration (out of 5 ):1  2  3  4  5  Number of significant surrogate variables is:  8 
Iteration (out of 5 ):1  2  3  4  5  Number of significant surrogate variables is:  10 
Iteration (out of 5 ):1  2  3  4  5  Number of significant surrogate variables is:  15 
Iteration (out of 5 ):1  2  3  4  5  
Code
ks <- do.call(rbind, rows)
write.csv(ks, "data/05_k_lambda_sweep.csv", row.names = FALSE)
Code
# Read the committed sweep back. It is a few hundred bytes and lives in the
# repository, so this works whether or not you ran the chunk above.
ks <- read.csv("data/05_k_lambda_sweep.csv")
knitr::kable(
  ks[, c("k", "npar", "resid_df", "lambda", "med_se", "se_ratio", "r2_smoke_sv", "df_F", "df_M")],
  digits = c(0, 0, 0, 4, 5, 4, 3, 0, 0),
  col.names = c("k", "n par", "resid df", "lambda", "median SE", "SE ratio",
                "R2(smoke~SV)", "resid df F", "resid df M"),
  caption = "Surrogate-variable count sweep. `SE ratio` is the median standard error relative to k = 0; `R2(smoke~SV)` is how much of the smoking proxy the SVs collectively absorb.")
Surrogate-variable count sweep. SE ratio is the median standard error relative to k = 0; R2(smoke~SV) is how much of the smoking proxy the SVs collectively absorb.
k n par resid df lambda median SE SE ratio R2(smoke~SV) resid df F resid df M
0 18 69 0.8458 0.05577 1.0000 NA 28 25
6 24 63 1.0217 0.04988 0.9492 0.394 22 19
8 26 61 1.0462 0.04938 0.9358 0.443 20 17
10 28 59 1.0724 0.04878 0.9231 0.462 18 15
15 33 54 1.0713 0.05025 0.9454 0.636 13 10

Reading down that table:

  • k = 0 is clearly wrong. λ = 0.85 is badly deflated, the signature of unmodeled structure inflating residual variance and washing out real differences.
  • k = 6 fixes the calibration (λ = 1.02) and captures nearly all of the available precision gain — the median SE drops about 5%, which is 76% of the total improvement available anywhere in the sweep.
  • k = 8, 10, and 15 buy almost nothing and cost real power. By k = 10, λ has drifted up to 1.07, which is still an acceptable lambda, and the SEs have barely moved, but the burden of degrees of freedom becomes problematic.
  • The SVs increasingly re-capture the smoking proxy. R² of smoke on the SVs climbs from 0.39 at k = 6 to 0.64 at k = 15. Since we specifically want smoking as a named, interpretable covariate, letting latent variables absorb it is a loss, not a gain.

k = 6 is the smallest k that gets λ to 1 and that leaves the most residual degrees of freedom.

Code
# What each retained SV tracks. A surrogate variable is only interpretable
# through its associations, so every candidate explanation gets a column --
# including PTSD, because an SV that tracks the exposure is absorbing signal
# rather than confounding.
r2 <- function(y, x) summary(lm(y ~ x))$r.squared
sv_table <- data.frame(
  SV       = seq_len(ncol(SV)),
  r2_slide = sapply(seq_len(ncol(SV)), function(j) r2(SV[, j], slidek)),
  r2_pos   = sapply(seq_len(ncol(SV)), function(j) r2(SV[, j], pos)),
  r2_smoke = sapply(seq_len(ncol(SV)), function(j) r2(SV[, j], smoke)),
  r2_ptsd  = sapply(seq_len(ncol(SV)), function(j) r2(SV[, j], mdk$ptsd)),
  r2_age   = sapply(seq_len(ncol(SV)), function(j) r2(SV[, j], as.numeric(mdk$age))),
  r2_sex   = sapply(seq_len(ncol(SV)), function(j) r2(SV[, j], sexk)))

knitr::kable(sv_table, digits = 4,
  col.names = c("SV", "R2 slide", "R2 position", "R2 smoke", "R2 PTSD", "R2 age", "R2 sex"),
  caption = "What each surrogate variable tracks. Chip (slide) and array position dominate; association with PTSD stays below 0.05 throughout.")
What each surrogate variable tracks. Chip (slide) and array position dominate; association with PTSD stays below 0.05 throughout.
SV R2 slide R2 position R2 smoke R2 PTSD R2 age R2 sex
1 0.1337 0.0945 0.0308 0.0008 0.1469 0.0012
2 0.0426 0.1176 0.0690 0.0014 0.0211 0.0005
3 0.0749 0.1140 0.1298 0.0017 0.0956 0.0100
4 0.3401 0.0570 0.1576 0.0403 0.0025 0.3727
5 0.6427 0.0229 0.0063 0.0011 0.0030 0.4570
6 0.1887 0.2283 0.0008 0.0251 0.0023 0.0021

SV5 is strongly chip-linked (R² = 0.64) and SV4 next (R² = 0.34). Note that SV4 and SV5 also carry substantial sex association (R² = 0.37 and 0.46) — a direct consequence of the chip × sex confound documented above.

Code
# Three of the sv_table columns, in long form. The factor levels are set
# explicitly so the legend reads technical -> biological -> exposure, which is
# the order the caption discusses them in.
bars <- rbind(
  data.frame(SV = sv_table$SV, r2 = sv_table$r2_slide, what = "Chip (technical)"),
  data.frame(SV = sv_table$SV, r2 = sv_table$r2_sex,   what = "Sex (biological)"),
  data.frame(SV = sv_table$SV, r2 = sv_table$r2_ptsd,  what = "PTSD (exposure)"))
bars$SV   <- factor(paste0("SV", bars$SV), levels = colnames(SV))
bars$what <- factor(bars$what, levels = c("Chip (technical)", "Sex (biological)",
                                          "PTSD (exposure)"))

p_sv <- ggplot(bars, aes(SV, r2, fill = what)) +
  geom_col(position = position_dodge(width = 0.78), width = 0.7) +
  scale_fill_manual(values = c("Chip (technical)" = "#0F3D43",   # teal_dark
                               "Sex (biological)" = "#B8873F",   # sand
                               "PTSD (exposure)"  = "#8C3A4A"),  # plum
                    name = NULL) +
  # fixed 0-1 axis: the point of the figure is how small the PTSD bars are, which
  # a free axis would hide by rescaling them
  scale_y_continuous(limits = c(0, 1), expand = expansion(mult = c(0, 0.04))) +
  labs(x = NULL, y = expression(R^2),
       title = "The surrogate variables absorb chip, not the exposure",
       subtitle = sprintf(paste("Buja-Eyuboglu suggested %d SVs on the full filtered matrix;",
                                "the %d carried into the model are shown"),
                          n_sv_be, ncol(SV))) +
  theme(legend.position = "top")
p_sv

Association (R²) of each surrogate variable with chip (technical), sex (biological), and PTSD (the exposure). The SVs carry the chip structure — SV5 reaches R² = 0.64 — and the sex signal entangled with it, while every SV stays near-orthogonal to PTSD (largest R² = 0.04). This is SVA working as intended: it absorbs unwanted technical variation without touching the signal we want to test.
Code
ggsave("data/05_sva_bars.png", plot = p_sv, width = 8.4, height = 4.4, dpi = 200)
Code
# The two numbers that decide whether the SVA was worth running: the SVs should
# be strongly chip-linked and near-orthogonal to the exposure.
chip_r2 <- sapply(1:ncol(SV), function(j) summary(lm(SV[,j] ~ factor(mdk$slide)))$r.squared)
ptsd_r2 <- sapply(1:ncol(SV), function(j) summary(lm(SV[,j] ~ factor(mdk$ptsd)))$r.squared)
cat(sprintf("Strongest chip-linked SV: SV%d (R2 = %.2f)\n", which.max(chip_r2), max(chip_r2)))
Strongest chip-linked SV: SV5 (R2 = 0.64)
Code
cat(sprintf("Largest SV-PTSD association: %.3f (all SVs are ~orthogonal to the exposure)\n",
            max(ptsd_r2)))
Largest SV-PTSD association: 0.040 (all SVs are ~orthogonal to the exposure)

We carry these surrogate variables forward as covariates in the EWAS model, alongside sex, age, and cell composition.

TipDon’t over-correct

More correction is not always better. Each surrogate variable spends a degree of freedom; adding too many, or adding SVs that happen to correlate with your exposure, removes real signal. num.sv gives a principled count; inspect the SV–exposure associations (as above) and, if any SV correlates strongly with the phenotype, treat that as a warning that the exposure and a batch are partially confounded.

Saving what we just computed

The PCA diagnostic, the stratified ComBat matrix, the smoking proxy and the surrogate variables are all inputs to later chapters, so each one is written out here, by the code that produced it.

Code
# 1. The PCA diagnostic. Small, and it is what the variable-association figures
#    are drawn from, so a reader can redraw them without re-running the PCA.
saveRDS(list(pcs = pcs, pve = pve, r2 = pc_r2, md = md, props = props,
             slide = slide, posrow = posrow),
        "data/05_batch_pca.rds")

# 2. The smoking proxy: the score itself, the panel it came from, and the PC1
#    loadings, so that what went into the covariate stays inspectable.
saveRDS(list(smoke = smoke, panel = panel, present = panel_probes,
             pve = panel_pve, loadings = pc$rotation[, 1]),
        "data/05_smoking_proxy.rds")

# 3. The model inputs chapters 06 and 07 read: the surrogate variables, the
#    87-sample metadata and cell proportions they were estimated alongside, and
#    the design record. `k_selected` is what the EWAS uses to slice `SV`.
saveRDS(list(SV = SV, n.sv = ncol(SV), keep = keep, mdk = mdk, propk = propk,
             smoke = smoke, panel_probes = panel_probes, panel_pve = panel_pve,
             excluded_n = excluded_n, tested_probes = tested_probes,
             prev_n_sv = n_sv_be, sv_table = sv_table,
             k_selected = k_selected, k_candidates = k_candidates,
             r2_smoke_sv = summary(lm(smoke ~ SV))$r.squared,
             chip_r2 = chip_r2, pos_r2 = sv_table$r2_pos,
             position_in_model = TRUE,
             design_note = paste("ComBat(slide) within sex strata;",
                                 "array position as fixed covariate; SVA k=6")),
        "data/05_sva.rds")

# 4. The corrected M-value matrix the EWAS is actually fit on. This one is
#    511 MB, which is why it is excluded from git and distributed as a Zenodo
#    tier rather than committed.
saveRDS(Mcb, "data/05_mvals_combat.rds")

The two expensive steps above — the full M-value matrix and the num.sv/sva pair — need about 9 GB of memory between them. If that is more than your machine has, the published checkpoints are the same files this chapter just wrote:

Terminal
./get_data.sh D_filtered E_model_inputs
RStudio Console
sva_o <- readRDS("data/05_sva.rds")
SV    <- sva_o$SV          # 87 x 6 surrogate variables
mdk   <- sva_o$mdk         # the 87 modeled samples
smoke <- sva_o$smoke       # the methylation-derived smoking score
Mcb   <- readRDS("data/05_mvals_combat.rds")   # stratified-ComBat M-values

What this route does not give you is the diagnostic reasoning — you inherit k = 6 and the stratified design as decisions already made, rather than watching the evidence for them accumulate.

7. When you don’t have IDATs: batch correction from a processed matrix

Batch diagnosis and latent-variable correction work on a β/M-matrix, so the non-IDAT path is largely the same — with two caveats:

  1. You may not have the batch annotation. Chip and position come from the IDAT filenames / Sentrix barcodes. If the submitter deposited only a processed matrix, check the sample metadata (pData) for Sentrix_ID, Sentrix_Position, plate, Slide, or a scan-date column — many deposit these even without IDATs. If they are absent, you cannot reconstruct the named batches, so a surrogate variable method is more appropriate.

  2. Diagnose and correct on M-values from the matrix. PCA + variable-association, sva, and ComBat all take a matrix. Convert deposited β to M-values first (see normalization), run the identical SVA recipe, and add the surrogate variables to your model:

    M <- log2(beta / (1 - beta))          # from a processed beta-matrix
    mod  <- model.matrix(~ exposure + covariates, data = pData)
    mod0 <- model.matrix(~ covariates, data = pData)
    SV   <- sva(M, mod, mod0)$sv

ComBat is likewise matrix-native (sva::ComBat(dat = M, batch = plate, mod = mod)), so if a clean, recorded, un-confounded batch variable is present in a matrix-only deposit, you can correct it directly.


Next: EWAS analysis — fit the association model for PTSD with sex, age, cell composition, and surrogate variables as covariates, and interpret the results honestly.

References

Bauer, Mario, Beate Fink, Loreen Thürmann, Markus Eszlinger, Gunda Herberth, and Irina Lehmann. 2015. “A Varying T Cell Subtype Explains Apparent Tobacco Smoking Induced Single CpG Hypomethylation in Whole Blood.” Clinical Epigenetics 7 (1): 81. https://doi.org/10.1186/s13148-015-0113-1.
Bollepalli, Sailalitha, Tellervo Korhonen, Jaakko Kaprio, Simon Anders, and Miina Ollikainen. 2019. EpiSmokEr: A Robust Classifier to Determine Smoking Status from DNA Methylation Data.” Epigenomics 11 (13): 1469–86. https://doi.org/10.2217/epi-2019-0206.
Elliott, Hannah R., Therese Tillin, Wendy L. McArdle, et al. 2014. “Differences in Smoking Associated DNA Methylation Patterns in South Asians and Europeans.” Clinical Epigenetics 6 (1): 4. https://doi.org/10.1186/1868-7083-6-4.
Gagnon-Bartsch, Johann A, and Terence P Speed. 2012. “Using Control Genes to Correct for Unwanted Variation in Microarray Data.” Biostatistics 13 (3): 539–52. https://doi.org/10.1093/biostatistics/kxr034.
Joehanes, Roby, Allan C Just, Riccardo E Marioni, et al. 2016. “Epigenetic Signatures of Cigarette Smoking.” Circulation: Cardiovascular Genetics 9 (5): 436–47. https://doi.org/10.1161/CIRCGENETICS.116.001506.
Johnson, W Evan, Cheng Li, and Ariel Rabinovic. 2007. “Adjusting Batch Effects in Microarray Expression Data Using Empirical Bayes Methods.” Biostatistics 8 (1): 118–27. https://doi.org/10.1093/biostatistics/kxj037.
Leek, Jeffrey T, W Evan Johnson, Hilary S Parker, Andrew E Jaffe, and John D Storey. 2012. “The Sva Package for Removing Batch Effects and Other Unwanted Variation in High-Throughput Experiments.” Bioinformatics 28 (6): 882–83. https://doi.org/10.1093/bioinformatics/bts034.
Leek, Jeffrey T, Robert B Scharpf, Héctor Corrada Bravo, et al. 2010. “Tackling the Widespread and Critical Impact of Batch Effects in High-Throughput Data.” Nature Reviews Genetics 11 (10): 733–39. https://doi.org/10.1038/nrg2825.
Shenker, Natalie S., Silvia Polidoro, Karin van Veldhoven, et al. 2013. “Epigenome-Wide Association Study in the European Prospective Investigation into Cancer and Nutrition (EPIC-Turin) Identifies Novel Genetic Loci Associated with Smoking.” Human Molecular Genetics 22 (5): 843–51. https://doi.org/10.1093/hmg/dds488.
Zeilinger, Sonja, Brigitte Kühnel, Norman Klopp, et al. 2013. “Tobacco Smoking Leads to Extensive Genome-Wide Changes in DNA Methylation.” PLoS ONE 8 (5): e63812. https://doi.org/10.1371/journal.pone.0063812.