Note
Why care about modules? A DE list of 800 genes is hard to interpret. A handful of co-regulated modules — each summarized by an eigengene and tied to a trait — is easy to act on. WGCNA reframes “which genes change?” as “which programs change?”
WGCNA concept — co-expression network → modules → traits
A hard threshold (binary network) loses information: any gene pair just barely above the cutoff looks identical to a perfectly correlated one. The weighted soft-thresholding preserves the gradient — and Zhang & Horvath (2005) showed it produces more biologically meaningful modules.
pickSoftThreshold() — it returns the \(R^2\) vs. \(\beta\) tablelibrary(WGCNA)
sft <- pickSoftThreshold(t(expr), powerVector = c(1:10, seq(12, 20, by = 2)),
networkType = "signed", verbose = 5)
plot(sft$fitIndices$Power, -sign(sft$fitIndices$slope) * sft$fitIndices$SFT.R.sq,
xlab = "Soft Threshold (power)", ylab = "Scale Free Topology Model Fit, R^2")
abline(h = 0.80, col = "red")Warning
If no β gives R² > 0.8 your data is probably too small (< ~20 samples), or contains technical structure (batches) overwhelming the biological signal. Fix that before running WGCNA — increasing β will not save you.
1 - TOM with average linkagecutreeDynamic) → modules (one per “branch”)For each module, the first principal component of the module’s expression matrix is the module eigengene (ME). It’s a single number per sample per module — and that’s what you correlate with traits.
Tip
Eigengenes are interpretable. A high ME for MEturquoise in patient X means the genes of that module are coordinately up in X. They reduce a 200-gene module to one column you can put in any model.
trait <- model.matrix(~ severity, data = phenoData)[, -1] # one-hot encoded
moduleTraitCor <- cor(MEs, trait, use = "pairwise.complete.obs")
moduleTraitPvalue <- corPvalueStudent(moduleTraitCor, n = nrow(MEs))
labeledHeatmap(Matrix = moduleTraitCor,
xLabels = colnames(trait),
yLabels = colnames(MEs),
textMatrix = signif(moduleTraitPvalue, 2),
colors = blueWhiteRed(50))Warning
Multiple testing. A WGCNA grid is many tests. Prefer FDR-adjusted p-values, and always check that a “significant” module survives a permutation null on shuffled trait labels.
| Strategy | When to use | Tools |
|---|---|---|
| Pseudobulk per (sample × cell type) | You have ≥ ~20 samples × cell-types | AggregateExpression() → classic WGCNA |
| Metacells (k-NN aggregates within a cell type) | Single-condition atlas; rich within-cluster structure | hdWGCNA (Morabito et al. 2023) |
# hdWGCNA sketch (signed, per-celltype)
seu <- SetupForWGCNA(seu, gene_select = "fraction", fraction = 0.05,
wgcna_name = "Tcells")
seu <- MetacellsByGroups(seu, group.by = "celltype",
k = 25, max_shared = 10, ident.group = "celltype")
seu <- NormalizeMetacells(seu)
seu <- TestSoftPowers(seu, networkType = "signed")
# soft_power here (9) is what TestSoftPowers picked on the *metacell* data;
# it differs from the bulk example's 18 because each scan chooses the smallest
# beta that reaches scale-free R^2 > 0.8 for *its* data — not a fixed default.
seu <- ConstructNetwork(seu, soft_power = 9, setDatExpr = FALSE)Note
hdWGCNA’s metacells are not the same as pseudobulk. Metacells aggregate within a cell type (creating “super-cells” inside the cluster), while pseudobulk aggregates all cells of one type per sample. Use metacells when you have one condition and want intra-cluster structure; use pseudobulk when you have multiple samples / conditions.
cor shadowing — a common R footgunWarning
WGCNA overwrites base::cor. Save and restore it around blockwiseModules:
Forgetting this causes confusing failures in any code that calls cor() after WGCNA runs.
Warning
WGCNA expects samples-as-rows, genes-as-columns. Most RNA-seq count tables are genes-as-rows × samples-as-columns. Always transpose before calling pickSoftThreshold or blockwiseModules:
Getting this backwards produces silently wrong results — no error, wrong modules.
Before networking, look at your samples:
goodSamplesGenes() additionally flags genes or samples with too many missing values.goodSamplesGenes(), drop sample/gene outliers — before normalization (outliers bias VST)DESeq2::vst, design ~ 1) for bulk; log-normalize per cell type for pseudobulkhclust + PCA) — drop visible outliers; PCA is more sensitive on small NpickSoftThreshold to scale-free \(R^2 \gtrsim 0.8\); smallest β that crosses the lineblockwiseModules (signed, TOMType = “signed”, randomSeed set)cor after blockwiseModules (see the cor-shadowing note)kME; functional enrichment per moduleTip
Reproducibility: set the random seed (set.seed(...)) before blockwiseModules — module colour assignment is deterministic only if the seed is fixed.
Tutorial 13 — WGCNA on GSE152418 walks through a worked example: COVID-19 patient PBMC bulk RNA-seq, building a signed network, and correlating modules with disease severity.
Single Cell RNA-seq Workshop · Lecture 13