Differential Expression Analysis

Last updated on 2026-10-27 | Edit this page

Overview

Questions

  • What are the main approaches for differential expression testing in scRNA-seq?
  • Why is pseudobulk analysis preferred over per-cell testing for multi-sample experiments?
  • How do we create volcano plots and other visualizations to interpret DE results?
  • How do we connect lists of DE genes to biological pathways using GO enrichment?
  • How should we export our results for sharing and reproducibility?

Objectives

  • Compare Wilcoxon, MAST, and pseudobulk DE testing approaches and choose the right one for your design
  • Run pseudobulk differential expression using AggregateExpression
  • Create volcano plots, feature plots, and violin plots to visualize DE results
  • Perform gene ontology enrichment analysis with clusterProfiler and interpret the results
  • Export Seurat objects, count matrices, and metadata for reproducibility and sharing
Prerequisite

Prerequisites

This episode requires an RStudio session on the Negishi cluster. Launch RStudio (bioconductor) under Bioinformatics Apps on Open OnDemand as described in the Setup instructions. You will also need the ifnb_annotated.rds object saved at the end of the previous episode.

Setup


R

library(Seurat)
library(ggplot2)
library(patchwork)
library(dplyr)
library(clusterProfiler)
library(org.Hs.eg.db)

Loading the Integrated Data


We continue from the integrated and annotated IFNB dataset produced in the previous episode. Make sure the cell type annotation is set as the active identity.

Set up the working directory to match the previous episodes:

R

work_dir <- paste0(
    "/scratch/negishi/", Sys.getenv("USER"),
    "/scrna_workshop/"
)
setwd(work_dir)
Callout

Starting here without the previous episode?

Copy the saved objects from the workshop data first (pbmc_annotated.rds is used by the export section at the end): file.copy(paste0(work_dir, c("rds/ifnb_annotated.rds", "rds/pbmc_annotated.rds")), work_dir)

R

ifnb <- readRDS(paste0(work_dir, "ifnb_annotated.rds"))
Idents(ifnb) <- "celltype"
DimPlot(ifnb, reduction = "umap", label = TRUE, repel = TRUE, split.by = "stim") +
    NoLegend()
UMAP split by CTRL and STIM conditions with thirteen cell types colored and labeled, showing identical spatial arrangement in both panels confirming successful integration.
Split UMAP showing CTRL and STIM panels with cells colored and labeled by cell type.

You should see two UMAP panels (CTRL and STIM) with the same cell type structure in both. Cell types are labeled and colored consistently across panels.

DE Testing Methods


Seurat supports several statistical tests for differential expression. The choice of method depends on your experimental design and whether you have biological replicates.

Method Seurat parameter Statistical model Best for
Wilcoxon rank-sum test.use = "wilcox" Non-parametric rank test Fast exploration, comparing clusters within a single sample
MAST test.use = "MAST" Hurdle model (logistic + Gaussian) Accounts for dropout and cellular detection rate; per-cell testing with better modeling
DESeq2 (pseudobulk) test.use = "DESeq2" Negative binomial GLM on aggregated counts Publication-quality results when biological replicates are available

Let’s start with the Wilcoxon test to quickly identify DE genes between stimulated and control CD14+ monocytes:

R

# Subset to CD14+ monocytes
mono <- subset(ifnb, idents = "CD14 Mono")

# Set condition as active identity
Idents(mono) <- "stim"

# Run Wilcoxon DE test
mono.wilcox <- FindMarkers(mono,
                           ident.1 = "STIM",
                           ident.2 = "CTRL",
                           test.use = "wilcox",
                           min.pct = 0.25,
                           logfc.threshold = 0.25)
head(mono.wilcox, 10)
Parameter Value Description
ident.1 "STIM" The first group (numerator). Positive avg_log2FC means higher in STIM.
ident.2 "CTRL" The second group (denominator).
test.use "wilcox" Wilcoxon rank-sum test. Fast, non-parametric, works well for exploration.
min.pct 0.25 Only test genes detected in at least 25% of cells in either group.
logfc.threshold 0.25 Only test genes with at least 0.25 log2FC between groups.

R

cat("Significant genes (padj < 0.05):", sum(mono.wilcox$p_val_adj < 0.05), "\n")

