All in One View

Content from Introduction to Single-Cell RNA-Seq


Last updated on 2026-09-08 | Edit this page

Estimated time: 30 minutes

Overview

Questions

  • What is single-cell RNA sequencing and how does it differ from bulk RNA-seq?
  • How does the 10x Genomics Chromium platform capture and barcode individual cells?
  • What does the resulting count matrix look like and why are UMIs important?
  • What are the main computational steps in a scRNA-seq analysis workflow?
  • What will this workshop cover and what is out of scope?

Objectives

  • Explain what single-cell RNA-seq measures and why cellular heterogeneity matters
  • Describe the 10x Chromium droplet-based workflow from cell capture through sequencing
  • Interpret the structure of a UMI count matrix and explain why it is sparse
  • Outline the end-to-end computational pipeline covered in this workshop
  • Identify the scope and limitations of this workshop

What Is Single-Cell RNA-Seq?


Every tissue in your body is made up of many different cell types, and each cell type carries out its function by expressing a distinct set of genes. Traditional bulk RNA-seq measures gene expression by grinding up a piece of tissue and sequencing the RNA from all cells at once. The result is a single average expression profile that blends signals from every cell type together. This is perfectly fine when the goal is to compare overall gene expression between two conditions, but it hides all of the cell-to-cell variation within the sample.

Think of it like making a smoothie. If you blend strawberries, blueberries, and bananas together, you can tell that the smoothie contains fruit, and you might even detect the flavors of the individual ingredients. But you cannot tell how many strawberries went in, whether one banana was riper than the others, or whether there was a single grape hiding at the bottom of the blender. Bulk RNA-seq gives you the smoothie. Single-cell RNA-seq (scRNA-seq) gives you the fruit bowl: every piece of fruit is kept separate, so you can count, sort, and inspect them one by one.

scRNA-seq works by isolating individual cells, capturing their mRNA, and sequencing each cell’s transcriptome independently. Instead of one expression profile per sample, you get thousands of individual expression profiles, one per cell. This lets you:

  • Discover cell types that were previously unknown or hard to distinguish
  • Track how cells change during development, disease progression, or in response to treatment
  • Identify rare populations such as stem cells or drug-resistant tumor cells that are invisible in bulk data
  • Map cell-cell communication by looking at which ligands and receptors are expressed in neighboring cell types

Since the first scRNA-seq experiment was published in 2009, the technology has scaled from single cells to millions of cells per experiment. It is now a routine tool in immunology, neuroscience, cancer biology, and developmental biology.

Callout

Bulk vs. Single-Cell

Feature Bulk RNA-seq Single-cell RNA-seq
Resolution One profile per sample One profile per cell
Input ~1 million cells (pooled) ~1,000 – 20,000+ individual cells
Output One expression vector per sample A cells-by-genes matrix
Genes detected per sample ~15,000 – 20,000 ~1,000 – 5,000 per cell (due to dropout)
Cost per sample ~$200 – $500 ~$2,000 – $5,000 (library + sequencing)
Best for Comparing conditions, differential expression with biological replicates Discovering cell types, studying heterogeneity, trajectory analysis

Single-cell experiments detect fewer genes per cell than bulk experiments because each cell contains only a small amount of mRNA. However, when you combine information across thousands of cells, the total number of genes detected across the full dataset is comparable to bulk RNA-seq.

How Does 10x Chromium Work?


The 10x Genomics Chromium platform is the most widely used droplet-based scRNA-seq system. It can profile thousands to tens of thousands of cells in a single run. The core idea is simple: wrap each cell in its own tiny droplet along with a barcoded gel bead, so that every mRNA molecule from that cell gets tagged with the same unique barcode.

Diagram showing the 10x Chromium microfluidic chip where cells, gel beads, and oil are combined to form gel bead-in-emulsion (GEM) droplets. Each GEM contains one gel bead and ideally one cell.
10x Chromium workflow overview

Here is how the process works, step by step:

1. GEM generation. A suspension of single cells is loaded onto a Chromium microfluidic chip along with gel beads and partitioning oil. The chip combines these three inputs into tiny droplets called GEMs (Gel Beads-in-Emulsion). Each GEM is designed to contain exactly one gel bead and at most one cell. In practice, most GEMs are empty (no cell), a fraction contain one cell, and a small number contain two or more cells (these are called doublets and will be addressed during quality control).

2. Cell lysis and barcoding. Inside each GEM, the cell is lysed and its mRNA is released. The gel bead dissolves, releasing millions of oligonucleotide primers that all share the same cell barcode – a 16-nucleotide sequence unique to that bead. Each primer also carries a UMI (Unique Molecular Identifier), a random 12-nucleotide sequence that is different on every primer. The mRNA molecules hybridize to these primers via their poly(A) tails and are reverse-transcribed into cDNA. Because all primers in the same GEM have the same cell barcode, every cDNA molecule from the same cell is tagged with the same barcode. And because each primer has a different UMI, each original mRNA molecule gets a unique tag.

3. Library construction. After reverse transcription, the GEMs are broken and the barcoded cDNA from all cells is pooled together. The cDNA is amplified by PCR, fragmented, and prepared into a sequencing library. Even though the cDNA from all cells is now mixed in one tube, the cell barcodes embedded in each molecule let us computationally sort the reads back to their cell of origin.

4. Sequencing. The library is sequenced on an Illumina sequencer, producing paired-end reads plus an index read. The read structure is:

Schematic showing three sequencing reads from a 10x Chromium library. Read 1 is 28 bp and contains the 16 bp cell barcode followed by the 12 bp UMI. Read 2 is variable length and contains the cDNA insert that maps to the transcriptome. The I1 index read contains the sample index for demultiplexing.
10x Chromium FASTQ read structure
  • Read 1 (28 bp): The first 16 bases are the cell barcode (identifies which cell). The next 12 bases are the UMI (identifies which original mRNA molecule). Read 1 does not contain any transcript sequence.
  • Read 2 (variable length, typically 90–150 bp): This is the actual cDNA insert that gets mapped to the reference transcriptome to determine which gene the mRNA came from.
  • Index read (I1, 8 bp): The sample index used for demultiplexing when multiple samples are pooled on the same sequencing lane.

The alignment software (Cell Ranger or STARsolo) reads all three components: it uses the index read to assign reads to samples, the cell barcode to assign reads to cells, the UMI to count unique molecules, and Read 2 to identify the gene of origin.

Callout

Other Single-Cell Platforms

While this workshop focuses on 10x Genomics Chromium, several other scRNA-seq platforms exist:

  • Drop-seq: An early droplet-based method developed in the McCarroll lab (Harvard). Uses a similar barcoding approach but with lower per-cell capture efficiency than 10x Chromium.
  • inDrop: Another droplet-based platform developed in the Bhatt lab (Harvard). Uses hydrogel beads that release barcoded primers upon UV exposure.
  • Smart-seq2 / Smart-seq3: Plate-based methods that capture full-length transcripts. They provide much deeper coverage per cell but are limited to hundreds of cells per experiment and are significantly more expensive per cell.
  • Parse Biosciences (Evercode): A combinatorial barcoding method that does not require specialized microfluidic equipment. Cells are split across wells and barcoded in multiple rounds, enabling very high throughput.

Each platform has trade-offs in throughput, cost, sensitivity, and transcript coverage. The 10x Chromium platform is the most common choice for large-scale cell atlas projects and is the standard in most core facilities, which is why we use it here.

The Count Matrix


The end result of raw data processing (covered in the next episode) is a UMI count matrix. This is a table where each row is a gene, each column is a cell, and each entry is the number of unique mRNA molecules (UMIs) detected for that gene in that cell.

Schematic of a count matrix with genes on rows and cells on columns. Most cells in the matrix contain zero, with occasional non-zero integer counts scattered throughout, illustrating the sparsity typical of single-cell RNA-seq data.
UMI count matrix schematic

A few important properties of this matrix:

It is very sparse. A typical human cell expresses around 2,000 to 5,000 genes out of a total of approximately 30,000 protein-coding genes in the genome. On top of that, scRNA-seq has limited sensitivity – it captures only a fraction of the mRNA molecules actually present in the cell. As a result, roughly 95% of the entries in the count matrix are zeros. This is not an error; it is an inherent property of the technology. Specialized statistical methods have been developed to handle this sparsity.

Counts are integers. Because UMIs deduplicate PCR artifacts, each entry in the matrix represents the number of distinct original mRNA molecules detected, not the number of sequencing reads. This makes UMI counts more quantitative than read counts.

UMIs solve the PCR duplication problem. During library preparation, cDNA is amplified by PCR so there is enough material to sequence. Without UMIs, a single mRNA molecule that was amplified 100 times would produce 100 reads and appear to be highly expressed. With UMIs, all 100 reads share the same UMI sequence and are collapsed into a single count. This means UMI counts are proportional to the actual number of mRNA molecules in the cell, not to PCR amplification efficiency.

For the PBMC 10k v3 dataset we will use in this workshop, the count matrix contains approximately 11,800 cells and 36,600 genes, but after filtering out lowly-expressed genes and low-quality cells, we will work with roughly 8,000 – 10,000 cells and 15,000 – 20,000 genes.

Analysis Workflow Overview


The computational analysis of scRNA-seq data follows a series of well-defined steps. Each step in the pipeline builds on the output of the previous one. This workshop covers the complete workflow from raw sequencing data through biological interpretation.

Flowchart of the scRNA-seq analysis pipeline covered in this workshop. Eight boxes arranged vertically show the progression: Raw Data Processing, Quality Control, Normalization and Feature Selection, Dimensionality Reduction and Clustering, Cell Type Annotation, Multi-Sample Integration, and Differential Expression. Arrows connect each step to the next.
Workshop analysis pipeline overview

1. Raw Data Processing (Episode 2). Starting from FASTQ files, we align reads to the human reference genome and quantify gene expression per cell. We will use two tools: Cell Ranger (the commercial pipeline from 10x Genomics) and STARsolo (a fast open-source alternative). Both produce the UMI count matrix described above.

2. Quality Control (Episode 3). Not all barcodes in the count matrix represent healthy, real cells. Some correspond to empty droplets, dying cells, or doublets. We filter cells based on three key metrics: the number of detected genes, the total UMI count, and the percentage of reads mapping to mitochondrial genes (a marker of cell stress). This step ensures we only carry high-quality cells forward.

3. Normalization and Feature Selection (Episode 4). Cells are sequenced to different depths, so raw counts are not directly comparable between cells. Normalization corrects for these differences. We will apply two methods: LogNormalize (a simple and widely used approach) and SCTransform (a more sophisticated variance-stabilizing method). We also select highly variable genes – the 2,000 or so genes that vary the most across cells and carry the strongest biological signal.

4. Dimensionality Reduction and Clustering (Episode 5). With 20,000+ genes per cell, we need to reduce the data to a manageable number of dimensions. PCA (Principal Component Analysis) compresses the data into the top principal components. We then use UMAP to create a 2D visualization and apply the Leiden algorithm to group similar cells into clusters.

5. Cell Type Annotation (Episode 6). Clusters are just numbers until we assign biological meaning. We identify cell types by examining marker genes – genes that are specifically expressed in one cluster but not others. We will annotate clusters both manually (using known PBMC markers) and automatically (using SingleR, which compares expression profiles to labeled reference datasets).

6. Multi-Sample Integration (Episode 7). When analyzing cells from multiple samples or experimental conditions, batch effects can cause cells to cluster by sample rather than by cell type. Integration algorithms correct for these technical differences while preserving real biological variation. We will use Seurat’s CCA (Canonical Correlation Analysis) integration on an IFN-beta stimulated PBMC dataset to learn how to combine control and treated samples.

7. Differential Expression (Episode 8). Finally, we ask: which genes change between conditions? We will compare gene expression between control and stimulated cells within each cell type. We will cover both cell-level testing and pseudobulk analysis (the statistically preferred approach for multi-sample experiments). We will also run gene ontology enrichment to connect lists of differentially expressed genes to biological pathways.

Key Points
  • Single-cell RNA-seq measures gene expression in individual cells, revealing cellular heterogeneity that bulk RNA-seq averages out
  • The 10x Chromium platform uses droplet-based barcoding: each cell gets a unique 16 bp cell barcode and each mRNA molecule gets a 12 bp UMI
  • The UMI count matrix is very sparse (~95% zeros) because each cell expresses only a fraction of all genes and capture efficiency is limited
  • The scRNA-seq analysis pipeline progresses through alignment, QC, normalization, dimensionality reduction, clustering, annotation, integration, and differential expression
  • This workshop focuses on 10x Chromium 3’ GEX data analyzed with Seurat v5 in R on the Purdue Negishi HPC cluster

Content from Raw Data Processing


Last updated on 2026-09-08 | Edit this page

Estimated time: 60 minutes

Overview

Questions

  • How do we organize scRNA-seq data files on an HPC cluster?
  • How do we align 10x Chromium FASTQ files with Cell Ranger?
  • How do we run STARsolo as an open-source alternative?
  • How do the outputs from Cell Ranger and STARsolo compare?

Objectives

  • Set up a working directory and verify FASTQ and reference files on the cluster
  • Write and submit a SLURM job script for Cell Ranger count
  • Write and submit a SLURM job script for STARsolo
  • Interpret Cell Ranger’s web_summary.html and key QC metrics
  • Compare the filtered count matrices produced by both tools
Prerequisite

Prerequisites

This episode requires an active account on Purdue’s Negishi cluster and familiarity with basic Linux commands (cd, ls, mkdir, cat). You should be comfortable submitting SLURM jobs with sbatch and monitoring them with squeue. This episode requires shell access to the Negishi cluster via SSH. See the SSH Setup section for connection instructions.

Callout

Cell Ranger Licensing

Cell Ranger is proprietary software from 10x Genomics. It is free to download and use, but it requires acceptance of the 10x Genomics End User License Agreement. STARsolo is fully open source (MIT license) and produces equivalent results. If you cannot use Cell Ranger due to licensing constraints, STARsolo is an excellent alternative. This workshop covers both tools so you can choose whichever is appropriate for your situation.

Data Organization


Before running any alignment, we need a clean directory structure. All workshop files live under ${RCAC_SCRATCH}/scrna_workshop/. Let’s create the directory layout and copy the data.

BASH

mkdir -p ${RCAC_SCRATCH}/scrna_workshop/{fastq,reference,cellranger_output,starsolo_output}

This creates the following structure:

${RCAC_SCRATCH}/scrna_workshop/
├── fastq/                  # Raw FASTQ files
├── reference/              # Reference genome and index
├── cellranger_output/      # Cell Ranger results
└── starsolo_output/        # STARsolo results

Now copy the pre-staged workshop data from the shared depot:

BASH

rsync -avP /depot/workshop/data/scrna_workshop/ ${RCAC_SCRATCH}/scrna_workshop/

Extract the 10x barcode whitelist from the Cell Ranger container (needed for STARsolo):

BASH

module load biocontainers
singularity exec ${BIOC_IMAGE_DIR}/cumulusprod_cellranger:10.0.0.sif \
    zcat /software/cellranger-10.0.0/lib/python/cellranger/barcodes/3M-february-2018_TRU.txt.gz \
    > ${RCAC_SCRATCH}/scrna_workshop/reference/3M-february-2018.txt

Verify that the FASTQ files are present:

BASH

ls -1 ${RCAC_SCRATCH}/scrna_workshop/fastq/

OUTPUT

pbmc_10k_v3_S1_L001_I1_001.fastq.gz
pbmc_10k_v3_S1_L001_R1_001.fastq.gz
pbmc_10k_v3_S1_L001_R2_001.fastq.gz
pbmc_10k_v3_S1_L002_I1_001.fastq.gz
pbmc_10k_v3_S1_L002_R1_001.fastq.gz
pbmc_10k_v3_S1_L002_R2_001.fastq.gz

Verify that the reference genome is present:

BASH

ls ${RCAC_SCRATCH}/scrna_workshop/reference/

OUTPUT

3M-february-2018.txt  refdata-gex-GRCh38-2024-A

Understanding the FASTQ file names

The file names follow the 10x Genomics naming convention:

pbmc_10k_v3_S1_L001_R1_001.fastq.gz
│            │  │    │   └── File number (always 001 for single-file lanes)
│            │  │    └────── Read type: R1 or R2
│            │  └─────────── Lane number: L001 or L002
│            └────────────── Sample index: S1
└─────────────────────────── Sample name

This dataset was sequenced across two lanes (L001, L002), producing four files total: two lanes times two reads per lane.

What is in each read?

Read 1 (R1) contains the cell barcode and UMI. It is 28 bp long: the first 16 bases are the cell barcode (which cell this read came from) and the next 12 bases are the UMI (which original mRNA molecule this read represents). Read 1 does not contain any transcript sequence.

Read 2 (R2) contains the cDNA insert. This is the actual transcript fragment that gets mapped to the reference genome to determine which gene the mRNA came from. It is typically 90–150 bp long.

Let’s inspect the first few reads from each file to see this in practice:

BASH

cd ${RCAC_SCRATCH}/scrna_workshop/fastq
zcat pbmc_10k_v3_S1_L001_R1_001.fastq.gz | head -8

You will see two FASTQ records. Each record has four lines: a header line starting with @, the sequence, a + separator, and quality scores. Notice that every R1 sequence is exactly 28 bp – that is the 16 bp cell barcode followed by the 12 bp UMI.

OUTPUT

@A00228:279:HFWFVDMXX:1:1101:3260:1000 1:N:0:NCAAGATG
NACCAACAGTCGAATAGTGTCATCTGCT
+
#FFFFFFFFFFFFFFFFFFFFFFFFFFF
@A00228:279:HFWFVDMXX:1:1101:3821:1000 1:N:0:NCAAGATG
NTGTCTTAGCCTAGGACGGCCTCCGCCA
+
#FFFFFFFFFFFFFFFFFFFFFFFFFFF

BASH

zcat pbmc_10k_v3_S1_L001_R2_001.fastq.gz | head -8

R2 reads are longer (91 bp in this dataset) and contain transcript sequence that will be mapped to the genome.

OUTPUT

@A00228:279:HFWFVDMXX:1:1101:3260:1000 2:N:0:NCAAGATG
NTATAAAATCACCACGGTCTTTAGCCATGCACAAACGGTAGTTTTGTGTGTTGGCTGCTCCACTGTCCTCTGCCAGCCTACAGGAGGAAAA
+
#FFFFFFFFFFFFFFFFF:FFFFFFFFFFFFFFFF:FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF
@A00228:279:HFWFVDMXX:1:1101:3821:1000 2:N:0:NCAAGATG
NTTCTATTGGAAACCCGGTCTTTACAAAAAAATACAAAAATCAGCTGGGCGTTGGCCGCGCGTGGTGGCTCACACCTGTAATCTCAGCACT
+
#FFFFFFFFFFFFFFFFFFFFFF:,FFFFFFFFFFFFFFFFFFFFFFFFFFFFF:FFFFFFF:FFFFFF:FFF:F:FFFFFFFFFFFFFFF

The reference genome

The reference genome is a 10x Genomics pre-built package based on the human GRCh38 assembly. This package contains everything Cell Ranger needs to align reads:

BASH

ls ${RCAC_SCRATCH}/scrna_workshop/reference/refdata-gex-GRCh38-2024-A/

OUTPUT

fasta/     genes/     reference.json     star/

The key components are:

  • fasta/genome.fa – the genome sequence (FASTA)
  • genes/genes.gtf – the gene annotation (GTF)
  • star/ – a pre-built STAR genome index (used internally by Cell Ranger)
  • reference.json – metadata about the reference build

Both Cell Ranger and STARsolo use STAR for alignment under the hood. Cell Ranger ships its own bundled STAR binary and uses the index inside this reference package. For STARsolo, we can either build our own STAR index or reuse the one inside this package.

