Single Cell RNAseq
Workshop: ~90 minutes hands-on · This page stays open for independent study and office-hours follow-up.
Learning Objectives
By the end of this workshop you will be able to:
- Understand single-cell sequencing technology and its underlying fundamentals
- Interpret a single-cell expression matrix and understand its file structure
- Build a Seurat object from barcode, feature, and matrix files
QC / Pre-processing
- Perform quality control on a Seurat object: score mitochondrial content and choose your own thresholds to filter low-count cells and flag potential doublets
- Evaluate the effect of your QC choices by comparing metrics before and after filtering
- Log-normalize and scale expression data in preparation for dimensionality reduction
Post-processing
- Reduce dimensionality with PCA, choose an appropriate number of principal components from an elbow plot, and construct a k-nearest neighbor (KNN) graph
- Cluster cells with the Louvain algorithm and visualize results with UMAP/t-SNE
Cell Type Annotation
- Research marker genes for an assigned cluster using primary literature and databases, and propose a cell type identity with supporting evidence
Single-cell RNA sequencing (scRNA-seq) resolves gene expression at the level of individual cells, revealing the diversity of cell types and states within a tissue that bulk RNA-seq would average together. It has become a foundational tool for mapping cell atlases, characterizing tumor microenvironments, and studying development.
In this workshop you will work with a processed single-cell dataset, learn how the cell × gene matrix differs from bulk data, perform quality control and normalization, cluster cells into groups, and identify cell types from marker genes, all visualized through UMAP embeddings.
How Single-Cell Sequencing Works
Before any analysis begins, individual cells have to be physically isolated and tagged so that every read can be traced back to the cell it came from. The 10x Genomics Chromium platform (the source of this workshop's dataset) does this with droplet microfluidics.
Each cell is paired with a single gel bead carrying millions of copies of a DNA oligo. Every oligo on a given bead shares the same 10x cell barcode, but has a different random UMI (Unique Molecular Identifier) and a poly(dT) tail that captures mRNA:
A microfluidic chip then merges one cell, one gel bead, and oil at a T-junction, encapsulating them together in a nanoliter droplet called a GEM (Gel bead-in-EMulsion):
The clip below is real microscopy footage of that junction: each droplet pinching off is one GEM, and (ideally) one cell, forming in real time:
Downstream, Cell Ranger uses the shared barcode to group reads by cell of origin and the UMI to collapse PCR duplicates into a single molecule count, which is exactly what becomes the rows and columns of the count matrix in Part 1.
Prerequisites
- Completion of the Introduction to Data Science series, or equivalent R experience
- Recommended: Differential Gene Expression (Bulk RNAseq)
Launch Your Workspace
You need a free GitHub account to use this workshop
This workshop runs in GitHub Codespaces, a cloud environment that requires a GitHub account. If you do not have one, create one for free at github.com before the session. No paid plan is required.
GitHub Free quota (per account, per month):
- 120 core-hours of compute, equivalent to 60 hours of run time on a standard 2-core Codespace
- 15 GB of storage
This is enough for workshops and occasional use, but is not intended to replace a local development environment for everyday work.
This workshop runs entirely in a cloud environment: no software installation required. One click opens a pre-configured RStudio session with Seurat and all required packages installed.
First launch takes several minutes
The first time the Codespace builds, GitHub installs Seurat and its dependencies. Subsequent launches are instant.
What you'll see:
- GitHub opens a VS Code editor in your browser, this is the Codespace container. You do not need to use VS Code for this workshop.
- A browser tab for RStudio will open automatically at port 8787. No login is required.
- If RStudio does not open automatically: in VS Code, click the Ports tab at the bottom panel, find port
8787, and click the globe icon to open it in a new tab.
Keep your Codespace awake, and stop it when you're done
Codespaces automatically pause after 30 minutes of inactivity, but suspended Codespaces still consume your monthly storage quota. Closing the browser tab does not stop the Codespace.
At the start of the workshop, open a new terminal tab in VS Code (Terminal → New Terminal) and run this keepalive loop:
while true; do echo "keepalive $(date)"; sleep 300; done
This pings the Codespace every 5 minutes to prevent it from suspending during the session.
How to stop your Codespace when you're done
- Switch to the VS Code terminal tab where the
keepaliveloop is running. - Press Ctrl+C to stop it.
- Go to github.com/codespaces, find your Codespace, click
···, and select Stop codespace.
Closing the browser tab is not enough: a suspended Codespace still counts against your monthly storage quota.
Getting oriented in RStudio:
Once RStudio is open, get the workshop files ready:
- In the Files pane (bottom-right), you will see the workshop directory. Click
scRNAseq.Rprojto open the project, this sets your working directory correctly so data paths in the script will resolve. - Open
workshop.Rmd(File → Open File, or click it in the Files pane). This is the file you will work through during the workshop. - Each grey block is a code chunk. Run a chunk by clicking the ▶ Run Current Chunk button (green play icon at the top-right of the chunk), or press Ctrl+Shift+Enter (Windows/Linux) / Cmd+Shift+Return (Mac).
Save your work
Press Ctrl+S / Cmd+S often. At the end of the session, use the Files pane → More → Export to download your completed notebook to your computer before closing the Codespace.
About This Dataset
10x Genomics PBMC 3k: Seurat Guided Clustering Tutorial
Source: 10x Genomics, Peripheral Blood Mononuclear Cells (PBMC), 2,700 single cells sequenced on the Illumina NextSeq 500
Data download: pbmc3k_filtered_gene_bc_matrices.tar.gz
Template: This workshop follows the structure of the Seurat Guided Clustering Tutorial (pbmc3k), developed and maintained by the Satija Lab and collaborators.
This dataset and analysis walkthrough is one of the most widely used entry points to single-cell analysis. The workshop exercises mirror its structure (quality control and filtering, log-normalization, variable feature selection, scaling, PCA, graph-based (Louvain) clustering, UMAP visualization), but the QC thresholds, PC cutoff, and final cell type annotation are yours to determine, not given to you.
Citing Seurat: if you use Seurat in your own research, cite the paper corresponding to the version you use:
- Hao, Y. et al. "Dictionary learning for integrative, multimodal and scalable single-cell analysis." Nature Biotechnology (2023), Seurat v5
- Hao*, Hao*, et al. "Integrated analysis of multimodal single-cell data." Cell (2021), Seurat v4
- Stuart*, Butler*, et al. "Comprehensive Integration of Single-Cell Data." Cell (2019), Seurat v3
Acknowledgement
Dataset courtesy of 10x Genomics. Tutorial structure and analysis workflow adapted from the Seurat project (satijalab.org/seurat), developed by the Satija Lab and collaborators.
Part 1: QC & Pre-processing
Load packages
Open workshop.Rmd and run the Setup chunk. This loads all packages and confirms the working directory is set correctly by scRNAseq.Rproj.
library(Seurat)
library(patchwork)
library(dplyr)
library(ggplot2)
getwd() # should end in .../docs/scRNAseq
What is a single-cell expression matrix?
Instead of one column per sample (as in bulk RNA-seq), a single-cell count matrix has one column per cell and one row per gene, thousands of columns instead of a handful. Each value is the number of UMIs (unique molecular identifiers) for that gene detected in that cell. The vast majority of entries are zero, since any one cell only expresses a fraction of the genome, so Seurat stores the matrix in sparse format to save memory.
The Read10X() function expects a directory containing three files, the standard output of the Cell Ranger pipeline:
| File | Contents |
|---|---|
barcodes.tsv.gz |
One cell barcode per line (the column names of the matrix) |
features.tsv.gz |
One gene per line (ID + symbol), the row names of the matrix |
matrix.mtx.gz |
The sparse count matrix itself, in MatrixMarket format |
Exercise 1.1: Load the data and build a Seurat object
Run the Exercise 1 chunk in workshop.Rmd:
pbmc.data <- Read10X(data.dir = "data/pbmc3k/filtered_gene_bc_matrices/hg19/")
pbmc <- CreateSeuratObject(counts = pbmc.data, project = "pbmc3k", min.cells = 3, min.features = 200)
pbmc
min.cells = 3 drops genes detected in fewer than 3 cells; min.features = 200 drops cells with fewer than 200 detected genes, a first, coarse pass at removing empty droplets and background noise.
Questions:
- How many cells (barcodes) came off the sequencer, and how many genes survive the
min.cells/min.featuresfilter? - Why filter genes and cells before you've even looked at the data?
Solution
pbmc.data <- Read10X(data.dir = "data/pbmc3k/filtered_gene_bc_matrices/hg19/")
pbmc <- CreateSeuratObject(counts = pbmc.data, project = "pbmc3k", min.cells = 3, min.features = 200)
pbmc
# An object of class Seurat
# 13714 features across 2700 samples within 1 assay
# Active assay: RNA (13714 features, 0 variable features)
The raw matrix has 32,738 genes × 2,700 cells. After the min.cells/min.features filter, 13,714 genes remain (genes never detected in at least 3 cells carry no information); all 2,700 cells are still present at this stage. min.features = 200 is a very permissive floor, mainly there to drop obviously empty droplets before real QC begins.
QC and selecting cells for further analysis
Three metrics distinguish a real cell from an empty droplet, background noise, or a doublet:
nFeature_RNA: the number of unique genes detected in a cell. Too low → empty droplet or a dying cell; abnormally high → possibly two cells captured in one droplet (a doublet).nCount_RNA: total UMIs detected in a cell (correlates withnFeature_RNA).percent.mt: the percentage of a cell's reads that map to mitochondrial genes. Dying or lysed cells leak cytoplasmic RNA but retain mitochondrial RNA, so a highpercent.mtflags low-quality cells.
PercentageFeatureSet() computes the mitochondrial percentage by summing counts for every gene matching a pattern: human mitochondrial genes are conventionally prefixed MT-.
Exercise 1.2: Score mitochondrial content and visualize QC metrics
Run the Exercise 2 chunk in workshop.Rmd:
pbmc[["percent.mt"]] <- PercentageFeatureSet(pbmc, pattern = "^MT-")
VlnPlot(pbmc, features = c("nFeature_RNA", "nCount_RNA", "percent.mt"), ncol = 3)
plot1 <- FeatureScatter(pbmc, feature1 = "nCount_RNA", feature2 = "percent.mt")
plot2 <- FeatureScatter(pbmc, feature1 = "nCount_RNA", feature2 = "nFeature_RNA")
plot1 + plot2
Questions:
- What is the median
percent.mtacross cells? Are there outlier cells with very high mitochondrial content? - In the
nCount_RNAvsnFeature_RNAscatter plot, what would a cell sitting well above the main trend line (high count, disproportionately high feature number) suggest? - Look closely at these plots, you'll use them to make real decisions in the next exercise. Where do the bulk of cells sit? Where do the tails start?
Solution
pbmc[["percent.mt"]] <- PercentageFeatureSet(pbmc, pattern = "^MT-")
summary(pbmc$percent.mt)
# Min. 1st Qu. Median Mean 3rd Qu. Max.
# 0.000 1.537 2.031 2.217 2.643 22.569
Median percent.mt is ~2%, typical for healthy PBMCs; most cells sit in a tight, low band. A handful of cells reach up into the teens and twenties, that's the tail you'll need to decide what to do with next.
A cell far above the main nCount_RNA vs nFeature_RNA trend (more total UMIs than its gene count would predict) is one of the signals used to flag potential doublets: two cells' worth of transcripts captured under one barcode.
Filtering low-quality cells and doublets
There is no universal cutoff for nFeature_RNA or percent.mt. Every dataset's distribution is different, and the "right" thresholds are a judgment call you make by looking at the plots from Exercise 1.2, not a number you memorize. In general you're looking for:
- A lower bound on
nFeature_RNAthat excludes the low-complexity tail (empty droplets, dying cells) without cutting into the main population - An upper bound on
nFeature_RNAnear the top of the distribution, as a proxy for doublets: a cell registering an unusually large number of unique genes is more likely to be two cells captured under one barcode than one unusually complex cell - An upper bound on
percent.mtthat excludes the high-mitochondrial tail (dying/lysed cells) without discarding legitimately active cells
Setting thresholds in practice
Dedicated doublet-detection tools (e.g. scDblFinder, DoubletFinder) formalize the upper nFeature_RNA bound further, but for this workshop you'll set all three thresholds by eye. Be ready to justify your choice: what would happen to your dataset if you moved the percent.mt cutoff from 5% to 20%? From 5% to 2%?
Exercise 1.3: Choose your own QC thresholds and filter
Run the Exercise 3 chunk in workshop.Rmd, but first replace the placeholder values with your own, chosen from the Exercise 1.2 plots:
min_features <- ___ # lower bound on nFeature_RNA
max_features <- ___ # upper bound on nFeature_RNA (your doublet proxy)
max_percent_mt <- ___ # upper bound on percent.mt
pbmc <- subset(
pbmc,
subset = nFeature_RNA > min_features & nFeature_RNA < max_features & percent.mt < max_percent_mt
)
pbmc
Questions:
- What values did you choose, and why? Point to a specific feature of the distribution (a gap, a tail, a knee in the curve) that justifies each one.
- How many cells did your thresholds remove?
- Compare with a classmate who chose different values. How different were your final cell counts? Whose thresholds were more permissive?
- Which of your three criteria removed the most cells?
There's no answer key, but a sanity check
Most published PBMC analyses land somewhere in the neighborhood of nFeature_RNA between roughly 200 and 2,500, with percent.mt below 5-10%, but the right values always depend on the shape of your distributions, not a memorized number. If your thresholds remove more than ~20-30% of cells, revisit the plots; you may be cutting into the healthy population instead of trimming a tail.
Seeing what filtering actually changed
Filtering doesn't just shrink a table, it changes the shape of your data. Re-running the same QC plots on the filtered object makes that concrete, and lets you check your own work.
Exercise 1.4: Re-visualize QC metrics after filtering
Run the Exercise 4 chunk in workshop.Rmd: the same plotting calls as Exercise 1.2, now on your filtered pbmc object.
VlnPlot(pbmc, features = c("nFeature_RNA", "nCount_RNA", "percent.mt"), ncol = 3)
plot1 <- FeatureScatter(pbmc, feature1 = "nCount_RNA", feature2 = "percent.mt")
plot2 <- FeatureScatter(pbmc, feature1 = "nCount_RNA", feature2 = "nFeature_RNA")
plot1 + plot2
Questions:
- Scroll up and compare these plots side-by-side with your Exercise 1.2 plots. What changed, and what stayed the same?
- Did the tails get clipped, or did the whole distribution shift? What would it mean if the entire distribution had shifted rather than just the extremes?
- If a classmate used looser thresholds than you, would you expect their post-filtering violin plots to look different from yours? How?
What to look for
The clearest change is usually the top of the nFeature_RNA and percent.mt violins getting clipped flat right at your chosen cutoffs, the long tail is gone. The bulk of the distribution, where most cells sit, should look nearly identical to before, since your thresholds were chosen specifically to avoid touching it. If the whole distribution moved rather than just the tails, that's a sign your cutoffs were more aggressive than intended.
Normalizing the data
Just as with bulk RNA-seq, raw UMI counts aren't directly comparable across cells: different cells capture different total numbers of transcripts due to technical variation in droplet capture efficiency, not biology. Seurat's default LogNormalize method:
- Divides each gene's count in a cell by that cell's total counts (library-size normalization)
- Multiplies by a scale factor (
10,000by default) - Log1p-transforms the result
pbmc <- NormalizeData(pbmc, normalization.method = "LogNormalize", scale.factor = 1e4)
Identifying highly variable features
Most genes are either uninformative "housekeeping" genes (similar expression everywhere) or too lowly expressed to be useful. FindVariableFeatures() models the mean–variance relationship across all genes and returns the subset that varies the most beyond what expression level alone would predict, by default the top 2,000. Downstream steps (scaling, PCA) focus on these features so that biological signal isn't drowned out by uninformative genes.
Exercise 1.5: Normalize and find variable features
Run the Exercise 5 chunk in workshop.Rmd:
pbmc <- NormalizeData(pbmc, normalization.method = "LogNormalize", scale.factor = 1e4)
pbmc <- FindVariableFeatures(pbmc, selection.method = "vst", nfeatures = 2000)
top10 <- head(VariableFeatures(pbmc), 10)
plot1 <- VariableFeaturePlot(pbmc)
LabelPoints(plot = plot1, points = top10, repel = TRUE)
Questions:
- What are the top 10 most variable genes? Do any look biologically familiar (hint: platelets, granulocytes)?
- On the variable feature plot, where do the top genes sit relative to the mean-expression trend line?
Solution
top10 <- head(VariableFeatures(pbmc), 10)
top10
# [1] "PPBP" "LYZ" "S100A9" "IGLL5" "GNLY" "FTL" "PF4" "FTH1"
# [9] "GNG11" "S100A8"
(Your exact list may differ slightly depending on which cells passed your QC filter in Exercise 1.3.) PPBP and PF4 are platelet markers, LYZ/S100A9/S100A8 are myeloid/monocyte markers, and GNLY marks NK/cytotoxic cells; this list is already hinting at the distinct cell populations we'll recover with clustering later. Highly variable genes sit above the mean-variance trend line: for their average expression level, they show more cell-to-cell variance than expected by chance.
Scaling the data
ScaleData() linearly transforms each gene so that, across cells, it has mean 0 and variance 1 (a z-score). This is standard practice before PCA: without it, highly-expressed genes would dominate the principal components purely because of their expression magnitude rather than their biological signal.
all.genes <- rownames(pbmc)
pbmc <- ScaleData(pbmc, features = all.genes)
Exercise 1.6: Scale the data
Run the Exercise 6 chunk in workshop.Rmd:
all.genes <- rownames(pbmc)
pbmc <- ScaleData(pbmc, features = all.genes)
Question: By default, ScaleData() only needs to be run on variable features for PCA to work; why scale all.genes here instead?
Solution
Scaling all genes (rather than just the 2,000 variable features) means the resulting scale.data slot can also be used later for visualizations like DoHeatmap() across any gene of interest, not just the ones used for PCA. It costs more compute but keeps the object flexible for downstream exploration.
Removing unwanted variation
ScaleData(pbmc, vars.to.regress = "percent.mt") can regress out a covariate like mitochondrial content directly during scaling, useful when a technical factor is confounding biological signal you care about.
Part 2: Post-processing
Perform linear dimensional reduction (PCA)
With scaled data in hand, RunPCA() computes principal components using the variable features. Each PC is a linear combination of genes (a "metafeature" summarizing a correlated expression pattern across cells), and the first several PCs typically capture the dominant axes of biological variation (here, differences between immune cell types).
pbmc <- RunPCA(pbmc, features = VariableFeatures(object = pbmc))
Exercise 2.1: Run and visualize PCA
Run the Exercise 7 chunk in workshop.Rmd:
pbmc <- RunPCA(pbmc, features = VariableFeatures(object = pbmc))
print(pbmc[["pca"]], dims = 1:5, nfeatures = 5)
VizDimLoadings(pbmc, dims = 1:2, reduction = "pca")
DimPlot(pbmc, reduction = "pca") + NoLegend()
Question: Looking at the top genes loading PC1, can you guess which cell populations it's separating?
Solution
PC1's top positive/negative loadings are dominated by myeloid genes (LYZ, CST3, S100A8/9) on one end and lymphocyte genes (CD3-family, IL7R) on the other: PC1 is primarily separating myeloid cells (monocytes/DCs) from lymphocytes (T/B/NK cells), the single largest axis of variation in PBMCs.
Determining the dimensionality of the dataset
Not every PC captures real signal; later PCs are increasingly dominated by noise. An elbow plot ranks PCs by the percentage of variance they explain; the "elbow" (where the curve flattens) is a heuristic for how many PCs carry real signal. Exactly where that elbow is, and how many PCs you decide to carry forward, is your call.
Exercise 2.2: Choose your number of PCs
Run the Exercise 8 chunk in workshop.Rmd:
ElbowPlot(pbmc, ndims = 30)
n_pcs <- ___ # pick your own cutoff based on the elbow plot above
Store your choice in n_pcs. Every remaining step in this workshop (FindNeighbors(), RunUMAP()) uses dims = 1:n_pcs, so this single choice propagates through the rest of your analysis, don't change it later without re-running everything downstream.
Questions:
- Where does the curve flatten out for you? Is there one obvious elbow, or a plausible range?
- The Seurat authors note that erring on the higher side rarely hurts (the difference between 10, 15, or 20 PCs is usually small), but using only 5 does noticeably degrade results. Does your choice reflect that advice?
- If you're torn between two values, which do you pick, and why?
No fixed answer, but a reference point
Using close-to-default QC thresholds, a common choice for this dataset lands around PC 9-10, but your own elbow plot will shift slightly depending on the QC cutoffs you chose in Part 1. Pick a number, write down why, and commit to it for the rest of the workshop.
Cluster the cells
Seurat clusters cells using a graph-based approach rather than a classic method like k-means:
FindNeighbors()builds a k-nearest neighbor (KNN) graph in PCA space, connecting each cell to its most similar neighbors by Euclidean distance on the chosen PCs, then refines edge weights by the shared overlap between neighborhoods (Jaccard similarity), producing a shared nearest neighbor (SNN) graph.FindClusters()partitions that graph into communities using the Louvain algorithm (the default modularity-optimization method), which iteratively groups cells to maximize the density of connections within clusters relative to between them.
The resolution parameter controls granularity, higher values produce more, smaller clusters. 0.4–1.2 is the typical useful range for a dataset of a few thousand cells.
Exercise 2.3: Build the KNN graph and cluster with Louvain
Run the Exercise 9 chunk in workshop.Rmd, using the n_pcs you chose in Exercise 2.2:
pbmc <- FindNeighbors(pbmc, dims = 1:n_pcs)
pbmc <- FindClusters(pbmc, resolution = 0.5)
head(Idents(pbmc), 5)
table(Idents(pbmc))
Questions:
- How many clusters do you get, and how many cells fall in the smallest one?
- What happens if you try
resolution = 0.1orresolution = 1.5? (Try it and re-runtable(Idents(pbmc)).) - Compare your cluster count with a classmate who chose a different
n_pcsor different QC thresholds. Who has more clusters? Any guesses why?
One reference run (10 PCs, QC thresholds 200/2500/5%, resolution 0.5)
pbmc <- FindNeighbors(pbmc, dims = 1:10)
pbmc <- FindClusters(pbmc, resolution = 0.5)
table(Idents(pbmc))
# 0 1 2 3 4 5 6 7 8
# 684 481 476 344 291 162 155 32 13
This particular combination of choices produces 9 clusters, ranging from 684 cells down to a rare population of just 13. Your own numbers will differ: lowering resolution merges related clusters together (fewer, larger groups); raising it splits them further (more, smaller groups); different n_pcs and QC thresholds shift things further still. There is no single "correct" answer, only one appropriate to how finely you want to distinguish cell states and how you chose to filter your data.
Run non-linear dimensional reduction (UMAP)
PCA and clustering happen in high-dimensional space (your chosen n_pcs); UMAP compresses that structure into 2D for visualization, aiming to keep cells that are close together in the KNN graph close together on the plot. It's a visualization tool, not a source of biological truth on its own; conclusions should rest on the clustering and marker analysis, not the UMAP layout alone.
pbmc <- RunUMAP(pbmc, dims = 1:n_pcs)
Exercise 2.4: Run UMAP
Run the Exercise 10 chunk in workshop.Rmd:
pbmc <- RunUMAP(pbmc, dims = 1:n_pcs)
DimPlot(pbmc, reduction = "umap")
Question: Do the clusters from FindClusters() form visually distinct islands on the UMAP plot, or do some blend together?
What to look for
The large lymphocyte clusters (naive/memory CD4+ T, CD8+ T) tend to sit close together with soft boundaries (reflecting real biological similarity between T cell subsets), while monocyte, B, NK, and platelet clusters typically form clearly separated islands. Overlap on the UMAP between two clusters doesn't mean the clustering was wrong; it reflects a continuum of similar transcriptional states.
Part 3: Cluster Marker Research Assignment
Clusters are just numbers until you connect them to biology, and unlike the earlier steps in this workshop, there's no shortcut here: real annotation requires reading about real genes. Because everyone chose their own QC thresholds and n_pcs, your clusters are not the same as your classmates': this assignment is based on your own results, not a shared answer key.
Find all cluster marker genes
FindAllMarkers() runs differential expression for every cluster against all other cells and reports the genes that best distinguish each one.
pbmc.markers <- FindAllMarkers(pbmc, only.pos = TRUE)
pbmc.markers %>%
group_by(cluster) %>%
dplyr::filter(avg_log2FC > 1) %>%
slice_head(n = 10) %>%
ungroup()
Exercise 3.1: Annotate your assigned cluster
Your instructor will assign you one cluster number from your own results.
-
Pull the top marker genes for your assigned cluster specifically. Run the Exercise 12 chunk in
workshop.Rmd:my_cluster <- ___ # the cluster number your instructor assigned you FindMarkers(pbmc, ident.1 = my_cluster) %>% filter(pct.1 > 0.25) %>% # expressed in a meaningful fraction of the cluster, not just a couple of cells arrange(desc(avg_log2FC)) %>% head(15)Watch out for noise genes
If you sort by
avg_log2FCwithout filtering onpct.1(the fraction of cells in your cluster expressing the gene) first, you'll often get genes detected in only 1-2% of cells with huge fold changes, statistically "significant" but not real markers of anything. A gene expressed in <10% of your cluster isn't a useful marker, no matter how large its fold change. -
Pick the 3-5 genes with the highest
avg_log2FCand lowestp_val_adj. Research each one using at least one of:- GeneCards
- The Human Protein Atlas
- PubMed (search the gene symbol plus "PBMC" or "immune cell")
- CellMarker 2.0
-
Sanity-check your candidate genes against your cluster visually:
my_genes <- c("GENE1", "GENE2", "GENE3") # substitute your chosen genes VlnPlot(pbmc, features = my_genes) FeaturePlot(pbmc, features = my_genes) -
Write a short paragraph (3-5 sentences) proposing a cell type identity for your cluster. Cite at least one source, and state your confidence: is this a well-known canonical marker, or a more ambiguous call?
Come prepared to present: your cluster number, your top marker genes, your proposed cell type, and the evidence that convinced you.
PBMCs contain a known, limited set of cell types
To narrow your search: peripheral blood mononuclear cells are overwhelmingly composed of T cells (multiple subsets), B cells, monocytes, NK cells, dendritic cells, and platelets. You don't need to consider rare or exotic cell types, but you do need to determine which of these your cluster is, and ideally which subset.
Finalizing the annotation as a class
Once everyone has presented, agree on a label for each cluster as a group and apply them together:
new.cluster.ids <- c("your", "class's", "agreed", "labels", "in", "cluster", "order") # extend/edit to match your clusters
names(new.cluster.ids) <- levels(pbmc)
pbmc <- RenameIdents(pbmc, new.cluster.ids)
DimPlot(pbmc, reduction = "umap", label = TRUE, pt.size = 0.5) + NoLegend()
Further Reading
- Seurat Guided Clustering Tutorial (pbmc3k): the source tutorial this workshop is adapted from
- Hao et al. 2023, Nature Biotechnology: the Seurat v5 paper
- Stuart* & Butler* et al. 2019, Cell: variable feature selection and anchor-based integration methodology
- Macosko et al. 2015, Cell: Drop-seq and the graph-based clustering approach Seurat builds on
- 10x Genomics PBMC 3k Dataset: original data source
Questions? Contact the CGDS Core: CGDS@mdibl.org