WGCNA / R and Bioconductor

WGCNA tutorial: soft threshold, module detection, module-trait association and hub genes

This page is for medical graduate students running WGCNA on GEO or TCGA data. All code was run on WGCNA 1.74 with the public dataset GSE130970 (RNA-seq of 78 liver biopsies with non-alcoholic fatty liver disease), and the official female mouse liver tutorial was reproduced. Every step reports the measured β, module count, run time and memory, as well as how far common mistakes shift the results.

Short answer

The standard WGCNA workflow: use at least 15 samples, preferably more than 20; transform RNA-seq counts with DESeq2 vst (or log2(x+1) after normalization), keep about 5000 genes by MAD or variance and do not filter by differential expression; remove outlier samples using standardized connectivity or the sample tree; in pickSoftThreshold take the first β whose scale-free fit R² exceeds 0.85 (or 0.8), and if none does, use the empirical value from the official FAQ (6 for unsigned and 12 for signed networks with more than 40 samples); in blockwiseModules prefer a signed network with bicor (maxPOutliers = 0.05), keep networkType identical to the one used to choose β, use minModuleSize 30, mergeCutHeight 0.25 and deepSplit 2, and set maxBlockSize above the gene count to avoid splitting into blocks; then pick modules from the eigengene-trait correlation heatmap, select hub genes using GS, MM and intramodular connectivity, and validate in an independent dataset with modulePreservation and clinical correlations.

Input data

WGCNA results are driven mainly by the input matrix. The official FAQ advises against WGCNA on fewer than 15 samples and recommends at least 20. The input is a matrix with samples in rows and genes in columns that has been normalized and log-transformed or variance-stabilized.

Installation (WGCNA is on CRAN, its dependencies on Bioconductor)r
# R >= 4.3; WGCNA 1.74 is on CRAN; its dependencies impute, preprocessCore and GO.db are on Bioconductor
install.packages("BiocManager")
BiocManager::install(c("impute", "preprocessCore", "GO.db", "AnnotationDbi",
                       "DESeq2", "org.Hs.eg.db"))
install.packages("WGCNA")
From GEO NCBI raw counts to the WGCNA input matrixr
# Load DESeq2 and other Bioconductor packages first and WGCNA last, so S4Vectors does not mask cor
suppressMessages({library(DESeq2); library(org.Hs.eg.db); library(WGCNA)})
options(stringsAsFactors = FALSE, timeout = 600)
acc <- "GSE130970"   # 78 NAFLD liver biopsies, RNA-seq, with fibrosis stage and other histology scores
dir.create("geo", showWarnings = FALSE)
f_cnt <- file.path("geo", paste0(acc, "_raw_counts.tsv.gz"))
f_mat <- file.path("geo", paste0(acc, "_series_matrix.txt.gz"))
if (!file.exists(f_cnt)) download.file(paste0(
  "https://www.ncbi.nlm.nih.gov/geo/download/?type=rnaseq_counts&acc=", acc,
  "&format=file&file=", acc, "_raw_counts_GRCh38.p13_NCBI.tsv.gz"), f_cnt, mode = "wb")
if (!file.exists(f_mat)) download.file(paste0(
  "https://ftp.ncbi.nlm.nih.gov/geo/series/GSE130nnn/", acc, "/matrix/", acc,
  "_series_matrix.txt.gz"), f_mat, mode = "wb")

cnt <- as.matrix(read.delim(f_cnt, row.names = 1, check.names = FALSE))  # 39376 x 78, row names are Entrez IDs
# 1. Drop low-count genes: count < 10 in more than 90% of samples (the example in the official FAQ)
keep <- rowSums(cnt >= 10) >= 0.1 * ncol(cnt)
sym  <- mapIds(org.Hs.eg.db, rownames(cnt), "SYMBOL", "ENTREZID")
cnt  <- cnt[keep & !is.na(sym), ]
rownames(cnt) <- make.unique(sym[rownames(cnt)])                      # 23337 genes
# 2. Variance-stabilizing transformation; never feed raw counts or un-logged FPKM into WGCNA
dds  <- DESeqDataSetFromMatrix(cnt, data.frame(row.names = colnames(cnt), x = rep(1, ncol(cnt))), ~1)
expr <- assay(vst(dds, blind = TRUE))
# 3. Keep the top 5000 genes by MAD and transpose to rows = samples, columns = genes
datExpr <- t(expr[order(apply(expr, 1, mad), decreasing = TRUE)[1:5000], ])
dim(datExpr)   # 78 5000