Processing with Cell Ranger


Cell Ranger is the official analysis pipeline from 10x Genomics. The cellranger count command takes FASTQ files and a reference genome as input and produces a filtered UMI count matrix as output. We will run it as a SLURM batch job on Negishi.

Create the job script:

BASH

cat << 'EOF' > ${RCAC_SCRATCH}/scrna_workshop/run_cellranger.sh
#!/bin/bash
#SBATCH --nodes=1
#SBATCH --ntasks=64
#SBATCH --time=1-00:00:00
#SBATCH --job-name=cellranger
#SBATCH --account=workshop
#SBATCH --partition=cpu
#SBATCH --output=cellranger_%j.out
#SBATCH --error=cellranger_%j.err

# Load modules
ml --force purge
ml biocontainers
ml cellranger

# Move to the output directory
cd ${RCAC_SCRATCH}/scrna_workshop/cellranger_output

# Run Cell Ranger count
cellranger count \
    --id=pbmc10k \
    --transcriptome=${RCAC_SCRATCH}/scrna_workshop/reference/refdata-gex-GRCh38-2024-A \
    --fastqs=${RCAC_SCRATCH}/scrna_workshop/fastq \
    --create-bam true \
    --sample=pbmc_10k_v3 \
    --localcores=${SLURM_CPUS_ON_NODE} \
    --localmem=100
EOF

Submit the job and monitor its progress:

BASH

cd ${RCAC_SCRATCH}/scrna_workshop
sbatch run_cellranger.sh
squeue -u ${USER} # or squeue --me

The job typically takes 30–60 minutes on 16 cores, depending on cluster load.

Cell Ranger count parameters

Parameter Value Description
--id pbmc10k A unique run ID. Cell Ranger creates an output directory with this name.
--transcriptome .../refdata-gex-GRCh38-2024-A Path to the 10x-compatible reference genome package containing the FASTA, GTF, and STAR index.
--fastqs .../fastq Directory containing the FASTQ files. Cell Ranger auto-detects files matching the sample name.
--create-bam true Generate a BAM file with aligned reads. Starting with Cell Ranger v8, BAM output is off by default; set to true if you need the BAM for downstream tools such as velocyto or variant calling.
--sample pbmc_10k_v3 The sample name prefix in the FASTQ file names. Cell Ranger selects files matching this prefix.
--localcores ${SLURM_CPUS_ON_NODE} Number of CPU cores to use. We set this to match the SLURM allocation so Cell Ranger does not try to use more cores than allocated.
--localmem 64 Maximum memory (in GB) that Cell Ranger is allowed to use.
Callout

What if I have multiple samples?

Q: I have 4 samples (WT_rep1, WT_rep2, KO_rep1, KO_rep2). Do I pass them all to one cellranger count command?

A: No. cellranger count processes one sample per invocation. Use a SLURM array job to run them in parallel:

BASH

#!/bin/bash
#SBATCH --array=0-3
#SBATCH --nodes=1
#SBATCH --ntasks=16
#SBATCH --time=1-00:00:00
#SBATCH --job-name=cellranger
#SBATCH --account=workshop

ml --force purge
ml biocontainers cellranger

SAMPLES=(WT_rep1 WT_rep2 KO_rep1 KO_rep2)
SAMPLE=${SAMPLES[$SLURM_ARRAY_TASK_ID]}

cd ${RCAC_SCRATCH}/scrna_workshop/cellranger_output

cellranger count \
    --id=${SAMPLE} \
    --transcriptome=${RCAC_SCRATCH}/scrna_workshop/reference/refdata-gex-GRCh38-2024-A \
    --fastqs=${RCAC_SCRATCH}/scrna_workshop/fastq \
    --create-bam true \
    --sample=${SAMPLE} \
    --localcores=${SLURM_CPUS_ON_NODE} \
    --localmem=64

For most downstream analyses, merge samples in Seurat (e.g., with merge() or IntegrateLayers()) rather than using cellranger aggr.

Cell Ranger output files

When the job completes, Cell Ranger creates the following directory structure:

BASH

ls ${RCAC_SCRATCH}/scrna_workshop/cellranger_output/pbmc10k/outs/

The key output files are:

pbmc10k/outs/
├── filtered_feature_bc_matrix/     # The count matrix (filtered to real cells)
│   ├── barcodes.tsv.gz             # List of cell barcodes
│   ├── features.tsv.gz             # List of genes
│   └── matrix.mtx.gz               # Sparse count matrix (MatrixMarket format)
├── raw_feature_bc_matrix/          # Count matrix for ALL barcodes (including empty drops)
├── web_summary.html                # Interactive QC report
├── metrics_summary.csv             # Key metrics as a CSV file
├── possorted_genome_bam.bam        # Aligned reads (large file)
└── molecule_info.h5                # Per-molecule info (used for aggregation)

The filtered_feature_bc_matrix/ directory is the most important output. It contains the UMI count matrix with only cell-containing barcodes (empty droplets have been removed by Cell Ranger’s cell calling algorithm). This is the file we will load into Seurat in the next episode.

The three files inside use the MatrixMarket sparse format:

  • barcodes.tsv.gz – one cell barcode per line
  • features.tsv.gz – one gene per line (Ensembl ID, gene symbol, feature type)
  • matrix.mtx.gz – the sparse matrix with row indices (genes), column indices (cells), and UMI counts
Callout

Interpreting web_summary.html

The web_summary.html file is an interactive HTML report that summarizes the quality of your Cell Ranger run. Open it in a web browser (you can copy it to your local machine with scp or view it through Open OnDemand). Here are the key metrics to check:

Metric Healthy range What it means
Estimated Number of Cells Matches expectations (e.g., ~10,000) The number of barcodes Cell Ranger identified as real cells.
Median Genes per Cell >1,000 for most tissues How many genes are detected in a typical cell. Very low values may indicate poor capture.
Reads Mapped Confidently to Transcriptome >50% Fraction of reads that align to annotated genes. Low values suggest reference mismatch or sample contamination.
Sequencing Saturation >50% for most experiments Fraction of reads that are PCR duplicates. Low saturation means more sequencing would detect additional UMIs. High saturation means you have enough sequencing depth.
Fraction Reads in Cells >70% Fraction of reads assigned to cell-containing barcodes. Low values may indicate high ambient RNA.

If any metric appears in red or orange in the web summary, investigate before proceeding to downstream analysis.

Interpreting Cell Ranger output

Screenshot of the Cell Ranger web_summary.html report for the PBMC 10k dataset showing key QC metrics
Cell Ranger web_summary.html for PBMC 10k

The web_summary.html file is the first thing to check after every Cell Ranger run. Here are the key QC metrics from our PBMC 10k run:

Metric Value What to look for
Estimated Number of Cells 11,809 Close to expected loading (~10k)
Median Genes per Cell 3,371 >1,000 for PBMCs is good
Reads Mapped Confidently to Transcriptome 79.1% >70% expected for 3’ GEX
Sequencing Saturation 68.2% >60% adequate; >80% ideal
Fraction Reads in Cells 95.7% >90% indicates clean cell calling
Valid Barcodes 97.3% >95% expected

A low Fraction Reads in Cells or low Median Genes per Cell would indicate problems with cell viability or library quality.

Callout

Sequencing saturation of 68.2%: is that enough?

A saturation of 68.2% means that roughly 32% of additional sequencing would yield new UMIs. For most differential expression and cell type identification workflows, this depth is sufficient. Diminishing returns set in above ~80% saturation, so deeper sequencing of this library would provide only marginal gains.

Processing with STARsolo


STARsolo is a single-cell RNA-seq processing mode built into the STAR aligner. It replicates the Cell Ranger pipeline – demultiplexing, alignment, barcode error correction, UMI deduplication, and cell filtering – but runs significantly faster because STAR is a highly optimized aligner. STARsolo is fully open source and produces results that are nearly identical to Cell Ranger.

Building a STAR genome index

STARsolo requires a STAR genome index. You can either build one from scratch or reuse the index that ships inside the Cell Ranger reference package. For this workshop, we will build a fresh index from the genome FASTA and GTF files included in the reference package. This ensures compatibility with the version of STAR installed on the cluster.

Callout

Reusing the Cell Ranger index

If you want to skip the index build, you can point STARsolo directly at the pre-built index inside the Cell Ranger reference: ${RCAC_SCRATCH}/scrna_workshop/reference/refdata-gex-GRCh38-2024-A/star/. However, this only works if the STAR version on the cluster matches the version Cell Ranger used to build the index. If the versions differ, STAR will exit with an error and you will need to build a new index.

Create the index build script:

BASH

cat << 'EOF' > ${RCAC_SCRATCH}/scrna_workshop/build_star_index.sh
#!/bin/bash
#SBATCH --nodes=1
#SBATCH --ntasks=64
#SBATCH --time=1-00:00:00
#SBATCH --job-name=star_index
#SBATCH --account=workshop
#SBATCH --partition=cpu
#SBATCH --output=star_index_%j.out
#SBATCH --error=star_index_%j.err

# Load modules
ml --force purge
ml biocontainers
ml star

REF=${RCAC_SCRATCH}/scrna_workshop/reference/refdata-gex-GRCh38-2024-A
STAR_INDEX=${RCAC_SCRATCH}/scrna_workshop/reference/star_index

mkdir -p ${STAR_INDEX}
gunzip -c ${REF}/genes/genes.gtf.gz > ${REF}/genes/genes.gtf
STAR \
    --runMode genomeGenerate \
    --runThreadN ${SLURM_CPUS_ON_NODE} \
    --genomeDir ${STAR_INDEX} \
    --genomeFastaFiles ${REF}/fasta/genome.fa \
    --sjdbGTFfile ${REF}/genes/genes.gtf
EOF

BASH

sbatch ${RCAC_SCRATCH}/scrna_workshop/build_star_index.sh

This step requires approximately 32 GB of RAM and takes about 30 minutes on 16 cores. The resulting index will be written to ${RCAC_SCRATCH}/scrna_workshop/reference/star_index/.

Running the STARsolo alignment

Once the index is ready, create the STARsolo alignment script. Note an important difference from Cell Ranger: STAR expects the cDNA read (R2) first, followed by the barcode read (R1) in the --readFilesIn argument.

BASH

cat << 'EOF' > ${RCAC_SCRATCH}/scrna_workshop/run_starsolo.sh
#!/bin/bash
#SBATCH --nodes=1
#SBATCH --ntasks=64
#SBATCH --time=1-00:00:00
#SBATCH --job-name=starsolo
#SBATCH --account=workshop
#SBATCH --partition=cpu
#SBATCH --output=starsolo_%j.out
#SBATCH --error=starsolo_%j.err

# Load modules
ml --force purge
ml biocontainers
ml star

# Define paths
FASTQ=${RCAC_SCRATCH}/scrna_workshop/fastq
STAR_INDEX=${RCAC_SCRATCH}/scrna_workshop/reference/star_index
WHITELIST=${RCAC_SCRATCH}/scrna_workshop/reference/3M-february-2018.txt

cd ${RCAC_SCRATCH}/scrna_workshop/starsolo_output

STAR \
    --runThreadN ${SLURM_CPUS_ON_NODE} \
    --genomeDir ${STAR_INDEX} \
    --sjdbGTFfile ${RCAC_SCRATCH}/scrna_workshop/reference/refdata-gex-GRCh38-2024-A/genes/genes.gtf \
    --readFilesIn \
        ${FASTQ}/pbmc_10k_v3_S1_L001_R2_001.fastq.gz,${FASTQ}/pbmc_10k_v3_S1_L002_R2_001.fastq.gz \
        ${FASTQ}/pbmc_10k_v3_S1_L001_R1_001.fastq.gz,${FASTQ}/pbmc_10k_v3_S1_L002_R1_001.fastq.gz \
    --readFilesCommand zcat \
    --soloType CB_UMI_Simple \
    --soloCBwhitelist ${WHITELIST} \
    --soloCBstart 1 \
    --soloCBlen 16 \
    --soloUMIstart 17 \
    --soloUMIlen 12 \
    --soloBarcodeMate 0 \
    --soloCBmatchWLtype 1MM_multi_Nbase_pseudocounts \
    --soloUMIfiltering MultiGeneUMI_CR \
    --soloUMIdedup 1MM_CR \
    --clipAdapterType CellRanger4 \
    --soloCellFilter EmptyDrops_CR \
    --outSAMtype BAM SortedByCoordinate \
    --outSAMattributes CR UR CY UY CB UB \
    --outFileNamePrefix starsolo_pbmc10k_
EOF

Submit the job:

BASH

sbatch ${RCAC_SCRATCH}/scrna_workshop/run_starsolo.sh

STARsolo parameter reference

Parameter Value Description
--runThreadN ${SLURM_CPUS_ON_NODE} Number of threads. Matches the SLURM allocation.
--genomeDir .../star_index Path to the STAR genome index directory.
--sjdbGTFfile .../genes/genes.gtf Gene annotation GTF file for splice junction detection and gene counting.
--readFilesIn R2_L001,R2_L002 R1_L001,R1_L002 Input FASTQ files. Important: the cDNA read (R2) comes first, then the barcode read (R1). Multiple lanes are comma-separated within each read group; a space separates R2 from R1.
--readFilesCommand zcat Decompression command for gzipped FASTQ files.
--soloType CB_UMI_Simple Barcode geometry: a single cell barcode followed by a UMI on the barcode read. This is the standard layout for 10x Chromium libraries.
--soloCBwhitelist .../3M-february-2018.txt The 10x barcode whitelist file. This contains all 3.7 million valid cell barcode sequences for v3 chemistry.
--soloCBstart 1 Position where the cell barcode starts on the barcode read (1-based).
--soloCBlen 16 Length of the cell barcode in bases.
--soloUMIstart 17 Position where the UMI starts on the barcode read (immediately after the cell barcode).
--soloUMIlen 12 Length of the UMI in bases. For 10x v3 chemistry this is 12; for v2 chemistry it is 10.
--soloBarcodeMate 0 Which mate carries the barcode. 0 means the barcode is on a separate read (R1), not embedded in the cDNA read.
--soloCBmatchWLtype 1MM_multi_Nbase_pseudocounts Barcode error correction strategy. Allows 1 mismatch when matching to the whitelist, handles N bases, and uses pseudocounts to resolve ambiguous corrections. This matches Cell Ranger’s correction algorithm.
--soloUMIfiltering MultiGeneUMI_CR Filters UMIs that map to multiple genes using the Cell Ranger algorithm.
--soloUMIdedup 1MM_CR UMI deduplication strategy allowing 1 mismatch, matching Cell Ranger’s approach.
--clipAdapterType CellRanger4 Clips adapter sequences using the same method as Cell Ranger 4+.
--soloCellFilter EmptyDrops_CR Cell calling algorithm. EmptyDrops_CR replicates Cell Ranger’s EmptyDrops-based method for distinguishing real cells from empty droplets.
--outSAMtype BAM SortedByCoordinate Output a coordinate-sorted BAM file.
--outSAMattributes CR UR CY UY CB UB Include cell barcode and UMI tags in the BAM file (raw and error-corrected versions plus quality scores).
--outFileNamePrefix starsolo_pbmc10k_ Prefix for all output file names.

STARsolo output files

STARsolo writes the count matrices under Solo.out/ inside the output directory:

BASH

ls ${RCAC_SCRATCH}/scrna_workshop/starsolo_output/starsolo_pbmc10k_Solo.out/Gene/filtered/

OUTPUT

barcodes.tsv  features.tsv  matrix.mtx

The filtered directory contains three files in the same format as Cell Ranger:

  • barcodes.tsv – cell barcodes that passed the EmptyDrops cell filter
  • features.tsv – gene identifiers (Ensembl ID and gene symbol)
  • matrix.mtx – the sparse UMI count matrix in MatrixMarket format

Note that STARsolo’s output files are uncompressed (.tsv and .mtx rather than .tsv.gz and .mtx.gz). Seurat’s Read10X() function handles both compressed and uncompressed formats automatically.

Callout

Runtime comparison

On 64 cores with the PBMC 10k v3 dataset, typical runtimes are:

  • Cell Ranger: ~7 hours
  • STARsolo: ~45 minutes

STARsolo is roughly 9x faster because STAR is a highly optimized C++ aligner, while Cell Ranger wraps STAR with additional Python and Java orchestration layers. Both produce nearly identical count matrices.

Interpreting STARsolo output

STARsolo writes alignment statistics to Log.final.out in the output directory. Here are the key metrics from our PBMC 10k run:

Metric Value What to look for
Uniquely mapped reads % 89.28% >80% for human GEX
Multi-mapping reads % 7.14% <10% typical
Unmapped: too short % 2.56% >10% suggests trimming or degradation
Mismatch rate per base 0.56% <1% expected
Mapping speed 923.7M reads/hr ~15x faster than Cell Ranger for this run

Unlike Cell Ranger, STARsolo does not produce an HTML summary report. Check Log.final.out for alignment statistics and Solo.out/Gene/Summary.csv for cell-level metrics such as the number of cells detected and UMIs per cell.

Comparing Outputs


Now that both tools have finished, let’s compare their outputs. We can count the number of cells (barcodes) and genes (features) detected by each tool directly from the output files.

Cell Ranger:

BASH

echo "Cell Ranger cells:"
zcat ${RCAC_SCRATCH}/scrna_workshop/cellranger_output/pbmc10k/outs/filtered_feature_bc_matrix/barcodes.tsv.gz | wc -l

echo "Cell Ranger genes:"
zcat ${RCAC_SCRATCH}/scrna_workshop/cellranger_output/pbmc10k/outs/filtered_feature_bc_matrix/features.tsv.gz | wc -l

STARsolo:

BASH

echo "STARsolo cells:"
wc -l < ${RCAC_SCRATCH}/scrna_workshop/starsolo_output/starsolo_pbmc10k_Solo.out/Gene/filtered/barcodes.tsv

echo "STARsolo genes:"
wc -l < ${RCAC_SCRATCH}/scrna_workshop/starsolo_output/starsolo_pbmc10k_Solo.out/Gene/filtered/features.tsv

Both tools should report very similar numbers:

OUTPUT

Cell Ranger cells:
11809
Cell Ranger genes:
38606

STARsolo cells:
11682
STARsolo genes:
38606

The cell counts may differ by a small amount (typically <1%) due to minor differences in the EmptyDrops cell-calling implementation. The gene count is identical because both tools use the same reference annotation.

When to use each tool

Use Cell Ranger when:

  • You need results that are directly comparable to 10x Genomics documentation and publications
  • You want the interactive web_summary.html report
  • Your institution has Cell Ranger installed and you don’t need to worry about licensing
  • You are running the standard 10x Chromium workflow with no custom modifications

Use STARsolo when:

  • You need faster turnaround (especially important for large datasets or parameter sweeps)
  • You need a fully open-source pipeline
  • You are working with non-standard barcode configurations or custom protocols
  • You want fine-grained control over alignment and counting parameters

For this workshop, the pre-computed filtered_feature_bc_matrix/ in the workshop data directory was generated with Cell Ranger. We will use it for all downstream analysis starting in the next episode.

Challenge

Challenge 1: Modify STARsolo for v2 Chemistry

The PBMC 10k dataset uses 10x Chromium v3 chemistry (16 bp barcode + 12 bp UMI). Older experiments used v2 chemistry, which has a different UMI length and barcode whitelist.

What parameters in the STARsolo command would you change to process a v2 chemistry library? (Hint: two parameters change and one additional parameter needs a different file.)