OUTPUT

        p_val avg_log2FC pct.1 pct.2 p_val_adj
IFIT1       0   7.319139 0.985 0.033         0
CXCL10      0   8.036564 0.984 0.035         0
RSAD2       0   6.741673 0.988 0.045         0
TNFSF10     0   6.991279 0.989 0.047         0
IFIT3       0   6.883785 0.992 0.056         0
IFIT2       0   7.179929 0.961 0.039         0
CXCL11      0   8.624208 0.932 0.012         0
CCL8        0   9.134191 0.918 0.017         0
IDO1        0   5.455898 0.965 0.089         0
MX1         0   5.059052 0.960 0.093         0


Significant genes (padj < 0.05): 825 

You should see approximately 825 significant genes at padj < 0.05. The top genes include interferon-stimulated genes (IFIT1/2/3, RSAD2, MX1) and IFN-inducible chemokines (CXCL10, CXCL11, CCL8). But notice that the p-values are extremely small (effectively zero). This is a symptom of per-cell testing with thousands of cells – even modest differences become “significant.”

Callout

The pseudobulk revolution

The Wilcoxon and MAST tests treat each cell as an independent observation. But cells from the same donor or the same sample are not independent – they share the same genetic background, the same library preparation, and the same sequencing run. This is called pseudoreplication, and it severely inflates p-values.

For example, if you have 2,000 monocytes from one control sample and 2,000 from one stimulated sample, per-cell testing treats this as n = 4,000. But the true sample size is n = 2 (one sample per condition). Every gene with even a tiny difference will appear significant simply because of the enormous “sample size.”

Pseudobulk analysis solves this by aggregating all cells of the same type from the same sample into a single expression profile. If you have 3 control donors and 3 stimulated donors, you get 3 pseudobulk replicates per condition – the same as a bulk RNA-seq experiment with n = 3 per group. This gives proper type I error control and realistic p-values.

Always use pseudobulk when you have biological replicates. Use per-cell tests only for exploratory analysis or when you have a single sample per condition and have no other option.

Animation: Cells are not replicates

Pseudobulk Analysis


Pseudobulk aggregation sums the raw UMI counts for each gene across all cells of the same cell type from the same sample, producing a bulk-like count matrix with one column per sample-celltype combination.

Callout

The IFNB dataset has no biological replicates

The IFNB dataset has only one orig.ident per condition (IMMUNE_CTRL and IMMUNE_STIM). If we aggregate by celltype + stim + orig.ident, we get one pseudobulk sample per group – not enough for DESeq2, which requires at least 3.

To demonstrate the pseudobulk workflow, we create pseudo-replicates by randomly splitting cells within each condition into 3 groups. This is a common workaround for teaching and exploration, but be aware: pseudo-replicates do not capture true biological variability across donors. For publication-quality DE, you need genuine biological replicates (multiple donors or independent experiments).

R

# Create pseudo-replicates by randomly assigning cells to 3 groups per condition
set.seed(42)
ifnb$pseudo_rep <- paste0("rep", sample(1:3, ncol(ifnb), replace = TRUE))

# Verify: should see 3 roughly equal groups per condition
table(ifnb$stim, ifnb$pseudo_rep)

OUTPUT

       rep1 rep2 rep3
  CTRL 2198 2198 2152
  STIM 2533 2465 2453

Now aggregate using the pseudo-replicate labels:

R

# Create pseudobulk counts
# Group by cell type, condition, and pseudo-replicate
bulk <- AggregateExpression(ifnb,
                            group.by = c("celltype", "stim", "pseudo_rep"),
                            return.seurat = TRUE)
bulk
Parameter Value Description
group.by c("celltype", "stim", "pseudo_rep") Metadata columns to group cells by. Each unique combination of cell type, condition, and pseudo-replicate becomes one pseudobulk column.
return.seurat TRUE Return a Seurat object (instead of a raw matrix) so we can use FindMarkers on it.

OUTPUT

An object of class Seurat
14053 features across 78 samples within 1 assay
Active assay: RNA (14053 features, 0 variable features)
 3 layers present: counts, data, scale.data

The number of samples equals the number of unique cell type x condition x pseudo-replicate combinations. With 13 cell types, 2 conditions, and 3 pseudo-replicates, you get up to 78 pseudobulk samples (fewer if a cell type has no cells in a given replicate).