# Clinical traits: parse the characteristics rows of the Series Matrix
sm  <- readLines(gzfile(f_mat))
row <- function(p) lapply(sm[startsWith(sm, p)], function(x) gsub('"', "", strsplit(x, "\t")[[1]][-1]))
ch  <- row("!Sample_characteristics_ch1")
tr  <- as.data.frame(lapply(ch, function(v) sub("^[^:]+: ", "", v)))
names(tr) <- sapply(ch, function(v) sub(":.*", "", v[1]))
rownames(tr) <- row("!Sample_geo_accession")[[1]]
tr <- tr[rownames(datExpr), ]
traits <- data.frame(fibrosis = as.numeric(tr$`fibrosis stage`),
                     NAS = as.numeric(tr$`nafld activity score`),
                     steatosis = as.numeric(tr$`steatosis grade`),
                     ballooning = as.numeric(tr$`cytological ballooning grade`),
                     inflammation = as.numeric(tr$`lobular inflammation grade`),
                     age = as.numeric(tr$`age at biopsy`),
                     male = as.numeric(tr$Sex == "M"), row.names = rownames(tr))

What happens with raw counts

On this page the GSE130970 raw counts were used untransformed and the top 5000 genes by MAD were selected: only 1296 overlap with the 5000 selected after vst, the selected genes have a median count of 1456 versus 91.5 for the vst selection, and the selection is dominated by highly expressed genes. The signed-network R² is still only 0.81 at β = 30. An integer matrix passed directly to blockwiseModules fails with REAL() can only be applied to a 'numeric', not a 'integer'.

What happens when genes are filtered by trait correlation

Building the network on the 2000 genes most correlated with fibrosis stage: the signed-network R² exceeds 0.85 only at β = 22; only 10 genes remain grey, so almost every gene is assigned to a module; the strongest module-fibrosis correlation rises from 0.58 to 0.73. The 0.73 comes from the filtering itself and cannot be reported as a result. Taking DEGs first and then running WGCNA has the same problem.

StepPracticeBasis or measurement
Sample sizeDo not run on fewer than 15; aim for more than 20; when building separate networks per category such as sex or tissue (consensus analysis), about 30 or more per categoryOfficial FAQ (updated 2020-06-10)
Low-count filter (RNA-seq)Remove genes with count < 10 in more than 90% of samplesExample given in the FAQ; GSE130970 goes from 39376 to 23337 genes
TransformationUse DESeq2 vst or varianceStabilizingTransformation for counts; log2(x + 1) for FPKM, TPM or normalized countsFAQ; on this page, the per-sample median correlation between vst and log2 CPM is 0.9985
Number of genesKeep roughly the top 5000 by MAD or variance (3000–10000 is common); this removes low-variability genesFAQ: mean and variance filters give similar results; the official liver tutorial uses 3600
Do not filter by differential expressionWGCNA is unsupervised; a network of DEGs collapses into one or a few highly correlated modules and the scale-free fit failsFAQ; measured below
BatchCheck for batch effects first; use sva::ComBat for categorical batches and linear regression for continuous technical variablesFAQ

Outlier samples

The official tutorial sets the cut height on the sample tree by hand (15 for the female liver data, removing 1 sample). With more samples and no clearly isolated branch, standardized connectivity Z.k is more objective; the common cutoff is Z.k < -2.5.

The highest merge height in the GSE130970 sample tree is 84.4 and there is no single isolated sample; Z.k flags GSM3758009 (-6.14) and GSM3758047. Removing them leaves 76 samples. In this dataset keeping the two samples gives similar results (β is 7 in both cases, 14 modules, 1334 grey genes); outliers matter more in small datasets.

When the sample tree shows two large branches, first plot clinical and technical variables (batch, sex, sequencing date) under the tree to see whether the split comes from biological groups or technical factors, then decide whether to remove samples, correct the data or build separate networks.

Forgetting to transpose is an error that raises no error: a matrix with genes in rows and samples in columns still returns TRUE from goodSamplesGenes, and the network is then built with samples as genes. Check dim(datExpr) before analysis; the number of rows should equal the number of samples.

Sample tree and Z.kr
gsg <- goodSamplesGenes(datExpr, verbose = 0); gsg$allOK      # FALSE if genes have missing values or zero variance
sampleTree <- hclust(dist(datExpr), method = "average")
plot(sampleTree, cex = 0.6, main = "Sample clustering")       # look for samples hanging off on their own

# Standardized connectivity Z.k: samples with very low connectivity in the sample network are outliers (common cutoff -2.5)
A  <- adjacency(t(datExpr), type = "distance")
Zk <- as.numeric(scale(colSums(A) - 1))
rownames(datExpr)[Zk < -2.5]          # GSE130970: GSM3758009 GSM3758047
datExpr <- datExpr[Zk >= -2.5, ]; traits <- traits[rownames(datExpr), ]
nSamples <- nrow(datExpr)             # 76

Soft threshold