Three changes are required for v2 chemistry:

  1. --soloUMIlen 10 instead of 12. v2 chemistry uses 10 bp UMIs (compared to 12 bp in v3).

  2. --soloCBwhitelist must point to the v2 whitelist file (737K-august-2016.txt) instead of the v3 whitelist (3M-february-2018.txt). The v2 whitelist contains ~737,000 valid barcodes; the v3 whitelist contains ~3.7 million.

  3. --soloCBlen 16 stays the same – both v2 and v3 use 16 bp cell barcodes. The cell barcode length did not change between chemistry versions.

The updated parameters would look like:

BASH

    --soloCBwhitelist /path/to/737K-august-2016.txt \
    --soloCBlen 16 \
    --soloUMIstart 17 \
    --soloUMIlen 10 \

Always check the chemistry version in your experiment’s documentation or the web_summary.html from a previous Cell Ranger run. Using the wrong whitelist or UMI length will result in very few cells being detected.

Challenge

Challenge 2: QC from Cell Ranger Summary

A colleague shares their Cell Ranger metrics_summary.csv with the following values. Review the metrics and identify which one suggests a potential quality concern.

Metric Value
Estimated Number of Cells 8,500
Mean Reads per Cell 45,000
Median Genes per Cell 2,100
Fraction Reads in Cells 55.2%
Reads Mapped Confidently to Transcriptome 62.5%
Sequencing Saturation 28.3%

Two metrics stand out:

  1. Fraction Reads in Cells (55.2%) is low. A healthy value is typically >70%. When nearly half of all reads come from non-cell barcodes, it indicates a high level of ambient RNA (free-floating mRNA from lysed cells in the droplet suspension). This does not necessarily mean the experiment failed, but it means a large fraction of the sequencing budget was spent on background noise. Downstream tools like SoupX or CellBender can help correct for ambient RNA contamination.

  2. Sequencing Saturation (28.3%) is low. This means that 71.7% of the reads are still detecting new UMIs – in other words, additional sequencing would reveal more unique transcripts. A value below 50% suggests the library was under-sequenced. If the budget allows, resequencing the same library would improve gene detection. However, for some applications (e.g., cell type identification), 28% saturation may still be sufficient.

The other metrics look reasonable: 8,500 cells is within expectations for a standard 10x run, median genes per cell >2,000 indicates decent capture quality, and 62.5% reads mapped to transcriptome is acceptable for a human sample.

Key Points
  • Workshop data should be organized under ${RCAC_SCRATCH}/scrna_workshop/ with separate directories for FASTQ files, reference, and outputs
  • Cell Ranger count aligns 10x FASTQ files and produces a filtered count matrix, a web summary report, and alignment metrics
  • STARsolo replicates Cell Ranger’s pipeline but runs approximately 9x faster and is fully open source
  • Both tools produce a filtered count matrix in the same format: barcodes.tsv, features.tsv, and matrix.mtx
  • Always inspect the Cell Ranger web_summary.html to check estimated cell count, median genes per cell, sequencing saturation, and fraction of reads in cells before proceeding

Content from Quality Control


Last updated on 2026-09-08 | Edit this page

Estimated time: 60 minutes

Overview

Questions

  • How do we load a 10x count matrix into R and create a Seurat object?
  • What quality metrics should we examine for scRNA-seq data?
  • How do we choose appropriate filtering thresholds?
  • How does mitochondrial gene percentage help identify dying cells?
  • What are doublets and how can we detect them?

Objectives

  • Load a Cell Ranger filtered count matrix into a Seurat v5 object
  • Calculate and visualize QC metrics: nFeature_RNA, nCount_RNA, and percent.mt
  • Apply filtering thresholds to remove low-quality cells
  • Describe what doublets are and when dedicated detection tools are needed
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.

Setup


R

library(Seurat)
library(ggplot2)
library(patchwork)

Loading Data into Seurat


The starting point for this episode is the filtered count matrix produced by Cell Ranger in the previous episode. This matrix lives in the filtered_feature_bc_matrix/ directory and consists of three files: barcodes.tsv.gz, features.tsv.gz, and matrix.mtx.gz. Seurat’s Read10X() function reads all three files and assembles them into a sparse matrix in R.

First, set up the path to the Cell Ranger output so that all file references in this episode use the same base directory:

R

data_dir <- paste0(
    "/scratch/negishi/", Sys.getenv("USER"),
    "/scrna_workshop/cellranger_output/pbmc10k/outs/filtered_feature_bc_matrix/"
)
work_dir <- paste0(
    "/scratch/negishi/", Sys.getenv("USER"),
    "/scrna_workshop/"
)

Now load the count matrix:

R

setwd(work_dir)
pbmc.data <- Read10X(data.dir = data_dir)

Read10X() returns a sparse matrix where rows are genes and columns are cell barcodes. Let’s check its dimensions before creating the Seurat object:

R

dim(pbmc.data)

OUTPUT

[1] 38606 11809

This tells us the matrix contains 38,606 genes (rows) and 11,809 cell barcodes (columns).

Now we create a Seurat object, which is the central data structure for all downstream analysis. The CreateSeuratObject() function takes the raw count matrix and applies two initial filters:

  • min.cells = 3 removes genes detected in fewer than 3 cells (these are too rare to be informative)
  • min.features = 200 removes cells with fewer than 200 detected genes (these are likely empty droplets or debris)

R

pbmc <- CreateSeuratObject(
    counts = pbmc.data,
    project = "pbmc10k",
    min.cells = 3,
    min.features = 200
)
pbmc

OUTPUT

An object of class Seurat
29155 features across 11721 samples within 1 assay
Active assay: RNA (29155 features, 0 variable features)
 1 layer present: counts

After the initial filters, we have 29,155 genes and 11,721 cells. The gene count dropped from 38,606 because many genes were detected in fewer than 3 cells and were removed.

Parameter Value Description
counts pbmc.data The raw UMI count matrix (genes x cells).
project "pbmc10k" A name for this project, stored in the object metadata.
min.cells 3 Keep only genes detected in at least 3 cells. Removes very rare genes.
min.features 200 Keep only cells with at least 200 detected genes. Removes likely empty droplets.

Understanding the Seurat v5 object

The Seurat object stores all data and analysis results in an organized structure. The key components are:

  • Assays: containers for expression data. Our object has one assay called RNA. Each assay can hold multiple layers of the data at different processing stages.
  • Layers: the expression matrices within an assay:
    • counts – the raw UMI counts (populated now)
    • data – the normalized expression values (populated after normalization)
    • scale.data – the scaled/centered values (populated after scaling)
  • Metadata: a data frame with one row per cell, storing QC metrics, cluster assignments, and any other per-cell annotations.
Diagram of the Seurat v5 object showing the RNA assay with its three layers (counts, data, scale.data) and the per-cell metadata columns
Structure of the Seurat v5 object

You can inspect the metadata that Seurat automatically computed during object creation:

R

head(pbmc@meta.data)

OUTPUT

                   orig.ident nCount_RNA nFeature_RNA
AAACCCAAGCGCCCAT-1    pbmc10k       4282         2152
AAACCCAAGGTTCCGC-1    pbmc10k      29509         5990
AAACCCACAGACAAGC-1    pbmc10k        574          358
AAACCCACAGAGTTGG-1    pbmc10k       8400         2881
AAACCCACAGGTATGG-1    pbmc10k       9675         3731
AAACCCACATAGTCAC-1    pbmc10k      10058         3365

Seurat automatically calculated two metrics per cell:

  • nCount_RNA – the total number of UMIs in that cell
  • nFeature_RNA – the number of unique genes detected in that cell
Callout

Seurat v5 layer syntax

Seurat v5 introduced a new layer-based architecture. To access expression data, use the $ operator on the assay:

R

# Seurat v5 syntax (use this)
pbmc[["RNA"]]$counts     # raw counts
pbmc[["RNA"]]$data       # normalized data (after NormalizeData)
pbmc[["RNA"]]$scale.data # scaled data (after ScaleData)

If you see older tutorials using the @ slot syntax, that is Seurat v4 and will not work correctly with v5 objects:

R

# Seurat v4 syntax (do NOT use)
pbmc@assays$RNA@counts   # deprecated

QC Metrics


Not every barcode in the count matrix represents a healthy cell. Some barcodes correspond to dying cells, empty droplets that slipped through Cell Ranger’s filter, or doublets (two cells in one droplet). Before we can analyze the data, we need to identify and remove these low-quality barcodes.

We evaluate cell quality using three metrics:

Metric What it measures Low-quality signal
nFeature_RNA Number of unique genes detected per cell Very low: empty droplet or debris. Very high: possible doublet.
nCount_RNA Total UMI counts per cell Very low: failed capture. Very high: possible doublet.
percent.mt Percentage of UMIs from mitochondrial genes High (>15–20%): dying or stressed cell.

Let’s calculate the mitochondrial percentage. Human mitochondrial gene names start with MT- (e.g., MT-CO1, MT-ND1, MT-ATP6), so we use a pattern match:

R

pbmc[["percent.mt"]] <- PercentageFeatureSet(pbmc, pattern = "^MT-")

Now let’s inspect the distribution of all three metrics. First, we look at the summary statistics:

R

summary(pbmc$nFeature_RNA)
summary(pbmc$nCount_RNA)
summary(pbmc$percent.mt)

OUTPUT

# nFeature_RNA
   Min. 1st Qu.  Median    Mean 3rd Qu.    Max.
    201    2895    3378    3557    4270    9540

# nCount_RNA
   Min. 1st Qu.  Median    Mean 3rd Qu.    Max.
    505    8047   10357   12280   15166  103741

# percent.mt
   Min. 1st Qu.  Median    Mean 3rd Qu.    Max.
 0.1752  4.8509  5.7882  6.7695  7.0114 89.8376 

Most cells have 2,900-4,300 genes (IQR), 8,000-15,000 UMIs (IQR), and 4.9-7.0% mitochondrial content (IQR). But notice the maximum values: one cell has 9,540 genes (possible doublet), another has over 103,000 UMIs, and one cell has nearly 90% mitochondrial reads (clearly dying). These outliers are what QC filtering removes.

Violin plots

Violin plots show the distribution of each metric across all cells. They are the standard first visualization for scRNA-seq QC.

R

VlnPlot(pbmc,
        features = c("nFeature_RNA", "nCount_RNA", "percent.mt"),
        ncol = 3,
        pt.size = 0,
        layer = "counts")
Violin plots of nFeature_RNA, nCount_RNA, and percent.mt showing the distribution of QC metrics across all cells before filtering
QC violin plots before filtering

Setting pt.size = 0 hides individual points so the distribution shape is easier to see with 10,000+ cells. If you want to see points, set pt.size = 0.1.

Scatter plots

Scatter plots reveal relationships between metrics. The most informative plot is nCount_RNA vs. nFeature_RNA, which should show a strong positive correlation: cells with more total UMIs generally detect more genes. Points that deviate from this trend are suspicious.

R

ggplot(pbmc@meta.data, aes(x = nCount_RNA, y = nFeature_RNA, color = percent.mt)) +
    geom_point(size = 0.5, alpha = 0.4) +
    scale_color_gradient(low = "grey90", high = "firebrick", name = "% MT") +
    theme_minimal() +
    labs(x = "Total UMI counts", y = "Number of genes")
Scatter plot of total UMI counts versus number of genes per cell colored by mitochondrial percentage, with high-mito cells highlighted in red
QC scatter plot colored by mitochondrial percentage

What to look for in this plot:

  • Main cloud: most cells should form a dense cloud along a rough diagonal. These are healthy cells with a proportional relationship between total RNA content and gene diversity.
  • Bottom-left outliers: cells with very few UMIs and very few genes. These are empty droplets or debris that slipped through the Cell Ranger filter.
  • Top-right outliers: cells with both very high UMI counts and very high gene counts. These are potential doublets – two cells captured in one droplet, producing double the RNA content.
  • High mitochondrial cells: points colored in darker shades on the grey-to-red gradient. These often sit below the main cloud because the cell is losing cytoplasmic RNA while retaining mitochondrial RNA.
Callout

What do dying cells look like?

When a cell is damaged or dying, its cell membrane becomes leaky. Cytoplasmic mRNA molecules – which are small and not membrane-bound – escape through the holes in the membrane. However, mitochondrial mRNA is protected inside the double-membraned mitochondria and does not leak out as readily.

The result is a cell with:

  • High percent.mt: the remaining RNA is disproportionately mitochondrial
  • Low nFeature_RNA: many cytoplasmic transcripts have leaked out
  • Low nCount_RNA: total RNA content is reduced

This is why mitochondrial percentage is such a reliable indicator of cell quality. A healthy PBMC typically has 3–8% mitochondrial content. Cells with >15% are likely damaged, and cells with >20% are almost certainly compromised.

Setting Thresholds


Now we apply filters to remove low-quality cells. There are no universal thresholds that work for every dataset. The right cutoffs depend on the tissue type, species, experimental protocol, and sequencing depth. The best approach is to inspect the distributions (as we just did) and choose thresholds that remove clear outliers while preserving the bulk of the data.

For this PBMC dataset, we apply the following thresholds:

  • nFeature_RNA > 200: remove cells with very few genes (likely empty)
  • nFeature_RNA < 7000: remove cells with an extreme number of genes (possible doublets)
  • nCount_RNA < 50000: remove cells with unusually high UMI counts (possible doublets)
  • percent.mt < 15: remove cells with high mitochondrial content (likely dying)

Let’s visualize where these cutoffs fall on the violin plots. Red dashed lines mark the thresholds:

R

p1 <- VlnPlot(pbmc, features = "nFeature_RNA", pt.size = 0.1, layer = "counts") +
    geom_hline(yintercept = c(205,7000), linetype = "dashed", color = "red") +
    NoLegend()
p2 <- VlnPlot(pbmc, features = "nCount_RNA", pt.size = 0.1, layer = "counts") +
    geom_hline(yintercept = 50000, linetype = "dashed", color = "red") +
    NoLegend()
p3 <- VlnPlot(pbmc, features = "percent.mt", pt.size = 0.1, layer = "counts") +
    geom_hline(yintercept = 15, linetype = "dashed", color = "red") +
    NoLegend()
p1 + p2 + p3 + plot_layout(ncol = 3)
Violin plots of nFeature_RNA, nCount_RNA, and percent.mt with red dashed lines showing the filtering thresholds and individual cells visible as points
Violin plots with filtering thresholds

Cells above or below the red lines will be removed. The small point size (pt.size = 0.1) lets you see how many individual cells fall outside the thresholds.

Let’s record how many cells we have before filtering:

R

cat("Cells before filtering:", ncol(pbmc), "\n")

Apply the filters:

R

pbmc <- subset(pbmc,
               subset = nFeature_RNA > 200 &
                        nFeature_RNA < 7000 &
                        nCount_RNA < 50000 &
                        percent.mt < 15)
cat("Cells after filtering:", ncol(pbmc), "\n")

OUTPUT

Cells before filtering: 11721
Cells after filtering: 11310

Filtering removes ~400 cells (~3.5% of the total). The exact number depends on minor differences in the Cell Ranger version and reference used. You should have roughly 11,300 cells remaining.

Let’s verify the filtering by looking at the QC distributions again:

R

VlnPlot(pbmc,
        features = c("nFeature_RNA", "nCount_RNA", "percent.mt"),
        ncol = 3,
        pt.size = 0,
        layer = "counts")
Violin plots of nFeature_RNA, nCount_RNA, and percent.mt after filtering, showing tighter distributions with extreme outliers removed
QC violin plots after filtering

The violin plots should now show tighter distributions with the extreme tails removed. The mitochondrial percentage should be capped below 15%.

Let’s also verify with a scatter plot:

R

ggplot(pbmc@meta.data, aes(x = nCount_RNA, y = nFeature_RNA, color = percent.mt)) +
    geom_point(size = 0.5, alpha = 0.4) +
    scale_color_gradient(low = "grey90", high = "firebrick", name = "% MT") +
    theme_minimal() +
    labs(x = "Total UMI counts", y = "Number of genes")
Scatter plot of total UMI counts versus number of genes after filtering, colored by mitochondrial percentage, showing a tighter cloud with outliers removed
Scatter plot after filtering

Finally, save the filtered object for the next episode:

R

saveRDS(pbmc, file = "pbmc_filtered.rds")
Callout

MAD-based filtering

Instead of choosing fixed thresholds by visual inspection, a more principled approach is MAD-based filtering (Median Absolute Deviation). The idea is to compute the median and MAD of each QC metric, then flag any cell that falls more than a set number of MADs from the median as an outlier.

The formula for an upper threshold is:

threshold = median + N x MAD

and for a lower threshold:

threshold = median - N x MAD

where N is typically 3 or 5. For example, a cell is flagged as high mitochondrial if:

percent.mt > median(percent.mt) + 3 x MAD(percent.mt)

This approach is adaptive: it adjusts automatically to the distribution of each dataset, so you don’t need to guess appropriate cutoffs. It is implemented in the scuttle Bioconductor package via the isOutlier() function.

For this workshop, we use fixed thresholds for simplicity and transparency, but MAD-based filtering is recommended for production analyses where you process many datasets with varying quality.

Doublet Detection


Callout

Doublets in scRNA-seq

A doublet occurs when two cells are captured in the same GEM droplet and tagged with the same cell barcode. The resulting “cell” has roughly double the RNA content of a real single cell, producing a hybrid expression profile that blends two cell types.

How doublets appear in QC metrics: Doublets tend to have high nCount_RNA and high nFeature_RNA because they contain RNA from two cells. Our upper threshold on nFeature_RNA < 7000 catches some of the most extreme doublets, but it cannot identify doublets between similar cell types (e.g., two T cells) because their combined profile looks like a normal high-quality T cell.

Dedicated doublet detection tools such as scDblFinder and DoubletFinder use a simulation-based approach: they create artificial doublets by randomly combining pairs of real cells, then train a classifier to identify real cells that resemble these artificial doublets.

When dedicated detection matters most:

  • High cell loading (>10,000 cells targeted) where doublet rates exceed 5–8%
  • Multiplexed experiments (e.g., cell hashing) with cross-sample doublets
  • Studies where rare intermediate populations could be confused with doublets

For this dataset, the 10x Chromium platform at standard loading (~10,000 cells targeted) produces a doublet rate of approximately 3–5%. At this level, most doublets will be removed by our QC filters or will form small insignificant clusters that can be identified and removed during cell type annotation. We will not run a dedicated doublet detection tool in this workshop, but be aware of these tools for your own analyses.

Challenge

Challenge 1: Propose Your Own Thresholds

Look at the violin plots and scatter plots you generated above. Based on the distributions, propose your own filtering thresholds for nFeature_RNA, nCount_RNA, and percent.mt. Justify your choices. How many cells pass your filters compared to the default thresholds (nFeature_RNA > 200 & nFeature_RNA < 5000 & percent.mt < 15)?

Run the following code with your chosen values:

R

# Replace the ??? with your thresholds
pbmc_custom <- subset(pbmc,
                      subset = nFeature_RNA > ??? &
                               nFeature_RNA < ??? &
                               percent.mt < ???)
cat("Cells with custom thresholds:", ncol(pbmc_custom), "\n")

There is no single correct answer – reasonable thresholds depend on how conservative you want to be. Here is one example of a slightly more conservative approach:

R

pbmc_custom <- subset(pbmc,
                      subset = nFeature_RNA > 300 &
                               nFeature_RNA < 4000 &
                               percent.mt < 10)
cat("Cells with custom thresholds:", ncol(pbmc_custom), "\n")

This retains 7,579 cells compared to 9,874 with the default thresholds – a loss of roughly 23% of the data. The trade-offs are:

  • nFeature_RNA > 300 (vs. 200): slightly more aggressive removal of low-complexity cells, but may also remove some small cell types like platelets that naturally express few genes.
  • nFeature_RNA < 4000 (vs. 5000): this is the biggest driver of cell loss here. The median nFeature_RNA in this dataset is ~3,378, so a 4,000 ceiling cuts into the upper quartile and removes a substantial number of valid cells, not just doublets.
  • percent.mt < 10 (vs. 15): removes more potentially stressed cells, but some immune cell types (e.g., activated T cells) naturally have moderately elevated mitochondrial content.