Now we can run DESeq2-based DE testing on the pseudobulk object. We subset to CD14+ monocytes and compare conditions:

R

# Subset pseudobulk to CD14 Mono samples
bulk.mono <- subset(bulk, celltype == "CD14 Mono")

# Set condition as active identity
Idents(bulk.mono) <- "stim"

# Run DESeq2 on pseudobulk counts
mono.pseudo <- FindMarkers(bulk.mono,
                           ident.1 = "STIM",
                           ident.2 = "CTRL",
                           test.use = "DESeq2")
head(mono.pseudo, 10)

R

cat("Significant genes (padj < 0.05):", sum(mono.pseudo$p_val_adj < 0.05, na.rm = TRUE), "\n")

OUTPUT

        p_val avg_log2FC pct.1 pct.2 p_val_adj
ISG15       0   6.526243     1     1         0
IFI6        0   3.627521     1     1         0
GBP1        0   2.583049     1     1         0
S100A8      0  -2.269779     1     1         0
RSAD2       0   5.939389     1     1         0
TMSB10      0   1.705161     1     1         0
VAMP5       0   3.068289     1     1         0
SSB         0   2.631016     1     1         0
PLSCR1      0   2.452130     1     1         0
TNFSF10     0   6.054715     1     1         0


Significant genes (padj < 0.05): 1539 

The pseudobulk analysis identifies 1539 significant genes – more than the Wilcoxon test (825). This may seem surprising: we said pseudobulk is more conservative, so why does it find more? Because we used pseudo-replicates, not true biological replicates. The 3 random splits within each condition have very low within-group variance, giving DESeq2 high statistical power.

With genuine biological replicates (different donors with real between-donor variability), the pseudobulk gene count would typically be much lower than the per-cell test. The high count here is an artifact of pseudo-replication and should not be interpreted as pseudobulk being more sensitive in general.

Visualization and Interpretation


Volcano plot

A volcano plot shows the relationship between statistical significance (-log10 p-value, y-axis) and biological effect size (log2 fold change, x-axis). Each point is a gene. Genes in the upper corners are both statistically significant and biologically meaningful.

R

# Use Wilcoxon results for visualization (more genes to plot)
mono.wilcox$gene <- rownames(mono.wilcox)
mono.wilcox$significance <- case_when(
    mono.wilcox$p_val_adj < 0.05 & mono.wilcox$avg_log2FC > 0.5  ~ "Up",
    mono.wilcox$p_val_adj < 0.05 & mono.wilcox$avg_log2FC < -0.5 ~ "Down",
    TRUE ~ "NS"
)

ggplot(mono.wilcox, aes(x = avg_log2FC, y = -log10(p_val_adj), color = significance)) +
    geom_point(size = 0.5, alpha = 0.6) +
    scale_color_manual(values = c("Up" = "firebrick", "Down" = "steelblue", "NS" = "grey70")) +
    geom_vline(xintercept = c(-0.5, 0.5), linetype = "dashed", color = "grey40") +
    geom_hline(yintercept = -log10(0.05), linetype = "dashed", color = "grey40") +
    labs(x = "Log2 fold change (STIM vs CTRL)",
         y = "-log10(adjusted p-value)",
         title = "CD14+ Monocytes: STIM vs CTRL",
         color = "Direction") +
    theme_minimal()
Volcano plot showing upregulated genes in red in the upper right, downregulated genes in blue in the upper left, and non-significant genes in grey near the bottom, with dashed cutoff lines for significance and fold change.
Volcano plot for CD14 monocytes STIM vs CTRL.

How to read the volcano plot:

  • Upper right: genes significantly upregulated in stimulated cells (ISGs, chemokines)
  • Upper left: genes significantly downregulated in stimulated cells
  • Bottom center: non-significant genes (low fold change or high p-value)
  • Dashed lines: significance cutoffs (|log2FC| > 0.5 and padj < 0.05)

Feature and violin plots

Visualize specific DE genes on the UMAP and across conditions:

R

FeaturePlot(ifnb,
            features = "IFIT1",
            split.by = "stim",
            cols = c("grey85", "firebrick"))