pickSoftThreshold returns the first β whose scale-free fit R² (signed R²) exceeds RsquaredCut; the default RsquaredCut is 0.85 and the default networkType is "unsigned". The networkType and correlation used to choose β must be identical to those used later in blockwiseModules.

When no power reaches the cutoff, the official FAQ (changed to more conservative values in December 2017) gives empirical β values: for unsigned and signed hybrid networks, 9 for fewer than 20 samples, 8 for 20–30, 7 for 30–40 and 6 for more than 40; for signed networks 18, 16, 14 and 12. The precondition is that batch effects, outlier samples and DEG-based gene filtering have been ruled out. On this page the FAQ value β = 12 for a signed network on 76 samples gives 13 modules, 1661 grey genes and a strongest fibrosis correlation of 0.53 (0.58 at β = 7).

The FAQ considers β below 15 reasonable for unsigned and signed hybrid networks and below 30 for signed networks. If R² never reaches 0.8 in that range and mean connectivity stays in the hundreds, the data usually contain a factor that pulls a subset of samples apart globally. This page simulated that case (adding 1.5 to 40% of genes in half of the samples): R² reaches only 0.68 at β = 30, mean connectivity is still 26 at β = 30, and β = 12 produces one large module of 2088 genes.

Signed or unsigned: the FAQ recommends signed or signed hybrid networks. An unsigned network puts positively and negatively correlated genes into the same module, so the direction of the module eigengene no longer matches every gene in the module. For correlation the FAQ recommends bicor (biweight midcorrelation) with maxPOutliers = 0.05 or 0.10; without it, when expression is strongly driven by a binary variable (disease status, genotype), bicor treats one group of samples as outliers.

Choosing β for a signed network with bicor, with the FAQ fallbackr
allowWGCNAThreads(4)    # if parallel runs fail inside RStudio, use disableWGCNAThreads()
powers <- c(1:10, seq(12, 20, 2))
sft <- pickSoftThreshold(datExpr, powerVector = powers, networkType = "signed",
                         corFnc = bicor, corOptions = list(maxPOutliers = 0.05),
                         RsquaredCut = 0.85, verbose = 0)
fi <- sft$fitIndices
data.frame(power = fi$Power, R2 = round(-sign(fi$slope) * fi$SFT.R.sq, 3), mean.k = round(fi$mean.k., 1))
beta <- sft$powerEstimate      # GSE130970 (76 samples): 7

# If no power reaches the cutoff, use the empirical values from the official FAQ (December 2017 version)
if (is.na(beta)) {
  n <- nSamples; signed <- TRUE
  beta <- if (n < 20) 9 else if (n < 30) 8 else if (n < 40) 7 else 6
  if (signed) beta <- beta * 2
}

par(mfrow = c(1, 2))
plot(fi$Power, -sign(fi$slope) * fi$SFT.R.sq, type = "n", xlab = "power", ylab = "signed R^2")
text(fi$Power, -sign(fi$slope) * fi$SFT.R.sq, fi$Power, col = "red"); abline(h = 0.85, col = "red")
plot(fi$Power, fi$mean.k., type = "n", xlab = "power", ylab = "mean connectivity")
text(fi$Power, fi$mean.k., fi$Power, col = "red")

R² cutoff of 0.8, 0.85 or 0.9

0.85 is the pickSoftThreshold default; the official tutorial draws a 0.90 line and picks β = 6 for the female liver data (R² 0.902); the FAQ uses 0.8 when judging whether the data have a problem. Rule: take the first β at which the R² curve reaches its plateau, not a higher β. In this dataset R² is essentially flat after β = 7, while mean connectivity keeps falling with higher β and is only 2.0 at β = 20, so the network becomes too sparse.

Fluctuation at high powers

Once mean connectivity drops below 1, the fit rests on a few genes and R² jumps around. For the 78-sample unsigned-pearson run on this page, R² is 0.918 at β = 22, drops to 0.340 at β = 24 and returns to 0.937 at β = 26. Use only the first plateau at the start of the curve.

The slope need not be near -1

Some posts require a slope close to -1. Signed networks usually have steeper slopes: on this page signed + bicor has a slope of -2.67 at β = 7. The official function and tutorial choose β by R² only.

The four combinations give different β

powerEstimate on the same data (78 samples): unsigned + pearson 6, unsigned + bicor 7, signed + pearson 9, signed + bicor 8. In a signed network the adjacency is (0.5 + 0.5 × cor)^β, which is denser than unsigned at the same β, so a larger β is needed.

βsigned R²Mean connectivity
50.754238.7
60.819143.6
70.86589.2
80.89257.2
100.87825.7
140.9167.2
200.8742.0
Measured on this page: GSE130970, 76 samples, top 5000 genes by MAD, signed + bicor. A cutoff of 0.8 gives 6, 0.85 gives 7, and 0.9 is nearly reached at 8 but first exceeded at 14.