The key point is that more aggressive filtering is not always better. Every cell you remove is data you lose. Dropping from 9,874 to 7,579 cells means losing nearly a quarter of the dataset, which could reduce statistical power and under-represent cell types that naturally have higher gene counts or mitochondrial content. The goal is to remove cells that would distort the analysis, not to enforce an artificially narrow definition of “high quality.”

Challenge

Challenge 2: Aggressive mitochondrial filtering

What happens if you set percent.mt < 5 instead of percent.mt < 15? Run the following code and compare the number of remaining cells. Would this threshold be too aggressive for PBMCs?

R

# Starting from the unfiltered object (re-load if needed)
pbmc_strict <- subset(pbmc,
                      subset = nFeature_RNA > 200 &
                               nFeature_RNA < 5000 &
                               percent.mt < 5)
cat("Cells with percent.mt < 5:", ncol(pbmc_strict), "\n")

pbmc_default <- subset(pbmc,
                       subset = nFeature_RNA > 200 &
                                nFeature_RNA < 5000 &
                                percent.mt < 15)
cat("Cells with percent.mt < 15:", ncol(pbmc_default), "\n")

With percent.mt < 5, only 2,709 cells remain, compared to 9,874 with percent.mt < 15. That is a loss of over 72% of the dataset.

This is far too aggressive for PBMCs. Here is why:

  • The median mitochondrial percentage in this dataset is ~5.8%, meaning a < 5 cutoff removes the majority of cells by definition – not just dying cells, but most of the healthy population.
  • Different immune cell types have naturally different mitochondrial content. Activated T cells, monocytes, and NK cells tend to have higher mitochondrial content (6-12%) because they are metabolically active. A < 5 threshold disproportionately removes these populations, biasing your downstream analysis toward cell types with low metabolic activity.
  • A threshold of < 5 might be appropriate for some cell lines or tissues where mitochondrial content is uniformly low, but for primary immune cells from blood, 10-15% is a more appropriate cutoff.

As a general rule, look at the distribution of percent.mt in your data. If there is a clear separation between a low-mt peak and a high-mt tail, set your threshold in the valley between them. If the distribution is unimodal (one smooth peak), a fixed cutoff based on MAD (see the callout above) is safer than an arbitrary round number.

Key Points
  • Read10X() loads a Cell Ranger count matrix and CreateSeuratObject() creates the central data structure for Seurat analysis
  • The three key QC metrics are nFeature_RNA (genes per cell), nCount_RNA (UMIs per cell), and percent.mt (mitochondrial percentage)
  • High mitochondrial percentage indicates damaged cells that are losing cytoplasmic RNA through a leaky membrane
  • Filtering thresholds should be chosen by inspecting the data distributions, not by applying universal fixed cutoffs
  • Dedicated doublet detection tools (scDblFinder, DoubletFinder) are available for high-loading experiments but are not always necessary at standard loading rates

Content from Normalization and Feature Selection


Last updated on 2026-09-08 | Edit this page

Estimated time: 55 minutes

Overview

Questions

  • Why do we need to normalize scRNA-seq data before comparing cells?
  • How does log normalization work and where are the results stored?
  • What are highly variable features and why do we select them?
  • What does scaling do and when should we regress out confounders?
  • How does SCTransform differ from the LogNormalize workflow?

Objectives

  • Apply log normalization to correct for differences in sequencing depth between cells
  • Identify highly variable features using variance-stabilizing transformation
  • Scale the data to prepare for PCA
  • Describe SCTransform as an alternative to the three-step LogNormalize workflow
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 pbmc_filtered.rds object saved at the end of the previous episode.

Setup


R

library(Seurat)
library(ggplot2)
library(patchwork)

Loading the Filtered Data


We start from the filtered Seurat object saved at the end of the previous episode. This object contains only cells that passed our QC thresholds.

Set up the working directory to match the previous episode:

R

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

R

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

OUTPUT

An object of class Seurat
29155 features across 11310 samples within 1 assay
Active assay: RNA (29155 features, 0 variable features)
 1 layer present: counts

The object has 29,155 genes and 11,310 cells. Only the counts layer is populated at this stage. By the end of this episode we will fill in the data and scale.data layers as well.

Right now, the only expression data we have is raw UMI counts. Before we can compare gene expression between cells, we need to address a fundamental problem: cells are sequenced to different depths.

Consider two cells that are biologically identical – they express the same genes at the same levels. If Cell A was sequenced to 5,000 total UMIs and Cell B to 10,000 total UMIs, every gene in Cell B will appear to have roughly twice the counts of Cell A. This is purely a technical artifact of sequencing depth, not a biological difference. Normalization corrects for this so that expression values are comparable across cells.

Log Normalization


The standard normalization in Seurat is LogNormalize. It applies a simple three-step transformation to each cell independently:

  1. Divide each gene’s count by the cell’s total UMI count
  2. Multiply by a scale factor (default 10,000) so the values are not tiny fractions
  3. Log-transform with log1p (natural log of 1 + x) to compress the dynamic range

The formula for a single gene g in cell c is:

normalized(g, c) = log(1 + count(g, c) / total_counts(c) x 10000)

R

pbmc <- NormalizeData(pbmc,
                      normalization.method = "LogNormalize",
                      scale.factor = 10000)
Parameter Value Description
normalization.method "LogNormalize" Divide by total counts, multiply by scale factor, log1p transform.
scale.factor 10000 Multiplicative factor applied after dividing by total counts. The default 10,000 means normalized values are in “counts per 10,000” before log transformation.

After normalization, the results are stored in the data layer of the RNA assay:

R

pbmc

OUTPUT

An object of class Seurat
29155 features across 11310 samples within 1 assay
Active assay: RNA (29155 features, 0 variable features)
 2 layers present: counts, data

Notice that we now have 2 layers: counts (raw) and data (normalized).

You can access each layer directly:

R

# Raw counts for the first 5 genes in the first 3 cells
pbmc[["RNA"]]$counts[1:5, 1:3]

# Normalized values for the same genes and cells
pbmc[["RNA"]]$data[1:5, 1:3]

OUTPUT

# Raw counts for the first 5 genes in the first 3 cells
5 x 3 sparse Matrix of class "dgCMatrix"
                AAACCCAAGCGCCCAT-1 AAACCCAAGGTTCCGC-1 AAACCCACAGACAAGC-1
ENSG00000238009                  .                  .                  .
ENSG00000239945                  .                  .                  .
ENSG00000241860                  .                  .                  .
ENSG00000290385                  .                  .                  .
ENSG00000235146                  .                  .                  .

# Normalized values for the same genes and cells
5 x 3 sparse Matrix of class "dgCMatrix"
                AAACCCAAGCGCCCAT-1 AAACCCAAGGTTCCGC-1 AAACCCACAGACAAGC-1
ENSG00000238009                  .                  .                  .
ENSG00000239945                  .                  .                  .
ENSG00000241860                  .                  .                  .
ENSG00000290385                  .                  .                  .
ENSG00000235146                  .                  .                  .

The first few genes in the matrix are mostly zeros, so the output above is not very illuminating. To see the actual difference between raw and normalized values, we can filter for genes with non-zero counts first:

R

# Raw counts for the first 5 genes in the first 3 cells, with row sum > 0
mat <- pbmc[["RNA"]]$counts[, 1:3]
mat[rowSums(mat) > 0, ][1:5, ]

# Normalized values for the same genes and cells, with row sum > 0
mat2 <- pbmc[["RNA"]]$data[, 1:3]
mat2[rowSums(mat2) > 0, ][1:5, ]

OUTPUT

# Raw counts for the first 5 genes in the first 3 cells, with row sum > 0
5 x 3 sparse Matrix of class "dgCMatrix"
         AAACCCAAGCGCCCAT-1 AAACCCAAGGTTCCGC-1 AAACCCACAGACAAGC-1
ISG15                     1                  2                  .
C1orf159                  .                  1                  .
SDF4                      .                  2                  .
B3GALT6                   .                  1                  .
DVL1                      .                  1                  .

# Normalized values for the same genes and cells, with row sum > 0
5 x 3 sparse Matrix of class "dgCMatrix"
         AAACCCAAGCGCCCAT-1 AAACCCAAGGTTCCGC-1 AAACCCACAGACAAGC-1
ISG15               1.20458          0.5174592                  .
C1orf159            .                0.2918332                  .
SDF4                .                0.5174592                  .
B3GALT6             .                0.2918332                  .
DVL1                .                0.2918332                  .

Visualizing the effect of normalization

To see what normalization does, let’s compare the distribution of a gene before and after normalization. We’ll look at LYZ, a marker for CD14+ monocytes that has high and variable expression across cells.

R

# Extract raw and normalized values for LYZ
lyz_raw <- pbmc[["RNA"]]$counts["LYZ", ]
lyz_norm <- pbmc[["RNA"]]$data["LYZ", ]

p1 <- ggplot(data.frame(x = as.numeric(lyz_raw)), aes(x = x)) +
    geom_histogram(bins = 50, fill = "steelblue", color = "white") +
    labs(title = "LYZ - Raw counts", x = "UMI counts", y = "Number of cells") +
    theme_minimal()

p2 <- ggplot(data.frame(x = as.numeric(lyz_norm)), aes(x = x)) +
    geom_histogram(bins = 50, fill = "darkred", color = "white") +
    labs(title = "LYZ - Log-normalized", x = "Normalized expression", y = "Number of cells") +
    theme_minimal()

p1 + p2
Two histograms for LYZ. Raw counts show a zero-dominated right-skewed distribution. Log-normalized values reveal a bimodal pattern with peaks near 0.5 and 4.5, separating non-monocytes from LYZ-expressing monocytes
LYZ raw vs. normalized expression

The raw count distribution is heavily right-skewed with a large spike at zero (cells that don’t express LYZ – mostly non-monocytes). After log normalization, the non-zero values are spread more evenly and the influence of sequencing depth differences is reduced.

Identifying Variable Features


The human genome contains roughly 20,000 protein-coding genes, but not all of them are informative for distinguishing cell types. Many genes are:

  • Housekeeping genes expressed at similar levels in every cell (e.g., ACTB, GAPDH). These carry no information about cell identity.
  • Low-expression genes detected in only a handful of cells. These are too noisy to contribute meaningfully.
  • Constant genes that show little variation across cells.

Highly variable features (HVGs) are genes that vary substantially from cell to cell. These are the genes most likely to capture biological differences between cell types. By selecting the top 2,000 variable features, we focus downstream analysis (PCA, clustering) on the genes that matter most, while reducing noise and computation time.

R

pbmc <- FindVariableFeatures(pbmc,
                             selection.method = "vst",
                             nfeatures = 2000)
Parameter Value Description
selection.method "vst" Variance-stabilizing transformation. Fits a mean-variance relationship across genes using local regression, then selects genes with the highest standardized variance (i.e., genes that vary more than expected given their expression level).
nfeatures 2000 Number of variable features to select. 2,000 is the standard default for most scRNA-seq analyses.

Let’s see which genes were selected:

R

head(VariableFeatures(pbmc), 10)

OUTPUT

 [1] "PPBP"            "PF4"             "LINC01478"       "LINC01374"       "PTGDS"
 [6] "JCHAIN"          "GP1BB"           "SOX5"            "ENSG00000225885" "EREG"     

The top variable features include PPBP and PF4 (platelet markers), GP1BB (also platelets), JCHAIN (B/plasma cells), and PTGDS (dendritic cells). Several lincRNAs and an Ensembl ID also appear due to sparse but extreme expression in a few cells. These genes are highly expressed in specific cell types but absent in others, producing high cell-to-cell variance.

Visualize the variable feature selection with a mean-variance plot:

R

vf_plot <- VariableFeaturePlot(pbmc)
top10 <- head(VariableFeatures(pbmc), 10)
LabelPoints(plot = vf_plot, points = top10, repel = TRUE)
Variable feature plot showing mean expression versus standardized variance for all genes, with the top 2000 variable features highlighted in red and the top 10 labeled
Variable feature selection plot

In this plot, each point is a gene. The x-axis shows mean expression across cells and the y-axis shows standardized variance. The red points are the 2,000 selected variable features. Genes in the upper right are both highly expressed and highly variable – these are strong cell-type markers. Genes along the bottom have low variance relative to their expression level and are excluded.

Scaling


The final step before PCA is scaling. ScaleData() applies a z-score transformation to each gene across all cells: it subtracts the mean expression and divides by the standard deviation. After scaling, each gene has mean 0 and standard deviation 1.

Why is this necessary? Without scaling, PCA would be dominated by highly expressed genes simply because they have larger absolute values, not because they carry more biological information. Scaling puts all genes on equal footing so that PCA identifies the axes of true biological variation rather than expression magnitude.

R

pbmc <- ScaleData(pbmc)

By default, ScaleData() only scales the variable features (the 2,000 genes selected above), not all ~29,000 genes. This is faster and sufficient for PCA, which only uses variable features. If you later need scaled values for all genes (e.g., for a heatmap of a non-variable gene), you can re-run ScaleData() with features = rownames(pbmc).

After scaling, a third layer appears in the object:

R

pbmc

OUTPUT

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

All three layers are now populated: counts (raw), data (log-normalized), and scale.data (z-scored variable features).

Regressing out confounders

ScaleData() can optionally regress out unwanted sources of variation using the vars.to.regress parameter. A common use case is regressing out mitochondrial percentage so that cell damage does not influence clustering:

R

# Optional: regress out mitochondrial percentage
pbmc_regressed <- ScaleData(pbmc, vars.to.regress = "percent.mt")

This fits a linear model for each gene with percent.mt as a covariate and uses the residuals as the scaled expression values. The effect is that variation driven by mitochondrial content is removed before PCA.

When to regress:

  • Mitochondrial percentage is driving a major axis of variation in PCA (i.e., one of the top PCs strongly correlates with percent.mt)
  • You see cells grouping by mitochondrial content rather than cell type

When NOT to regress:

  • Mitochondrial content correlates with real biology. Some cell types (e.g., cardiomyocytes, metabolically active immune cells) naturally have higher mitochondrial gene expression. Regressing it out would remove legitimate biological signal.
  • The QC filtering in the previous episode already removed high-mt cells, and the remaining variation is modest.

For this workshop, we proceed without regression because our QC filtering already removed cells with percent.mt > 15, and the remaining variation does not dominate the PCA.

Save the normalized object for the next episode:

R

saveRDS(pbmc, file = "pbmc_normalized.rds")

SCTransform as an Alternative


Callout

SCTransform: a one-step alternative

The three-step LogNormalize workflow (NormalizeData + FindVariableFeatures + ScaleData) works well for most datasets, but it has limitations. The log transformation can over-stabilize variance for lowly expressed genes and under-stabilize it for highly expressed genes. This matters most when there is high technical noise in the data.

SCTransform addresses this by using regularized negative binomial regression. It models each gene’s expression as a function of total UMI counts per cell, estimates the expected variance at each expression level, and returns Pearson residuals that serve as normalized, variance-stabilized values. A single function call replaces all three steps:

R

pbmc_sct <- SCTransform(pbmc, vst.flavor = "v2")
Parameter Value Description
vst.flavor "v2" Use the improved v2 algorithm (Choudhary & Satija, 2022) with better variance estimation for sparse data.

Key differences from LogNormalize:

  • Replaces three steps with one: normalization, feature selection, and scaling are all handled internally.
  • Selects 3,000 variable features by default (vs. 2,000 for FindVariableFeatures), which can improve resolution for complex tissues.
  • Better variance stabilization across the full range of expression levels.
  • Stored in a separate assay: results go into an assay called SCT rather than modifying the RNA assay. The RNA assay retains the raw counts for reference.

When to use SCTransform:

  • For publication-quality analyses, especially with complex tissues or datasets with high technical variance.
  • When you observe that LogNormalize produces clusters driven by sequencing depth rather than biology.

When LogNormalize is sufficient:

  • For exploratory analysis and quick overviews.
  • When the dataset is well-behaved and you want a simpler, faster workflow.
  • For direct comparability with older analyses and tutorials.

In this workshop, we use LogNormalize throughout for simplicity and because it produces excellent results with this well-characterized PBMC dataset. However, SCTransform is the preferred method for most production analyses.

Challenge

Challenge 1: LogNormalize vs. SCTransform variable features

Run both normalization approaches on the same filtered dataset and compare the top 20 variable features from each method. How many genes appear in both lists?

R

# LogNormalize pathway (already done above)
lognorm_top20 <- head(VariableFeatures(pbmc), 20)

# SCTransform pathway
pbmc_sct <- SCTransform(pbmc, vst.flavor = "v2")
sct_top20 <- head(VariableFeatures(pbmc_sct), 20)

# Compare
cat("LogNormalize top 20:\n")
print(lognorm_top20)
cat("\nSCTransform top 20:\n")
print(sct_top20)
cat("\nGenes in both lists:\n")
shared <- intersect(lognorm_top20, sct_top20)
print(shared)
cat("\nOverlap:", length(shared), "out of 20\n")

OUTPUT

LogNormalize top 20:
 [1] "PPBP"            "PF4"             "LINC01478"       "LINC01374"       "PTGDS"
 [6] "JCHAIN"          "GP1BB"           "SOX5"            "ENSG00000225885" "EREG"
[11] "TCF4"            "IGKC"            "ZNF385D"         "GNLY"            "LYPD2"
[16] "CUX2"            "LINGO2"          "CAVIN2"          "CLNK"            "HTR1F"

SCTransform top 20:
 [1] "S100A9" "S100A8" "GNLY"   "IGKC"   "PPBP"   "IGLC2"  "LYZ"    "PF4"    "GP1BB"  "NRGN"   "IGLC3"
[12] "IGHM"   "CAVIN2" "AFF3"   "NKG7"   "GNG11"  "BANK1"  "VCAN"   "TUBB1"  "LINGO2"

Genes in both lists:
[1] "PPBP"   "PF4"    "GP1BB"  "IGKC"   "GNLY"   "LINGO2" "CAVIN2"

The overlap is 7 out of 20 (~35%), which is lower than you might expect. The shared genes are PPBP, PF4, GP1BB, IGKC, GNLY, LINGO2, and CAVIN2.

The two methods rank variable genes quite differently:

  • LogNormalize + vst picks up several lincRNAs (LINC01478, LINC01374) and even an Ensembl ID (ENSG00000225885) in the top 20. These are genes with sparse but extreme expression in a few cells, which inflates their residual variance in the vst model.
  • SCTransform surfaces well-known immune markers instead: S100A9, S100A8, LYZ, NKG7, VCAN. Its negative binomial model is more robust to outlier cells, so it favors genes with consistent cell-type-specific expression over genes that are simply noisy.

Despite only 35% overlap in the top 20, the broader variable feature sets (all 2,000-3,000 genes) agree much more. The top-ranked differences rarely change the downstream PCA, clustering, or cell type assignments because both methods capture the same major axes of biological variation. The practical difference is that SCTransform tends to produce cleaner ranked lists with fewer noise-driven genes at the top.

Callout

glmGamPoi speeds up SCTransform

If you see the warning about glmGamPoi not being installed, SCTransform still runs but falls back to a slower implementation. Install it for faster runs:

R

BiocManager::install("glmGamPoi")
Challenge

Challenge 2: Housekeeping vs. Variable Gene Behavior

Plot the expression of ACTB (a housekeeping gene) and LYZ (a variable monocyte marker) before and after normalization. What changes for each gene, and why?

R

