---
title: "Tutorial 06 — DESeq2: From Bulk Primer to Pseudobulk DE"
subtitle: "First a clean bulk-RNA-seq walkthrough on `airway`, then pseudobulk DE on `ifnb` STIM vs CTRL"
author: "Single Cell RNA-seq Workshop"
format:
html:
toc: true
toc-depth: 3
code-fold: false
code-overflow: wrap
highlight-style: github
embed-resources: true
execute:
eval: false
echo: true
warning: false
message: false
editor: visual
---
::: {.callout-note title="Running the code in this tutorial"}
Every code chunk here is tagged with `#| eval: false`, so the published page shows the code **without running it**. To run it yourself, open the downloaded `.qmd` in **RStudio**:
- **Run one chunk:** click the green ▶ (*Run Current Chunk*) at the chunk's top-right, or press *Ctrl/Cmd + Enter*. This runs the chunk **regardless of its `eval` setting** — the easiest way to work through the tutorial interactively.
- **Run a chunk on render:** change that chunk's `#| eval: false` to `#| eval: true` (or delete the line) so it executes when the document is rendered.
- **Render the whole tutorial:** click the **Render** button in RStudio (or run `quarto render` in a terminal) to execute the chunks top-to-bottom and knit a finished HTML. To run everything on render, set `eval: true` once in the YAML header at the top.
:::
## About this tutorial
This tutorial covers the **full DESeq2 story** in two parts:
- **Part A — DESeq2 primer on `airway`** (the Bioconductor-shipped 8-sample bulk RNA-seq dataset). Gets the mechanics out of the way on a clean, small bulk dataset before you confront the single-cell aggregation step.
- **Part B — Pseudobulk DE on `ifnb`** (STIM vs CTRL within each cell type). The full single-cell DE workflow: aggregate to *(donor × condition × cell type)* counts and run a proper bulk-style test per cell type.
Paired with **[Lecture 06 — DESeq2: Bulk Fundamentals + Pseudobulk DE](../Lecture_Folder/Lecture_06_DESeq2_DE.html)**.
::: callout-note
**Companion book chapter:** [Chapter 6 — DESeq2: Bulk Fundamentals + Pseudobulk DE](../Resources_Folder/Chapter_06_DESeq2_DE.html) — the long-form prose treatment of this tutorial's material. · **HPC version (Talapas):** [Talapas analysis pipeline — `06_pseudobulk_de.R`](Tutorial_10_Talapas_Pipeline.html)
:::
Part A is a standard bulk DESeq2 walkthrough on the `airway` data. Part B starts from the integrated, annotated object saved at the end of Tutorial 05 (`ifnb_integrated.rds`).
::: {.callout-tip title="How to use this page"}
The rendered HTML shows the code but does **not** execute it. To run it:
1. Download the `.qmd` source: Tutorial_06_DESeq2_DE.qmd. If your browser saves the file as `Tutorial_06_DESeq2_DE.qmd.txt`, **drop the trailing `.txt`** so the filename ends in `.qmd`, then open it in RStudio.
2. For Part A: install the `airway` Bioconductor package — no other download needed.
3. For Part B: make sure you've completed **[Tutorial 05](Tutorial_05_Integration.html)** — it writes `ifnb_integrated.rds`.
4. Work through the chunks, flipping `eval: true` as you go.
:::
::: {.callout-tip title="Solutions / instructor copy"}
The *Think about it* prompts on this page have inline answers (click "Show answer" / "Click for answer" to expand). For the full **runnable instructor copy** with every chunk pre-evaluated and outputs shown:
- **For students:** the same `.qmd` you downloaded already contains every solution inline — just expand the collapsed answers
- **For instructors:** render with `quarto render --profile solutions` from the `Exercise_Folder/` directory. The fully-evaluated HTML is written to `docs/Exercise_Folder/_solutions/Tutorial_06_DESeq2_DE.html`. See [Exercise_Folder/_quarto-solutions.yml](https://github.com/wcresko/scRNAseq_tutorial/blob/main/Exercise_Folder/_quarto-solutions.yml) for the build profile
:::
------------------------------------------------------------------------
# Part A — DESeq2 Primer on `airway`
## Dataset — `airway`
**What it is.** RNA-seq of four primary human airway smooth muscle cell lines, each **untreated** vs. **dexamethasone**-treated → **8 samples**. Standard reference dataset for teaching DESeq2.
- Bioconductor landing page:
- Original paper: Himes et al. (2014) *PLoS ONE* 9: e99625
There's no separate download — the counts and metadata ride along with the package, which is installed in the [Tutorial 00 setup](Tutorial_00_Setup_RStudio_Packages.html).
::: callout-tip
**Can't install the `airway` package?** Download the preassembled `airway_raw.rds` and use `airway <- readRDS("../data/airway_raw.rds")` in place of `data(airway)` below — it's the identical object. Details on the [Datasets](../Datasets.html#core-data-load) page.
:::
::: {.callout-warning title="Common errors / things that bite"}
**Wrong sign on `log2FoldChange`** — `relevel()` matters. The reference level becomes the *denominator*. `dds$dex <- relevel(dds$dex, ref = "untrt")` makes `trt vs untrt` the comparison, with positive LFCs meaning higher in treated. Skip this and your "up" / "down" labels are reversed.
**`lfcShrink(... type = "apeglm")` errors with "requires installing the Bioconductor package 'apeglm'"** — `apeglm` is only a *Suggests* of DESeq2, so it isn't pulled in automatically. It's in the [Tutorial 00 setup](Tutorial_00_Setup_RStudio_Packages.html) list; if you set up earlier, install it once with `BiocManager::install("apeglm")`.
**`apeglm` errors with "coef ... not in resultsNames" or a `fitType` message** — `apeglm` needs the design **coefficient name**, not a contrast vector. This tutorial renames the `dex` levels to `treated`/`untreated`, so the coefficient is `dex_treated_vs_untreated` (not `dex_trt_vs_untrt`). Run `resultsNames(dds)` to see the exact names available.
:::
## A.1 — Setup
```{r}
#| label: M6-setup_airway
library(DESeq2)
library(airway)
library(tidyverse)
library(patchwork) # combine + annotate multi-panel plots
set.seed(2026)
# ---------------------------------------------------------------------------
# Output directory for this module's figures and tables.
# Every figure/table chunk below writes a file named Mod6_C_
# into ../output/Mod6/ so it can be cross-referenced from the rest of the site.
# Mod6 = Module 6 (this tutorial); C = the nth code chunk.
# ---------------------------------------------------------------------------
out_dir <- "../output/Mod6"
dir.create(out_dir, recursive = TRUE, showWarnings = FALSE)
dir.create("../data", showWarnings = FALSE) # shared .rds hand-off folder (sibling of scripts/, created one level up)
message("This module writes its figures & tables to: ", normalizePath(out_dir))
# --- Figure saving --------------------------------------------------------
# save_fig() writes every figure as a .png (for viewing) AND a .svg (vector,
# editable in Illustrator / Inkscape for a manuscript). Pass the .png path; the
# .svg is written alongside with the same basename. (.svg uses the 'svglite'
# package, installed in Tutorial 00.)
save_fig <- function(filename, plot, width, height, dpi = 300, ...) {
ggplot2::ggsave(filename, plot, width = width, height = height, dpi = dpi, ...)
svg_path <- paste0(tools::file_path_sans_ext(filename), ".svg")
tryCatch(ggplot2::ggsave(svg_path, plot, width = width, height = height, ...),
error = function(e) message(" (could not write ", basename(svg_path),
" - install 'svglite'? ", conditionMessage(e), ")"))
}
# save_base_fig() does the same for BASE-graphics figures (plot()/heatmap()/etc.):
# pass a no-argument function that draws the figure; it is rendered to .png and .svg.
save_base_fig <- function(filename, draw, width = 7, height = 5, res = 300) {
grDevices::png(filename, width = width, height = height, units = "in", res = res)
draw(); grDevices::dev.off()
svg_path <- paste0(tools::file_path_sans_ext(filename), ".svg")
tryCatch({ grDevices::svg(svg_path, width = width, height = height); draw(); grDevices::dev.off() },
error = function(e) message(" (could not write ", basename(svg_path), ")"))
}
```
## A.2 — Load the data and pull out counts + metadata
```{r}
#| label: M6-load_airway
data(airway)
airway
counts_data <- assay(airway) # genes × samples integer matrix
colData <- as.data.frame(colData(airway))
# Tidy the metadata: keep cell line + treatment,
# rename "trt"/"untrt" to something readable.
colData <- colData[, c("cell", "dex")]
colData$dex <- factor(gsub("trt", "treated", colData$dex))
colData$dex <- factor(gsub("untrt", "untreated", colData$dex))
head(counts_data[, 1:4])
colData
# --- Tables out: sample metadata + a peek at the counts matrix --------------
colData |>
tibble::rownames_to_column("sample") |>
readr::write_csv(file.path(out_dir, "Mod6_C3_airway_coldata.csv"))
as.data.frame(counts_data) |>
tibble::rownames_to_column("gene") |>
readr::write_csv(file.path(out_dir, "Mod6_C3_airway_counts.csv"))
```
::: {.callout-important title="Think about it"}
1. What's in the rows of `counts_data`? In the columns?
2. Why do we want `dex` as a `factor` rather than a `character`?
Show answers
1. Rows are **Ensembl gene IDs**; columns are **sample IDs** (`SRR…` accessions). Values are integer read counts.
2. DESeq2 builds a model matrix from factors and uses **factor levels** to decide which group is the *reference* (denominator) in `log2FoldChange`. A character column would be coerced silently and might pick the wrong reference.
:::
## A.3 — Sanity-check the metadata
DESeq2 fails fast and unhelpfully if `colData` rows don't line up with `countData` columns. Always check.
```{r}
#| label: M6-sanity_airway
all(colnames(counts_data) %in% rownames(colData))
all(colnames(counts_data) == rownames(colData))
```
::: callout-warning
Both should return `TRUE`. If the second is `FALSE`, **reorder `colData`** to match — don't reorder the counts (you'll forget which orientation you fixed). `colData <- colData[colnames(counts_data), ]`.
:::
## A.4 — Build the `DESeqDataSet`
```{r}
#| label: M6-build_dds_airway
dds <- DESeqDataSetFromMatrix(
countData = counts_data,
colData = colData,
design = ~ cell + dex
)
dds
```
::: {.callout-important title="Think about it"}
The design is `~ cell + dex`. Why include `cell` if we only care about `dex`?
Show answers
The eight samples come from **four different cell lines**. Each cell line is a paired (treated, untreated) block. Adding `cell` as a covariate **soaks up the cell-line effect** so the test for `dex` is within-line — the bulk equivalent of a paired analysis.
:::
## A.5 — Pre-filter low-count genes
```{r}
#| label: M6-prefilter_airway
keep <- rowSums(counts(dds)) >= 10
dds <- dds[keep, ]
nrow(dds)
```
::: callout-tip
**Pre-filtering is for speed only**, not for FDR control. DESeq2 already does *independent filtering* at the `results()` step, which protects multiple testing. Dropping rows with `< 10` total counts just makes `DESeq()` faster.
:::
## A.6 — Set the reference level
```{r}
#| label: M6-relevel_airway
dds$dex <- relevel(dds$dex, ref = "untreated")
```
::: callout-warning
**This step is easy to forget and easy to misread.** With `ref = "untreated"`, a positive `log2FoldChange` means the gene is **higher in treated**. Without `relevel()`, R's alphabetical default would flip the sign on you.
:::
## A.7 — Run `DESeq()` and pull results
[]{#lst-M6-run_deseq_airway}
```{r}
#| label: M6-run_deseq_airway
dds <- DESeq(dds)
res <- results(dds, contrast = c("dex", "treated", "untreated"))
summary(res)
# --- Table out: full DESeq2 results (treated vs untreated) ------------------
as.data.frame(res) |>
tibble::rownames_to_column("gene") |>
readr::write_csv(file.path(out_dir, "Mod6_C8_airway_deseq_results.csv"))
```
::: {.callout-tip title="Reading the output"}
The `summary(res)` printout ([code](#lst-M6-run_deseq_airway)) reports the total number of genes tested and how many have `LFC > 0` (up) or `LFC < 0` (down) at the chosen `alpha` threshold (default 0.1). The "outliers" and "low counts" lines tell you how many genes were **removed from testing** before FDR adjustment: outliers have a Cook's-distance flag (one sample is wildly different) and are set to `padj = NA`; low-count genes fail DESeq2's independent filtering step because they lack statistical power at the chosen FDR. A healthy summary shows a handful of outliers (often 0–50), a moderate low-count fraction, and a biologically plausible number of significant genes — for `airway` treated vs untreated, expect several hundred significant genes.
:::
::: {.callout-important title="Think about it"}
`summary(res)` reports counts of "LFC > 0 (up)" and "outliers" and "low counts". What do "outliers" and "low counts" mean here?
Show answers
- **Outliers** are genes flagged by Cook's distance — a single sample with a wildly different count from the rest. DESeq2 sets `padj = NA` for these so they don't drive results.
- **Low counts** are genes filtered by *independent filtering*: their mean expression is so low they have no power to be detected at the chosen FDR. DESeq2 estimates the optimal cut adaptively.
:::
## A.8 — Shrink the LFCs before ranking / plotting
```{r}
#| label: M6-lfcshrink_airway
res_shrunk <- lfcShrink(dds,
coef = "dex_treated_vs_untreated",
type = "apeglm")
plotMA(res_shrunk, ylim = c(-3, 3),
main = "airway: shrunken LFC (treated vs untreated)",
xlab = "Mean of normalized counts",
ylab = "log2 fold change (apeglm-shrunken)")
# --- Figure out: base-graphics MA plot via png device ----------------------
save_base_fig(file.path(out_dir, "Mod6_C9_airway_ma_plot.png"), width = 7, height = 5, draw = function() {
plotMA(res_shrunk, ylim = c(-3, 3),
main = "airway: shrunken LFC (treated vs untreated)",
xlab = "Mean of normalized counts",
ylab = "log2 fold change (apeglm-shrunken)")
})
```
::: {.callout-tip title="Reading the output"}
The x-axis is average normalized expression (`baseMean`); the y-axis is the apeglm-shrunken `log2FoldChange` (positive = higher in treated). Each point is a gene; red points are significant at the chosen FDR. Notice that the cloud of points is narrow and centered near zero for low-`baseMean` genes — that is shrinkage working: raw fold changes for lowly-expressed genes are pulled back toward zero because they carry little information. Well-expressed, genuinely regulated genes (ISGs in `ifnb`, corticosteroid-response genes in `airway`) will sit away from the band and remain red even after shrinkage. A healthy MA plot looks like a horizontal band with a handful of off-axis red points; a funnel that opens up at low baseMean without shrinkage signals that you should always apply `lfcShrink` before ranking.
:::
::: callout-tip
**Always rank by shrunk LFCs.** Genes with `baseMean = 2` can show wild raw LFCs purely by sampling noise; `apeglm` shrinks those toward zero and de-clutters your top-hits list.
:::
## A.9 — Inspect a top hit
[]{#lst-M6-top_hit_airway}
```{r}
#| label: M6-top_hit_airway
#| fig-cap: "Per-sample normalized counts for the top 5 airway hits (by padj), each panel labelled with its gene, split by treatment."
top_genes <- as.data.frame(res_shrunk) |>
rownames_to_column("gene") |>
arrange(padj) |>
head(5)
top_genes
# Gather per-sample normalized counts for the top 5 genes (plotCounts can return
# the data instead of drawing), so we can plot them together with gene labels.
counts_top <- purrr::map_dfr(top_genes$gene, function(g) {
d <- plotCounts(dds, gene = g, intgroup = "dex", returnData = TRUE)
d$gene <- g
d
})
# Keep the panels ordered by significance (padj rank), not alphabetically.
counts_top$gene <- factor(counts_top$gene, levels = top_genes$gene)
p_top <- ggplot(counts_top, aes(x = dex, y = count, colour = dex)) +
geom_jitter(width = 0.12, height = 0, size = 2, alpha = 0.85) +
facet_wrap(~ gene, nrow = 1, scales = "free_y") + # facet strip = the gene label
scale_y_log10() +
labs(
title = "Top 5 airway hits (by padj) — per-sample normalized counts",
subtitle = "Each panel is a gene; points are samples, split by treatment",
x = "Treatment (dex)",
y = "Normalized count (log scale)",
colour = "Treatment"
) +
theme(legend.position = "none")
p_top
# --- Table out: top 5 hits by padj -----------------------------------------
readr::write_csv(top_genes, file.path(out_dir, "Mod6_C10_airway_top_hits.csv"))
# --- Figure out: per-sample counts for the top 5 hits (png + svg) -----------
save_fig(file.path(out_dir, "Mod6_C10_airway_top_hit_counts.png"), p_top,
width = 11, height = 4, dpi = 300)
```
::: {.callout-tip title="Reading the output"}
Each panel ([code](#lst-M6-top_hit_airway)) shows one gene (ordered left to right by significance); the x-axis splits by treatment (`untreated` vs `treated`) and each point is one sample on a log-scale y-axis of normalized counts. What to look for: a clear, consistent shift in the median across all four cell lines — if any panel has one outlier sample dragging the effect, that gene deserves closer scrutiny before reporting it. For `airway`, the top hits should show tight, concordant up-regulation in all four treated replicates. A gene where two treated replicates are high and two are near the untreated level is a borderline call, regardless of the p-value.
:::
::: {.callout-important title="Think about it"}
Why is `plotCounts()` a more honest way to confirm a hit than just trusting the p-value?
Show answers
It shows the **per-sample** values. A single outlier sample can drag the model into "significance" that disappears under inspection. Eyeballing the per-sample dots beats a single p-value every time.
:::
## A.10 — Sanity check by transformation + PCA
Quality-control your samples *post-hoc* with a variance-stabilizing transform (`vst`) and a PCA:
```{r}
#| label: M6-vst_pca_airway
vsd <- vst(dds, blind = FALSE)
p_pca <- plotPCA(vsd, intgroup = c("dex", "cell")) +
labs(
title = "airway: sample PCA on variance-stabilized counts",
subtitle = "Look for treated vs untreated separation within each cell line",
colour = "Treatment · cell line"
)
p_pca
# --- Figure out ------------------------------------------------------------
save_fig(file.path(out_dir, "Mod6_C11_airway_vst_pca.png"), p_pca,
width = 7, height = 5, dpi = 300)
```
::: {.callout-tip title="Reading the output"}
Each point is one of the eight samples; the axes are PC1 and PC2 from the variance-stabilized count matrix. Look for two things: (1) samples of the same treatment (`treated` vs `untreated`) should cluster together, ideally along the same PC; (2) the four cell lines should form paired clusters, with treated and untreated replicates from each line sitting near each other. In `airway`, PC1 typically captures the treatment effect and PC2 the cell-line differences — exactly the structure the `~ cell + dex` design exploits. If samples cluster purely by cell line with no treatment separation, the treatment effect is too small relative to the between-line variance to trust the DE calls.
:::
::: callout-tip
**What you want to see.** Treated and untreated separated on PC1 or PC2 *within* each cell line. If samples cluster by cell line only and not by `dex`, your treatment effect is small relative to between-line variation — interpret the DE results accordingly.
:::
## Part A — What you've practiced
- Building a `DESeqDataSet` from a counts matrix + sample table
- Adding a covariate to a design
- Reading DESeq2 output (LFC, lfcSE, padj, baseMean)
- Shrinking LFCs for honest ranking
- QCing samples with `vst` + PCA
You're now ready for **Part B**, where the same DESeq2 mechanics apply to a per-cell-type aggregated count matrix.
------------------------------------------------------------------------
# Part B — Pseudobulk DE on `ifnb` (STIM vs CTRL)
We start from the **integrated, annotated object saved at the end of Tutorial 05** (`ifnb_integrated.rds`) and learn to:
- Aggregate single-cell counts to a *(donor × condition × cell type)* pseudobulk matrix
- Run a proper, well-calibrated DE test with `DESeq2`, one cell type at a time
- Apply LFC shrinkage for ranking and visualize results
- Avoid the most common pseudobulk pitfalls
::: callout-note
**The contrast is real this time.** The `ifnb` dataset has a built-in STIM (IFN-β stimulated) vs CTRL (control) contrast. In Tutorial 05 we **integrated** to remove the STIM-vs-CTRL axis from the embedding so cell types co-cluster. Here we **measure** the STIM-vs-CTRL effect *within each cell type* — the biology that integration was careful **not** to erase.
:::
## Dataset — resuming from Tutorial 05
| File | Produced by | What it is |
|---|---|---|
| `../data/ifnb_integrated.rds` | Tutorial 05 | Seurat object with integrated PCA / UMAP / clusters, the original `RNA` assay restored as default, and `seurat_annotations` cell-type labels per cell. |
### About donor information
The Kang *et al.* (2017) study profiled PBMCs from **eight lupus donors**, each split into a control and an IFN-β–stimulated aliquot. The `muscData::Kang18_8vs8()` loader we use in [Tutorial 01](Tutorial_01_QC_Preprocessing.html) **carries the real per-cell donor IDs** in the `ind` column, so we have genuine biological replicates: eight donors, each measured in both conditions.
Pseudobulk DE needs **biological replicates within each condition**, and here we have them: the eight real donors become the replicates, one pseudobulk profile per *(donor × condition × cell type)*. The per-cell-type test below is `~ condition`; because each donor appears in both conditions you can upgrade to the paired `~ donor + condition` (see *Going further*). The code detects the donor column automatically:
- **`ind` is present (the expected case)** — we use the eight real donors as biological replicates. This is what you'll see when you load `ifnb` as in Tutorial 01.
- **Fallback only — donor column absent** — if an object somehow reaches this step without donor metadata (e.g. an older SeuratData-sourced `ifnb`), the code creates **synthetic pseudo-donors** by random cell assignment. This is a teaching shortcut, **not** something you'd ever do in a paper: the contrast still lets DESeq2 fit, but the resulting p-values aren't biologically meaningful.
::: {.callout-warning title="Common errors / things that bite"}
**`AggregateExpression` returns a different structure than expected** — the API changed across Seurat versions. In v5+ it returns an Assay5 object; subset with `$RNA$counts`. In v4, it returns a sparse matrix list — use `$RNA`. The tutorial's parsing handles both, but if you're on a custom branch this can bite.
**Underscores in donor or cell-type names break the column-name parsing** — if your sample is `donor_03_treated`, the `sub("^[^_]+_", "", colnames(pb))` parse will get confused. Defensive parsing approach: pull the metadata directly from the Seurat object's `@meta.data` rather than parsing column names. The tutorial shows this.
**`colData` comes back all `NA` / numeric donor IDs get a `g` prefix** — `ifnb`'s donor column (`ind`) is **numeric** (101, 107, …), and `AggregateExpression()` prepends `g` to any `group.by` value that starts with a digit (it prints *"…starts with a number, appending 'g'…"*). The aggregated columns become `g101_…`, which no longer match a reconstructed `101_…` key, so the join yields all `NA` and every group is dropped. Fix: make donor a valid name up front — the tutorial uses `ifnb$donor <- paste0("d", ind)`.
**`count(...)` errors with "Argument 'x' is not a vector: list"** — loading `DESeq2`/`SummarizedExperiment` attaches `matrixStats`, whose `count()` **masks** `dplyr::count()` (along with `desc`, `slice`, `rename`, `first` from S4Vectors/IRanges). Call the dplyr ones explicitly, e.g. `dplyr::count(...)`. Watch the package-startup messages for the list of masked names.
**Volcano plot errors with "Faceting variables must have at least one value"** — the cell types you asked to plot don't exist in `de_all$celltype`. The `muscData` labels are `CD14+ Monocytes`, `CD4 T cells`, `B cells`, … — not the older `CD14 Mono`/`B`. The tutorial now selects the cell types **data-driven** (the 3 with the most significant genes), so it adapts to your labels; if you hardcode names, match them to `unique(de_all$celltype)`.
**Benign warnings during the per-cell-type loop** — `converting counts to integer mode` (pseudobulk sums stored as doubles; the loop now `round()`s them), `some variables in design formula are characters, converting to factors` (silenced by making `condition` an explicit factor), and `nbinomGLM … line search routine failed` (an `apeglm` optimizer note on a few noisy genes — harmless, not fixable by factor coercion). The DE table is correct regardless.
**Synthetic pseudo-donors are a fallback, not the default** — objects loaded the way this series does (`muscData::Kang18_8vs8()`, Tutorial 01) carry real donor IDs in `ind`, so you'll take the real-donor path automatically. The synthetic `set.seed(2026)` 4-pseudo-donor split only triggers if no donor column is present (e.g. an older SeuratData `ifnb`); it's a teaching device whose p-values are not biologically meaningful — say so in any writeup.
:::
## B.1 — Setup
```{r}
#| label: M6-setup_pb
library(Seurat)
library(DESeq2)
library(tidyverse)
library(patchwork)
set.seed(2026)
ifnb <- readRDS("../data/ifnb_integrated.rds")
DefaultAssay(ifnb) <- "RNA"
ifnb
```
```{r}
#| label: M6-peek_meta
# Look for a real donor column
candidate_cols <- intersect(c("donor_id","donor","ind"), colnames(ifnb@meta.data))
candidate_cols
```
## B.2 — Define donor (real or synthetic) and condition
```{r}
#| label: M6-donor
if (length(candidate_cols) > 0) {
# Prefix with a letter: the ifnb donor IDs (`ind`) are numbers (101, 107, ...),
# and AggregateExpression() prepends "g" to any group.by value that starts with
# a digit. Making donor a valid name up front ("d101") keeps the aggregated
# column names stable so the colData join below matches.
ifnb$donor <- paste0("d", ifnb@meta.data[[ candidate_cols[1] ]])
message("Using real donor column: ", candidate_cols[1])
} else {
# Synthetic pseudo-donors: 4 per stim, assigned by random cell index
set.seed(2026)
ifnb$donor <- paste0(
ifnb$stim, "_d",
sample(1:4, size = ncol(ifnb), replace = TRUE)
)
message("Created synthetic pseudo-donors (teaching shortcut). ",
"DO NOT do this for a real publication.")
}
ifnb$condition <- ifnb$stim # CTRL or STIM
ifnb$celltype <- ifnb$seurat_annotations
table(ifnb$donor, ifnb$condition)
# --- Table out: cells per donor x condition --------------------------------
as.data.frame(table(donor = ifnb$donor, condition = ifnb$condition)) |>
readr::write_csv(file.path(out_dir, "Mod6_C14_donor_by_condition.csv"))
```
::: {.callout-important title="Think about it"}
Why is it not enough to just run `FindMarkers(ifnb, ident.1 = "STIM", ident.2 = "CTRL")` on the per-cell expression matrix?
Click for answer
Because cells from one donor are **not statistically independent**. Running a per-cell test treats every cell as a separate replicate, which inflates the effective sample size and produces **anti-conservative** p-values — you'll get hundreds of "significant" genes that wouldn't replicate. The number of independent biological replicates is the **number of donors** (or pseudo-donors), not the number of cells. Pseudobulk fixes this by aggregating to one observation per (donor × condition × cell type).
For a thorough demonstration of how badly per-cell DE fails on this dataset specifically, see Squair *et al.* 2021 (Nat Commun, [doi](https://doi.org/10.1038/s41467-021-25960-2)).
:::
## B.3 — Aggregate to pseudobulk
```{r}
#| label: M6-aggregate
pb <- AggregateExpression(
ifnb,
assays = "RNA",
slot = "counts",
group.by = c("donor", "condition", "celltype"),
return.seurat = FALSE
)$RNA
dim(pb) # genes × (donor × condition × celltype) replicates
head(colnames(pb), 6)
```
The column names look like `__`. Build the matching `colData`:
```{r}
#| label: M6-colData
# Seurat's AggregateExpression replaces "_" with "-" by default in some versions.
# Be defensive and parse from the metadata directly instead.
group_meta <- ifnb@meta.data |>
distinct(donor, condition, celltype) |>
mutate(group_id = paste(donor, condition, celltype, sep = "_"))
# Match the colnames that AggregateExpression actually used. Different
# Seurat versions differ; this should align them.
fix_id <- function(x) gsub("[ /]", "-", x)
group_meta$group_id_clean <- fix_id(group_meta$group_id)
colnames(pb) <- fix_id(colnames(pb))
meta_pb <- tibble(group_id_clean = colnames(pb)) |>
left_join(group_meta, by = "group_id_clean")
head(meta_pb)
# --- Table out: pseudobulk column metadata (donor x condition x celltype) ---
readr::write_csv(meta_pb, file.path(out_dir, "Mod6_C16_pseudobulk_coldata.csv"))
```
## B.4 — Filter low-count groups
A pseudobulk column built from very few cells is noise. Drop them.
```{r}
#| label: M6-filter_pb
# NOTE: use dplyr::count explicitly — loading DESeq2/SummarizedExperiment attaches
# matrixStats, whose count() masks dplyr::count() (you'd get "Argument 'x' is not
# a vector: list").
cells_per_group <- ifnb@meta.data |>
dplyr::count(donor, condition, celltype) |>
mutate(group_id_clean = fix_id(paste(donor, condition, celltype, sep = "_")))
meta_pb <- meta_pb |>
left_join(cells_per_group |> select(group_id_clean, n_cells = n),
by = "group_id_clean")
# Keep groups with at least 10 cells
keep <- !is.na(meta_pb$n_cells) & meta_pb$n_cells >= 10
pb <- pb[, keep]
meta_pb <- meta_pb[keep, ]
table(meta_pb$celltype, meta_pb$condition)
# --- Table out: surviving pseudobulk groups per celltype x condition --------
as.data.frame(table(celltype = meta_pb$celltype, condition = meta_pb$condition)) |>
readr::write_csv(file.path(out_dir, "Mod6_C17_groups_per_celltype.csv"))
```
::: {.callout-important title="Think about it"}
Why is `n_cells >= 10` reasonable? Why might you raise or lower it?
Click for answer
The threshold balances signal against noise: pseudobulk built from 1–2 cells is dominated by stochastic dropout and is unlikely to represent the cell type. A floor of \~10 cells is the de-facto default in the field (see Squair *et al.* 2021 and Crowell *et al.* 2020). Raise it (e.g. 20–50) for very sparse cell types or shallow libraries; lower it only if you also lower your statistical-significance expectations.
:::
## B.5 — Run DESeq2 per cell type
```{r}
#| label: M6-run_deseq_pb
run_de <- function(ct) {
keep <- meta_pb$celltype == ct
if (sum(keep) < 4 || length(unique(meta_pb$condition[keep])) < 2) return(NULL)
cd <- as.data.frame(meta_pb[keep, ])
# Make condition an explicit factor with CTRL as the reference. This silences
# DESeq2's "variables in design formula are characters, converting to factors"
# note and guarantees the STIM-vs-CTRL direction.
cd$condition <- factor(cd$condition, levels = c("CTRL", "STIM"))
dds <- DESeqDataSetFromMatrix(
countData = round(pb[, keep]), # round() avoids the "converting counts to integer mode" note
colData = cd,
design = ~ condition
)
dds <- DESeq(dds, quiet = TRUE)
res <- results(dds, contrast = c("condition","STIM","CTRL"))
res <- lfcShrink(dds, coef = "condition_STIM_vs_CTRL",
res = res, type = "apeglm")
as.data.frame(res) |>
rownames_to_column("gene") |>
mutate(celltype = ct)
}
de_all <- map_dfr(unique(meta_pb$celltype), run_de) |>
filter(!is.na(padj))
glimpse(de_all)
# --- Table out: full per-cell-type pseudobulk DE results --------------------
readr::write_csv(de_all, file.path(out_dir, "Mod6_C18_pseudobulk_de_all.csv"))
```
## B.6 — Inspect the top hits
```{r}
#| label: M6-top_hits_pb
de_top <- de_all |>
group_by(celltype) |>
slice_min(padj, n = 10) |>
ungroup()
de_top
# --- Table out: top 10 hits per cell type by padj --------------------------
readr::write_csv(de_top, file.path(out_dir, "Mod6_C19_pseudobulk_de_top.csv"))
```
::: {.callout-tip title="Reading the output"}
The printed tibble is grouped by `celltype`; within each group the rows are ordered by `padj` (smallest first). The `log2FoldChange` column uses apeglm-shrunken estimates: a value of 2 means the gene is four-fold higher in STIM than CTRL on average across donors. `baseMean` is the average normalized count across all pseudobulk samples for that gene — a very low `baseMean` with a large `log2FoldChange` is a warning sign (little evidence, large noise). The key sanity check: are the top rows dominated by well-known interferon-stimulated genes (`ISG15`, `MX1`, `OAS1`, `IFIT1`)? If so, the contrast is working exactly as expected.
:::
::: {.callout-important title="Think about it"}
1. Many of the top hits per cell type will be **interferon-stimulated genes** (`ISG15`, `IFI6`, `IFIT1/2/3`, `MX1`, `OAS1`, `RSAD2`). Are they up in STIM or CTRL?
2. Are **the same** ISGs at the top of every cell type, or do you see cell-type-specific responses?
Click for answer
1. Up in STIM — the IFN-β response is what STIM is, mechanistically. `log2FoldChange > 0` means higher in STIM (because we set `contrast = c("condition","STIM","CTRL")` with STIM as the numerator).
2. A core ISG cluster (`ISG15`, `IFI6`, `MX1`) tops every cell type, **but the magnitude varies**. Monocytes (CD14, CD16) typically show the **strongest** ISG induction; T and B cells respond more modestly; some rare populations may be near-flat. This cell-type-specific quantitative difference is exactly the kind of biology pseudobulk DE is designed to find — and the kind of biology that the per-cell `FindMarkers` test cannot resolve confidently.
:::
## B.7 — Visualize
A simple per-cell-type volcano plot:
```{r}
#| label: M6-volcano
# Pick the 3 cell types with the most significant genes — data-driven, so it works
# whatever your cell-type labels are (the muscData ifnb uses "CD14+ Monocytes",
# "CD4 T cells", "B cells", ... — not the older "CD14 Mono"/"B" names).
plot_types <- de_all |>
dplyr::filter(padj < 0.05) |>
dplyr::count(celltype, sort = TRUE) |>
head(3) |>
dplyr::pull(celltype)
if (length(plot_types) == 0) plot_types <- head(unique(de_all$celltype), 3)
p_volcano <- de_all |>
filter(celltype %in% plot_types) |>
ggplot(aes(log2FoldChange, -log10(padj))) +
geom_point(alpha = 0.4, size = 1) +
geom_hline(yintercept = -log10(0.05), linetype = 2) +
geom_vline(xintercept = c(-1, 1), linetype = 3, color = "grey50") +
facet_wrap(~ celltype) +
labs(title = "Pseudobulk DE: STIM vs CTRL (log2FC > 0 = up in STIM)",
subtitle = "Dashed line: padj = 0.05; dotted lines: |log2FC| = 1",
x = "log2 fold change (apeglm-shrunken)",
y = "-log10(padj)",
caption = "Module 6 · DESeq2 pseudobulk DE")
p_volcano
# --- Figure out ------------------------------------------------------------
save_fig(file.path(out_dir, "Mod6_C20_pseudobulk_volcano.png"), p_volcano,
width = 10, height = 4, dpi = 300)
```
::: {.callout-tip title="Reading the output"}
Each panel is one cell type; each point is one gene. The x-axis is the apeglm-shrunken `log2FoldChange` (positive = higher in STIM), and the y-axis is `-log10(padj)` — so genes in the upper-right corner are strongly induced in STIM with high statistical confidence. The horizontal dashed line marks `padj = 0.05`; the vertical dotted lines mark `|log2FC| = 1` (twofold change). For `ifnb`, you should see a dense cloud of significant points in the upper-right of monocyte panels (strong ISG response) and a sparser, flatter cloud in T and B cell panels. Points that are statistically significant but close to the x-axis origin have large sample sizes and small effects — biologically real but possibly not the most actionable hits. An enrichment tool (Tutorial 07) will help you interpret them collectively.
:::
::: {.callout-important title="Think about it"}
Some cell types will show many DE genes; others almost none. What can drive this pattern *that has nothing to do with biology*?
Click for answer
Three usual suspects:
1. **Cell counts per group.** Cell types that are abundant in every donor have well-estimated dispersions; rare types do not.
2. **Sample size.** With 4 pseudo-donors split 4-vs-4 (or 8-vs-8 with real donors), you have moderate power on a clean signal — but very little if a cell type is missing in some donors.
3. **Library composition.** A cell type whose library is dominated by a few hyper-expressed genes will have noisier dispersion estimates and fewer detected hits.
Always inspect `n_cells` per group and the dispersion plot before reporting "X genes are significantly DE".
:::
## B.8 — Save results
```{r}
#| label: M6-save_de
# Pipeline hand-off: Tutorial 07 reads these from data/ — keep them there.
write_csv(de_all, "../data/ifnb_pseudobulk_de.csv")
write_csv(de_top, "../data/ifnb_pseudobulk_de_top.csv")
# --- Tables out: student-facing copies in the module output dir -------------
readr::write_csv(de_all, file.path(out_dir, "Mod6_C21_pseudobulk_de.csv"))
readr::write_csv(de_top, file.path(out_dir, "Mod6_C21_pseudobulk_de_top.csv"))
```
## Wrap-up
You now have:
- A working understanding of the DESeq2 mechanics from a clean bulk dataset (`airway`).
- A pseudobulk matrix per *(donor, condition, cell type)* and per-cell-type DE results with shrunken LFCs.
- A workflow you can re-point at *any* condition contrast in your own data — replace `condition = stim` with your own covariate and you're done.
::: {.callout-tip title="Going further"}
- Add covariates (donor, sex, age) to the design: `~ donor + condition` (when donors are real).
- Use `IHW` or `ashr` for adaptive multiple-testing.
- **Interaction terms.** Try `design = ~ cell + dex + cell:dex` on `airway` and compare results.
- **Volcano plot.** Plot `-log10(padj)` vs. `log2FoldChange` from `res_shrunk` and label your top 20 hits.
- Hand the gene lists to `clusterProfiler::compareCluster` for cross-cell-type enrichment — see the GO-enrichment section of [scNotebooks Module 04](https://integrativebioinformatics.github.io/scNotebooks/modules/en/module04/module04.html) for a worked example.
- **Differential abundance** with `miloR` — the *complement* to differential expression. DE asks "for cells of the same type, what genes change between conditions?" Differential abundance asks "do the *cell-type proportions themselves* change between conditions?"
- Compare against `muscat::pbDS` (formal pseudobulk wrapper) and `glmGamPoi`.
:::
## Continue to Tutorial 07 — functional interpretation
The DE table you wrote (`ifnb_pseudobulk_de.csv`) is the input to **[Tutorial 07 — Functional Analysis with `clusterProfiler`](Tutorial_07_FunctionalAnalysis.html)**, which runs GO over-representation, GSEA, and Reactome enrichment per cell type and shows you how to compare the biological themes across cell types.
For the *complementary* condition-level question — "do cell-type **proportions** change?" rather than "which genes change?" — see **[Tutorial 08 — Differential Abundance with miloR](Tutorial_08_DifferentialAbundance.html)**.
## See also
- [Lecture 06 — DESeq2: Bulk Fundamentals + Pseudobulk DE](../Lecture_Folder/Lecture_06_DESeq2_DE.html)
- Squair *et al.* 2021 — the paper that made pseudobulk the default ([doi](https://doi.org/10.1038/s41467-021-25960-2))
- Crowell *et al.* 2020 — `muscat` package paper ([doi](https://doi.org/10.1038/s41467-020-19894-4))
## Credits
- Data: [`airway`](https://bioconductor.org/packages/airway/) (Himes et al., 2014, *PLoS ONE*)