Module detection

The defaults of the one-step blockwiseModules function differ from what the official tutorial uses. The table lists WGCNA 1.74 defaults, the tutorial values and the effects measured on this page, with a baseline of 76 samples, 5000 genes, signed + bicor and β = 7 (14 modules, 1369 grey genes).

This page reproduced official Tutorial I: 3600 female liver probes, 134 samples, β = 6, TOMType = "unsigned", minModuleSize = 30 and mergeCutHeight = 0.25 give 18 modules and 99 grey genes, with every module size identical to the tutorial table, in 7.9 seconds on a single thread.

β also changes the module count. On the same data, β = 2, 3, 4 and 7 give 8, 8, 12 and 14 modules. There is no correct number of modules; reporting the parameters used together with a sensitivity analysis holds up better than repeatedly tuning until one module correlates strongly with a trait.

Build the network and detect modulesr
net <- blockwiseModules(datExpr, power = beta,
         networkType = "signed", TOMType = "signed",     # networkType must match pickSoftThreshold
         corType = "bicor", maxPOutliers = 0.05,
         maxBlockSize = 6000,                             # larger than the gene count, so there is a single block
         minModuleSize = 30, deepSplit = 2, mergeCutHeight = 0.25,
         pamRespectsDendro = FALSE, numericLabels = TRUE, verbose = 3)
moduleColors <- labels2colors(net$colors)
table(moduleColors)       # GSE130970: 14 modules, 1369 genes in grey
plotDendroAndColors(net$dendrograms[[1]], moduleColors[net$blockGenes[[1]]], "Module",
                    dendroLabels = FALSE, hang = 0.03, addGuide = TRUE, guideHang = 0.05)
Parameter1.74 defaultCommon valueMeaning and measured effect
networkType"unsigned""signed"Must match pickSoftThreshold. Using the β chosen for a signed network (14, 20) in an unsigned network raises grey genes from 1903 to 4121 and 4659 (of 5000)
TOMType"signed"Tutorial uses "unsigned"For an unsigned network, signed TOM keeps the correlation sign and then takes the absolute value. Measured results are almost identical to "unsigned": the 18 female-liver modules are exactly the same; on GSE130970 grey is 1903 vs 1903 and the largest module 623 vs 622
corType / maxPOutliers"pearson" / 1"bicor" / 0.05Without maxPOutliers, bicor treats expression patterns driven by a binary variable as outliers
minModuleSizemin(20, genes/2)3020/30/50/100 → 16/14/11/9 modules
deepSplit220–4 → 11/12/14/19/20 modules; higher values split more finely and leave fewer grey genes
mergeCutHeight0.150.25Modules whose eigengenes correlate above 1 − mergeCutHeight are merged. 0.15/0.25/0.35 → 14/14/12 modules
maxBlockSize5000Above the gene countMore genes than this triggers block splitting; see the next section
pamRespectsDendroTRUETutorial uses FALSEGrey 1360 vs 1369 in this dataset, a small difference
randomSeed54321Keep the defaultBlock pre-clustering uses random numbers; R 3.6.0 changed random number generation, so reproduce old results with RNGkind("Mersenne-Twister", "Inversion", "Rounding")

Memory

TOM is a genes × genes matrix, so memory grows with the square of the gene count. When the gene count exceeds maxBlockSize (default 5000), blockwiseModules first pre-clusters genes into blocks with a k-means-like method, builds a network in each block and finally merges similar modules. Block splitting misassigns genes whose natural module spans blocks, and the official tutorial recommends using as few blocks as possible.

Effect of block splitting on results: comparing 15,000 genes in 4 blocks with 1 block, the adjusted Rand index (ARI) of the module assignments is 0.42; of the 27 single-block modules, 15 keep fewer than 70% of their genes together in the blockwise result. The genes of the fibrosis-related yellow module from the main analysis fall mainly into 2 modules (190 and 128 genes) in the 15,000-gene single-block result, and are spread across 3 modules (123, 92 and 70 genes) in the blockwise result. The official tutorial compared blockwise and single-block analysis on 3600 probes and found them very similar; that conclusion does not hold at tens of thousands of genes.

Memory guidance in the official tutorial: about 8000–10000 probes with 4 GB, about 20000 with 16 GB, about 30000 with 32 GB. On the 16 GB machine used here, 20,000 genes in one block completed with a 6.5 GB peak, and about 22,500 genes in one block crashed (other programs were using memory at the same time). The hard upper limit of maxBlockSize is sqrt(2^31) ≈ 46340.

When memory is short, first reduce the gene count (the top 5000–10000 genes by MAD usually capture the main co-expression structure) so that all genes fit in one block; if you really need every gene, use a machine with more memory. Describe the block boundaries in the paper if you split into blocks.