# Extract raw and normalized values
actb_raw  <- as.numeric(pbmc[["RNA"]]$counts["ACTB", ])
actb_norm <- as.numeric(pbmc[["RNA"]]$data["ACTB", ])
lyz_raw   <- as.numeric(pbmc[["RNA"]]$counts["LYZ", ])
lyz_norm  <- as.numeric(pbmc[["RNA"]]$data["LYZ", ])

p1 <- ggplot(data.frame(x = actb_raw), aes(x)) +
    geom_histogram(bins = 50, fill = "steelblue") +
    labs(title = "ACTB - Raw counts") + theme_minimal()
p2 <- ggplot(data.frame(x = actb_norm), aes(x)) +
    geom_histogram(bins = 50, fill = "darkred") +
    labs(title = "ACTB - Normalized") + theme_minimal()
p3 <- ggplot(data.frame(x = lyz_raw), aes(x)) +
    geom_histogram(bins = 50, fill = "steelblue") +
    labs(title = "LYZ - Raw counts") + theme_minimal()
p4 <- ggplot(data.frame(x = lyz_norm), aes(x)) +
    geom_histogram(bins = 50, fill = "darkred") +
    labs(title = "LYZ - Normalized") + theme_minimal()

(p1 + p2) / (p3 + p4)
Four histograms in a 2x2 grid. Top row: ACTB raw counts show a right-skewed distribution peaking near zero; ACTB normalized shows a unimodal bell shape centered around 3.5. Bottom row: LYZ raw counts show a massive zero spike with a flat tail to 500; LYZ normalized reveals a bimodal pattern with peaks near 0.5 and 4.5
ACTB and LYZ raw vs. normalized expression

ACTB (housekeeping gene):

  • Before normalization: the raw count distribution is right-skewed because cells with higher sequencing depth have proportionally more ACTB counts. The apparent variation is mostly technical (sequencing depth), not biological.
  • After normalization: the distribution becomes approximately bell-shaped, centered around 3.5-4 on the log-normalized scale. Because ACTB is expressed at similar levels across all cell types, removing depth effects reveals a tight, unimodal distribution. This is exactly why ACTB is not selected as a variable feature – it doesn’t help distinguish cell types.

LYZ (variable gene):

  • Before normalization: there is a massive spike at zero (~7,000 cells that don’t express LYZ) and a low, flat spread of non-zero values extending to ~500. The zero-dominated distribution makes it hard to see any structure in the expressing cells.
  • After normalization: the zero spike persists (you can’t normalize away true absence of expression), but the non-zero cells now reveal a clear bimodal pattern – a small cluster of cells with low expression (~1-2) and a distinct second peak of highly expressing cells (~4-5). This bimodality reflects the biological reality: CD14+ monocytes express LYZ at high levels, while other cell types either don’t express it at all or express it minimally. This separation is why LYZ is selected as a highly variable feature.

The key takeaway: normalization makes expression comparable across cells by removing sequencing depth effects. Housekeeping genes become unimodal (confirming they don’t vary biologically), while cell-type marker genes reveal biologically meaningful structure – like the bimodal split in LYZ – that was obscured by technical noise in the raw counts.

Key Points
  • Log normalization corrects for sequencing depth by dividing by total counts, scaling, and log-transforming each cell independently
  • Highly variable features are the ~2,000 genes with the most cell-to-cell variation, capturing biological differences while excluding housekeeping genes and noise
  • Scaling (z-score transformation) centers and standardizes gene expression so that PCA is not dominated by highly expressed genes
  • Regressing out confounders like percent.mt during scaling is optional and should only be done when technical variation is obscuring biology
  • SCTransform is a one-step alternative that uses regularized negative binomial regression and is recommended for publication-quality analyses

Content from Dimensionality Reduction and Clustering


Last updated on 2026-09-08 | Edit this page

Estimated time: 60 minutes

Overview

Questions

  • Why do we need to reduce the dimensions of scRNA-seq data before clustering?
  • How do we choose the right number of principal components?
  • What does UMAP show us and what can it NOT tell us?
  • How does graph-based clustering work and what does the resolution parameter control?
  • How do we evaluate whether our clustering resolution is appropriate?

Objectives

  • Run PCA on variable features and select an appropriate number of PCs using the elbow plot
  • Generate a UMAP embedding for 2D visualization of cell relationships
  • Construct a shared nearest neighbor graph and identify clusters with the Louvain algorithm
  • Explore multiple clustering resolutions and use clustree to assess cluster stability
  • Visualize known marker genes on the UMAP to preview cell type identity
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 pbmc_normalized.rds object saved at the end of the previous episode.

Setup


R

library(Seurat)
library(ggplot2)
library(patchwork)
library(clustree)

Loading the Normalized Data


We start from the normalized, scaled Seurat object saved at the end of the previous episode. This object has the counts, data, and scale.data layers populated, with 2,000 variable features selected.

Set up the working directory to match the previous episodes:

R

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

R

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

OUTPUT

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

Principal Component Analysis (PCA)


Our dataset has ~2,000 variable features, but many of these genes are correlated – they go up and down together across cells because they are co-regulated in the same cell types. PCA (Principal Component Analysis) exploits these correlations to compress the data into a smaller set of principal components (PCs). Each PC is a linear combination of genes that captures a distinct axis of variation in the data.

The key properties of PCA:

  • PC1 captures the direction of greatest variance across all cells
  • PC2 captures the next greatest variance, orthogonal to PC1
  • Each subsequent PC captures decreasing amounts of variance
  • The first 10–20 PCs typically capture the major biological structure (cell types), while later PCs are dominated by noise

R

pbmc <- RunPCA(pbmc, features = VariableFeatures(object = pbmc))
Parameter Value Description
features VariableFeatures(object = pbmc) Which genes to use for PCA. We use the 2,000 variable features selected in the previous episode.

By default, Seurat computes 50 PCs. Let’s examine which genes drive the first two components:

R

VizDimLoadings(pbmc, dims = 1:2, reduction = "pca")
Dot plots of the top 30 genes by loading magnitude for PC1 and PC2, with PC1 dominated by myeloid markers and PC2 by B cell markers.
Dot plots showing the top 30 genes by loading magnitude for PC1 and PC2. PC1 shows myeloid markers such as LYZ, CST3, and S100A9. PC2 shows B cell markers such as BANK1, CD79A, and MS4A1.

Each point represents a gene’s loading (contribution) to that PC. For PC1, you should see monocyte/myeloid markers (CST3, LYZ, S100A9, FCN1) among the top genes. This tells us that PC1 separates myeloid from lymphoid cells – the largest source of variation in PBMCs. PC2 is dominated by B cell markers (BANK1, CD79A, MS4A1, PAX5), indicating that B cells are the next major axis of variation.

We can also visualize PCs as heatmaps to see which genes and cells contribute most:

R

DimHeatmap(pbmc, dims = 1:9, cells = 500, balanced = TRUE)
Parameter Value Description
dims 1:9 Which PCs to plot.
cells 500 Number of cells to include (randomly sampled from each extreme of the PC).
balanced TRUE Plot equal numbers of cells from the positive and negative ends of each PC.

Each heatmap shows the top genes (rows) ordered by their loading, with cells (columns) ordered by their PC score. Clear blocks of correlated expression indicate PCs that capture real biological structure. PCs that look noisy (no clear pattern) are capturing technical variation or random noise.

Grid of nine heatmaps for PC1 through PC9, each showing top genes ordered by loading and cells ordered by PC score to reveal distinct expression programs.
Grid of nine heatmaps showing gene expression patterns for PC1 through PC9. Each heatmap shows the top genes ordered by loading with cells ordered by PC score, revealing distinct expression programs.

Choosing the number of PCs

Not all 50 PCs are informative. Later PCs capture decreasing amounts of variance and eventually become dominated by noise. We need to decide how many PCs to retain for downstream steps (UMAP and clustering).

The elbow plot shows the standard deviation explained by each PC:

R

ElbowPlot(pbmc, ndims = 30)
Elbow plot of standard deviation versus PC number showing a steep decline through PC7 and a plateau after PC10, with the elbow around PC 8 to 10.
Dot plot of standard deviation versus principal component number. The curve drops steeply for the first 7 PCs then gradually flattens, with an elbow around PC 8 to 10.

Look for the elbow – the point where the curve transitions from steep decline to a flatter plateau. PCs before the elbow capture substantial biological signal; PCs after the elbow are mostly noise.

Callout

How many PCs?

For the PBMC 10k dataset, the elbow typically falls around PC 12–15. We will use 15 PCs for the rest of this episode.

In practice, the exact number is not critical. Using 10 vs. 20 PCs usually produces very similar clustering results for well-separated cell types. The choice matters more for subtle distinctions between closely related populations.

When in doubt, err on the side of including more PCs (e.g., 20). Using too few PCs risks losing biological signal from rarer cell types. Using a few extra PCs adds a small amount of noise but rarely changes the major structure.

An alternative to visual inspection is the JackStraw procedure (JackStrawPlot()), which uses a permutation test to identify statistically significant PCs. However, it is computationally expensive for large datasets and is rarely necessary when the elbow is clear.

UMAP Visualization


PCA reduces our data from 2,000 dimensions to 15, but we still cannot visualize 15 dimensions directly. UMAP (Uniform Manifold Approximation and Projection) further reduces the data to just 2 dimensions for plotting. Unlike PCA, UMAP is a non-linear method: it can unfold complex relationships that PCA cannot capture, placing similar cells close together and dissimilar cells far apart in 2D space.

R

pbmc <- RunUMAP(pbmc, dims = 1:15)
Parameter Value Description
dims 1:15 Which PCs to use as input. We use the 15 PCs selected above.

Visualize the UMAP embedding:

R

DimPlot(pbmc, reduction = "umap")
UMAP scatter plot of 11310 cells in a single color before clustering, with cells forming several spatially distinct groups that correspond to different cell types.
UMAP plot of all cells shown in a single color before clustering. Cells naturally organize into several spatially distinct groups corresponding to different cell types.

At this point, cells are not yet clustered (all are the same color). But you can already see that cells organize into distinct groups on the UMAP. These groups correspond to different cell types.

UMAP parameters

UMAP has two key parameters that control the appearance of the plot:

Parameter Default Effect
n.neighbors 30 Controls the balance between local and global structure. Low values (5–15) emphasize local neighborhoods, producing tighter clusters. High values (50–200) emphasize global structure, producing smoother layouts.
min.dist 0.3 Controls how tightly UMAP packs points. Low values (0.01–0.1) produce dense, compact clusters. High values (0.5–1.0) spread clusters out more evenly.

The defaults work well for most datasets. Adjusting these parameters changes the visual appearance of the plot but does not change the underlying data or the clustering results (which operate on PCA space, not UMAP space).

Callout

UMAP interpretation pitfalls

UMAP plots are powerful visualization tools, but they can be misleading if over-interpreted. Three things you should NOT infer from a UMAP:

  1. Distances between clusters do not reflect true biological similarity. Two clusters that appear far apart on the UMAP may actually be transcriptionally very similar, and vice versa. UMAP distorts global distances to preserve local neighborhoods.

  2. Cluster size on the plot does not reflect population size. A large, spread-out cluster may contain fewer cells than a small, compact one. UMAP adjusts spacing to show local structure, not proportional representation.

  3. Different random seeds produce different layouts. Running UMAP twice with different seeds will give a different arrangement of clusters. The overall groupings will be the same (the same cells will cluster together), but their positions and orientations on the plot will differ. Never compare spatial positions across two separate UMAP runs.

Always verify biological conclusions with quantitative methods (marker gene expression, differential expression tests, cluster composition statistics) rather than relying on visual impressions from UMAP alone.

Callout

t-SNE as an alternative

Before UMAP became standard, t-SNE (t-distributed Stochastic Neighbor Embedding) was the most common visualization method for scRNA-seq. You can run it with RunTSNE(pbmc, dims = 1:15). t-SNE is similar in purpose to UMAP but tends to be slower and produces rounder, more separated clusters. The same interpretation caveats apply. Most current analyses use UMAP because it is faster, better preserves global structure, and scales better to large datasets.

Graph-Based Clustering


With the PCA embedding computed, we can now group cells into clusters. Seurat uses a graph-based clustering approach that works in two steps:

Step 1: Build a neighbor graph

FindNeighbors() constructs a K-nearest neighbor (KNN) graph in PCA space. For each cell, it identifies its k most similar cells based on Euclidean distance in the first 15 PCs. These connections are then refined into a shared nearest neighbor (SNN) graph, where the connection strength between two cells depends on how much their neighborhoods overlap.

R

pbmc <- FindNeighbors(pbmc, dims = 1:15)
Parameter Value Description
dims 1:15 Which PCs to use for computing distances. Must match the PCs used for UMAP.
k.param 20 (default) Number of nearest neighbors per cell. Higher values produce a more connected graph with smoother clustering. Lower values preserve finer local structure.

Step 2: Detect communities

FindClusters() applies a community detection algorithm to the SNN graph. The algorithm finds groups of cells that are densely connected to each other but sparsely connected to the rest of the graph. The resolution parameter controls the granularity of clustering:

R

pbmc <- FindClusters(pbmc, resolution = 0.5)
Parameter Value Description
resolution 0.5 Controls the number of clusters. Lower values (0.1–0.3) produce fewer, larger clusters. Higher values (1.0–2.0) produce more, smaller clusters.
algorithm 1 (default) Community detection method. 1 = Louvain (default, fast and reliable). 4 = Leiden (more robust against poorly connected clusters, requires the leidenalg Python package).

At resolution 0.5, you should get approximately 18 clusters, numbered starting from 0. The exact number may vary slightly depending on random seed and software versions.

OUTPUT

Modularity Optimizer version 1.3.0 by Ludo Waltman and Nees Jan van Eck

Number of nodes: 11310
Number of edges: 410535

Running Louvain algorithm...
Maximum modularity in 10 random starts: 0.9317
Number of communities: 18
Elapsed time: 1 seconds

The cluster assignments are stored in the object metadata. Let’s check them:

R

head(Idents(pbmc))
table(Idents(pbmc))

OUTPUT

AAACCCAAGCGCCCAT-1 AAACCCAAGGTTCCGC-1 AAACCCACAGACAAGC-1 AAACCCACAGAGTTGG-1 AAACCCACAGGTATGG-1
                 0                 12                 13                  1                  7
AAACCCACATAGTCAC-1
                 4
Levels: 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17


   0    1    2    3    4    5    6    7    8    9   10   11   12   13   14   15   16   17
2112 1810 1187 1015 1006  716  639  634  517  429  357  234  176  175  122   78   64   39 

Cluster 0 is the largest with 2,112 cells (T cells, which are the most abundant PBMC type). Cluster 17 is the smallest with only 39 cells (likely a rare population like platelets or dendritic cells).

Now let’s visualize the clusters on the UMAP:

R

DimPlot(pbmc, reduction = "umap", label = TRUE, label.size = 5) +
    NoLegend()
UMAP plot colored by Louvain cluster identity at resolution 0.5, showing 18 clusters numbered 0 through 17 with large clusters in the center and small clusters at the periphery.
UMAP plot with 18 clusters labeled 0 through 17, each shown in a distinct color. Large clusters like 0 and 1 dominate the center and bottom, while smaller clusters appear at the periphery.

Exploring multiple resolutions

The resolution parameter is the most important choice in clustering. Too low and distinct cell types are merged; too high and single cell types are split into artificial sub-clusters. The best way to evaluate the right resolution is to try several and compare.

R

pbmc <- FindClusters(pbmc, resolution = 0.2)
pbmc <- FindClusters(pbmc, resolution = 0.5)
pbmc <- FindClusters(pbmc, resolution = 0.8)
pbmc <- FindClusters(pbmc, resolution = 1.2)

Each call adds a new column to the metadata named RNA_snn_res.X (where X is the resolution value). We can visualize how cells move between clusters at different resolutions using clustree:

R

clustree(pbmc, prefix = "RNA_snn_res.")
Clustree diagram tracing cluster membership from resolution 0.2 to 1.2, with clean branches indicating stable splits and tangled arrows indicating unstable over-splitting.
Clustree diagram showing how clusters split as resolution increases from 0.2 to 1.2. At low resolution a few large clusters exist, which progressively split into more subclusters at higher resolutions. Stable splits show clean branches while unstable splits show cells moving between multiple clusters.

In the clustree plot, each row is a resolution and each node is a cluster. Arrows show how cells move between clusters as resolution increases. Look for:

  • Clean splits: one cluster at a lower resolution cleanly divides into two at a higher resolution. This suggests a real biological distinction.
  • Unstable splits: cells from one cluster scatter across multiple clusters at a higher resolution, or cells bounce between clusters. This suggests over-splitting.

Set the active cluster identity to our chosen resolution:

R

Idents(pbmc) <- "RNA_snn_res.0.5"

Save the clustered object for the next episode:

R

saveRDS(pbmc, file = "pbmc_clustered.rds")

Exploring Clusters


Before formal cell type annotation (next episode), we can already preview cell identity by overlaying known marker genes on the UMAP. If our clustering is good, each marker should light up in a distinct cluster.

R

FeaturePlot(pbmc,
            features = c("CD3D", "MS4A1", "LYZ", "GNLY"),
            ncol = 2)
Four UMAP panels showing expression of CD3D, MS4A1, LYZ, and GNLY on a grey-to-purple gradient, each marking distinct cell populations for T cells, B cells, monocytes, and NK cells.
Four UMAP plots showing expression of CD3D, MS4A1, LYZ, and GNLY. Each gene is highlighted in a different cluster region, indicating T cells, B cells, monocytes, and NK cells respectively.
Gene Cell type Expected pattern
CD3D T cells Lights up the large T cell clusters (the biggest groups on the UMAP)
MS4A1 B cells Lights up a compact cluster separate from T cells
LYZ CD14+ Monocytes Lights up monocyte clusters, distinct from lymphocytes
GNLY NK cells Lights up a cluster adjacent to but separate from T cells

We can also view these markers as violin plots to see the expression distribution across clusters:

R

VlnPlot(pbmc,
        features = c("CD3D", "MS4A1", "LYZ", "GNLY"),
        ncol = 2,
        pt.size = 0)
Violin plots of CD3D, MS4A1, LYZ, and GNLY expression across 18 clusters, showing each marker with high expression in specific clusters and near-zero expression elsewhere.
Violin plots showing expression of CD3D, MS4A1, LYZ, and GNLY across all clusters. Each marker shows high expression in one or two specific clusters and low or zero expression in the others.

Even without formal annotation, you can see that these markers segregate cleanly into distinct clusters. CD3D marks several clusters (T cell subtypes), MS4A1 is specific to one cluster (B cells), LYZ is strong in one or two clusters (monocytes), and GNLY is specific to another (NK cells). This gives us confidence that the clustering is capturing biologically meaningful groupings. In the next episode, we will systematically identify marker genes for every cluster and assign cell type labels.

Challenge

Challenge 1: Choosing a Resolution with Clustree

Run clustering at resolutions 0.2, 0.5, 0.8, and 1.2 (we already did this above). Use the clustree plot to answer:

  1. At which resolution do clusters start splitting unstably?
  2. Which resolution gives a number of clusters that best matches the known PBMC cell types (~8–10 major types)?
  3. How many clusters do you get at each resolution?

R

# Count clusters at each resolution
cat("Resolution 0.2:", length(unique(pbmc$RNA_snn_res.0.2)), "clusters\n")
cat("Resolution 0.5:", length(unique(pbmc$RNA_snn_res.0.5)), "clusters\n")
cat("Resolution 0.8:", length(unique(pbmc$RNA_snn_res.0.8)), "clusters\n")
cat("Resolution 1.2:", length(unique(pbmc$RNA_snn_res.1.2)), "clusters\n")