Feature plot of IFIT1 expression split by condition showing near-absent expression in control cells and strong expression across all cell types in stimulated cells, especially monocytes.
FeaturePlot of IFIT1 split by CTRL and STIM.

R

VlnPlot(ifnb,
        features = c("IFIT1", "CXCL10", "MX1"),
        split.by = "stim",
        pt.size = 0,
        ncol = 3)
Three violin plot panels showing IFIT1 and MX1 upregulated in stimulated cells of every cell type and CXCL10 mainly in stimulated monocytes, dendritic cells and B cells, with control violins near zero.
Violin plots for IFIT1, CXCL10, and MX1 across cell types, split by condition.

Dot plot across cell types

A dot plot can show the top DE genes across all cell types simultaneously, revealing which responses are shared and which are cell-type-specific:

R

top_de_genes <- head(rownames(mono.wilcox), 10)
DotPlot(ifnb,
        features = top_de_genes,
        split.by = "stim",
        cols = c("steelblue", "firebrick")) +
    coord_flip() +
    theme(axis.text.x = element_text(angle = 45, hjust = 1))
Dot plot showing top 10 DE genes across all cell types split by CTRL and STIM, with dot size indicating percent expressed and color indicating average expression, showing consistently larger and redder dots in stimulated cells.
Dot plot of top 10 DE genes across cell types split by condition.

Functional Enrichment


A list of hundreds of DE genes is difficult to interpret gene by gene. Gene Ontology (GO) enrichment analysis asks: are the DE genes enriched for specific biological processes? This connects individual gene changes to broader biological themes.

Animation: What enrichGO tests

Converting gene symbols to Entrez IDs

clusterProfiler requires Entrez gene IDs. We convert from gene symbols using bitr():

R

# Get significant upregulated genes
sig_genes <- mono.wilcox %>%
    filter(p_val_adj < 0.05, avg_log2FC > 0.5) %>%
    rownames()

cat("Number of significant upregulated genes:", length(sig_genes), "\n")

# Convert gene symbols to Entrez IDs
gene_ids <- bitr(sig_genes,
                 fromType = "SYMBOL",
                 toType = "ENTREZID",
                 OrgDb = org.Hs.eg.db)
head(gene_ids)
Parameter Value Description
fromType "SYMBOL" Input ID type (gene symbols like ISG15, IFIT1).
toType "ENTREZID" Output ID type (Entrez gene IDs like 9636, 3434).
OrgDb org.Hs.eg.db Organism annotation database (human).

OUTPUT

Number of significant upregulated genes: 293

   SYMBOL ENTREZID
1   IFIT1     3434
2  CXCL10     3627
3   RSAD2    91543
4 TNFSF10     8743
5   IFIT3     3437
6   IFIT2     3433

Running GO enrichment

R

go_results <- enrichGO(gene = gene_ids$ENTREZID,
                       OrgDb = org.Hs.eg.db,
                       ont = "BP",
                       pAdjustMethod = "BH",
                       qvalueCutoff = 0.05,
                       readable = TRUE)
Parameter Value Description
gene gene_ids$ENTREZID Vector of Entrez gene IDs to test.
OrgDb org.Hs.eg.db Organism annotation database.
ont "BP" GO ontology category: “BP” (Biological Process), “MF” (Molecular Function), or “CC” (Cellular Component).
pAdjustMethod "BH" Benjamini-Hochberg method for multiple testing correction.
qvalueCutoff 0.05 Only return terms with q-value below this threshold.
readable TRUE Convert Entrez IDs in the output back to gene symbols for readability.

Visualize the top enriched GO terms with a dot plot:

R

dotplot(go_results, showCategory = 15) +
    ggtitle("GO Biological Process: STIM vs CTRL CD14+ Monocytes")
GO enrichment dot plot showing 15 biological process terms with response to virus, defense response to virus, and viral process as the top three by gene ratio, with dot color from red to blue indicating adjusted p-value.
GO Biological Process dot plot for STIM vs CTRL CD14 monocytes.

The top enriched GO terms should include:

  • response to virus
  • defense response to virus
  • viral process
  • viral life cycle
  • regulation of innate immune response
  • regulation of viral process
  • viral genome replication