GenesmaxBlockSizeBlocksblockwiseModules timePeak process memoryResult
5000600015.9 s1.6 GB14 modules
1000010000118.3 s2.5 GB21 modules
1500015000172 s3.9 GB27 modules, 3136 grey
1500050004 (4996/4819/2739/2446)29.8 s1.6 GB29 modules, 3864 grey
20000200001196 s6.5 GB31 modules
22490233371—5.5–7.1 GB at crashCrashed after the TOM step (segfault / bad binding access)
Measured on this page: Apple M-series, 8 cores, 16 GB, R 4.4.3, WGCNA 1.74, OpenBLAS, 4 threads, 76 samples, signed + bicor (the last row crashed with both pearson and bicor).

Module-trait

Each module is represented by its module eigengene (ME, the first principal component of the module expression matrix), which is then correlated with clinical traits. Each heatmap cell shows the correlation and its P value; red is positive and blue is negative.

Heatmap of eigengene-trait correlationsr
MEs <- orderMEs(moduleEigengenes(datExpr, moduleColors)$eigengenes)
moduleTraitCor <- cor(MEs, traits, use = "p")
moduleTraitP   <- corPvalueStudent(moduleTraitCor, nSamples)
textMatrix <- paste0(signif(moduleTraitCor, 2), "\n(", signif(moduleTraitP, 1), ")")
par(mar = c(6, 9, 3, 3))
labeledHeatmap(moduleTraitCor, xLabels = names(traits), yLabels = names(MEs), ySymbols = names(MEs),
               colorLabels = FALSE, colors = blueWhiteRed(50), textMatrix = textMatrix,
               setStdMargins = FALSE, cex.text = 0.6, zlim = c(-1, 1), main = "Module-trait relationships")
# bicor also works for binary or ordinal traits, but turn off robust handling of the trait:
# bicor(MEs, traits, maxPOutliers = 0.05, robustY = FALSE)

Negatively correlated modules are equally valid

turquoise correlates with NAS at -0.67, meaning the module as a whole goes down as disease worsens. Select modules by the absolute value of the correlation.

Put confounders in the heatmap

The pink and tan modules correlate with sex at -0.76 and 0.77 (P = 2.8e-16); they are sex modules. Including sex, age and batch in the trait table identifies such modules and prevents interpreting them as disease-related.

Multiple comparisons

14 modules × 7 traits make 98 tests; on this page 33 have P < 0.05 and 17 remain after Bonferroni correction (0.05/98). When choosing modules, look at both correlation strength and corrected significance, and decide the main trait of interest in advance.

Do not interpret the grey row

grey is the set of genes not assigned to any module; its ME-trait correlation has no biological meaning and can be dropped from the plot.

Binary and ordinal traits

0/1 variables such as sex or group and ordinal variables such as stage can be used directly with Pearson correlation. With bicor, set robustY = FALSE; when most samples share one value, bicor warns zero MAD in variable 'y' and falls back to Pearson for that column. On this page Pearson and bicor(robustY = FALSE) differ by at most 0.15.

ModuleFibrosis stageNAS scoreBallooningMale
yellow (405 genes)0.58 (P = 3.1e-8)0.69 (P = 8.4e-12)0.57-0.12
turquoise (798 genes)-0.52-0.67-0.54-0.07
pink (164 genes)-0.02-0.16-0.11-0.76
tan (98 genes)-0.26-0.03-0.060.77
Measured on this page: GSE130970, 76 samples, 14 modules × 7 traits.

Hub genes

GS (gene significance) is the correlation of a gene's expression with the trait, MM (module membership, i.e. kME) is the correlation of a gene's expression with the module eigengene, and kWithin is intramodular connectivity. In a module significantly correlated with a trait, a significant positive correlation between GS and MM means the module's core genes are more likely to be related to the trait.

Whether |GS| > 0.2 is significant depends on sample size. The |GS| needed for two-sided P < 0.05 is 0.444 with 20 samples, 0.361 with 30, 0.279 with 50, 0.226 with 76 and 0.197 with 100; 0.2 becomes significant only at 97 samples. With fewer than 97 samples, genes passing |GS| > 0.2 may have no significant correlation with the trait; convert the cutoff according to sample size or require a significant GS P value directly.

WGCNA author Peter Langfelder wrote on the Bioconductor support site that kME and intramodular connectivity usually give very similar rankings; his group usually uses kME because it is simpler to calculate and has an associated P value; the ranking is only a guide for prioritizing follow-up, several genes with similarly high kME can all be considered hubs, and rank 3 versus rank 7 makes no real difference. The top 10 genes by kWithin in the yellow module on this page are TNFRSF12A, YWHAH, KPNA2, ARPC5, THBS2, ANXA2, TIGAR, ANXA2P2, LGALS3 and CCL20, which largely coincide with the 12 genes passing |MM| > 0.8 and |GS| > 0.2.