# Visualize side by side
p1 <- DimPlot(pbmc, group.by = "RNA_snn_res.0.2", label = TRUE) +
    NoLegend() + ggtitle("Resolution 0.2")
p2 <- DimPlot(pbmc, group.by = "RNA_snn_res.0.5", label = TRUE) +
    NoLegend() + ggtitle("Resolution 0.5")
p3 <- DimPlot(pbmc, group.by = "RNA_snn_res.0.8", label = TRUE) +
    NoLegend() + ggtitle("Resolution 0.8")
p4 <- DimPlot(pbmc, group.by = "RNA_snn_res.1.2", label = TRUE) +
    NoLegend() + ggtitle("Resolution 1.2")

(p1 + p2) / (p3 + p4)
Side-by-side UMAP plots at resolutions 0.2, 0.5, 0.8, and 1.2 showing progressively more clusters as resolution increases.
Four UMAP plots comparing clustering at resolutions 0.2, 0.5, 0.8, and 1.2. Lower resolutions produce fewer large clusters while higher resolutions split cells into progressively more subclusters.

Expected cluster counts:

Resolution Approximate clusters
0.2 16
0.5 18
0.8 21
1.2 25

Resolution 0.5 gives 18 clusters, which captures the 8 major PBMC cell types (CD4+ T, CD8+ T, B cells, CD14+ monocytes, FCGR3A+ monocytes, NK cells, dendritic cells, platelets) along with some sub-clusters (e.g., naive vs. memory T cells), which is biologically reasonable.

Resolution 0.2 under-clusters with 16 clusters: it merges some distinct cell subtypes. You may lose meaningful biological distinctions.

Resolution 0.8 starts to over-split with 21 clusters: the clustree plot shows clusters from resolution 0.5 splitting into sub-clusters, with some cells moving between clusters in ways that are not stable. This does not mean 0.8 is wrong – it may reveal real sub-populations – but the splits become harder to annotate confidently.

Resolution 1.2 clearly over-splits with 25 clusters, many of which are fragments of the same cell type. The clustree plot shows extensive cross-cluster mixing at this resolution.

For this workshop we use resolution 0.5 as a good balance between capturing distinct cell types and avoiding excessive fragmentation.

Challenge

Challenge 2: Mapping Markers to Clusters

Use FeaturePlot() to visualize three markers on the UMAP:

  • CD3D (T cells)
  • MS4A1 (B cells)
  • LYZ (monocytes)

Then use the cluster-labeled UMAP to determine which cluster number(s) correspond to each cell type.

R

FeaturePlot(pbmc, features = c("CD3D", "MS4A1", "LYZ"), ncol = 3)
DimPlot(pbmc, reduction = "umap", label = TRUE, label.size = 5) + NoLegend()
Three UMAP panels showing expression of CD3D, MS4A1, and LYZ. CD3D expression is concentrated in the upper-left and lower-left regions. MS4A1 lights up a compact group in the upper-right. LYZ is strongest in the lower-left clusters.
FeaturePlot of CD3D, MS4A1, and LYZ on the UMAP, each highlighting distinct cell populations
UMAP plot with 18 clusters labeled 0 through 17 for cross-referencing with the FeaturePlot above.
Cluster-labeled UMAP for reference

By comparing the FeaturePlot panels with the labeled UMAP, you should see the following pattern (exact cluster numbers may differ depending on your run):

Marker Cell type Cluster(s)
CD3D T cells Clusters 0, 2, 3, 5, 6 (several groups across the upper-left and lower-left UMAP). CD3D is expressed in both CD4+ and CD8+ T cells, so it marks multiple clusters.
MS4A1 B cells Cluster 7 (a compact, clearly separated group in the upper-right). MS4A1 (also known as CD20) is highly specific to B cells.
LYZ CD14+ Monocytes Clusters 1 and 2 (large groups in the lower portion of the UMAP). You may also see weaker LYZ expression in a smaller monocyte cluster (FCGR3A+).

The fact that each marker cleanly maps to specific clusters confirms that our clustering at resolution 0.5 is capturing meaningful biological groupings. Notice that CD3D marks multiple clusters – this is expected because there are several T cell subtypes (CD4+ naive, CD4+ memory, CD8+) that form separate clusters but all express the pan-T cell marker CD3D.

In the next episode, we will use FindAllMarkers() to systematically identify the genes that define each cluster and assign proper cell type labels.

Key Points
  • PCA reduces ~2,000 variable genes to ~15 principal components that capture the major axes of biological variation
  • The elbow plot helps choose how many PCs to retain; for PBMCs, 15 PCs is typically sufficient
  • UMAP provides an intuitive 2D visualization but should not be used to infer distances, cluster sizes, or quantitative relationships
  • Graph-based clustering builds a shared nearest neighbor graph in PCA space and detects communities using the Louvain or Leiden algorithm
  • The resolution parameter controls clustering granularity; clustree helps identify the resolution where clusters begin to split unstably

Content from Cell Type Annotation


Last updated on 2026-09-08 | Edit this page

Estimated time: 60 minutes

Overview

Questions

  • How do we systematically find the genes that define each cluster?
  • How do we assign cell type labels using known marker genes?
  • How does automated annotation with SingleR work?
  • When do manual and automated annotations disagree and what should we do?
  • What are best practices for confident, reproducible annotation?

Objectives

  • Run FindAllMarkers to identify differentially expressed genes for every cluster
  • Interpret marker gene statistics: avg_log2FC, pct.1, pct.2, and p_val_adj
  • Manually annotate clusters using known PBMC marker genes
  • Use SingleR with the Monaco Immune reference for automated cell type annotation
  • Compare manual and automated annotations and resolve discrepancies
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 pbmc_clustered.rds object saved at the end of the previous episode.

Setup


R

library(Seurat)
library(ggplot2)
library(patchwork)
library(SingleR)
library(celldex)

Loading the Clustered Data


We start from the clustered Seurat object saved at the end of the previous episode. Make sure the active identity is set to the resolution 0.5 clusters.

Set up the working directory to match the previous episodes:

R

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

R

pbmc <- readRDS(paste0(work_dir, "pbmc_clustered.rds"))
Idents(pbmc) <- "RNA_snn_res.0.5"
DimPlot(pbmc, reduction = "umap", label = TRUE, label.size = 5) + NoLegend()
UMAP plot with 18 clusters labeled 0 through 17 at resolution 0.5, matching the clustering results from the previous episode.
Clustered UMAP from previous episode

You should see the UMAP with 18 clusters labeled by number (0 to 17), matching the clustering results from the previous episode.

Finding Marker Genes


Up to now, our clusters are just numbers. To give them biological meaning, we need to find the genes that distinguish each cluster from all other clusters. These are called marker genes – genes that are significantly upregulated in one cluster compared to the rest.

FindAllMarkers() runs a differential expression test for every cluster, comparing cells in that cluster against all other cells:

R

pbmc.markers <- FindAllMarkers(pbmc,
                               only.pos = TRUE,
                               min.pct = 0.25,
                               logfc.threshold = 0.25)
Parameter Value Description
only.pos TRUE Only return genes that are upregulated in the cluster (positive log fold change). We are interested in markers that define a cluster, not genes that are absent from it.
min.pct 0.25 Only test genes detected in at least 25% of cells in either the cluster or the rest. This speeds up the computation by skipping very rare genes that cannot be reliable markers.
logfc.threshold 0.25 Only test genes with at least a 0.25 log2 fold change between the cluster and the rest. This pre-filters out genes with trivially small differences.

This step may take 5 minutes.

OUTPUT

Calculating cluster 0
Calculating cluster 1
Calculating cluster 2
Calculating cluster 3
Calculating cluster 4
Calculating cluster 5
Calculating cluster 6
Calculating cluster 7
Calculating cluster 8
Calculating cluster 9
Calculating cluster 10
Calculating cluster 11
Calculating cluster 12
Calculating cluster 13
Calculating cluster 14
Calculating cluster 15
Calculating cluster 16
Calculating cluster 17

The result is a data frame with one row per gene per cluster. Let’s look at the key columns:

R

head(pbmc.markers)

OUTPUT

       p_val avg_log2FC pct.1 pct.2 p_val_adj cluster   gene
INPP4B     0   2.607036 0.972 0.345         0       0 INPP4B
CAMK4      0   1.653160 0.950 0.324         0       0  CAMK4
IL7R       0   2.067086 0.935 0.336         0       0   IL7R
CD3D       0   1.523352 0.923 0.334         0       0   CD3D
ITK        0   1.775408 0.920 0.338         0       0    ITK
BCL11B     0   1.586310 0.991 0.413         0       0 BCL11B

The columns mean:

Column Meaning
p_val Raw p-value from the Wilcoxon rank-sum test (default test).
avg_log2FC Average log2 fold change between the cluster and all other cells. A value of 1.0 means the gene is on average 2x higher in this cluster.
pct.1 Fraction of cells in the cluster where the gene is detected.
pct.2 Fraction of cells outside the cluster where the gene is detected.
p_val_adj Bonferroni-adjusted p-value. Use this for significance filtering (typically < 0.05).
cluster Which cluster this gene is a marker for.
gene The gene symbol.

A good marker gene has a high avg_log2FC, high pct.1 (expressed in most cells of the cluster), low pct.2 (not expressed in other clusters), and a significant p_val_adj.

Top markers per cluster

Let’s extract the top 5 markers per cluster ranked by fold change:

R

top5 <- pbmc.markers %>%
    dplyr::group_by(cluster) %>%
    dplyr::slice_max(n = 5, order_by = avg_log2FC)
top5

OUTPUT

# A tibble: 90 × 7
# Groups:   cluster [18]
   p_val avg_log2FC pct.1 pct.2 p_val_adj cluster gene
   <dbl>      <dbl> <dbl> <dbl>     <dbl> <fct>   <chr>
 1     0       5.23 0.258 0.011         0 0       IATPR
 2     0       3.69 0.345 0.036         0 0       ST8SIA1
 3     0       3.17 0.346 0.052         0 0       IL2RA
 4     0       2.88 0.414 0.072         0 0       ICOS
 5     0       2.75 0.521 0.103         0 0       FAAH2
 6     0       4.46 0.29  0.023         0 1       ENSG00000289381
 7     0       4.28 0.5   0.05          0 1       AQP9
 8     0       4.13 0.423 0.043         0 1       MTARC1
 9     0       4.09 1     0.549         0 1       S100A8
10     0       3.98 0.996 0.248         0 1       S100A12   

Visualizing markers with a heatmap

A heatmap of top marker genes across all clusters shows the expression patterns at a glance. Each column is a cell (grouped by cluster), each row is a gene, and color intensity represents scaled expression:

R

top3 <- pbmc.markers %>%
    dplyr::group_by(cluster) %>%
    dplyr::slice_max(n = 3, order_by = avg_log2FC)

DoHeatmap(pbmc, features = top3$gene) + NoLegend()
Heatmap showing expression of the top 3 marker genes per cluster across all 18 clusters. Each cluster shows a distinct block of upregulated genes, confirming distinct transcriptional identities.
Heatmap of top 3 markers per cluster

Each cluster should show a distinct block of highly expressed genes (bright yellow/white) that are low in other clusters (dark purple). Clusters with very similar heatmap profiles may represent subtypes of the same cell type.

Dot plot summary

A dot plot provides a compact summary of marker expression across clusters. The dot size encodes the percentage of cells expressing the gene, and the color intensity encodes the average expression level:

R

DotPlot(pbmc, features = unique(top3$gene)) + coord_flip() +
    theme(axis.text.x = element_text(angle = 45, hjust = 1))
Dot plot showing expression of top 3 marker genes across all 18 clusters. Dot size indicates the percentage of cells expressing each gene; dot shading indicates average expression level.
Dot plot of top marker genes

Manual Annotation with Known Markers


Now we match the marker gene patterns to known PBMC cell types. The table below lists the canonical markers for each expected cell type:

Cell type Key markers Expected %
CD4+ T cells CD3D, IL7R, CCR7 ~35%
CD8+ T cells CD3D, CD8A, CD8B ~10%
B cells MS4A1, CD79A ~10%
CD14+ Monocytes LYZ, CD14, S100A9 ~20%
FCGR3A+ Monocytes FCGR3A, MS4A7 ~5%
NK cells GNLY, NKG7, KLRD1 ~10%
Dendritic cells FCER1A, CST3 ~3%
Platelets PPBP, PF4 ~2%

Let’s visualize these markers on the UMAP to see which clusters they map to:

R

FeaturePlot(pbmc,
            features = c("CD3D", "IL7R", "CD8A", "MS4A1",
                         "LYZ", "FCGR3A", "GNLY", "FCER1A"),
            ncol = 4)
Grid of 8 UMAP panels showing expression of CD3D, IL7R, CD8A, MS4A1, LYZ, FCGR3A, GNLY, and FCER1A. Each marker highlights a distinct UMAP region: CD3D and IL7R in the upper-left T cell area, CD8A in a small cluster on the right, MS4A1 in B cells on the right, LYZ in monocytes at the lower-left, FCGR3A in a small cluster at the lower-right, GNLY in NK cells at the upper-center, and FCER1A in a small dendritic cell group.
FeaturePlot of 8 canonical PBMC markers

And as violin plots to see expression across clusters:

R

VlnPlot(pbmc,
        features = c("CD3D", "IL7R", "CD8A", "MS4A1",
                     "LYZ", "FCGR3A", "GNLY", "PPBP"),
        ncol = 4,
        pt.size = 0)
Violin plots of CD3D, IL7R, CD8A, MS4A1, LYZ, FCGR3A, GNLY, and PPBP across all 18 clusters. Each marker shows high expression in the clusters corresponding to its known cell type and low expression elsewhere.
Violin plots of 8 PBMC markers across clusters

Based on these plots and the FindAllMarkers results, we can build a mapping from cluster numbers to cell type names. Your exact cluster numbers will depend on the random seed, but the logic is the same: find which clusters express each set of markers.

R

# Build the cluster-to-cell-type mapping
# Adjust cluster IDs to match YOUR results
new.cluster.ids <- c(
    "CD4 T",           # 0: CD3D+, IL7R+, large central cluster
    "CD14 Mono",       # 1: LYZ+ high, large bottom cluster
    "CD14 Mono",       # 2: LYZ+ high, adjacent to cluster 1
    "CD4 T",           # 3: CD3D+, IL7R+, upper-left
    "CD8 T",           # 4: CD8A+, right side
    "CD4 T",           # 5: CD3D+, IL7R+, upper-center
    "NK",              # 6: GNLY+ high, center-right
    "NK",              # 7: GNLY+, upper-right island
    "B",               # 8: MS4A1+, right side
    "FCGR3A+ Mono",   # 9: FCGR3A+ high, lower-right
    "CD4 T",           # 10: CD3D+, IL7R+, small upper cluster
    "CD14 Mono",       # 11: LYZ+, between mono clusters
    "pDC",             # 12: small isolated, low expression of all major markers
    "DC",              # 13: FCER1A+, small upper-right
    "B",               # 14: MS4A1+, adjacent to cluster 8
    "Platelet",        # 15: PPBP+ high, small isolated bottom
    "CD14 Mono",       # 16: LYZ+, near mono territory
    "CD4 T"            # 17: CD3D+, small isolated far-left
)
names(new.cluster.ids) <- levels(pbmc)
pbmc <- RenameIdents(pbmc, new.cluster.ids)
Callout

Annotation is iterative

Your first-pass annotation will not always be correct. This is normal. After assigning labels, go back and check:

  • Do the labels make biological sense? Is the proportion of each cell type reasonable for PBMCs?
  • Are there clusters where multiple marker sets overlap? This may indicate a mixed cluster that should be sub-clustered at higher resolution.
  • Are there clusters with no clear markers? These may be low-quality cells that slipped through QC, or a rare cell type you didn’t expect.

Annotation is an iterative process: assign labels, verify with markers, revise, and repeat until you are confident in the assignments.

Visualize the final annotated UMAP:

R

DimPlot(pbmc, reduction = "umap", label = TRUE, repel = TRUE) + NoLegend()
UMAP plot with cells labeled by manually assigned cell type names: CD4 T, CD14 Mono, NK, B, CD8 T, FCGR3A+ Mono, DC, pDC, and Platelet.
Annotated UMAP with cell type labels

Store the annotation in the metadata so it persists after saving:

R

pbmc$manual_annotation <- Idents(pbmc)

Automated Annotation with SingleR


Manual annotation requires expert knowledge of marker genes for every expected cell type. SingleR offers an automated alternative: it compares the expression profile of each cell to a labeled reference dataset and assigns the label of the most similar reference sample.

Download the reference

We use the Monaco Immune Data reference from the celldex package. This reference contains bulk RNA-seq profiles of sorted human immune cell populations, making it well-suited for PBMC annotation.

R

ref <- celldex::MonacoImmuneData()
ref

OUTPUT

class: SummarizedExperiment
dim: 46077 114
metadata(0):
assays(1): logcounts
rownames(46077): A1BG A1BG-AS1 ... ZYX ZZEF1
rowData names(0):
colnames(114): DZQV_CD8_naive DZQV_CD8_CM ... G4YW_Neutrophils G4YW_Basophils
colData names(3): label.main label.fine label.ont

The reference has 114 samples across multiple immune cell types. The label.main column has broad cell type labels (e.g., “Monocytes”, “CD4+ T cells”) that match well with our expected PBMC types.

Run SingleR

SingleR works on a SingleCellExperiment object. We convert our Seurat object and then run the annotation:

R

# Convert to SingleCellExperiment
sce <- as.SingleCellExperiment(DietSeurat(pbmc))

# Run SingleR with Monaco Immune reference
results <- SingleR(test = sce,
                   ref = ref,
                   labels = ref$label.main)
Parameter Value Description
test sce The query dataset as a SingleCellExperiment. SingleR uses the log-normalized expression values.
ref ref The reference dataset with known labels.
labels ref$label.main Which column of the reference metadata to use as cell type labels. label.main gives broad categories; label.fine gives more specific subtypes.

This runs for 1–2 minutes. SingleR assigns a label to every individual cell, not to clusters. Let’s transfer the labels to our Seurat object:

R

pbmc$singler_labels <- results$labels

Visualize the SingleR annotations on the UMAP:

R

DimPlot(pbmc, group.by = "singler_labels", reduction = "umap",
        label = TRUE, repel = TRUE) + NoLegend()
UMAP plot with cells colored by SingleR automated annotation labels from the Monaco Immune reference, showing CD4+ T cells, CD8+ T cells, T cells, Monocytes, B cells, NK cells, Dendritic cells, Progenitors, and Basophils.
SingleR annotated UMAP

Comparing manual and automated annotations

The real power of annotation comes from comparing multiple approaches. Let’s create a cross-tabulation of our manual labels versus SingleR labels:

R

table(Manual = pbmc$manual_annotation, SingleR = pbmc$singler_labels)

OUTPUT

              SingleR
Manual         B cells Basophils CD4+ T cells CD8+ T cells Dendritic cells Monocytes NK cells Progenitors
  CD4 T              3         0         2596          583               0         5       43          22
  CD14 Mono          3         0            0            0              32      3259        1           0
  CD8 T           1006         0            0            0               0         0        0           0
  NK                 0         0            3            1               0         0      607           0
  B                608         0            0            0               5         0       15           0
  FCGR3A+ Mono       0         0            0            0               1       428        0           0
  pDC                0         0            0            0             157        19        0           0
  DC                 4         2            3            2               1         6        0         154
  Platelet           0         0            0            0              78         0        0           0
              SingleR
Manual         T cells
  CD4 T            987
  CD14 Mono          0
  CD8 T              0
  NK               662
  B                 11
  FCGR3A+ Mono       0
  pDC                0
  DC                 3
  Platelet           0