These terms are entirely consistent with IFN-beta stimulation. IFN-beta is a type I interferon that activates antiviral defense programs, and the GO enrichment confirms that the DE genes are concentrated in exactly these pathways.

KEGG pathway enrichment

For a complementary view, we can also test KEGG pathways:

R

kegg_results <- enrichKEGG(gene = gene_ids$ENTREZID,
                           organism = "hsa",
                           pAdjustMethod = "BH",
                           qvalueCutoff = 0.05)
head(kegg_results, 5)

OUTPUT

                   category               subcategory       ID
hsa04621 Organismal Systems             Immune system hsa04621
hsa05168     Human Diseases Infectious disease: viral hsa05168
hsa04612 Organismal Systems             Immune system hsa04612
hsa05169     Human Diseases Infectious disease: viral hsa05169
hsa05164     Human Diseases Infectious disease: viral hsa05164
                                 Description GeneRatio  BgRatio RichFactor
hsa04621 NOD-like receptor signaling pathway    26/179 187/9451 0.13903743
hsa05168    Herpes simplex virus 1 infection    20/179 180/9451 0.11111111
hsa04612 Antigen processing and presentation    13/179  82/9451 0.15853659
hsa05169        Epstein-Barr virus infection    19/179 205/9451 0.09268293
hsa05164                         Influenza A    17/179 173/9451 0.09826590
         FoldEnrichment    zScore       pvalue     p.adjust       qvalue
hsa04621       7.341021 12.168483 9.114111e-16 2.114474e-13 2.114474e-13
hsa05168       5.866543  9.159024 1.551182e-10 1.799371e-08 1.799371e-08
hsa04612       8.370555  9.313570 3.728060e-09 2.883033e-07 2.883033e-07
hsa05169       4.893555  7.830726 9.849154e-09 5.712510e-07 5.712510e-07
hsa05164       5.188330  7.724892 2.584269e-08 1.101704e-06 1.101704e-06
                                                                                                                                           geneID
hsa04621 79792/10010/115362/2634/3320/6773/7295/10628/4939/3428/10616/8767/4938/6347/1508/3665/3326/834/115361/6772/330/4940/2633/114769/837/2635
hsa05168                                      6672/3133/6892/64135/6773/29992/4939/684/4938/6347/6890/3665/3107/8717/6772/330/4940/3134/5610/3105
hsa04612                                                                         3133/6892/3320/1520/3303/1508/6890/3107/3326/5721/3134/3105/1514
hsa05169                                            3133/6892/6773/4939/4616/3627/4938/6890/3665/3107/8717/958/6772/4940/3134/5610/3105/4067/9636
hsa05164                                                     91543/64135/6773/103/4939/3627/4938/6347/4600/3665/4599/8717/834/8743/6772/4940/5610
         Count
hsa04621    26
hsa05168    20
hsa04612    13
hsa05169    19
hsa05164    17

The top KEGG pathways are “NOD-like receptor signaling pathway”, “Herpes simplex virus 1 infection”, “Antigen processing and presentation”, “Epstein-Barr virus infection” and “Influenza A” – all involving the interferon and antiviral response.

Exporting Results


At the end of an analysis, you should export your results in formats that support reproducibility, sharing, and downstream use.

Save the annotated Seurat object

R

saveRDS(ifnb, file = paste0(work_dir, "ifnb_de.rds"))

Export the count matrix and metadata

For the PBMC 10k dataset from earlier episodes, you can export the raw counts and metadata as CSV files. First load the annotated object saved in the cell type annotation episode:

R

pbmc <- readRDS(paste0(work_dir, "pbmc_annotated.rds"))

# Export raw count matrix (warning: can be large for big datasets)
counts_matrix <- GetAssayData(pbmc, layer = "counts")
Matrix::writeMM(counts_matrix, file = "counts.mtx")

# Export metadata
write.csv(pbmc@meta.data, file = "metadata.csv", quote = FALSE)

# Export gene names
write.csv(data.frame(gene = rownames(pbmc)), file = "features.csv",
          row.names = FALSE, quote = FALSE)

A dense CSV stores every zero, so keep it to small datasets or a subset of cells. Here, the first 500 cells:

R

write.csv(as.matrix(GetAssayData(pbmc, layer = "counts")[, 1:500]),
          file = "counts_dense_500cells.csv")