Within the yellow module, |MM| and |GS| correlate at 0.715 (P = 1.5e-64). A high correlation means the module's core genes are also the genes most correlated with fibrosis. When this correlation is close to 0, the module's hub genes are not necessarily related to the trait even if the ME-trait correlation is significant.

Langfelder et al. (PLoS One, 2013) compared hub gene selection with standard meta-analysis: hub genes in consensus modules were more useful for biological interpretation, while standard meta-analysis did as well as or better than the network method in validation success. When the goal is a biomarker, put GS significance ahead of MM.

GS-MM scatter plot, intramodular connectivity and hub genesr
module <- "yellow"; trait <- "fibrosis"
MM <- cor(datExpr, MEs[, paste0("ME", module)], use = "p")[, 1]          # module membership (kME)
GS <- cor(datExpr, traits[[trait]], use = "p")[, 1]                       # gene significance
inMod <- moduleColors == module
verboseScatterplot(abs(MM[inMod]), abs(GS[inMod]), xlab = paste("MM in", module),
                   ylab = paste("GS for", trait), col = module, abline = TRUE)
abline(v = 0.8, h = 0.2, lty = 2)

# |GS| needed for two-sided P < 0.05 at sample size n
tq <- qt(0.975, nSamples - 2); sqrt(tq^2 / (tq^2 + nSamples - 2))   # 0.226 at n = 76

# Intramodular connectivity kWithin
adj <- adjacency(datExpr, power = beta, type = "signed",
                 corFnc = "bicor", corOptions = "maxPOutliers = 0.05")
kIM <- intramodularConnectivity(adj, moduleColors)
hub <- data.frame(gene = colnames(datExpr), MM = MM, GS = GS, kWithin = kIM$kWithin)[inMod, ]
hub <- hub[order(-hub$kWithin), ]
head(hub, 10)
subset(hub, abs(MM) > 0.8 & abs(GS) > 0.2)      # GSE130970: 12 genes
cor(hub$kWithin, hub$MM, method = "spearman")    # 0.95: the two rankings are almost identical
CriterionGenes selected in the yellow module (405 genes)Note
|MM| > 0.8 and |GS| > 0.212The most common criterion in Chinese papers; it does not appear in the official WGCNA tutorial or FAQ
|MM| > 0.9 and |GS| > 0.20A slightly stricter cutoff can leave no genes at all
|MM| > 0.8 and |GS| > 0.48
|MM| > 0.8 and |GS| > 0.55
Top 30 by kWithin30, including all 12 aboveSpearman correlation between kWithin and MM is 0.95

Errors and pitfalls

Most error messages below were reproduced on WGCNA 1.74; the rest come from the official FAQ.

Fixing the masked cor function and the integer matrix errorr
# Error: unused arguments (weights.x = NULL, weights.y = NULL, cosine = FALSE)
find("cor")                 # fails when IRanges / S4Vectors come before WGCNA
cor <- WGCNA::cor           # set it temporarily, restore after blockwiseModules
net <- blockwiseModules(datExpr, power = beta, networkType = "signed", numericLabels = TRUE)
cor <- stats::cor
# Or restart R, load DESeq2 and the other packages first, then library(WGCNA) last

# Error: REAL() can only be applied to a 'numeric', not a 'integer'
storage.mode(datExpr) <- "double"   # triggered by an integer matrix (e.g. raw counts), which should not be used anyway
Symptom or error messageCauseFix
unused arguments (weights.x = NULL, weights.y = NULL, cosine = FALSE)WGCNA's own cor is masked by another package's cor. Measured on this page: loading library(WGCNA) and then library(DESeq2) puts the IRanges / S4Vectors cor generic first and triggers the error; the reverse order does notLoad WGCNA last; or set cor <- WGCNA::cor before blockwiseModules and restore stats::cor afterwards
REAL() can only be applied to a 'numeric', not a 'integer'The input is an integer matrix (usually raw counts used directly)Apply vst or a log transform first; to change only the type use storage.mode(x) <- "double"
could not find function, or GOenrichmentAnalysis reports deprecatedWGCNA did not load; or the call is to GOenrichmentAnalysis, removed in 1.74 (it now only returns a message)Run library(WGCNA) and read the error; use clusterProfiler or the author's anRichment for enrichment
pickSoftThreshold parallel error in RStudioMultithreading problem in third-party GUIsRun disableWGCNAThreads() and retry
thread 0 could not be started successfully. Error code: 11The cluster allocates only one core per jobdisableWGCNAThreads()
malloc: *** mmap(size=...) failed ... can't allocate region (Mac)The FAQ considers this message harmlessIgnore it; if R then crashes, treat it as out of memory
R crashes (segfault, bad binding access)Too many genes in one block. On the 16 GB machine used here, about 22,500 genes in one block crashed after the TOM stepReduce the gene count or use a machine with more memory; describe the cost if you split into blocks
Probe names gain an X prefixConverting a matrix to a data.frame prepends X to column names that start with a digitKeep a matrix throughout, or read files with check.names = FALSE
More than half of genes are greyβ does not match networkType; β is too high and the network too sparse; minModuleSize is too large; few samples and high noiseMake sure pickSoftThreshold and blockwiseModules use the same networkType; with correct settings grey is about 27% on this page
Only 1–2 modules or one giant moduleGenes were filtered by DEGs or trait correlation; batch or tissue differences dominate expression; β is too lowFilter by MAD or variance instead; inspect the sample tree for batches and correct them; check against the FAQ whether to use the empirical β