The table reveals a mix of strong agreement and instructive disagreements:

  • Strong agreement: CD14+ Monocytes are overwhelmingly called “Monocytes” by SingleR (3,259 of 3,295). B cells agree well (608 of 639). FCGR3A+ Monocytes are correctly called “Monocytes” (428 of 429). These cell types have distinctive transcriptional profiles that both methods recognize.
  • CD4 T cell splitting: SingleR splits our CD4 T cells across “CD4+ T cells” (2,596), generic “T cells” (987), and “CD8+ T cells” (583). The generic “T cells” label reflects SingleR’s uncertainty about subtype when activation or memory signatures blur the CD4/CD8 boundary. The 583 cells called “CD8+ T” may be misclassified by SingleR or represent a subset where our manual annotation was too broad.
  • CD8 T mislabeled as B cells: this is the largest disagreement. All 1,006 cells we labeled “CD8 T” are called “B cells” by SingleR. This likely means our manual annotation of cluster 4 as CD8 T is wrong and should be reconsidered – check the MS4A1 and CD8A expression in that cluster (visible in the VlnPlot) to determine whether SingleR’s B cell call is more appropriate. Go back and verify marker expression in this cluster.
  • NK split with T cells: SingleR calls 607 of our NK cells as “NK cells” but labels 662 as “T cells.” This likely reflects NKT cells or cytotoxic CD8+ T cells that share expression of GNLY and NKG7 with NK cells. Our manual annotation grouped them based on GNLY expression alone, which is insufficient to distinguish NK from NKT populations.
  • DC and pDC: SingleR calls our pDC cluster “Dendritic cells” (157), which is correct at a coarser level. However, it calls our DC cluster “Progenitors” (154), suggesting the reference dataset’s progenitor signature overlaps with the markers in that small cluster. This is a case where manual annotation with known markers (FCER1A, CST3) is more trustworthy.
  • Platelets misclassified: SingleR calls all 78 platelets “Dendritic cells.” Platelets are often absent from SingleR reference datasets (Monaco Immune Data does not include them), so the classifier assigns the nearest available label. This is a known limitation – always verify rare or non-standard cell types manually.

The key takeaway: neither method is always right. SingleR caught a likely error in our manual CD8 T annotation but failed on platelets and DCs. The best practice is to use both methods and investigate every disagreement.

Visualize both annotations side by side:

R

p1 <- DimPlot(pbmc, group.by = "manual_annotation", label = TRUE, repel = TRUE) +
    NoLegend() + ggtitle("Manual annotation")
p2 <- DimPlot(pbmc, group.by = "singler_labels", label = TRUE, repel = TRUE) +
    NoLegend() + ggtitle("SingleR annotation")
p1 + p2
Two UMAP plots side by side. Left panel shows manual annotation with CD4 T, CD14 Mono, NK, B, CD8 T, FCGR3A+ Mono, DC, pDC, and Platelet labels. Right panel shows SingleR annotation with CD4+ T cells, CD8+ T cells, T cells, Monocytes, B cells, NK cells, Dendritic cells, Progenitors, and Basophils. Most cell groups receive consistent labels between the two methods.
Manual vs SingleR annotation side by side

When the two methods agree, you can be very confident in the label. When they disagree, check the marker gene expression for the disputed cluster and decide which label fits better. In general:

  • Trust manual annotation when the markers are clear and well-established for the tissue type you are studying
  • Trust SingleR when you are unfamiliar with the tissue or when the reference provides finer subtypes than you would identify manually

Save the final annotated object:

R

saveRDS(pbmc, file = "pbmc_annotated.rds")

Annotation Best Practices


Callout

Guidelines for confident annotation

  1. Use multiple references. No single reference is perfect. The celldex package provides several options for human immune cells:

    • MonacoImmuneData() – sorted immune populations, best for PBMCs
    • HumanPrimaryCellAtlasData() (HPCA) – broader coverage including non-immune cell types, but less specific for immune subtypes
    • DatabaseImmuneCellExpressionData() (DICE) – another immune-focused reference with different experimental conditions

    Running SingleR with multiple references and checking for agreement increases confidence.

  2. Cross-validate automated labels with marker genes. Never accept automated labels blindly. Always check that the assigned label is consistent with the expression of established markers for that cell type.

  3. Document your decisions. Record which markers you used, which reference datasets, and any ambiguous cases you resolved manually. This makes your analysis reproducible and allows others to evaluate your annotation choices.

  4. Sub-cluster when needed. If a cluster shows mixed marker expression (e.g., both CD4 and CD8 markers), it may contain two cell types that were merged at the current resolution. Re-run FindClusters() at higher resolution on just that subset of cells to resolve the mixture:

    R

    subset_cells <- subset(pbmc, idents = "mixed_cluster")
    subset_cells <- FindNeighbors(subset_cells, dims = 1:15)
    subset_cells <- FindClusters(subset_cells, resolution = 0.5)
Challenge

Challenge 1: Identifying T cell subtypes from marker combinations

The CD4 T cell group shows high expression of CD3D, SELL, and IL7R. But look at the CD69 panel – the violin is wide, not uniformly low or high. What does this heterogeneity tell you about the composition of this group?

R

VlnPlot(pbmc, features = c("CD3D", "SELL", "CCR7", "CD69", "IL7R"), ncol = 5, pt.size = 0)
Violin plots of CD3D, SELL, CCR7, CD69, and IL7R across all annotated cell types. CD4 T cells show uniformly high CD3D and IL7R, high SELL, moderate CCR7, and a broad bimodal distribution of CD69 indicating a mixture of naive and recently activated T cells.
Violin plots of T cell subtype markers

The broad CD69 violin in CD4 T cells reveals that this group is not a single subtype – it contains a mixture of naive and activated T cells that were merged during our annotation.

Marker CD4 T pattern Interpretation
CD3D Uniformly high Confirms T cell identity across the group
SELL (CD62L) High Lymph node homing receptor; enriched in naive and central memory T cells
CCR7 Moderate, variable Another homing receptor; high in naive, low in effector memory
CD69 Broad (bimodal) Early activation marker; low in naive, high in recently activated cells
IL7R (CD127) High IL-7 receptor for homeostatic survival; expressed on naive and memory T cells

The CD69-low subset represents naive T cells (SELL-high, CCR7+, CD69-low) that recirculate between blood and lymph nodes but have not yet encountered antigen. The CD69-high subset represents recently activated T cells that have been stimulated.

Also note how these markers behave in other cell types:

  • NK cells show the highest CD69 expression of any group, consistent with constitutive activation in circulating NK cells.
  • B cells express SELL and IL7R, so these markers alone cannot distinguish T cells from B cells – you need CD3D to confirm T cell identity.
  • CD8 T cells show high CD69 but low SELL, suggesting they are predominantly effector or effector memory cells rather than naive.

This illustrates why our coarse “CD4 T” label is a simplification. With higher clustering resolution or sub-clustering, you could separate the naive and activated subsets. For many analyses this level of granularity is sufficient, but for studies focused on T cell biology you would want to sub-cluster further.

Challenge

Challenge 2: Comparing two SingleR references

Run SingleR with both MonacoImmuneData and HumanPrimaryCellAtlasData. For which cell types do the two references disagree? Why might they disagree?

R

# Monaco Immune reference (already computed above)
ref_monaco <- celldex::MonacoImmuneData()
results_monaco <- SingleR(test = sce, ref = ref_monaco, labels = ref_monaco$label.main)

# Human Primary Cell Atlas reference
ref_hpca <- celldex::HumanPrimaryCellAtlasData()
results_hpca <- SingleR(test = sce, ref = ref_hpca, labels = ref_hpca$label.main)

# Transfer labels
pbmc$singler_monaco <- results_monaco$labels
pbmc$singler_hpca <- results_hpca$labels

# Compare
table(Monaco = pbmc$singler_monaco, HPCA = pbmc$singler_hpca)

OUTPUT

                 HPCA
Monaco            B_cell   BM  CMP  GMP HSC_-G-CSF  MEP Monocyte NK_cell Platelets Pre-B_cell_CD34-
  B cells           1575    0    0    0          1    0        3       0         2                1
  Basophils            0    0    0    0          0    0        0       0         2                0
  CD4+ T cells         0    0    0    0          0    0        0       0         0                0
  CD8+ T cells         0    0    0    0          0    0        0       0         1                0
  Dendritic cells     36    0    0    8          1    0      218       0         1                6
  Monocytes            4    0    0    0          7    0     3669       5         5                8
  NK cells             2    0    0    0          0    0        0     649         0                1
  Progenitors          0    1   20    0          0    1        3       0       151                0
  T cells              3    0    0    0          0    0        0      20         1                0
                 HPCA
Monaco            Pro-Myelocyte T_cells
  B cells                     1      41
  Basophils                   0       0
  CD4+ T cells                0    2602
  CD8+ T cells                0     585
  Dendritic cells             0       4
  Monocytes                   0      19
  NK cells                    0      14
  Progenitors                 0       0
  T cells                     0    1639

The two references agree well on some cell types and disagree sharply on others. Here are the key patterns:

1. Strong agreement: Monocytes, B cells, NK cells. Monaco “Monocytes” map almost entirely to HPCA “Monocyte” (3,669 of 3,717). Monaco “B cells” map to HPCA “B_cell” (1,575 of 1,624). Monaco “NK cells” map to HPCA “NK_cell” (649 of 666). These cell types have distinctive profiles that both references recognize.

2. T cell subtyping collapses in HPCA. Monaco distinguishes “CD4+ T cells” (2,602), “CD8+ T cells” (586), and generic “T cells” (1,663). HPCA labels all of these simply as “T_cells.” If your analysis requires T cell subtype resolution, Monaco is the better reference. Interestingly, Monaco assigns 41 B cells to HPCA “T_cells,” suggesting a small population that sits near the T/B boundary (possibly NKT cells or doublets).

3. Dendritic cells split across HPCA labels. Monaco calls 274 cells “Dendritic cells,” but HPCA scatters these across “Monocyte” (218), “CMP” (8), “Pre-B_cell_CD34-” (6), “B_cell” (36), and only 4 as “T_cells.” HPCA lacks a dedicated dendritic cell category with sufficient resolution, so DCs get assigned to the nearest available profile – usually monocytes, which share many myeloid markers.

4. Progenitors become Platelets in HPCA. Monaco labels 176 cells as “Progenitors,” and HPCA calls 151 of those “Platelets.” These are the cells we manually annotated as platelets. Monaco does not have a platelet reference profile, so it assigns them to “Progenitors” (the nearest match based on low transcriptional complexity). HPCA does include a platelet profile and gets this right. Neither reference is wrong – they just have different coverage.

5. HPCA introduces hematopoietic progenitor labels. HPCA assigns some cells to “BM,” “CMP,” “GMP,” “MEP,” “HSC_-G-CSF,” and “Pro-Myelocyte” – categories that should not be present in PBMCs. These are artifacts of the HPCA reference containing bone marrow profiles. When a cell’s expression sits between two immune types, HPCA may assign a progenitor label rather than committing to either.

Lesson: No single reference is universally best. Monaco gives better immune subtype resolution (especially for T cells and DCs) and avoids spurious non-immune labels, making it the better default for PBMCs. HPCA correctly identifies platelets where Monaco fails. The practical approach: use Monaco as your primary reference for immune tissues, cross-check disagreements with HPCA, and always validate automated labels against known marker genes.

Key Points
  • FindAllMarkers identifies genes upregulated in each cluster versus all others; key output columns are avg_log2FC, pct.1, pct.2, and p_val_adj
  • Manual annotation maps cluster marker genes to known cell-type-specific markers and requires domain expertise
  • SingleR automates annotation by comparing each cell’s expression profile to a labeled reference dataset
  • Combining manual and automated approaches increases confidence; agreement between methods strongly supports the assigned label
  • Annotation is iterative and should be cross-validated with multiple references and marker gene visualization

Content from Multi-Sample Integration


Last updated on 2026-09-08 | Edit this page

Estimated time: 60 minutes

Overview

Questions

  • Why do cells from different samples cluster separately even when they are the same cell type?
  • How does CCA-based integration correct for batch effects?
  • How do we evaluate whether integration was successful without removing real biology?
  • How do we find genes that differ between conditions within a specific cell type?
  • When should we NOT integrate datasets?

Objectives

  • Explain the batch effect problem and why simple merging is insufficient
  • Integrate two conditions using Seurat v5 CCA integration with IntegrateLayers
  • Evaluate integration by checking condition mixing and preserved marker expression
  • Identify conserved markers and condition-specific differentially expressed genes
  • Recognize when integration is inappropriate for the experimental design
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.

Setup


R

library(Seurat)
library(SeuratData)
library(ggplot2)
library(patchwork)

Set up the working directory to match the previous episodes:

R

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

Why Integration Is Needed


In the previous episodes, we analyzed a single sample of PBMCs. But many experimental designs involve multiple samples – different patients, different time points, or different conditions (treated vs. control). When we combine these samples for joint analysis, a common problem emerges: cells cluster by which sample they came from rather than by what cell type they are.

This happens because of batch effects: technical differences between samples caused by variation in cell handling, library preparation, sequencing depth, or reagent lots. These technical differences can be large enough to dominate over the biological differences between cell types, making it impossible to compare the same cell type across conditions.

Integration algorithms correct for batch effects by finding shared biological structure across samples. The goal is to align the same cell types so they co-cluster, while preserving the real biological differences (like changes in gene expression due to treatment).

Let’s see this problem in practice using a dataset of PBMCs from control and IFN-beta stimulated conditions.

Loading the IFNB Dataset


The IFNB dataset from the SeuratData package contains PBMCs from two conditions:

  • CTRL: untreated control PBMCs
  • STIM: PBMCs stimulated with interferon-beta (IFN-beta), a cytokine that activates antiviral immune responses

R

InstallData("ifnb")
ifnb <- LoadData("ifnb")
ifnb
An object of class Seurat
14053 features across 13999 samples within 1 assay
Active assay: RNA (14053 features, 0 variable features)
 2 layers present: counts, data

The dataset has approximately 14,000 cells and 14,000 genes. The stim column in the metadata records which condition each cell came from.

R

table(ifnb$stim)
CTRL STIM
6548 7451

Roughly 6,500 control cells and 7,500 stimulated cells.

Preparing Data


In Seurat v5, multi-sample data is handled by splitting the assay into layers – one layer per sample. This keeps everything in a single object while allowing integration methods to process each sample separately.

R

ifnb[["RNA"]] <- split(ifnb[["RNA"]], f = ifnb$stim)
ifnb
An object of class Seurat
14053 features across 13999 samples within 1 assay
Active assay: RNA (14053 features, 0 variable features)
 4 layers present: counts.CTRL, counts.STIM, data.CTRL, data.STIM

The counts are now stored in two separate layers: counts.CTRL and counts.STIM.

Now run the standard preprocessing steps. When the assay is split, NormalizeData and FindVariableFeatures process each layer independently:

R

ifnb <- NormalizeData(ifnb)
ifnb <- FindVariableFeatures(ifnb, selection.method = "vst", nfeatures = 2000)
ifnb <- ScaleData(ifnb)
ifnb <- RunPCA(ifnb)

Let’s see what happens if we run UMAP and clustering without integration:

R

ifnb <- FindNeighbors(ifnb, dims = 1:30, reduction = "pca")
ifnb <- FindClusters(ifnb, resolution = 0.5)
ifnb <- RunUMAP(ifnb, dims = 1:30, reduction = "pca")

R

DimPlot(ifnb, reduction = "umap", group.by = "stim")
UMAP plot of the IFNB dataset before integration showing CTRL and STIM cells forming separate clusters driven by batch effects rather than cell type identity.
UMAP plot of IFNB dataset before integration. CTRL (salmon) and STIM (teal) cells form largely separate clusters. Several clusters are dominated by one condition, indicating batch-driven rather than cell-type-driven clustering.

You should see a clear separation between control (CTRL) and stimulated (STIM) cells. Instead of clustering by cell type (T cells with T cells, monocytes with monocytes), the cells cluster by condition. This means we cannot directly compare the same cell type between conditions – which is exactly what we want to do. This is the batch effect problem.

Running Integration


Seurat v5 provides IntegrateLayers() as a unified interface for dataset integration. We will use the CCA (Canonical Correlation Analysis) method, which identifies shared sources of variation between the two conditions and uses them to align the data.

CCA works by:

  1. Finding canonical correlation vectors – directions in gene expression space along which the two datasets are maximally correlated
  2. Identifying anchor pairs – cells from different conditions that are each other’s mutual nearest neighbors in the shared CCA space
  3. Using the anchors to compute a correction vector that aligns matching cell types across conditions

R

ifnb <- IntegrateLayers(object = ifnb,
                        method = CCAIntegration,
                        orig.reduction = "pca",
                        new.reduction = "integrated.cca")
Parameter Value Description
method CCAIntegration Use Canonical Correlation Analysis for integration.
orig.reduction "pca" The unintegrated PCA reduction to use as input.
new.reduction "integrated.cca" Name for the new integrated reduction that will be stored in the object.

Now re-run the downstream steps using the integrated reduction instead of the original PCA:

R

ifnb <- FindNeighbors(ifnb, reduction = "integrated.cca", dims = 1:30)
ifnb <- FindClusters(ifnb, resolution = 0.5)
ifnb <- RunUMAP(ifnb, reduction = "integrated.cca", dims = 1:30)

Visualize the integrated result:

R

DimPlot(ifnb, reduction = "umap", group.by = "stim")
UMAP plot of the IFNB dataset after CCA integration showing control and stimulated cells intermingled within each cluster, indicating successful batch correction.
UMAP plot after CCA integration. CTRL and STIM cells are now intermingled within each cluster, with teal (STIM) dominating the overlay. Cell types co-cluster regardless of condition.

The control and stimulated cells should now be intermingled within each cluster. Cell types from both conditions co-cluster, which is what we want.

Let’s verify with a split view:

R

DimPlot(ifnb, reduction = "umap", split.by = "stim")
Split UMAP view showing CTRL and STIM conditions side by side after integration, with both panels displaying the same 14 clusters in identical spatial arrangement.
Two side-by-side UMAP panels split by condition (CTRL and STIM) after integration. Both panels show 14 clusters numbered 0 through 13, with the same spatial arrangement in each panel, confirming successful integration.

Both panels should show the same overall structure, with the same clusters present in both conditions. This confirms that integration successfully aligned shared cell types.

Before proceeding to analysis, rejoin the layers so that expression data from both conditions is accessible in a single matrix:

R

ifnb[["RNA"]] <- JoinLayers(ifnb[["RNA"]])
Callout

Other integration methods

Seurat v5 supports several integration methods through the same IntegrateLayers() interface. You can swap CCAIntegration for any of these:

Method Function Strengths
CCA CCAIntegration Best for datasets with shared cell types. Robust to differences in cell type composition between batches. The default choice for most experiments.
Harmony HarmonyIntegration Very fast, scales well to large datasets (100k+ cells). Uses iterative soft clustering. Requires the harmony R package. Widely used and well-benchmarked.
RPCA RPCAIntegration Faster than CCA (uses reciprocal PCA instead of full CCA). Good when datasets are large or when CCA is too slow. May be less robust when cell type composition differs substantially between batches.
scVI scVIIntegration Deep learning approach. Excellent for complex batch structures (many batches, large composition differences). Requires Python and the scvi-tools package. Slower to run but often produces superior results on difficult datasets.

For most standard experiments with two to five batches and shared cell types, CCA or Harmony work well. Use scVI for more complex scenarios (many batches, different tissues, cross-species integration).

Callout

When NOT to integrate