Format conversion for Python users

If collaborators use the Python/Scanpy ecosystem, you can write your Seurat object as an AnnData .h5ad file with the Bioconductor package anndataR. It writes the file directly from R, so no Python installation is needed:

R

# Install anndataR if needed: BiocManager::install("anndataR")
library(anndataR)
write_h5ad(pbmc, "pbmc_annotated.h5ad", mode = "w")  # "w" overwrites an earlier export

The counts, normalized data, cell metadata and the PCA and UMAP embeddings are all carried over. Three warnings are expected. One says the scale.data layer is skipped “with unexpected dimensions”: it holds only the 2,000 variable genes, an AnnData layer must cover every gene, and scaled values are easy to recompute in Python. Two say that matrix column names “cannot be written”: AnnData stores embeddings without column names. To read the file in Python, collaborators need a recent anndata (0.13 reads it; 0.10 does not).

Callout

Why not sceasy?

Older tutorials use sceasy::convertFormat(). Its latest version (0.0.7) calls GetAssayData(slot = ...), which SeuratObject 5 no longer accepts, so it stops with an error on Seurat v5 objects.

GEO submission

For submitting data to NCBI GEO, you typically need:

  • Raw count matrix (genes x cells) in MTX or CSV format
  • Cell metadata (barcodes, cell types, conditions) as a TSV/CSV
  • Processing description (software versions, parameters, filtering criteria)
Callout

Reproducibility checklist

Before sharing or publishing your analysis, ensure you have:

  • Saved your R session info so others know exactly which package versions you used:

    R

    writeLines(capture.output(sessionInfo()), "session_info.txt")
  • Pinned package versions in your analysis scripts or used renv to create a reproducible environment.

  • Saved intermediate RDS files at each major step (pbmc_filtered.rds, pbmc_normalized.rds, pbmc_clustered.rds, pbmc_annotated.rds) so others can enter the pipeline at any point.

  • Documented all parameter choices: QC thresholds, number of PCs, clustering resolution, normalization method, integration method, and DE test used.

  • Recorded the random seed if you used one (e.g., set.seed(42) before UMAP or clustering).

Challenge

Challenge 1: Wilcoxon vs. Pseudobulk

Run both Wilcoxon (per-cell) and pseudobulk (DESeq2) DE testing for stimulated vs. control CD14+ monocytes. Compare the number of significant genes and the p-value distributions.

R

# Wilcoxon (already computed above)
n_wilcox <- sum(mono.wilcox$p_val_adj < 0.05)
cat("Wilcoxon significant genes:", n_wilcox, "\n")

# Pseudobulk (already computed above)
n_pseudo <- sum(mono.pseudo$p_val_adj < 0.05, na.rm = TRUE)
cat("Pseudobulk significant genes:", n_pseudo, "\n")

# Compare p-value distributions for shared genes
shared_genes <- intersect(rownames(mono.wilcox), rownames(mono.pseudo))
comparison <- data.frame(
    gene = shared_genes,
    wilcox_pval = mono.wilcox[shared_genes, "p_val_adj"],
    pseudo_pval = mono.pseudo[shared_genes, "p_val_adj"]
)
comparison <- comparison[complete.cases(comparison), ]

ggplot(comparison, aes(x = -log10(wilcox_pval), y = -log10(pseudo_pval))) +
    geom_point(size = 0.5, alpha = 0.5) +
    geom_abline(slope = 1, intercept = 0, color = "red", linetype = "dashed") +
    labs(x = "-log10(p-value) Wilcoxon",
         y = "-log10(p-value) Pseudobulk",
         title = "P-value comparison: Wilcoxon vs Pseudobulk") +
    theme_minimal()

Which method gives more conservative results? Why?

Scatter plot of negative log10 p-values from Wilcoxon versus pseudobulk for shared genes, with a red dashed diagonal showing equal significance and points broadly scattered indicating different significance rankings between methods.
Scatter plot comparing p-values from Wilcoxon and pseudobulk DE tests.

You should find that:

  • Wilcoxon identifies 825 significant genes
  • Pseudobulk identifies 1539 (with pseudo-replicates)