Peer review

The most frequent objections to WGCNA papers are sample size and unvalidated hub genes. Validation can be done entirely with public data: test with modulePreservation whether modules are preserved in an independent dataset, then check the correlation of hub genes with the same trait.

Module preservation in an independent datasetr
# External data processed the same way into a vst matrix (rows = samples, columns = genes): GSE135251, 216 samples
common <- intersect(colnames(datExpr), colnames(datExpr2))
mp <- modulePreservation(
  list(ref = list(data = datExpr[, common]), test = list(data = datExpr2[, common])),
  list(ref = setNames(moduleColors, colnames(datExpr))[common]),
  referenceNetworks = 1, nPermutations = 50, networkType = "signed",
  corFnc = "bicor", randomSeed = 1, maxGoldModuleSize = 300, maxModuleSize = 1000, verbose = 0)
z <- mp$preservation$Z$ref.ref$inColumnsAlsoPresentIn.test
z[, c("moduleSize", "Zsummary.pres")]     # Zsummary > 10 strong, 2-10 moderate, < 2 not preserved
ObjectionResponse
Small sample sizeState the sample size and the FAQ recommendation (≥ 15, preferably ≥ 20); if β did not reach the cutoff, state that the FAQ empirical value was used; convert the GS cutoff into a significance threshold for your sample size
Hub genes not validatedRun module preservation in an independent cohort and test hub gene-trait correlations. This page validated with GSE135251 (216 samples): the yellow module has Zsummary 20.4 (> 10 means strong preservation); 10 of 11 candidate hubs correlate significantly with fibrosis stage (Spearman rho 0.21–0.60), and ARPC5 does not (P = 0.096); run time 157 seconds
DEGs were taken before WGCNAFilter all expressed genes by MAD or variance instead; intersect DEGs with module genes after WGCNA
Unclear basis for βShow both the R² and mean connectivity plots, and state RsquaredCut, networkType and the correlation used
Arbitrary parametersReport minModuleSize, deepSplit, mergeCutHeight, maxBlockSize and whether blocks were split; add a sensitivity analysis over deepSplit or β showing whether key modules are stable
Module-trait correlations may reflect confoundingInclude sex, age and batch in the heatmap; fit confounder-adjusted regressions for key modules
Bioinformatics onlyValidate hub genes with qPCR, immunohistochemistry or external data (TCGA, HPA, other GEO cohorts)

Chinese community

The following come from WGCNA tutorials on CSDN, Jianshu, Tencent Cloud and Zhihu columns; each has an error message or source code, and each was reproduced or checked on this page.

The cor conflict error and the temporary replacement