Integration assumes that the same cell types exist in all batches and aligns them. This is the wrong approach when:

  • All cells are expected to differ between conditions. For example, comparing tumor cells to normal epithelial cells – these are fundamentally different populations and should not be forced to co-cluster.
  • You want to find condition-specific cell types. Integration pushes cells together, which can mask the appearance of a cell state that exists in only one condition.
  • The batch effect is minimal. If cells already mix well by condition on the UMAP (as in some well-controlled experiments), integration is unnecessary and may introduce artifacts.

Always visualize the data before integration to assess whether batch effects are actually present. If conditions already mix well, skip integration and analyze directly.

Analysis on Integrated Data


Now that the data is integrated, we can annotate cell types and compare between conditions.

Annotating integrated clusters

Find markers for each integrated cluster:

R

ifnb.markers <- FindAllMarkers(ifnb,
                                only.pos = TRUE,
                                min.pct = 0.25,
                                logfc.threshold = 0.25)

Annotate the clusters using known PBMC markers (the same markers from Episode 6):

R

# Adjust cluster IDs to match YOUR results
new.cluster.ids <- c(
    "CD14 Mono",    # 0
    "CD4 T",        # 1
    "CD4 T",        # 2
    "CD4 T",        # 3
    "CD8 T",        # 4
    "NK",           # 5
    "B",            # 6
    "CD14 Mono",    # 7
    "FCGR3A Mono",  # 8
    "DC",           # 9
    "CD14 Mono",    # 10
    "Mk",           # 11
    "B",            # 12
    "Eryth"         # 13
)
names(new.cluster.ids) <- levels(ifnb)
ifnb <- RenameIdents(ifnb, new.cluster.ids)
ifnb$celltype <- Idents(ifnb)

R

DimPlot(ifnb, reduction = "umap", label = TRUE, repel = TRUE) + NoLegend()
Annotated UMAP plot of the integrated IFNB dataset with nine labeled cell types including CD14 Mono, CD4 T, CD8 T, B, NK, FCGR3A Mono, DC, Mk, and Eryth.
UMAP plot with cells colored and labeled by annotated cell type. Nine cell types are visible: CD14 Mono (large cluster, right), CD4 T (large, upper left), CD8 T (upper right), B (center left), NK (left), FCGR3A Mono (lower right), DC (small, center), Mk (small, center right), and Eryth (small, bottom).

Conserved markers

Conserved markers are genes that are markers for a cell type in both conditions. FindConservedMarkers() tests for differential expression within each condition separately and then combines the results:

R

conserved <- FindConservedMarkers(ifnb,
                                  ident.1 = "CD14 Mono",
                                  grouping.var = "stim",
                                  only.pos = TRUE)


head(conserved, 10)
Parameter Value Description
ident.1 "CD14 Mono" The cell type to find markers for.
grouping.var "stim" The metadata column defining conditions. The test is run within each level of this variable.
only.pos TRUE Only return upregulated genes.

The output has columns for each condition (e.g., CTRL_avg_log2FC, STIM_avg_log2FC) showing that the marker is consistent across both. Genes like LYZ, S100A9, and CD14 should appear as strong conserved markers for monocytes.

         CTRL_p_val CTRL_avg_log2FC CTRL_pct.1 CTRL_pct.2 CTRL_p_val_adj    STIM_p_val STIM_avg_log2FC
TYROBP            0        2.356269      0.940      0.227              0  0.000000e+00        2.337569
FCER1G            0        2.197678      0.930      0.233              0  0.000000e+00        2.549589
S100A8            0        4.739502      0.743      0.071              0  0.000000e+00        5.318497
C15orf48          0        2.720354      0.869      0.201              0  0.000000e+00        2.751572
IL8               0        3.651882      0.799      0.144              0 9.967253e-252        3.819957
CD63              0        2.740801      0.958      0.333              0  0.000000e+00        2.889984
S100A9            0        4.606045      0.691      0.069              0  0.000000e+00        5.236085
CTSB              0        3.116819      0.772      0.183              0  0.000000e+00        3.400786
LGALS1            0        2.400262      0.905      0.321              0  0.000000e+00        2.848345
TYMP              0        2.084914      0.860      0.282              0  0.000000e+00        2.267578
         STIM_pct.1 STIM_pct.2 STIM_p_val_adj      max_pval minimump_p_val
TYROBP        0.952      0.199   0.000000e+00  0.000000e+00              0
FCER1G        0.910      0.197   0.000000e+00  0.000000e+00              0
S100A8        0.445      0.020   0.000000e+00  0.000000e+00              0
C15orf48      0.860      0.201   0.000000e+00  0.000000e+00              0
IL8           0.295      0.028  1.400698e-247 9.967253e-252              0
CD63          0.931      0.276   0.000000e+00  0.000000e+00              0
S100A9        0.584      0.036   0.000000e+00  0.000000e+00              0
CTSB          0.822      0.183   0.000000e+00  0.000000e+00              0
LGALS1        0.867      0.217   0.000000e+00  0.000000e+00              0
TYMP          0.926      0.438   0.000000e+00  0.000000e+00              0

Condition-specific differential expression

The most interesting biological question is: which genes change between control and stimulated cells within the same cell type? To answer this, we subset to a single cell type and compare conditions:

R

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

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

# Find DE genes between stimulated and control
mono.de <- FindMarkers(mono,
                       ident.1 = "STIM",
                       ident.2 = "CTRL",
                       min.pct = 0.25,
                       logfc.threshold = 0.25)
head(mono.de, 10)
        p_val avg_log2FC pct.1 pct.2 p_val_adj
IFIT1       0   7.117626 0.965 0.034         0
IFIT3       0   6.737338 0.971 0.057         0
TNFSF10     0   6.322227 0.959 0.062         0
RSAD2       0   6.619577 0.920 0.042         0
IFIT2       0   6.893956 0.909 0.040         0
MX1         0   4.824046 0.953 0.099         0
CXCL10      0   7.975000 0.873 0.032         0
LY6E        0   4.255152 0.990 0.174         0
CXCL11      0   8.521702 0.800 0.011         0
CCL8        0   9.063100 0.800 0.015         0

The top DE genes are all interferon-stimulated genes (ISGs): IFIT1, IFIT3, TNFSF10, RSAD2, IFIT2. These are exactly the genes you would expect to be upregulated by IFN-beta stimulation, confirming that our integration preserved the biological signal while correcting the batch effect.

Save the annotated ifnb object for the next episode:

R

saveRDS(ifnb, file = "ifnb_annotated.rds")

Visualizing condition-specific changes

Let’s visualize a few ISGs to see the condition-specific response:

R

FeaturePlot(ifnb,
            features = c("IFIT3", "IFIT1"),
            split.by = "stim",
            cols = c("grey85", "firebrick"))
FeaturePlot of ISG genes split by condition showing near-absent expression in CTRL panels and strong red expression in STIM panels, especially in monocyte clusters.
Four-row FeaturePlot split by CTRL and STIM for ISG15, IFIT1, TNFSF10, and RSAD2. CTRL panels show mostly grey cells with sparse expression. STIM panels show strong red expression across monocyte and other clusters for all four genes.

R

VlnPlot(ifnb,
        features = c("IFIT1", "IFIT3", "TNFSF10", "RSAD2"),
        split.by = "stim",
        idents = "CD14 Mono",
        ncol = 4,
        pt.size = 0)
Violin plots of IFIT1, IFIT3, TNFSF10, and RSAD2 in CD14 monocytes showing flat expression in control and broad high expression in stimulated cells.
Four violin plots for IFIT1, IFIT3, TNFSF10, and RSAD2 in CD14 monocytes. Each panel shows two violins: a thin line at zero for control and a broad teal shape peaking around expression level 3 to 4 for stimulated, confirming strong IFN-beta induction.

IFIT1 and IFIT3 are strongly induced in the stimulated condition across multiple cell types, with particularly strong expression in monocytes. This demonstrates that integration corrected the batch effect (cell types co-cluster) while preserving the biological effect of IFN-beta stimulation (ISGs are still differentially expressed).

Challenge

Challenge 1: Quantifying integration quality

After integration, check whether control and stimulated cells of the same type are well mixed within each cluster. Use DimPlot(split.by = "stim") to visualize, then compute the proportion of each condition per cluster.

R

# Split UMAP by condition
DimPlot(ifnb, reduction = "umap", split.by = "stim", label = TRUE, repel = TRUE) +
    NoLegend()

# Proportion table: condition per cell type
prop.table(table(Condition = ifnb$stim, CellType = Idents(ifnb)), margin = 2)

Are the proportions roughly balanced (close to 50/50) within each cell type?

         CellType
Condition CD14 Mono  CD4 T  CD8 T     NK      B FCGR3A Mono     DC     Mk  Eryth
     CTRL     0.504  0.432  0.477  0.435  0.482       0.530  0.447  0.446  0.458
     STIM     0.496  0.568  0.523  0.565  0.518       0.470  0.553  0.554  0.542
Split UMAP showing CTRL and STIM panels with cell type labels, demonstrating balanced mixing of conditions within each annotated cluster.
Split UMAP with CTRL and STIM panels, cells labeled by cell type. Both panels show identical cluster arrangement with CD4 T, CD8 T, B, NK, CD14 Mono, FCGR3A Mono, DC, Mk, and Eryth labels, confirming balanced condition mixing within each cell type.

Most cell types fall in the 0.43–0.57 range, confirming that integration successfully mixed cells from both conditions. The split UMAP shows the same cell types present in both panels with similar spatial arrangement – each cluster appears in both CTRL and STIM with comparable density.

Some observations worth discussing:

  • CD14 Mono is the best-mixed cell type (50.4/49.6), essentially a perfect split. This is expected for the most abundant population.
  • CD4 T and NK show the largest deviation (~43/57), with more cells in the stimulated condition. This could reflect a real biological shift – IFN-beta stimulation may promote T cell and NK cell survival or proliferation in culture – rather than a failure of integration.
  • Rare populations (Mk, Eryth, DC) show reasonable balance despite small cell numbers, where stochastic variation has more impact.

The key diagnostic: if integration had failed, you would see a cell type that is 90%+ from one condition, or you would see the same cell type forming separate clusters in the two panels. Neither is the case here. The moderate CD4 T skew (43/57) is well within the range of expected biological variation between a control and stimulated sample.

Challenge

Challenge 2: Interferon response genes in monocytes

Find the top 10 differentially expressed genes between stimulated and control CD14+ monocytes (we computed this above as mono.de). Are these genes consistent with an interferon stimulation response?

R

# Top 10 upregulated genes in stimulated vs control CD14+ monocytes
top10_de <- head(mono.de[order(mono.de$avg_log2FC, decreasing = TRUE), ], 10)
print(top10_de)

# Visualize the top 4 genes
FeaturePlot(ifnb,
            features = rownames(top10_de)[1:4],
            split.by = "stim",
            ncol = 4,
            cols = c("grey85", "firebrick"))
FeaturePlot of CCL8, CXCL11, CXCL10, and HESX1 split by condition showing absent expression in control and strong monocyte-concentrated expression in stimulated cells.
Four-row FeaturePlot split by CTRL and STIM showing CCL8, CXCL11, CXCL10, and HESX1. CTRL panels are almost entirely grey. STIM panels show strong red expression concentrated in the monocyte clusters for all four chemokine and ISG genes.
                 p_val avg_log2FC pct.1 pct.2     p_val_adj
CCL8      0.000000e+00   9.063    0.800 0.015  0.000000e+00
CXCL11    0.000000e+00   8.522    0.800 0.011  0.000000e+00
CXCL10    0.000000e+00   7.975    0.873 0.032  0.000000e+00
HESX1    1.756212e-217   7.935    0.335 0.002 2.468004e-213
IFIT1     0.000000e+00   7.118    0.965 0.034  0.000000e+00
IFIT2     0.000000e+00   6.894    0.909 0.040  0.000000e+00
IFIT3     0.000000e+00   6.737    0.971 0.057  0.000000e+00
GMPR     1.772282e-239   6.634    0.371 0.005 2.490588e-235
RSAD2     0.000000e+00   6.620    0.920 0.042  0.000000e+00
APOBEC3B 1.261976e-127   6.600    0.273 0.034 1.773454e-123

The top 10 genes fall into two functional categories, both consistent with IFN-beta stimulation:

Gene log2FC pct.1 / pct.2 Function
CCL8 9.06 0.80 / 0.02 Chemokine; recruits monocytes and T cells to sites of inflammation
CXCL11 8.52 0.80 / 0.01 IFN-inducible chemokine; attracts activated T cells via CXCR3
CXCL10 7.98 0.87 / 0.03 IFN-inducible chemokine (IP-10); major recruiter of immune cells
HESX1 7.93 0.34 / 0.00 Transcription factor; emerging role in IFN signaling
IFIT1 7.12 0.97 / 0.03 Directly induced by type I IFN; inhibits viral translation
IFIT2 6.89 0.91 / 0.04 Antiviral effector; regulates translation during infection
IFIT3 6.74 0.97 / 0.06 Antiviral effector; forms complex with IFIT1 and IFIT2
GMPR 6.63 0.37 / 0.01 Guanosine monophosphate reductase; modulates purine metabolism
RSAD2 6.62 0.92 / 0.04 Viperin; broad-spectrum antiviral enzyme induced by IFN
APOBEC3B 6.60 0.27 / 0.03 Cytidine deaminase; innate antiviral defense via RNA editing

Two patterns stand out:

Chemokines dominate the top 3. CCL8, CXCL11, and CXCL10 have the highest fold changes (7.9-9.1), reflecting the monocyte’s primary role as a sentinel cell: upon IFN stimulation, monocytes broadcast chemokine signals to recruit other immune cells. Note the pct.2 values (0.01-0.03) – these chemokines are essentially absent in control cells and massively induced by stimulation.

Classical ISGs fill the rest. IFIT1/2/3, RSAD2, and APOBEC3B are canonical interferon-stimulated genes that directly inhibit viral replication. The IFIT family shows the highest pct.1 values (0.91-0.97), meaning nearly every stimulated monocyte expresses them – a hallmark of a robust, uniform interferon response.

The FeaturePlot confirms this visually: CCL8, CXCL11, CXCL10, and HESX1 are virtually absent in the CTRL panels (gray) and strongly expressed in the STIM panels (red), with the signal concentrated in the monocyte clusters (right side of the UMAP). CXCL10 and CXCL11 also show some expression in the stimulated CD4 T and NK clusters, consistent with these cell types also responding to IFN-beta, though less strongly than monocytes.

Key Points
  • Batch effects cause cells to cluster by sample rather than by cell type, preventing cross-condition comparisons
  • CCA integration finds shared correlation structure between conditions and aligns matching cell types using anchor pairs
  • In Seurat v5, multi-sample data is handled by splitting assay layers, integrating, then rejoining layers for downstream analysis
  • Always verify integration by checking that conditions mix within clusters AND that known biological differences (like ISG expression) are preserved
  • Integration is not always appropriate; skip it when conditions have no shared cell types or when batch effects are minimal

Content from Differential Expression Analysis


Last updated on 2026-09-08 | Edit this page

Estimated time: 60 minutes

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)

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 nine 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.118    0.965 0.034         0
IFIT3       0   6.737    0.971 0.057         0
TNFSF10     0   6.322    0.959 0.062         0
RSAD2       0   6.620    0.920 0.042         0
IFIT2       0   6.894    0.909 0.040         0
MX1         0   4.824    0.953 0.099         0
CXCL10      0   7.975    0.873 0.032         0
LY6E        0   4.255    0.990 0.174         0
CXCL11      0   8.522    0.800 0.011         0
CCL8        0   9.063    0.800 0.015         0


Significant genes (padj < 0.05): 765 

You should see approximately 765 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.

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 54 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 9 cell types, 2 conditions, and 3 pseudo-replicates, you get up to 54 pseudobulk samples (some may be fewer if a cell type has very few 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.441088     1     1         0
IFI6       0   3.705585     1     1         0
IFI44L     0   3.374846     1     1         0
GBP1       0   2.535916     1     1         0
S100A8     0  -2.276587     1     1         0
MNDA       0   2.661545     1     1         0
RSAD2      0   5.839812     1     1         0
TMSB10     0   1.655318     1     1         0
VAMP5      0   2.977336     1     1         0
SSB        0   2.470712     1     1         0


Significant genes (p_val_adj < 0.05): 1455 

The pseudobulk analysis identifies 1455 significant genes – more than the Wilcoxon test (765). 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, CXCL10, and MX1 dramatically upregulated in stimulated cells across all cell types, with control violins near zero and stimulated violins broad and tall.
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.

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: 271

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

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                         Description GeneRatio
hsa04621 Organismal Systems             Immune system hsa04621 NOD-like receptor signaling pathway    22/166
hsa05168     Human Diseases Infectious disease: viral hsa05168    Herpes simplex virus 1 infection    17/166
hsa05169     Human Diseases Infectious disease: viral hsa05169        Epstein-Barr virus infection    18/166
hsa05164     Human Diseases Infectious disease: viral hsa05164                         Influenza A    16/166
hsa04612 Organismal Systems             Immune system hsa04612 Antigen processing and presentation    11/166
          BgRatio RichFactor FoldEnrichment    zScore       pvalue     p.adjust       qvalue
hsa04621 187/9382 0.11764706       6.649185 10.472204 1.358486e-12 3.124518e-10 2.702672e-10
hsa05168 180/9382 0.09444444       5.337818  7.886310 1.677668e-08 1.503560e-06 1.300562e-06
hsa05169 205/9382 0.08780488       4.962562  7.698570 1.961165e-08 1.503560e-06 1.300562e-06
hsa05164 173/9382 0.09248555       5.227105  7.531253 6.092210e-08 3.503021e-06 3.030073e-06
hsa04612  82/9382 0.13414634       7.581693  8.033604 1.806443e-07 8.309640e-06 7.187743e-06
                                                                                                                        geneID
hsa04621 4938/3665/115361/2633/4939/4940/6347/3428/115362/10616/7295/3320/6772/837/2635/79792/114769/3326/2634/834/10010/10628
hsa05168                                 4938/3665/5610/64135/684/4939/4940/6347/3133/6890/6772/3107/8717/6672/29992/6892/3134
hsa05169                              3627/4938/9636/3665/5610/4939/4940/3133/6890/6772/3107/4067/958/8717/9541/4616/6892/3134
hsa05164                                       8743/91543/4599/3627/4938/4600/3665/5610/64135/4939/4940/6347/6772/8717/103/834
hsa04612                                                                3303/3133/5721/6890/3320/3107/1520/3326/1514/6892/3134
         Count
hsa04621    22
hsa05168    17
hsa05169    18
hsa05164    16
hsa04612    11

Top KEGG pathways should include entries like “Influenza A”, “Hepatitis C”, “RIG-I-like receptor signaling pathway”, and “NOD-like receptor signaling pathway” – all pathways involving the interferon/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_annotated.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:

R

# Export raw count matrix (warning: can be large for big datasets)
counts_matrix <- GetAssayData(pbmc, layer = "counts")
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)

For smaller datasets, you can export as a dense CSV:

R

write.csv(as.matrix(GetAssayData(pbmc, layer = "counts")),
          file = "counts_dense.csv")

Format conversion for Python users

If collaborators use the Python/Scanpy ecosystem, you can convert your Seurat object to AnnData format using the sceasy package:

R

# Install sceasy if needed: remotes::install_github("cellgeni/sceasy")
library(sceasy)
sceasy::convertFormat(pbmc,
                      from = "seurat",
                      to = "anndata",
                      outFile = "pbmc_annotated.h5ad")

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 765 significant genes
  • Pseudobulk identifies 1455 (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 viral process Genes involved in the viral life cycle, many of which are ISGs that restrict it
4 viral life cycle Overlap with viral process; reflects IFIT, OAS, and MX family genes
5 regulation of viral process Regulatory genes that modulate antiviral defense (e.g., TRIM, IRF family)

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