Single-cell RNA-seq from start to finish: beginner
PBMCs from 8 donors, control vs interferon-beta (Kang et al. 2018)
Author
Andrew Milner
This is one complete single-cell analysis on a laptop, from a count matrix to pathways. Every step says what we do and why we do it. Most Seurat tutorials cover one step each, so you have to stitch them together yourself. Here everything is in one place and in order.
The data. Blood cells (PBMCs) from 8 people. Each person’s cells were split in two: one half left alone (ctrl) and one half treated with interferon-beta (stim). That gives us 8 control samples and 8 treated samples, and every treated sample has a matching control from the same person. It was run on 10x Genomics Chromium.
The question. Which genes and pathways change in each cell type when cells are treated with interferon-beta?
The steps.
Load the data
Remove ambient RNA
Find doublets
Quality control
Normalize
Integrate
Cluster and UMAP
Find marker genes
Name the clusters
Differential expression between conditions (pseudobulk)
Pathway analysis
Save
Want to run it yourself? The setup steps cover installing R and RStudio, the packages, and running this file. No experience needed.
Code
library(Seurat)library(SingleCellExperiment)library(muscData) # the example datalibrary(celda) # decontX, for ambient RNAlibrary(scDblFinder) # doubletslibrary(scuttle) # QC helperlibrary(harmony) # integrationlibrary(DESeq2) # differential expressionlibrary(fgsea) # pathway analysislibrary(msigdbr) # gene setslibrary(ggplot2)library(dplyr)set.seed(42) # so you get the same results every timedir.create("results", showWarnings =FALSE)
1. Load the data
What: download the count matrix. Rows are genes, columns are cells, and each value is how many RNA molecules of that gene were counted in that cell.
Why: everything else starts from these raw counts. The first download takes a minute, and after that the file is cached.
A few genes appear more than once, for example CD79A_ENSG00000105369 and CD79A_ENSG00000272985. The extra copies come from alternative versions of some chromosome regions and have almost no counts. We keep the copy with the most counts for each gene and give it its plain name, so that names like CD79A work in plots and match the pathway gene sets later.
The table that comes with the cells tells us who each cell came from (ind), whether it was treated (stim), and what the original authors called it (cell). It also has a multiplets column, which we come back to in step 3.
Code
# how many cells per person and conditiontable(sce$ind, sce$stim)
In this experiment all control cells were run in one 10x lane and all treated cells in another. A lane is called a capture. Ambient RNA and doublets happen inside one capture, so steps 2 to 4 are done separately for each capture. Here the capture is the same as stim.
2. Remove ambient RNA
What: when cells are loaded, some float-around RNA from broken cells ends up in every droplet. This is called ambient RNA or soup. We estimate how much each cell has and subtract it.
Why: without this, genes from very common cells show up in every cell type. For example, monocyte genes can appear in T cells. This makes clusters look mixed and can create false differences later.
Which tool: if you have the raw_feature_bc_matrix from Cell Ranger (all droplets, including empty ones), SoupX is the usual choice. This dataset only has the cells, with no empty droplets, so we use decontX. It works out the contamination from the cells alone.
Code
sce <-decontX(sce, batch = sce$stim)# contamination per cell, from 0 (none) to 1 (all soup)summary(sce$decontX_contamination)
Min. 1st Qu. Median Mean 3rd Qu. Max.
0.0000771 0.0094743 0.0276354 0.0709858 0.0789133 0.8500350
Most cells have a small amount of contamination. We keep the corrected counts, rounded to whole numbers because later steps expect counts.
Code
corrected <-round(decontXcounts(sce))
3. Find doublets
What: sometimes two cells land in the same droplet. That droplet looks like one cell that expresses the genes of both, for example a T cell and a B cell at once. These are doublets.
Why: doublets can form fake clusters that look like a new cell type. We want to remove them before clustering.
How: scDblFinder makes fake doublets by adding random pairs of cells together, then learns what doublets look like and scores every real cell. It runs on each capture separately because doublets can only form inside one capture.
This dataset has a bonus. The authors mixed cells from 8 people in each lane and used each person’s DNA differences (with a tool called demuxlet) to find droplets containing two people. That is in the multiplets column. Let’s compare the two.
demuxlet
scDblFinder ambs doublet singlet
singlet 1150 1507 23102
doublet 67 1662 1577
demuxlet can only see doublets made of two different people, and scDblFinder can only see doublets made of two different cell types, so they catch different droplets. We remove a cell if either tool calls it a doublet. demuxlet’s ambs (ambiguous) cells are also removed, because we can’t tell which person they came from.
4. Quality control
What: remove cells that look broken or empty.
Why: a cell with very few genes is probably a broken cell or an empty droplet. A cell with a lot of mitochondrial RNA is often a dying cell that has lost most of its other RNA. These cells cluster together and look like a separate cell type when they are not.
First we put the data into Seurat, which we use for the rest of the analysis. We also keep the uncorrected counts as a second assay, so you can compare later.
An object of class Seurat
65876 features across 29065 samples within 2 assays
Active assay: RNA (32938 features, 0 variable features)
1 layer present: counts
1 other assay present: uncorrected
The three numbers we look at:
nCount_RNA: total RNA molecules in the cell
nFeature_RNA: number of different genes found in the cell
percent.mt: percent of RNA from mitochondrial genes
Code
VlnPlot(obj, features =c("nCount_RNA", "nFeature_RNA", "percent.mt"),group.by ="stim", pt.size =0, ncol =3)
How to pick cutoffs. Don’t copy numbers from another dataset. Instead, flag cells that are far from the middle of their own capture. “Far” here means more than 3 MADs (median absolute deviations, a robust version of the standard deviation). We use log values because counts are skewed.
For mitochondrial percent we only flag the high side. We also never set that cutoff below 10%, because in healthy blood cells mito is low and a pure MAD rule would throw out good cells.
An object of class Seurat
65876 features across 22914 samples within 2 assays
Active assay: RNA (32938 features, 0 variable features)
1 layer present: counts
1 other assay present: uncorrected
5. Normalize
What: make cells comparable. Some cells were sequenced more deeply than others, so their counts are all higher. Normalizing divides each cell by its total, multiplies by 10,000, and takes the log.
Why: without this, the biggest difference between cells would just be how deeply each one was sequenced.
We then pick the 2,000 most variable genes. These carry most of the information about cell types, and using only them makes the next steps faster and less noisy. Scaling puts every gene on the same range so that very highly expressed genes don’t dominate. PCA then squeezes those 2,000 genes into 30 summary dimensions.
The elbow plot shows how much each PCA dimension explains. Past about 20 to 30 the line flattens, so 30 is a safe choice.
Code
ElbowPlot(obj, ndims =50)
6. Integrate
What: line up the two captures so the same cell type from each sits together.
Why: interferon changes the expression of hundreds of genes in every cell. On top of that, the two conditions were run in different lanes. So before integration, a treated T cell and a control T cell end up far apart, and we can’t name cell types across both.
First look without integration. Always do this first. If cells already mix, you don’t need integration.
Each cell type splits into a control island and a treated island. We use Harmony, which nudges the PCA dimensions until the two groups mix within each cell type.
Important: Harmony only changes the embedding (the coordinates used for clustering and UMAP). The counts stay untouched. We never use integrated values to test genes.
7. Cluster and UMAP
What: group cells that have similar expression.
Why: clusters are the starting point for naming cell types.
FindNeighbors links each cell to the cells most like it. FindClusters cuts that network into groups. The resolution controls how many groups you get: higher means more, smaller clusters. 0.5 is a common start for PBMCs.
Modularity Optimizer version 1.3.0 by Ludo Waltman and Nees Jan van Eck
Number of nodes: 22914
Number of edges: 797751
Running Louvain algorithm...
Maximum modularity in 10 random starts: 0.9038
Number of communities: 13
Elapsed time: 2 seconds
What: for each cluster, find genes that are higher there than in all other clusters.
Why: marker genes tell you what each cluster is. For example, CD3E marks T cells and MS4A1 marks B cells.
We use the normalized RNA values from step 5 and the Wilcoxon test. The p-values here only describe the clusters. Each cell is not an independent sample, so don’t use them as real statistics.
Code
obj <-JoinLayers(obj)markers <-FindAllMarkers(obj, only.pos =TRUE, min.pct =0.25, logfc.threshold =0.5)write.csv(markers, "results/cluster_markers.csv", row.names =FALSE)# top 5 per clustertop5 <- markers %>%group_by(cluster) %>%slice_max(avg_log2FC, n =5)top5
Why: the rest of the analysis is done per cell type.
How: first make an automatic guess from known marker genes, then check it by eye.
We write down a short list of well-known marker genes for each blood cell type. AddModuleScore gives every cell a score for each list: how much higher those genes are in that cell than random genes with similar expression. Each cell gets the name of its highest-scoring list.
Code
cell_type_markers <-list("CD14+ Monocytes"=c("CD14", "LYZ", "S100A8", "S100A9"),"FCGR3A+ Monocytes"=c("FCGR3A", "MS4A7"),"CD4+ T cells"=c("CD3D", "IL7R"),"CD8+ T cells"=c("CD8A", "CD8B"),"NK cells"=c("GNLY", "NKG7", "KLRF1"),"B cells"=c("MS4A1", "CD79A"),"Dendritic cells"=c("FCER1A", "CD1C"),"pDC"=c("LILRA4", "CLEC4C"),"Megakaryocytes"=c("PPBP", "PF4"))obj <-AddModuleScore(obj, features = cell_type_markers, name ="score")# the scores are saved as score1, score2, ... in the same order as the listscores <- obj@meta.data[, paste0("score", 1:length(cell_type_markers))]colnames(scores) <-names(cell_type_markers)# each cell gets the name with the highest scoreobj$marker_guess <-colnames(scores)[apply(scores, 1, which.max)]
agreement is the share of cells in the cluster with that guess. Low agreement means the cluster is mixed or the marker lists don’t separate it well, so check the dot plot and the top markers from step 8.
An automatic guess is only as good as its marker lists. A cluster with markers that aren’t on any list, for example stressed cells with high heat-shock genes, still gets the closest name. That is why a person checks every label.
There are other ways to make the automatic guess. Reference-based tools such as SingleR or Azimuth compare your cells to a labelled dataset. They need extra packages and a reference download, and they make mistakes too.
Your turn. Open results/annotation_proposed.csv, check each label against the dot plot and markers, change the celltype column if needed, and save it as annotation_approved.csv next to this file. If that file exists, the notebook uses it.
What: for each cell type, find genes that go up or down with interferon.
Why pseudobulk: the replicates in this experiment are the 8 people, not the thousands of cells. Cells from the same person are more alike than cells from different people. If you test cell by cell, you pretend you have thousands of replicates and get far too many “significant” genes.
How: for each cell type, add up the raw counts of all cells from each sample. That gives one column per sample, like a small bulk RNA-seq experiment. Then run DESeq2, the standard bulk tool.
Because each person has both a control and a treated sample, the design is ~ ind + stim. This compares treated to control within each person, which removes differences between people.
Code
celltypes <-unique(obj$celltype)de_results <-list()for (ct in celltypes) { cells <-subset(obj, subset = celltype == ct)# only use samples with at least 10 cells of this type cell_counts <-table(cells$sample) keep_samples <-names(cell_counts)[cell_counts >=10]# need at least 2 samples in each condition, or there is nothing to compare n_ctrl <-sum(grepl("-ctrl$", keep_samples)) n_stim <-sum(grepl("-stim$", keep_samples))if (n_ctrl <2| n_stim <2) {cat(paste0("Skipping ", ct, ": only ", n_ctrl, " control and ", n_stim," treated samples have 10 or more cells.\n"))next } cells <-subset(cells, subset = sample %in% keep_samples)# add up counts per sample pb <-AggregateExpression(cells, assays ="RNA", group.by ="sample")$RNA pb <-round(as.matrix(pb))# sample table: which person and condition each column is info <-data.frame(sample =colnames(pb)) info$ind <-factor(cells$ind[match(info$sample, cells$sample)]) info$stim <-factor(cells$stim[match(info$sample, cells$sample)], levels =c("ctrl", "stim"))# drop genes with almost no counts pb <- pb[rowSums(pb >=10) >=2, ] dds <-DESeqDataSetFromMatrix(pb, colData = info, design =~ ind + stim) dds <-DESeq(dds, quiet =TRUE) res <-as.data.frame(results(dds, contrast =c("stim", "stim", "ctrl"))) res$gene <-rownames(res) res$celltype <- ct de_results[[ct]] <- res}
Skipping pDC: only 0 control and 0 treated samples have 10 or more cells.
What: find groups of genes (pathways) that move together.
Why: a list of hundreds of genes is hard to read. Pathways turn it into a few ideas, like “interferon response is up”.
How: GSEA (with the fgsea package). Rank every tested gene by the DESeq2 statistic, from most up to most down, and ask whether the genes of a pathway pile up at the top or bottom. It uses all genes, not just the significant ones, so it also works when few genes pass the cutoff.
Gene sets come from MSigDB: Hallmark (50 broad, well-curated sets) and Reactome (detailed pathways).
The advanced tutorial starts from raw sequencing files (FASTQ) on a computing cluster, runs Cell Ranger, and analyses single-nucleus data from human kidney. It uses SoupX with the raw droplet matrix and SCTransform normalization.