Pseudobulk finds more significant genes here, which may seem to contradict the usual advice that pseudobulk is more conservative. The reason: our pseudo-replicates (random cell splits) have very low within-group variance, so DESeq2 gets very high power. With real biological replicates (different donors), the between-donor variability would inflate variance estimates and the gene count would drop substantially.

The scatter plot shows a complex pattern:

  • A dense band along the x-axis (Wilcoxon significant, pseudobulk not) – genes with small effects that Wilcoxon detects via per-cell power but pseudobulk misses.
  • Many points above the diagonal – genes where pseudobulk yields smaller p-values, because DESeq2’s count-based model is more powerful when within-group variance is low.
  • Some points below the diagonal – genes where Wilcoxon is more significant.

With true biological replicates, you would expect most points to shift below the diagonal and the overall pseudobulk gene count to drop well below the Wilcoxon count.

Rule of thumb: Use Wilcoxon for quick exploration and hypothesis generation. Use pseudobulk for any result you plan to report or publish. And for publication, ensure you have true biological replicates, not pseudo-replicates.

Challenge

Challenge 2: GO Enrichment of IFN-beta Response Genes

Run GO enrichment on the top 200 upregulated genes (by fold change) in stimulated vs. control CD14+ monocytes. What are the top 5 biological processes? Do they make sense for IFN-beta stimulation?

R

# Get top 200 upregulated genes by fold change
top200 <- mono.wilcox %>%
    filter(p_val_adj < 0.05, avg_log2FC > 0) %>%
    arrange(desc(avg_log2FC)) %>%
    head(200)
top200_genes <- rownames(top200)

# Convert to Entrez IDs
top200_ids <- bitr(top200_genes,
                   fromType = "SYMBOL",
                   toType = "ENTREZID",
                   OrgDb = org.Hs.eg.db)

# Run GO enrichment
go_top200 <- enrichGO(gene = top200_ids$ENTREZID,
                      OrgDb = org.Hs.eg.db,
                      ont = "BP",
                      pAdjustMethod = "BH",
                      qvalueCutoff = 0.05,
                      readable = TRUE)

# Show top 5 terms
head(go_top200, 5)

# Visualize
dotplot(go_top200, showCategory = 10) +
    ggtitle("GO BP: Top 200 upregulated genes in STIM CD14+ Monocytes")
GO enrichment dot plot for top 200 upregulated genes showing 10 biological process terms with response to virus and defense response to virus having the largest gene ratios and deepest red color indicating lowest adjusted p-values.
GO BP dot plot for the top 200 upregulated genes in STIM CD14 monocytes.

The top 5 biological processes should include terms like:

Rank GO Term Interpretation
1 response to virus Broad viral response including both detection and effector mechanisms
2 defense response to virus Direct antiviral effector programs activated by IFN-beta
3 negative regulation of viral genome replication ISGs that block viral replication directly (IFIT, OAS, MX family genes)
4 regulation of viral process Regulatory genes that modulate antiviral defense (e.g., TRIM, IRF family)
5 viral genome replication Overlaps with rank 3; genes acting on the replication step itself

These results make complete biological sense. IFN-beta (interferon beta) is a type I interferon that signals through the IFNAR receptor complex, activating the JAK-STAT pathway, which turns on hundreds of interferon-stimulated genes (ISGs). These ISGs encode proteins that directly inhibit viral replication (MX1, IFIT1/2/3), degrade viral RNA (RSAD2), recruit immune cells via chemokines (CXCL10, CXCL11, CCL8), and alert neighboring cells to the viral threat.

The GO enrichment confirms that our DE analysis is capturing the expected biology: the top upregulated genes in stimulated monocytes are overwhelmingly involved in antiviral and interferon signaling pathways. This serves as important biological validation that the computational pipeline (integration, DE testing, enrichment) is working correctly.

Key Points
  • Per-cell DE tests (Wilcoxon, MAST) treat each cell as independent, inflating p-values through pseudoreplication
  • Pseudobulk analysis aggregates cells by sample and cell type, providing statistically valid results with proper type I error control
  • Volcano plots visualize the relationship between effect size (log2FC) and significance (-log10 p-value) for all tested genes
  • Gene ontology enrichment connects lists of DE genes to biological pathways, providing functional interpretation of differential expression results
  • Always export session info, save intermediate objects, and document parameter choices for reproducibility