---
title: "Tutorial 01 — scRNA-seq: QC & Preprocessing"
subtitle: "Load a two-sample dataset (ifnb), filter cells, normalize, pick HVGs, scale"
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
The first hands-on tutorial walking the standard **Seurat** scRNA-seq workflow on a **laptop-friendly** dataset. We use the **`ifnb`** dataset (Kang *et al.* 2017) — peripheral blood mononuclear cells (PBMCs) from eight lupus patients, split into a **control** sample and an **interferon-β-stimulated** sample. Two samples, \~24,000 cells, no 10x click-through.
::: callout-note
**Companion lecture:** [Lecture 01 — Pre-processing: QC, doublets, normalization, HVGs](../Lecture_Folder/Lecture_01_Preprocessing.html) · **Companion book chapter:** [Chapter 1 — Preprocessing](../Resources_Folder/Chapter_01_Preprocessing.html) — the long-form prose treatment of this tutorial's material, with cross-references to the prerequisite appendices. · **HPC version (Talapas):** [Talapas analysis pipeline — `01_qc_preprocessing.R`](Tutorial_10_Talapas_Pipeline.html)
:::
Why two samples from the start? Because Tutorial 05 will teach **integration** — and integration only makes sense if you've already loaded, QC-filtered, and clustered samples that come from different conditions / batches. We carry both `STIM` and `CTRL` cells through every step.
::: callout-tip
**Want to run this on the cluster, or scale up later?** The [**Talapas HPC track**](Tutorial_10_Talapas_Pipeline.html) runs this *same* `ifnb` workflow as R + SLURM scripts — see [Tutorial — SLURM Basics](Tutorial_09_SLURM_Basics.html). To go all the way from **raw FASTQs** on a larger real dataset, see the optional [Full Talapas run from raw FASTQs](../Resources_Folder/Talapas_FullRun_FromFASTQs.html) (a self-paced bonus track).
:::
::: {.callout-tip title="How to use this page"}
The rendered HTML shows the code but does **not** execute it (chunks default to `eval: false`). To actually run the analysis:
1. Download the `.qmd` source: Tutorial_01_QC_Preprocessing.qmd. If your browser saves the file as `Tutorial_01_QC_Preprocessing.qmd.txt`, **drop the trailing `.txt`** so the filename ends in `.qmd`, then open it in RStudio.
2. Install the `ifnb` dataset once (one R command — see [Setup](#setup)).
3. Work through the chunks, flipping `eval: true` as you go.
:::
## Dataset — `ifnb` (Kang *et al.* 2017)
**What it is.** Peripheral blood mononuclear cells (PBMCs) from eight lupus patients. Each donor's PBMCs were split into two aliquots:
- **`CTRL`** — control (no stimulation), \~12,000 cells
- **`STIM`** — treated with interferon-β for 6 hours, \~12,000 cells
Together \~24,000 annotated singlets across two samples on the **10x Chromium 3' v1** assay (the `muscData` build of `ifnb` is larger than the older SeuratData `ifnb` you may have seen quoted at \~14k). **Cell-type labels** (e.g. `CD14+ Monocytes`, `CD4 T cells`, `B cells`, `NK cells`, `Dendritic cells`) are bundled in the metadata so you can sanity-check clustering and integration without doing your own annotation.
- **Reference:** Kang *et al.* (2017) *Nat Biotechnol* 36: 89–94. [doi:10.1038/nbt.4042](https://doi.org/10.1038/nbt.4042)
- **Origin:** GEO accession [GSE96583](https://www.ncbi.nlm.nih.gov/geo/query/acc.cgi?acc=GSE96583)
- **Distributed via:** the Bioconductor [`muscData`](https://bioconductor.org/packages/muscData/) package as `Kang18_8vs8()` — cached locally through `ExperimentHub` on first download
- **Used in:** Tutorials 01 → 04 of this workshop, and the official [Seurat integration vignette](https://satijalab.org/seurat/articles/integration_introduction.html)
::: callout-note
**Why this dataset for teaching.** It's small enough to run on a laptop, it has a clean two-condition structure (so integration is meaningful), and the cell types are PBMCs — well-known, well-marked, and easy to reason about. The interferon response is a strong, biologically real signal that drives both batch-style separation (use case for integration) **and** condition-level differential expression (use case for pseudobulk DE in Tutorial 06).
:::
### Disk-space budget
| Item | Size |
|------------------------------------------|---------------|
| `ifnb` raw counts (compressed cache) | \~25 MB |
| `ifnb` Seurat object in memory | \~600 MB |
| `.rds` snapshots saved between tutorials | \~500 MB each |
Plan for **\~1.5 GB total** including intermediate `.rds` files for Tutorials 01–08.
### Data dictionary — what's in the loaded `ifnb` Seurat object
| Slot | Class | Contents |
|----|----|----|
| `ifnb@assays$RNA@counts` | `dgCMatrix` | Raw UMI counts. \~35,000 genes × \~24,000 cells (sparse). |
| `ifnb@meta.data$stim` | factor | `"CTRL"` or `"STIM"` — the **sample-of-origin** label. This becomes the integration covariate. |
| `ifnb@meta.data$seurat_annotations` | factor | Author-curated cell-type labels from the original paper. Useful as ground truth in Tutorial 03. |
| `ifnb@meta.data$nCount_RNA`, `nFeature_RNA` | numeric | Auto-populated by `CreateSeuratObject` — UMIs and unique genes per cell. |
## Learning goals
By the end of this tutorial you will be able to:
- Fetch a published scRNA-seq dataset from Bioconductor's `muscData` package and assemble it as a Seurat object
- Inspect a multi-sample Seurat object and split it by sample
- Add per-cell QC metrics (`percent.mt`, `percent.rb`) and visualize them per sample
- Choose filtering thresholds that are defensible for *your* dataset
- Run `NormalizeData` → `FindVariableFeatures` → `ScaleData` and understand what each does
- Save the QC'd object so Tutorial 02 can pick it up
::: {.callout-warning title="Common errors / things that bite"}
**`library(muscData)` errors with "there is no package called 'muscData'"** — it should already be installed from the [Tutorial 00 setup](Tutorial_00_Setup_RStudio_Packages.html); if it's missing, re-run that setup block.
**`scDblFinder` returns a different result than the workshop** — `scDblFinder` is stochastic. The workshop uses `set.seed(2026)` *immediately before* calling it; if you set the seed earlier or not at all, the doublet calls will differ. The 5–10% range is what to expect; exact cell IDs will vary.
**SoupX errors with "no raw matrix found"** (or `subscript out of bounds` from `load10X`) — this is correct behaviour. The `Kang18_8vs8()` download only ships the *filtered* counts. SoupX needs both. The SoupX block ([code](#lst-M1-soupx)) is an **illustrative template** for your own 10x data and now stops with a clear message if you run it with the placeholder path. Run it for real on data that has a Cell Ranger `raw_feature_bc_matrix/` folder.
**Warnings (not errors) during doublet detection** — when Step 6 runs you'll see `Layer 'data'/'scale.data' is empty` and `'normalizeCounts'/'librarySizeFactors' is deprecated`. Both are harmless: doublet detection runs on **raw counts before normalization** (so those layers are legitimately empty), and the deprecation notes come from `scran`/`scuttle` *inside* `scDblFinder`, not your code. As long as you see `Creating … artificial doublets`, it's working.
**Plotting warnings before normalization (harmless)** — the QC plots ([code](#lst-M1-qc_plots)) and doublet plots ([code](#lst-M1-dbl)) draw per-cell QC *metadata* (`nFeature_RNA`, `nCount_RNA`, …) before normalization, so Seurat prints `Default search for "data" layer … utilizing "counts" layer instead`. The QC values are metadata, so the layer it uses is irrelevant. Likewise the HVG plot ([code](#lst-M1-hvgs)) uses a log10 axis and prints `log-10 transformation introduced infinite values` for genes with zero mean expression (they're not variable features). The tutorial wraps these plots in `suppressWarnings()` so you shouldn't see them, but they're safe either way.
**Gene names look like `SYMBOL_ENSG…` / `SYMBOL-ENSG…`** — `muscData`'s `Kang18_8vs8()` stores each feature as `SYMBOL_ENSEMBLID` (e.g. `ISG15_ENSG00000187608`). The loader strips the trailing Ensembl-ID suffix so only the gene symbol remains (`ISG15`), which is what canonical markers, Azimuth, and `org.Hs.eg.db` match on. It strips *only* the Ensembl suffix — not everything after the first dash — so real dashed symbols (`HLA-A`, `MT-CO1`) are preserved, and it reports how many names it simplified.
:::
::: {.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_01_QC_Preprocessing.html`. See [Exercise_Folder/\_quarto-solutions.yml](https://github.com/wcresko/scRNAseq_tutorial/blob/main/Exercise_Folder/_quarto-solutions.yml) for the build profile
:::
## Setup {#setup}
::: {.callout-important title="Set up your project folder first — read this once"}
These tutorials assume a simple **project layout**. Make a project folder, put a **`scripts/`** subfolder inside it, and save every downloaded tutorial `.qmd` into `scripts/`:
```
my_scrnaseq_project/
├── scripts/ ← put the downloaded Tutorial_*.qmd files here, and run them from here
├── data/ ← auto-created on first run: the .rds objects passed between tutorials
└── output/ ← auto-created on first run: each module's figures (Mod1/, Mod2/, …)
```
Run each tutorial **from `scripts/`** (open the `.qmd` in RStudio from there). The setup chunk below then creates `data/` and `output/` as **siblings of `scripts/`** — i.e. at the project root — using the paths `../data` and `../output/Mod1`. (The `..` is "one level up from `scripts/`".) Two practical rules:
1. **`data/` is the hand-off.** Tutorial 01 writes `../data/ifnb_preprocessed.rds`; Tutorial 02 reads it back from the same `../data/`. Keep all the `.qmd` files together in `scripts/` so they share one project `data/`.
2. **Can't find a file?** The setup chunk prints the exact absolute path (`This module writes its figures & tables to: …`). If it's not where you expect, check `getwd()` — it should be your `scripts/` folder. See the downloadable **README** (linked on the [Materials](../Materials.html) page) for the full layout.
:::
```{r}
#| label: M1-setup
# Packages are installed once in Tutorial 00 (Setup) — here we just load them.
library(Seurat)
library(muscData)
library(SingleCellExperiment)
library(tidyverse)
library(patchwork) # combine + annotate multi-panel Seurat plots
set.seed(2026)
# --- 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), ")"))
}
# ---------------------------------------------------------------------------
# Output directory for this module's figures and tables.
# Every figure/table chunk below writes a file named Mod1_C_
# into ../output/Mod1/ so it can be cross-referenced from the rest of the site.
# Mod1 = Module 1 (this tutorial); C = the nth code chunk.
# ---------------------------------------------------------------------------
out_dir <- "../output/Mod1"
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))
```
::: callout-note
`muscData` (installed in the [Tutorial 00 setup](Tutorial_00_Setup_RStudio_Packages.html)) ships the data through Bioconductor's `ExperimentHub`: the first call to `Kang18_8vs8()` downloads \~25 MB into your local cache, and subsequent calls are instant.
:::
::: callout-tip
**Can't install `muscData`?** Download the preassembled `ifnb_raw.rds` instead and replace the load block below with `ifnb <- readRDS("../data/ifnb_raw.rds")`. It is the exact object Step 1 produces, so you pick up right at QC. Details on the [Datasets](../Datasets.html#core-data-load) page.
:::
------------------------------------------------------------------------
## Step 1 — Load the `ifnb` dataset
```{r}
#| label: M1-load
# Fetch the Kang et al. 2017 PBMC dataset (CTRL + IFN-beta STIM) from
# Bioconductor's ExperimentHub. The first call downloads ~25 MB and caches it;
# every subsequent call is a no-op.
sce <- Kang18_8vs8()
# Keep only true singlets that have an author-assigned cell-type label (drop the
# multiplexed multiplets and unlabeled droplets). This leaves ~24,000 cells — the
# muscData build is larger than the historical SeuratData `ifnb` (~14k).
sce <- sce[, sce$multiplets == "singlet" & !is.na(sce$cell)]
# Gene (feature) name cleanup. muscData's Kang18_8vs8 stores each feature as
# SYMBOL_ENSEMBLID (e.g. "ISG15_ENSG00000187608"). We keep only the readable
# SYMBOL so canonical markers ("CD14", "MS4A1", ...) and reference tools (Azimuth,
# org.Hs.eg.db) match by name. Without this, Seurat would also rewrite the '_' to
# '-' and warn ("Feature names cannot have underscores ...").
#
# We strip ONLY a trailing "_ENSEMBLID" suffix, on the original underscore names.
# We deliberately do NOT cut at the first dash — real symbols contain dashes
# (HLA-A, MT-CO1, HLA-DRB1) and must be preserved. If a feature has no symbol
# (blank / pure Ensembl ID) we keep its original name so nothing is dropped, and
# make.unique() de-duplicates any symbols that collapse to the same name.
cm <- counts(sce)
orig <- rownames(cm)
sym <- sub("_ENS[A-Z]*[0-9]+(\\.[0-9]+)?$", "", orig) # SYMBOL_ENSG... -> SYMBOL
blank <- is.na(sym) | sym == "" | grepl("^ENS[A-Z]*[0-9]+", sym)
sym[blank] <- orig[blank]
rownames(cm) <- make.unique(gsub("_", "-", sym)) # Seurat-safe, unique
n_changed <- sum(rownames(cm) != orig)
if (n_changed > 0) {
i <- which(rownames(cm) != orig)[1]
message(n_changed, " of ", length(orig), " feature names simplified to gene ",
"symbols (e.g. '", orig[i], "' -> '", rownames(cm)[i], "')")
}
# Convert to a Seurat object, mirroring the metadata column names the rest of
# this tutorial series expects:
# - `stim` as a factor with levels c("CTRL", "STIM")
# - `seurat_annotations` for the author-curated cell-type labels
ifnb <- CreateSeuratObject(
counts = cm,
meta.data = as.data.frame(colData(sce))
)
ifnb$stim <- factor(toupper(ifnb$stim), levels = c("CTRL", "STIM"))
ifnb$seurat_annotations <- factor(ifnb$cell)
ifnb
# What samples are in here?
table(ifnb$stim)
# Author-curated cell-type labels (we'll use these as ground truth in Tut 03)
table(ifnb$seurat_annotations)
# --- Tables out: per-sample and per-cell-type cell counts ------------------
tibble::enframe(table(ifnb$stim),
name = "sample", value = "n_cells") |>
dplyr::mutate(n_cells = as.integer(n_cells)) |>
readr::write_csv(file.path(out_dir, "Mod1_C2_cells_per_sample.csv"))
tibble::enframe(table(ifnb$seurat_annotations),
name = "cell_type", value = "n_cells") |>
dplyr::mutate(n_cells = as.integer(n_cells)) |>
dplyr::arrange(dplyr::desc(n_cells)) |>
readr::write_csv(file.path(out_dir, "Mod1_C2_cells_per_celltype.csv"))
```
::: {.callout-important title="Think about it"}
1. Why do real integration tutorials almost always have at least two samples?
2. What's the difference between a "sample" (`stim`) and a "cell type" (`seurat_annotations`) in this dataset?
Show answers
1. Integration aligns the **shared biological signal** between samples while preserving any **real differences** between them. With one sample there's nothing to align — clustering on PCs alone is enough. The `ifnb` dataset has CTRL and STIM as two samples drawn from the same donors, which is the perfect minimal setup to teach the alignment step.
2. `stim` is the **technical / experimental covariate** — which condition the cell came from. `seurat_annotations` is the **biological identity** — what kind of cell it is. Integration aims to remove `stim`-driven structure from the embedding *without* erasing `seurat_annotations`-driven structure.
:::
## Step 2 — Inspect dimensions, split by sample
The `ifnb` object already arrives as a single Seurat object containing both conditions. For a two-sample QC workflow, it's useful to look at QC metrics **per sample** rather than globally — that way you'll catch a sample-specific quality issue (e.g. one sample with much lower depth) before it confounds the analysis.
```{r}
#| label: M1-split
# Record the starting matrix dimensions
dim_raw <- dim(ifnb)
dim_raw
# --- Table out: starting matrix dimensions (features x barcodes) -----------
tibble::tibble(
stage = "Step 1 — loaded (singlets, annotated)",
features = dim_raw[1],
barcodes = dim_raw[2]
) |>
readr::write_csv(file.path(out_dir, "Mod1_C3_starting_dimensions.csv"))
```
::: {.callout-important title="Think about it"}
1. The `ifnb` object holds both CTRL and STIM cells in a single counts matrix. What information distinguishes a CTRL barcode from a STIM barcode?
2. Why might it matter to split visualizations by `stim` even though clustering happens on the joint matrix?
Show answers
1. The `stim` column in `meta.data`. Each barcode also carries the `-1` / `-2` GEM-well suffix from Cell Ranger, but you should never rely on barcode suffixes for sample membership — the metadata is the source of truth.
2. Per-sample plots reveal **batch-level QC issues** (lower median depth in one sample, different mitochondrial fractions, different cell counts per cluster) that can be hidden by a global histogram. If one sample's distribution is shifted, you may need different filter thresholds for it — or you may need to investigate before going further.
:::
## Step 3 — Add QC metrics
```{r}
#| label: M1-qc_metrics
ifnb[["percent.mt"]] <- PercentageFeatureSet(ifnb, pattern = "^MT-")
ifnb[["percent.rb"]] <- PercentageFeatureSet(ifnb, pattern = "^RP[SL]")
head(ifnb@meta.data)
# --- Table out: per-sample summary of the QC metrics just computed ---------
ifnb@meta.data |>
dplyr::group_by(stim) |>
dplyr::summarise(
n_cells = dplyr::n(),
median_nFeature = median(nFeature_RNA),
median_nCount = median(nCount_RNA),
median_percent_mt = round(median(percent.mt), 2),
median_percent_rb = round(median(percent.rb), 2),
.groups = "drop"
) |>
readr::write_csv(file.path(out_dir, "Mod1_C4_qc_metric_summary.csv"))
```
::: callout-note
The `ifnb` raw matrix uses HUGO gene symbols, so `^MT-` matches the 13 protein-coding mitochondrial genes (MT-ND1, MT-CO1, …) and `^RP[SL]` matches the ribosomal protein genes (RPS\*, RPL\*). For mouse data the conventions are lowercase: `^mt-` and `^Rp[sl]`.
:::
::: {.callout-important title="Think about it"}
1. What biological signal does `percent.mt` capture?
2. Why might `percent.rb` (ribosomal-protein fraction) be useful?
Show answers
1. Dying or stressed cells leak cytoplasmic RNA while retaining mitochondrial RNA, so their mitochondrial fraction rises. High `percent.mt` flags apoptotic / compromised cells.
2. Ribosomal-protein genes are **highly expressed** and **broadly variable** across cell states. A very low or very high `percent.rb` per cluster is sometimes a tell-tale sign of a metabolic / activation difference rather than a cell-type difference — useful to separate "this cluster is biologically distinct" from "this cluster is just translationally quiet."
:::
## Step 4 — Visualize QC, per sample
```{r}
#| label: M1-qc_plots
# Violin panel: four QC metrics, split by sample, fully labelled.
# These features are per-cell QC METADATA plotted before normalization, so Seurat
# warns that the "data" layer is empty and falls back to "counts". That's
# irrelevant here (metadata isn't read from an expression layer), so we silence
# the expected note.
p_qc_vln <- suppressWarnings(
VlnPlot(ifnb,
features = c("nFeature_RNA", "nCount_RNA", "percent.mt", "percent.rb"),
group.by = "stim",
ncol = 4,
pt.size = 0) &
xlab("Sample (stim condition)") &
theme(plot.title = element_text(size = 11)))
p_qc_vln <- p_qc_vln +
patchwork::plot_annotation(
title = "Per-cell QC metrics by sample — ifnb (CTRL vs IFN-β STIM)",
subtitle = "Genes/cell, UMIs/cell, mitochondrial % and ribosomal % distributions",
caption = "Module 1 · QC & Preprocessing"
)
p_qc_vln
# Scatter: UMIs vs unique genes, coloured by sample, with a linear trend
p_qc_scatter <- suppressWarnings(FeatureScatter(ifnb,
feature1 = "nCount_RNA",
feature2 = "nFeature_RNA",
group.by = "stim")) +
geom_smooth(method = "lm") +
labs(
title = "Sequencing depth vs library complexity",
subtitle = "Each point is a cell; the line is a per-group linear fit",
x = "nCount_RNA (total UMIs per cell)",
y = "nFeature_RNA (unique genes per cell)",
colour = "Sample"
)
p_qc_scatter
# --- Figures out -----------------------------------------------------------
save_fig(file.path(out_dir, "Mod1_C5_qc_violins.png"), p_qc_vln,
width = 12, height = 4, dpi = 300)
save_fig(file.path(out_dir, "Mod1_C5_qc_scatter.png"), p_qc_scatter,
width = 6, height = 5, dpi = 300)
```
::: {.callout-tip title="Reading the output"}
**Violin panel** — one violin per sample (x-axis = `CTRL` vs `STIM`) for each of the four QC metrics; the width of a violin at a given height is how many cells sit at that value, so you're reading the *shape* of each per-cell distribution. Healthy cells form the fat central body; the thin upper tail on `nFeature_RNA`/`nCount_RNA` is where doublets hide, and the upper tail on `percent.mt` is where dying cells sit. **Scatter** — each point is one cell: x = total UMIs (`nCount_RNA`), y = unique genes (`nFeature_RNA`). The two scale together along a tight curve; points far *below* the trend are low-complexity/dying cells, points far *above* at the top-right are doublet-like. The per-sample fit lines let you spot a sample with systematically different depth. (`scater::isOutlier()` is a data-driven alternative to eyeballing these thresholds.)
:::
::: {.callout-important title="Think about it"}
1. Compare the violins between CTRL and STIM. Are the medians similar? Does either sample have a much fatter upper tail on `nFeature_RNA`?
2. What would a doublet look like on the `nCount_RNA` vs `nFeature_RNA` scatter? A dying cell?
Show answers
1. In `ifnb` the two samples are very similar in median QC metrics — that's a feature, not a bug, because the donors are matched. A noticeably fatter upper tail in one sample would suggest more **doublets** there (perhaps a higher loading concentration). A noticeably *lower* median `nFeature_RNA` would suggest sequencing depth or cell-quality differences.
2. Doublets sit **high on both axes**, often above the main trend line. Dying cells are **low** on both and tend to have **high `percent.mt`** (color the scatter by `percent.mt` or facet by it).
:::
## Step 5 — Optional: ambient-RNA correction with SoupX
In a 10x droplet experiment, lysed-cell debris in the bulk solution leaks **ambient mRNA** into every droplet. After Cell Ranger, this shows up as a low background of "wrong" transcripts in every cell — most visibly when a cell expresses a marker for a *different* cell type at low level. **`SoupX`** estimates the ambient profile and corrects each cell's counts. It runs *before* QC filtering and replaces the counts matrix with a cleaned one.
::: callout-warning
**SoupX needs both the raw and filtered Cell Ranger matrices** (`raw_feature_bc_matrix/` and `filtered_feature_bc_matrix/`) because it learns the soup profile from empty droplets. `Kang18_8vs8()` only ships the **filtered** counts, so you cannot run SoupX on `ifnb` directly. The code below is the canonical pattern — use it on **your own** 10x data, or in the optional [Full Talapas run from raw FASTQs](../Resources_Folder/Talapas_FullRun_FromFASTQs.html), where the raw matrix is available.
:::
*Optional reference pattern — left `#| eval: false` because it needs a raw Cell Ranger matrix (not shipped with `ifnb`). You don't need to change anything here; use it on your own 10x data.*
```{r}
#| label: M1-soupx
#| eval: false
# Pattern for any 10x dataset where you have the raw Cell Ranger output.
# This block does NOT run on ifnb (which ships only *filtered* counts) and will
# NOT run as-is: edit CR_OUTS to point at a real Cell Ranger 'outs/' directory
# that contains BOTH raw_feature_bc_matrix/ and filtered_feature_bc_matrix/.
library(SoupX)
library(DropletUtils)
# 1. Point at the Cell Ranger 'outs/' directory (containing raw_feature_bc_matrix/
# and filtered_feature_bc_matrix/)
CR_OUTS <- "path/to/cellranger/outs" # <-- EDIT to your own Cell Ranger outs/
# Fail early with a clear message rather than a cryptic "subscript out of bounds"
# if the path is still the placeholder or is missing the two required matrices.
if (!dir.exists(file.path(CR_OUTS, "raw_feature_bc_matrix")) ||
!dir.exists(file.path(CR_OUTS, "filtered_feature_bc_matrix"))) {
stop("SoupX needs a real Cell Ranger 'outs/' folder containing BOTH ",
"raw_feature_bc_matrix/ and filtered_feature_bc_matrix/.\n",
" '", CR_OUTS, "' is not valid — ifnb ships only the filtered counts, so ",
"this block is a template for your OWN 10x data, not a step you run on ifnb.")
}
sc <- load10X(CR_OUTS)
# 2. (Recommended) provide preliminary clusters so SoupX can refine the soup
# profile per cluster — compute them with a quick Seurat workflow first:
# sc <- setClusters(sc, setNames(seu$seurat_clusters, colnames(seu)))
# sc <- setDR(sc, Embeddings(seu, "umap"))
# 3. Estimate the ambient contamination fraction
sc <- autoEstCont(sc)
rho <- sc$fit$rhoEst
message("Estimated ambient fraction: ", round(rho, 3))
# 4. Adjust the counts (round to integers so downstream UMI tools are happy)
adj <- adjustCounts(sc, roundToInt = TRUE)
# 5. Use `adj` as the input to CreateSeuratObject() in Step 1 instead of the raw
# filtered matrix; everything else in this tutorial then runs on the cleaned counts.
```
::: {.callout-important title="Think about it"}
1. Why does SoupX need the **raw** (pre-filter) matrix? What information does it pull from there?
2. When is SoupX overkill? When is it essential?
Show answers
1. The raw matrix contains droplets that captured **no real cell** — they hold only ambient RNA. SoupX uses those empty droplets to estimate the soup's expression profile, then subtracts a fraction of that profile from every called cell.
2. **Overkill:** small contamination (rho ≲ 0.05), well-prepared samples, no marker bleed-through. **Essential:** complex tissues with high cell death / large cell-type differences (tumor microenvironment, mucosal tissues), or any time you see a marker like `HBB` or `IGKC` showing up faintly in cells that shouldn't express it.
:::
## Step 6 — Doublet detection with `scDblFinder`
A **doublet** is one barcode's worth of counts that came from two cells getting trapped in the same droplet. They show up as cells with anomalously high `nCount_RNA` and `nFeature_RNA` and a mixed expression profile (e.g. T-cell markers AND B-cell markers in the same cell). The simple `nFeature_RNA < 2500` filter applied later ([code](#lst-M1-filter)) catches some, but doublets that pair two *different* cell types of similar size will pass that filter — you need a model.
`scDblFinder` simulates artificial doublets, trains a classifier to distinguish them from singletons, and returns a per-cell score and class. Critically, **doublets form within a sample, not across samples**, so we run it per sample.
```{r}
#| label: M1-dbl
library(scDblFinder)
set.seed(2026)
# Run scDblFinder per sample, then merge the results back into the Seurat object.
# NOTE on the messages you'll see here (all harmless, NOT errors):
# - "Layer 'data'/'scale.data' is empty" — doublet detection runs BEFORE
# normalization (Step 8), so those layers aren't populated yet; scDblFinder
# works on raw counts and normalizes internally. We wrap the conversion in
# suppressWarnings() to quiet those expected notes.
# - "'normalizeCounts'/'librarySizeFactors' is deprecated" — these come from
# scran/scuttle *inside* scDblFinder, not from this code; safe to ignore.
ifnb_list <- SplitObject(ifnb, split.by = "stim")
ifnb_list <- lapply(ifnb_list, function(x) {
sce <- suppressWarnings(as.SingleCellExperiment(x))
sce <- scDblFinder(sce, returnType = "sce")
x$scDblFinder.score <- colData(sce)$scDblFinder.score
x$scDblFinder.class <- colData(sce)$scDblFinder.class # "singlet" or "doublet"
x
})
ifnb <- merge(ifnb_list[[1]], y = ifnb_list[-1])
# `merge()` in Seurat v5 leaves the RNA assay with per-sample split layers
# (counts.CTRL / counts.STIM, etc.). Downstream tools like FindMarkers expect
# a single joined layer, so rejoin them now.
ifnb <- JoinLayers(ifnb)
# How many doublets were called per sample?
table(sample = ifnb$stim, class = ifnb$scDblFinder.class)
# --- Table out: doublet calls per sample -----------------------------------
as.data.frame(table(sample = ifnb$stim, class = ifnb$scDblFinder.class)) |>
readr::write_csv(file.path(out_dir, "Mod1_C7_doublet_counts.csv"))
# Inspect: doublets should sit at the high end of nCount/nFeature.
# (Same pre-normalization metadata plot as Step 4 — silence the expected
# "data layer empty, using counts" note.)
p_dbl <- suppressWarnings(
VlnPlot(ifnb,
features = c("nFeature_RNA", "nCount_RNA"),
group.by = "scDblFinder.class", pt.size = 0) &
xlab("scDblFinder class") &
theme(plot.title = element_text(size = 11)))
p_dbl <- p_dbl +
patchwork::plot_annotation(
title = "Library size and complexity for called singlets vs doublets",
subtitle = "Doublets are expected to sit at the high end of both metrics",
caption = "Module 1 · QC & Preprocessing"
)
p_dbl
# --- Figure out ------------------------------------------------------------
save_fig(file.path(out_dir, "Mod1_C7_doublet_violins.png"), p_dbl,
width = 7, height = 4, dpi = 300)
```
::: {.callout-tip title="Reading the output"}
The `table()` is a 2×2 of sample × class: the key number is the **doublet fraction** per sample (`doublet / (singlet + doublet)`), which should land around **5–10%** for a normal 10x run — much higher points to chip overloading. In the violins (x-axis = `singlet` vs `doublet`), the `doublet` group should sit visibly **higher** on both `nFeature_RNA` and `nCount_RNA`; that separation is the sanity check that the classifier keyed on the expected signal. `scDblFinder.score` (0–1) is the per-cell confidence if you want to threshold more or less aggressively than the default class call.
:::
::: {.callout-important title="Think about it"}
1. Why run `scDblFinder` **per sample** instead of on the merged object?
2. scDblFinder marks \~5–10% of cells as doublets in a typical 10x run. If your run shows 25%, what likely happened?
3. Some pipelines run doublet detection *after* clustering and remove doublet-enriched clusters wholesale. Why might that be more conservative than removing per-cell doublet calls?
Click for answer
1. Doublets are made when **two cells from the same droplet load** are co-encapsulated. Cells from different samples (CTRL and STIM here) were prepared in separate channels, so a STIM and a CTRL cell can never be a doublet. Running per-sample also lets each sample's doublet-rate be modeled correctly.
2. Either you over-loaded the chip (the loading concentration was too high — typical for "nominal 10k cells, recovered 18k") or there's a quality problem (debris, large cell aggregates) making the simulated-doublet classifier confused. Inspect `nCount_RNA`/`nFeature_RNA` distributions and the ambient fraction.
3. Per-cell doublet calls have false positives. If you trust them and remove individual cells, you may *also* be removing legitimate cycling cells, large activated cells, or megakaryocytes (which look doublet-like by sheer transcriptional output). Removing doublet-enriched **clusters** is more conservative — only act on a cluster where the doublet fraction is clearly elevated and the markers don't make biological sense.
:::
## Step 7 — Filter low-quality cells
```{r}
#| label: M1-filter
ifnb <- subset(
ifnb,
subset = nFeature_RNA > 200 &
nFeature_RNA < 2500 &
percent.mt < 5 &
scDblFinder.class == "singlet"
)
ifnb
table(ifnb$stim)
dim_after_filter <- dim(ifnb)
# --- Table out: cells surviving the QC filter, per sample ------------------
tibble::enframe(table(ifnb$stim),
name = "sample", value = "n_cells_after_filter") |>
dplyr::mutate(n_cells_after_filter = as.integer(n_cells_after_filter)) |>
readr::write_csv(file.path(out_dir, "Mod1_C8_cells_after_filter.csv"))
```
::: {.callout-important title="Think about it"}
1. Did the CTRL or STIM sample lose more cells in this filter? If they lost noticeably different fractions, what would that suggest?
2. How would you set thresholds *empirically* rather than by default?
Show answers
1. If one sample loses a much larger fraction at `percent.mt < 5` it usually means its cells were under more stress at dissociation or stimulation. For `ifnb`, IFN-β stimulation can mildly elevate `percent.mt` in some donors — flag the sample, but don't necessarily relax the threshold without thinking.
2. Look at the per-sample violin / histogram shape; choose thresholds that trim the clear **tails** (not the mode). For an automated rule, use `scater::isOutlier()` or `median ± k·MAD` — and consider running it **per sample** so a globally lopsided distribution doesn't push the cutoff in the wrong direction for one sample.
:::
## Step 8 — Normalize
```{r}
#| label: M1-normalize
ifnb <- NormalizeData(ifnb)
# Equivalent explicit form:
# NormalizeData(ifnb,
# normalization.method = "LogNormalize",
# scale.factor = 10000)
dim_after_normalize <- dim(ifnb)
```
::: {.callout-important title="Think about it"}
1. What does `LogNormalize` do algebraically?
2. When would you prefer `SCTransform()` instead?
Show answers
1. For each cell: divide each gene's count by the cell's total count, multiply by `scale.factor` (default 10 000), then take `log1p`. In formula form: `log(1 + (count_gc / total_c) * 10000)`.
2. `SCTransform` models the mean–variance relationship per gene with a regularized negative binomial, which is usually better for **low-count** datasets and more faithful downstream variance. It also replaces the NormalizeData → FindVariableFeatures → ScaleData chain in a single call. For `ifnb` either approach works fine.
:::
## Step 9 — Highly variable features
```{r}
#| label: M1-hvgs
ifnb <- FindVariableFeatures(
ifnb,
selection.method = "vst",
nfeatures = 2000
)
top10 <- head(VariableFeatures(ifnb), 10)
plot1 <- VariableFeaturePlot(ifnb)
p_hvg <- LabelPoints(plot = plot1, points = top10, repel = TRUE) +
labs(
title = "Highly variable genes (vst) — top 2,000 selected",
subtitle = "Top 10 most variable genes labelled; many are interferon-stimulated genes",
x = "Average expression (log scale)",
y = "Standardized variance",
colour = "Selected as HVG"
)
# VariableFeaturePlot draws mean expression on a log10 x-axis. Genes with zero
# mean (undetected across all retained cells) become -Inf and ggplot drops them
# with "log-10 transformation introduced infinite values" — harmless, since those
# genes are not variable features. Silence it at draw and save time.
suppressWarnings(print(p_hvg))
# --- Outputs: HVG plot + the labelled top-10 genes as a table --------------
suppressWarnings(
save_fig(file.path(out_dir, "Mod1_C10_variable_features.png"), p_hvg,
width = 7, height = 5, dpi = 300))
tibble::tibble(rank = seq_along(top10), gene = top10) |>
readr::write_csv(file.path(out_dir, "Mod1_C10_top10_hvgs.csv"))
```
::: {.callout-tip title="Reading the output"}
Each dot is a gene: x-axis = **average expression** (log scale), y-axis = **standardized variance** (how much more variable the gene is than expected for its mean). The \~2,000 genes coloured/highlighted as variable are the ones that rise above the fitted mean–variance curve — these are the only genes PCA will use in Tutorial 02. The labelled top-10 are the most variable of all; in `ifnb` they're dominated by **interferon-stimulated genes** (`ISG15`, `IFI6`, `IFIT1`…) because the STIM half of the data switches them on and the CTRL half doesn't. Seeing ISGs at the top here is your first preview of the batch-style split you'll visualize in Tutorial 02 and correct in Tutorial 05.
:::
::: {.callout-important title="Think about it"}
1. Which genes are at the top of `top10`? Many will be **interferon-stimulated genes** (ISGs) like `ISG15`, `IFI6`, `IFIT1` — why?
2. What happens if you set `nfeatures = 500`? Or `nfeatures = 10000`?
Show answers
1. Because half the dataset (the STIM sample) has IFN-β–induced ISG expression while the other half (CTRL) does not, ISGs have **enormous variance across cells** and rise to the top of the HVG list. This is exactly the signal we'll see drive batch-style separation in Tutorial 02 — and it's exactly what integration in Tutorial 05 will need to handle without erasing.
2. Too few (500) risks missing subtle cell-type signals and is brittle to noisy gene choices. Too many (10 000) pulls in low-information genes and dilutes the signal. 1 500–3 000 is a common sweet spot; 2 000 is a good default.
:::
## Step 10 — Scale
```{r}
#| label: M1-scale
ifnb <- ScaleData(ifnb)
```
By default `ScaleData` only scales the variable features identified in Step 9 (\~2,000 HVGs) — that's all PCA needs, and it keeps the `scale.data` slot (and the saved `.rds`) small. If you later need scaled values for a specific non-HVG gene (e.g. to draw it on a heatmap), re-run `ScaleData(ifnb, features = )` at that point.
::: {.callout-important title="Think about it"}
1. What does `ScaleData` do to each gene numerically?
2. Why is scaling necessary *before* PCA?
3. Could you `vars.to.regress = "stim"` here and skip integration in Tutorial 05?
Show answers
1. Centers each gene's expression to mean 0 and scales to unit variance across cells (z-scoring).
2. PCA finds directions of maximum variance. Without scaling, highly-expressed genes (which inherently have large raw variance) would dominate PCs, regardless of whether they are biologically informative.
3. You *could*, but regressing on a binary covariate is a **brittle** form of batch correction — it removes a linear effect uniformly across all cells, but real batch effects are usually non-linear and cell-type-specific. Modern integration methods (Harmony, anchors, scVI) are much better tools. We use `ScaleData` here as the standard pre-PCA step and reserve the integration question for Tutorial 05.
:::
## Counts matrix progression — summary
```{r}
#| label: M1-dim_summary
dim_summary <- tibble::tribble(
~step, ~features_after, ~barcodes_after,
"Step 1 — Kang18_8vs8 (singlets, raw)", dim_raw[1], dim_raw[2],
"Step 7 — subset() QC filter (nFeature_RNA, percent.mt)", dim_after_filter[1], dim_after_filter[2],
"Step 8 — NormalizeData (no dimension change)", dim_after_normalize[1], dim_after_normalize[2]
)
dim_summary
# --- Table out: counts-matrix progression summary --------------------------
readr::write_csv(dim_summary, file.path(out_dir, "Mod1_C12_dimension_progression.csv"))
```
You should see roughly:
| Step | Features after | Barcodes after |
|----|---:|---:|
| Step 1 — `Kang18_8vs8` (singlets, annotated) | \~35,635 | \~24,500 |
| Step 7 — `subset()` QC filter (`nFeature_RNA`, `percent.mt`) | \~35,635 | \~23,000 |
| Step 8 — `NormalizeData` (no dimension change) | \~35,635 | \~23,000 |
(Numbers will vary by a few hundred depending on package versions.)
## Save and continue to Tutorial 02
```{r}
#| label: M1-save
# Make sure data/ exists
dir.create("../data", showWarnings = FALSE)
saveRDS(ifnb, file = "../data/ifnb_preprocessed.rds")
```
::: callout-tip
The saved `.rds` is the starting point for **Tutorial 02 — Dimensionality reduction & clustering**. We deliberately keep both `CTRL` and `STIM` cells in the same object — Tutorial 02 will run PCA + clustering on the unintegrated joint data so you can **see the batch effect** before fixing it in Tutorial 05.
:::
## Credits
- Dataset: Kang *et al.* (2017) *Nat Biotechnol* 36: 89–94, [doi:10.1038/nbt.4042](https://doi.org/10.1038/nbt.4042) — fetched via Bioconductor's [`muscData::Kang18_8vs8()`](https://bioconductor.org/packages/muscData/)
- Reference vignettes: [Seurat integration tutorial](https://satijalab.org/seurat/articles/integration_introduction.html) · [Seurat PBMC 3k tutorial](https://satijalab.org/seurat/articles/pbmc3k_tutorial.html)
- Inspiration for the multi-step QC pacing: [scNotebooks](https://integrativebioinformatics.github.io/scNotebooks/) (Sengupta lab)