A CSDN post (2019) shows the blockwiseModules error Error in (new("standardGeneric", .Data = function (x, y = NULL, ...: unused arguments (weights.x = NULL, weights.y = NULL, cosine = FALSE); running cor <- WGCNA::cor fixed it, and the author reminds readers to restore stats::cor afterwards. This page reproduced the same error and traced it to IRanges / S4Vectors, which DESeq2 depends on, being loaded after WGCNA.

MAD filtering and empirical β

The WGCNA tutorial by Shengxin Baodian on Jianshu (2018) removes the 25% of genes with the lowest MAD (with MAD at least 0.01), uses the FAQ table when no suitable β exists, and recommends signed networks with bicor. Note that its module-trait code reads if (corType == "pearsoon"); the typo means the Pearson branch never runs, so copied code uses bicor even when pearson is chosen.

β and network type do not match

A 2026 CSDN post on interpreting results chooses β with networkType = "signed" but calls blockwiseModules without networkType (default unsigned) and with TOMType = "unsigned". This page measured that mismatch: with the signed β used in an unsigned network, 82%–93% of genes end up grey.

Log-transform FPKM first

Tencent Cloud's RNA-seq hands-on series applies log2(x + 1) to FPKM, keeps the top 5000 genes by MAD and sets maxBlockSize to the gene count, consistent with the FAQ; the same article says power = 16 in the text while the code uses 15, so follow the code and rerun pickSoftThreshold yourself. A highly ranked Zhihu tutorial filters FPKM by mean only without taking logs, which is a counterexample.

Hand off to an agent

Downloading, transforming, choosing β, building the network, plotting and external validation follow fixed steps and suit a science agent; choosing the dataset, defining traits and interpreting modules remain your job.

Example one-line instruction: "Run WGCNA on GSE130970: filter low counts in the NCBI raw counts and apply vst, keep the top 5000 genes by MAD, remove outlier samples with Z.k < -2.5; choose β for a signed network with bicor (maxPOutliers = 0.05), run single-block blockwiseModules (minModuleSize 30, mergeCutHeight 0.25); make a module-trait heatmap with fibrosis, NAS, sex and age; for the most correlated module output the GS-MM scatter plot and a hub table ranked by kWithin; then run modulePreservation and hub gene-fibrosis correlations in GSE135251; add a sensitivity analysis over deepSplit 0–4."

  1. 01

    Data and preprocessing

    Download counts and the Series Matrix, parse traits, apply low-count filtering, vst and MAD selection, and output the sample tree and list of outliers.

  2. 02

    Choose β and build the network

    Run pickSoftThreshold and plot it, confirm that networkType matches, use a single block if memory allows, and record time and memory.

  3. 03

    Module-trait and hubs

    Output the heatmap (with corrected P values), GS-MM scatter plot, hub gene table and module gene lists.

  4. 04

    Validation and sensitivity analysis

    Run module preservation and hub gene correlations in an independent dataset; check whether key modules are stable across deepSplit and β.

  5. 05

    Adversarial review

    Check for DEG-based filtering, whether β and network type match, whether blocks were split, whether the |GS| cutoff is significant, and whether the heatmap contains confounding modules such as sex.

  • The workspace keeps the raw data, R scripts, sessionInfo, β selection plots, module assignment table, heatmap and validation results.
  • You still need to check that traits are coded correctly, that outlier removal is justified and how the selected modules are interpreted biologically.
  • Wet-lab validation such as qPCR and immunohistochemistry is up to you.

References

FAQ

How many samples does WGCNA need?

The official FAQ advises against data with fewer than 15 samples and recommends at least 20; more samples give more stable modules. For separate networks per category such as sex or tissue, about 30 or more per category. With few samples, β often fails to reach the R² cutoff and the significance threshold for GS is higher (|GS| must exceed 0.444 with 20 samples).

What if the soft threshold never reaches 0.8 or 0.85?

First look for the cause: genes filtered by DEGs, batch effects or outlier samples, or two large branches in the sample tree. Once you have confirmed that the heterogeneity is a meaningful biological difference, take β from the empirical values in the official FAQ: for fewer than 20, 20–30, 30–40 and more than 40 samples, use 9, 8, 7 and 6 for unsigned networks and 18, 16, 14 and 12 for signed networks.

Can RNA-seq counts, FPKM or TPM go directly into WGCNA?

Not directly. Filter low counts and apply DESeq2 vst to counts; use log2(x + 1) for FPKM or TPM. The FAQ notes that RPKM, FPKM and normalized counts make little difference to WGCNA; what matters is that all samples are processed the same way and variance-stabilized or log-transformed.

Should I use a signed or an unsigned network?

The official FAQ recommends signed or signed hybrid networks: genes in a module change in the same direction, so the direction of the module eigengene can be interpreted directly. The networkType used to choose β must match blockwiseModules. Signed networks usually need a higher β: the FAQ empirical values are twice the unsigned ones, and on this page Pearson correlation gave 9 versus 6.

Where does the |MM| > 0.8, |GS| > 0.2 hub gene criterion come from?

It is a convention common in Chinese papers; the official WGCNA tutorial and FAQ do not contain it. |GS| > 0.2 corresponds to P < 0.05 only with at least 97 samples. A safer approach is to require a significant GS P value, rank genes by MM or intramodular connectivity, and validate them in external data.

What if blockwiseModules runs out of memory?

The official guidance is about 20,000 genes per block with 16 GB of memory; on this page 20,000 genes peaked at 6.5 GB. First reduce the gene count so that everything fits in one block (the top 5000–10000 by MAD) and set maxBlockSize above the gene count. Block splitting saves memory, but in measurements 15,000 genes split into 4 blocks agreed with the single-block result at an ARI of only 0.42.

Hand WGCNA off to Scientify

Give the GEO accession, traits and parameters; the science agent handles data processing, β selection, network construction, the module-trait heatmap, hub gene screening and external validation in an isolated cloud computer, and keeps every script, figure and log. New users get $5 of free credit.