Intro to Single Cell RNA Sequencing
2026 Jul 23
See all posts
Intro to Single Cell RNA Sequencing
Single cell RNA sequencing, known as scRNA-seq, is the process of
determining the level of gene expression across individual cells. Before
it, we could only measure average gene expression across many cell types
in a sample, hiding cell-type-specific responses. In the mid-2010s,
special "droplet" machines made scRNA-seq practical by isolating
individual cells from a sample into droplets that contain chemical
machinery to tag both the cell and individual RNA molecules in the cell.
That is a big upgrade. For example, bulk sequencing had made clear in
the 2000s that autoimmune diseases like lupus create chronic "alarm"
markers in their white blood cells. scRNA-seq showed us exactly which
type of white blood cells were responsible for the expression, giving us
a much deeper understanding of the pathology.
Although these "droplet" machines are advanced, the raw data they
output still requires a significant amount of statistical analysis to
classify cells into cell types and build a picture of gene expression
per cell type. That downstream analysis is mostly packaged in libraries
like Seurat or Scanpy, but they contain many
knobs that are hard to know how to use without a deeper understanding of
what the analysis is doing or even the broader sample collection
process. The "hello world" of scRNA-seq datasets is a sample of ~3k
mononuclear white blood cells (Peripheral
Blood Mononuclear
Cells, PBMCs)
from a healthy donor called pbmc3k.
This post supplies the missing conceptual background to really
understand the Seurat
tutorial on that dataset.
If you are coming from a general computing background (like me) one
relevant bit of biology that you need to know is the relationship
between all the "NAs" (nucleic acids) like DNA, mRNA, cDNA etc. Roughly
speaking, the nucleus contains DNA which is a full blueprint of every
gene the cell might use. The epigenome, also inside the nucleus, is an
on/off filter which can block access to parts of this blueprint. Parts
of the blueprint are read (transcription) by nucleus internal machinery
and exported as instructions via messenger RNA (mRNA).
Builders outside the nucleus (ribosomes) use those instructions to build
proteins and other things (translation). It's generally possible to
deterministically reverse mRNA (reverse transcription) back into a
mirrored copy of DNA called complementary DNA (cDNA).
It's mirrored in the sense that DNA itself is constructed of pairs of
base molecules (ACTG) which only pair in unique, deterministic ways. So
knowing one element of the pair allows you to infer the other one. With
that biology in mind, we can start with how the raw dataset is obtained
in a wet lab.
Wet Lab
The wet lab steps are particularly important to understand where
errors can occur and why we have to take various steps for quality
control. The process is roughly as follows.
- Extract PBMCs. Blood is drawn from the donor,
diluted and layered on top of a special solution called a Ficoll-Paque.
It's then spun in a centrifuge where substances denser than ficoll (like
red blood cells and non-PBMC white blood cells) slip below it and
substances less dense stay above (like PBMCs). PBMCs can then be
pipetted out visually from above the ficoll separator.
- Cryopreserve. Usually blood sampling and the
droplet machine are done by different people/in different places so they
have to be decoupled. So the PBMCs are frozen before transport, notably
using dimethyl sulfoxide to stop ice crystals from forming and breaking
cell membranes.
- Prepare for droplet machine. Samples are thawed
quickly around 37C. The cells can stay alive for a bit if kept in the
right culture (nutrients/temperature), allowing you to for example apply
some treatment to them. You wash the cells with a dye that dead/leaky
cells will grab on to (trypan blue), so you can identify and remove
them.
- Run through droplet machine. The sample is then fed
through a machine like 10x Genomics' Chromium. The machine will force
the sample through one extremely small tunnel and force gel beads
containing reactants and chemical barcode machinery through another
tunnel. The tunnels meet and create a single droplet with a cell,
reactants and barcode information to identify the cell and its
molecules. This process is not perfect, the machine can be tuned to
either have more doublets (2 cells in a droplet) or more empty droplets.
Doublets are basically data loss, so usually it's tuned for a very low
doublet rate in exchange for a fair amount of cellless droplets. The
output is millions of tiny droplets suspended in solution.
- Run the droplet reactions. Heat the solution (using
a "thermal cycler" machine) on a schedule to trigger the reactions in
each droplet. The reaction that happens is reverse transcription (RT)
which converts some of the expressed mRNA floating around the
cell back into complementary DNA (cDNA) and simultaneously tags it with
both a cellID and specific molecule ID (unique
molecular identifier or UMI). Now each piece of cDNA that was
captured inside the droplet can be identified globally. Importantly,
some reactions might just execute better than others, resulting in a
"deeper" capture where the UMI counts are higher across all genes in
that cell.
- Sequence everything. Since each piece of cDNA has
already been tagged with a cell and molecule ID, we can break all the
droplets into a single large solution. Use a special enzyme (polymerase)
to multiply the copies of the cDNA so it's easier to detect in a
sequencer. Notably, not all cDNA gets scaled by the same factor, but
since we have the UMIs on each molecule we can deduplicate. We then take
the amplified cDNA (different number of duplicates per UMI) and sequence
it in a machine like Illumina. There is further sampling error with the
sequencing machine: you get a total number of reads which are used on
unevenly amplified, cell-tagged cDNA. Illumina outputs a raw sequence
file containing the cDNA reads and the quality/confidence of those
reads.
- Convert sequence file to cell count matrix.
Software like Cell
Ranger understands the chemistry of a 10x Genomics droplet machine
as well as sequence files. It takes a sequence file, peels off the
cellID + UMI, matches the remaining cDNA to a known gene, deduplicates
UMIs then writes a gene x cell count matrix into a .mtx file (a
standardized Matrix
Market file).
The "Gene / cell matrix filtered" file from the pbmc3k dataset
contains this final .mtx file. Filtered just means it has removed empty
droplets that may have arisen in step 4. There still may be doublets
present, though usually not a significant quantity of them to mess with
the end result. It looks like this:
%%MatrixMarket matrix coordinate real general
%
32738 2700 2286884
32709 1 4
32707 1 1
32706 1 10
32704 1 1
...
The header tells you that the matrix is "coordinate" oriented,
meaning the non-zeros are specified instead of a full gene x cell
matrix. It's more efficient that way since the matrix is sparse, many
genes are only expressed in a few cells. The first row gives you the
dimensions: 32,738 genes, 2700 cells and 2,286,884 count records or
non-zero matrix entries. The schema for each subsequent row is
<gene index in genes.tsv> <cell index in barcodes.tsv> <UMI count>,
where the indices are 1-based, not 0-based. You can think of it like a
list of (gene, cell, count) records, which we'll represent as a genes x
cells matrix which is mostly zeroes.
Given this sparse matrix of gene x cell counts, the statistical task
is to first group the cells based on their gene expression and then
label those groups with a specific cell type based on a biological
understanding of the relationship between gene expression and cell
type.
Data Loading
The first step is to load the matrix into an appropriate python
library. While it would be possible to use just the standard library
(dicts/lists), some of the statistical algorithms would be prohibitively
slow on a matrix with millions of non-zero entries. So we use SciPy, which is optimized
for this kind of work. scipy.io.mmread will read a matrix
market file into a coordinate format (COO) sparse matrix by default
(notably changing to a different default soon). For some reason, this
COO matrix format doesn't support all the operations we need and the
"compressed sparse row" (CSR) format is just superior in all ways so we
convert to that with tocsr(). Cell Ranger stores
genes × cells; for cell typing we often transpose with
.T so each row is one cell, but the counts themselves are
easier to read as genes × cells. Replacing the gene indexes with gene
names is helpful as well for later analysis. The loaded matrix
conceptually looks like:
\[
\begin{array}{r|ccc}
& \text{cell}_1 & \text{cell}_2 & \text{cell}_3 & \cdots
\\
\hline
\text{CD3D} & 1 & 0 & 0 \\
\text{MS4A1} & 5 & 2 & 192 \\
\text{LYZ} & 0 & 0 & 97 \\
\vdots & \vdots & \vdots & \vdots
\end{array}
\]
Quality Control (QC)
The gene counts we get may still have several sources of experimental
error:
- Doublets, as discussed in step 4.
- The droplets are not perfectly sealed. RNA can leak into empty
droplets from other broken cells.
- The sequencer isn't perfect. It can occasionally report a wrong
base, resulting in a misclassification of the gene by Cell Ranger.
- The read from the sequencer may be a short sequence that could align
with multiple places in the reference genome, known as ambiguous
alignment.
- Some cells may be dying and their gene expression is dominated by
programmed cell death (apoptosis).
To filter out these spurious readings, we do the following
(thresholds just experimentally determined / recommended by the Seurat
folks):
- Drop genes seen in fewer than 3 cells. Most likely these are
sequencing/alignment errors or ambient debris.
- Drop cells with fewer than 200 genes. Likely empty cells that just
happened to have some debris in them.
- Drop cells with more than 2500 genes. These are likely
doublets.
- Drop cells where there is significant (>5%) mitochondrial gene
expression (MT-*). These cells are likely in the process of dying.
Normalize
After QC, we're left with a genes x cells matrix containing (we hope)
only valid cells. The raw counts of expressed genes per cell in the
matrix depend on how well the droplet reaction executes (Step 5),
sequencing unevenly amplified cDNA (Step 6) as well as the actual
expressed differences. In this example:
\[
\begin{array}{r|ccc}
& \text{cell}_1 & \text{cell}_2 & \text{cell}_3 & \cdots
\\
\hline
\text{CD3D} & 1 & 0 & 0 \\
\text{MS4A1} & 5 & 2 & 192 \\
\text{LYZ} & 0 & 0 & 97 \\
\vdots & \vdots & \vdots & \vdots
\end{array}
\]
you can see how cell 3 counts 20x+ that of cell 1. Cell 3 may
actually have much more total RNA or it just had a strong reaction / was
sequenced "deeply." To remove this experimental effect, we can divide
each count by the total UMI count in the cell. We lose the potential
signal from the absolute expression, but the relative expression is
still useful biologically.
\[
\begin{array}{r|ccc}
& \text{cell}_1 & \text{cell}_2 & \text{cell}_3 \\
\hline
\text{CD3D} & 0.17 & 0 & 0 \\
\text{MS4A1} & 0.83 & 1 & 0.66 \\
\text{LYZ} & 0 & 0 & 0.34
\end{array}
\]
To put the values on a more convenient scale (instead of tiny
fractions) we multiply by a constant. 10k is the default in Seurat and
that works fine.
\[
\begin{array}{r|ccc}
& \text{cell}_1 & \text{cell}_2 & \text{cell}_3 \\
\hline
\text{CD3D} & 1700 & 0 & 0 \\
\text{MS4A1} & 8300 & 10000 & 6600 \\
\text{LYZ} & 0 & 0 & 3400
\end{array}
\]
The remaining problem is that the ranges of 0 to 10000 are still too
large in absolute terms for further statistical processing. In absolute
terms, a count changing from \(10 \to
20\) can be drowned out by a count changing from \(100 \to 200\) in the same experiment. To
map the values into a smaller absolute range we can take \(\log(1 + x)\) (the \(+1\) prevents \(\log(0)\)) to prevent this drowning out
effect:
\[
\begin{array}{r|ccc}
& \text{cell}_1 & \text{cell}_2 & \text{cell}_3 \\
\hline
\text{CD3D} & 7.44 & 0 & 0 \\
\text{MS4A1} & 9.02 & 9.21 & 8.79 \\
\text{LYZ} & 0 & 0 & 8.13
\end{array}
\]
Highly Variable Genes (HVG)
The pbmc3k dataset starts with around 32k genes, as per the matrix
market file header. QC filters that down to roughly ~13k. However, a
relatively large chunk of the remaining genes that pass QC don't
actually vary much between cells, so they won't help us in
distinguishing cell types. For example, a gene like ACTB
(index 11888 in the genes.tsv of pbmc3k) has 2677 hits in the matrix
file. It's in almost every cell, because it is the gene that produces
actin which is needed for basic cytoskeleton construction. Before we run
more computationally intensive steps like PCA,
we can simply remove these low variable genes.
Principal Component Analysis
(PCA)
So far we've filtered out erroneous data, normalized and removed
genes that are roughly constant across cell types. What remains is ~3k
cells, each with ~2k count values per variable gene. One can consider
the gene counts per cell as coordinates in a ~2k dimensional space, each
point in that space a cell. Could we then just compute the "distance"
between each cell in that ~2k dimensional space and look for clusters?
In principle yes, but the clusters would not be biologically meaningful
because of the curse of
dimensionality. As the number of dimensions grows, a definition of
distance like the Euclidean
distance
\[
\sqrt{(x_1-y_1)^2 + (x_2-y_2)^2 + \cdots}
\]
will cease to be useful because if the cluster is really defined by
low variance in a small handful of dimensions (e.g. T cells defined by a
few T cell specific genes), that will be drowned out by high variance in
all the other dimensions.
To counteract this problem, we want to construct dimensions upon
which the data spreads maximally. You can think of these new
dimensions/axes as weighted combinations of genes called "principal
components" (PCs), as opposed to the axes just simply being the genes
themselves. They represent some kind of biological mechanism that leads
to correlated gene expression, for example certain cell types are
defined by genes ABC with increased expression and DEF with
decreased.
A helpful analogy is imagining a dataset of people's weights and
heights. That's a 2 dimensional dataset where the 2 variables are
correlated: if you are taller you are more likely to weigh more and vice
versa. A principal component of that dataset would be an axis
representing something like "size." A weighted combination of height and
weight, such that bigger people have higher values and smaller people
have lower values.
Importantly, each additional axis should be orthogonal to each other
otherwise we'd be adding a dimension which is at least partially
capturing the same variance as another axis, wasting a costly dimension.
Principal Component Analysis or PCA, is the process by which we build
these axes such that \(\mathrm{PC}_2\)
is orthogonal to \(\mathrm{PC}_1\),
\(\mathrm{PC}_3\) is orthogonal to
\(\mathrm{PC}_1\) and \(\mathrm{PC}_2\) and so on. In the
height/weight analogy, \(\mathrm{PC}_1\) could represent "size" and
\(\mathrm{PC}_2\) could represent
"lanky/stockiness." After you account for someone's overall size, people
can still vary by how lanky/stocky they are: it's an orthogonal axis of
variation.
Before running PCA we also scale each gene across cells (zero mean /
unit variance) so genes that just happen to have bigger numeric ranges
don't dominate the components. After that, the cell data points can be
redefined from \(\text{cell}_1 =
(\text{gene}_1, \text{gene}_2, \ldots)\) to \(\text{cell}_1 = (\mathrm{PC}_1, \mathrm{PC}_2,
\ldots)\). PCA itself can generate up to \(\min(n_{\text{cells}}, n_{\text{genes}})\)
dimensions; in practice you keep a small prefix (often on the order of
10–50) that captures most of the useful variance for clustering.
Clustering
With the data now living in a much smaller number of optimally chosen
dimensions ("PC space"), we can cluster the cells. There are other ways
to measure the distance between cells, but the simplest and default in
Seurat is the Euclidean distance as defined earlier. The k-nearest
neighbours approach (KNN) says to pick a value \(k\) and look for the \(k\) closest cells to each cell. \(k\) can be tuned to see if more
biologically sound (i.e. the genes expressed in a cluster corresponds to
a known cell type) clusters emerge or not. Empirically a starting point
is 20.
A cluster of cells can then be defined roughly as cells who share a
lot of KNN defined neighbours. Given 2 cells and their \(k\) nearest neighbours, one can define the
"Jaccard
similarity" as the number of neighbours common to both divided by
the total number of unique neighbours across the 2 cells. One then
weights edges between cells by this similarity metric to build a
weighted graph.
Given this weighted graph, how do we label each cell with a cluster
ID? Turns out this is a surprisingly difficult problem. First we must
define a score that represents how good the clustering assignment is,
known as "modularity"
or conceptually "clumpiness." Can we just define it as the total weight
of all the edges connecting cells within a cluster? No, because then a
single cluster would always have the highest score. Modularity instead
compares each within-cluster edge to a random baseline: for every pair
of cells \(i, j\) in the same proposed
cluster, take the actual edge weight \(A_{ij}\) minus the weight you'd expect if
connections were random while preserving each cell's total connection
strength \(k_i\). Sum those differences
over same-cluster pairs (and scale by the total edge weight \(2m\)):
\[
Q = \frac{1}{2m}\sum_{ij}\left(A_{ij} - \frac{k_i
k_j}{2m}\right)\delta(c_i, c_j)
\]
where \(\delta(c_i, c_j)\) is 1 only
when \(i\) and \(j\) share a cluster label. Positive
contributions mean "more linked than chance"; the goal is to maximize
\(Q\). The randomly connected weight
can be scaled by a "resolution" factor; the higher the resolution, the
higher above the random baseline the proposed cluster needs to be in
order to move the modularity up.
A brute force approach then could be to just try all possible
assignments for increasing cluster sizes and compute the modularity for
each. That process grows exponentially though because for say 4 cells
and 2 clusters, there are 16 or \(2^4\)
different assignments. In general, \(N_{\text{clusters}}^{N_{\text{cells}}}\),
which with 2700 cells, is obviously intractable.
The Leiden
algorithm's approach to the computational complexity is:
- Start with every cell in its own cluster.
- Take a random cell, look at its edges and compute whether moving it
into the community of a neighbouring cell would improve modularity or
not. This is where the resolution parameter comes in. If the resolution
is high, that means the random baseline weight is scaled up, so the edge
has a higher bar to clear before triggering a merge. The effect is
"merge pickiness", and probably more distinct clusters in the end.
- Repeat for another random cell, until local moves stop
helping.
- Repeat the process inside each cluster. This should break apart any
loosely connected clusters.
- Treat each cluster as a single super-cell with aggregate weighted
edges to other super-cells and repeat the whole algorithm.
Leiden is not guaranteed to find the global optimum assignment, it
instead finds a local optimum which is usually good enough. The
randomness of selecting cells also means that different random seeds can
give you different runs.
Note the Seurat actually defaults to Louvain, which is essentially
Leiden without the refinement step (4) that avoids leaving poorly
connected clusters.
With a set of clusters, we can now examine their gene expression to
assign a cell type based on known biology. The pbmc3k dataset had
already filtered out many types of other cells that would normally be in
the blood like red blood cells, neutrophils, etc. The remaining set we'd
expect to see are adaptive immune cells (T cells and B cells) and some
innate immune cells (NK, DC, monocytes). Platelets are hard to fully
isolate as well because they stick to other cells. Each of those cell
populations have subtypes, such as whether the cell has fought a
pathogen before or not (naive vs memory) or whether the cell has a
specific protein on its surface known as its cluster of differentiation
(CD). We may or may not be able to assign a cluster to a specific
subtype. The pbmc tutorial, for example, ends up with 9 clusters:
- 3 types of T cells
- 2 types of monocytes
- B cells
- Dendritic cells (DC)
- Natural Killer (NK) cells
- Platelets
In that particular dataset it just so happens that distinct clusters
broke out for the subtypes of T cells and not for B cells. It's possible
that by modifying the parameters we used in the analysis (\(k\), resolution, number of PCs etc.) or
with a deeper biological understanding of what genes are expressed in
which cell types that we'd be able to cluster in a more fine grained
manner.
Assigning the labels is non-deterministic and somewhat of an art. For
example, in the Seurat tutorial, how do they distinguish between naive
CD4 T cells, memory CD4 T cells and CD8 T cells (a broader grouping of
both)? They use a set of genes that are known to be expressed as part of
specific processes:
- CD3D encodes part of the T cell receptor
- IL7R is expressed commonly in resting T cells to signal
survival/maintenance
- CCR7 encodes a receptor that helps T cells move into lymph
nodes
- S100A4 encodes a protein associated with cytoskeletal
remodeling
- CD8 encodes a part of the CD8 protein
Given these known gene expression associations, you can then reason
what type of a cell a cluster is. High expression of {CD3D,IL7R,CCR7} is
very likely a T-cell. If S100A4 is also present, that's evidence of a
memory T-cell. If CD8 is present, that is evidence of a CD8 T cell type,
but notably CD8 can also be expressed in NK cells.
Over time, researchers have externalized this type of reasoning into
common sets of markers, but again most of it isn't deterministic because
of the overlapping expression across cell types.
Summary
To recap, a scRNA-seq pipeline runs a sample of cells through a
specialized machine to capture the gene expression in each cell.
Determining the set of cell types in the sample from these raw gene
counts per cell involves filtering out erroneous data (QC), normalizing
for capture/sequencing depth, focusing on highly variable genes,
reducing dimensions with PCA, clustering cells that share expression
neighbourhoods (e.g. Leiden on a KNN/SNN graph), and finally biological
reasoning to assign those patterns of gene expression to a known cell
type.
Questions or comments? Email feedback@cstein.xyz
Intro to Single Cell RNA Sequencing
2026 Jul 23 See all postsSingle cell RNA sequencing, known as scRNA-seq, is the process of determining the level of gene expression across individual cells. Before it, we could only measure average gene expression across many cell types in a sample, hiding cell-type-specific responses. In the mid-2010s, special "droplet" machines made scRNA-seq practical by isolating individual cells from a sample into droplets that contain chemical machinery to tag both the cell and individual RNA molecules in the cell. That is a big upgrade. For example, bulk sequencing had made clear in the 2000s that autoimmune diseases like lupus create chronic "alarm" markers in their white blood cells. scRNA-seq showed us exactly which type of white blood cells were responsible for the expression, giving us a much deeper understanding of the pathology.
Although these "droplet" machines are advanced, the raw data they output still requires a significant amount of statistical analysis to classify cells into cell types and build a picture of gene expression per cell type. That downstream analysis is mostly packaged in libraries like Seurat or Scanpy, but they contain many knobs that are hard to know how to use without a deeper understanding of what the analysis is doing or even the broader sample collection process. The "hello world" of scRNA-seq datasets is a sample of ~3k mononuclear white blood cells (Peripheral Blood Mononuclear Cells, PBMCs) from a healthy donor called pbmc3k. This post supplies the missing conceptual background to really understand the Seurat tutorial on that dataset.
If you are coming from a general computing background (like me) one relevant bit of biology that you need to know is the relationship between all the "NAs" (nucleic acids) like DNA, mRNA, cDNA etc. Roughly speaking, the nucleus contains DNA which is a full blueprint of every gene the cell might use. The epigenome, also inside the nucleus, is an on/off filter which can block access to parts of this blueprint. Parts of the blueprint are read (transcription) by nucleus internal machinery and exported as instructions via messenger RNA (mRNA). Builders outside the nucleus (ribosomes) use those instructions to build proteins and other things (translation). It's generally possible to deterministically reverse mRNA (reverse transcription) back into a mirrored copy of DNA called complementary DNA (cDNA). It's mirrored in the sense that DNA itself is constructed of pairs of base molecules (ACTG) which only pair in unique, deterministic ways. So knowing one element of the pair allows you to infer the other one. With that biology in mind, we can start with how the raw dataset is obtained in a wet lab.
Wet Lab
The wet lab steps are particularly important to understand where errors can occur and why we have to take various steps for quality control. The process is roughly as follows.
The "Gene / cell matrix filtered" file from the pbmc3k dataset contains this final .mtx file. Filtered just means it has removed empty droplets that may have arisen in step 4. There still may be doublets present, though usually not a significant quantity of them to mess with the end result. It looks like this:
The header tells you that the matrix is "coordinate" oriented, meaning the non-zeros are specified instead of a full gene x cell matrix. It's more efficient that way since the matrix is sparse, many genes are only expressed in a few cells. The first row gives you the dimensions: 32,738 genes, 2700 cells and 2,286,884 count records or non-zero matrix entries. The schema for each subsequent row is
<gene index in genes.tsv> <cell index in barcodes.tsv> <UMI count>, where the indices are 1-based, not 0-based. You can think of it like a list of (gene, cell, count) records, which we'll represent as a genes x cells matrix which is mostly zeroes.Given this sparse matrix of gene x cell counts, the statistical task is to first group the cells based on their gene expression and then label those groups with a specific cell type based on a biological understanding of the relationship between gene expression and cell type.
Data Loading
The first step is to load the matrix into an appropriate python library. While it would be possible to use just the standard library (dicts/lists), some of the statistical algorithms would be prohibitively slow on a matrix with millions of non-zero entries. So we use SciPy, which is optimized for this kind of work.
scipy.io.mmreadwill read a matrix market file into a coordinate format (COO) sparse matrix by default (notably changing to a different default soon). For some reason, this COO matrix format doesn't support all the operations we need and the "compressed sparse row" (CSR) format is just superior in all ways so we convert to that withtocsr(). Cell Ranger stores genes × cells; for cell typing we often transpose with.Tso each row is one cell, but the counts themselves are easier to read as genes × cells. Replacing the gene indexes with gene names is helpful as well for later analysis. The loaded matrix conceptually looks like:\[ \begin{array}{r|ccc} & \text{cell}_1 & \text{cell}_2 & \text{cell}_3 & \cdots \\ \hline \text{CD3D} & 1 & 0 & 0 \\ \text{MS4A1} & 5 & 2 & 192 \\ \text{LYZ} & 0 & 0 & 97 \\ \vdots & \vdots & \vdots & \vdots \end{array} \]
Quality Control (QC)
The gene counts we get may still have several sources of experimental error:
To filter out these spurious readings, we do the following (thresholds just experimentally determined / recommended by the Seurat folks):
Normalize
After QC, we're left with a genes x cells matrix containing (we hope) only valid cells. The raw counts of expressed genes per cell in the matrix depend on how well the droplet reaction executes (Step 5), sequencing unevenly amplified cDNA (Step 6) as well as the actual expressed differences. In this example:
\[ \begin{array}{r|ccc} & \text{cell}_1 & \text{cell}_2 & \text{cell}_3 & \cdots \\ \hline \text{CD3D} & 1 & 0 & 0 \\ \text{MS4A1} & 5 & 2 & 192 \\ \text{LYZ} & 0 & 0 & 97 \\ \vdots & \vdots & \vdots & \vdots \end{array} \]
you can see how cell 3 counts 20x+ that of cell 1. Cell 3 may actually have much more total RNA or it just had a strong reaction / was sequenced "deeply." To remove this experimental effect, we can divide each count by the total UMI count in the cell. We lose the potential signal from the absolute expression, but the relative expression is still useful biologically.
\[ \begin{array}{r|ccc} & \text{cell}_1 & \text{cell}_2 & \text{cell}_3 \\ \hline \text{CD3D} & 0.17 & 0 & 0 \\ \text{MS4A1} & 0.83 & 1 & 0.66 \\ \text{LYZ} & 0 & 0 & 0.34 \end{array} \]
To put the values on a more convenient scale (instead of tiny fractions) we multiply by a constant. 10k is the default in Seurat and that works fine.
\[ \begin{array}{r|ccc} & \text{cell}_1 & \text{cell}_2 & \text{cell}_3 \\ \hline \text{CD3D} & 1700 & 0 & 0 \\ \text{MS4A1} & 8300 & 10000 & 6600 \\ \text{LYZ} & 0 & 0 & 3400 \end{array} \]
The remaining problem is that the ranges of 0 to 10000 are still too large in absolute terms for further statistical processing. In absolute terms, a count changing from \(10 \to 20\) can be drowned out by a count changing from \(100 \to 200\) in the same experiment. To map the values into a smaller absolute range we can take \(\log(1 + x)\) (the \(+1\) prevents \(\log(0)\)) to prevent this drowning out effect:
\[ \begin{array}{r|ccc} & \text{cell}_1 & \text{cell}_2 & \text{cell}_3 \\ \hline \text{CD3D} & 7.44 & 0 & 0 \\ \text{MS4A1} & 9.02 & 9.21 & 8.79 \\ \text{LYZ} & 0 & 0 & 8.13 \end{array} \]
Highly Variable Genes (HVG)
The pbmc3k dataset starts with around 32k genes, as per the matrix market file header. QC filters that down to roughly ~13k. However, a relatively large chunk of the remaining genes that pass QC don't actually vary much between cells, so they won't help us in distinguishing cell types. For example, a gene like ACTB (index 11888 in the genes.tsv of pbmc3k) has 2677 hits in the matrix file. It's in almost every cell, because it is the gene that produces actin which is needed for basic cytoskeleton construction. Before we run more computationally intensive steps like PCA, we can simply remove these low variable genes.
Principal Component Analysis (PCA)
So far we've filtered out erroneous data, normalized and removed genes that are roughly constant across cell types. What remains is ~3k cells, each with ~2k count values per variable gene. One can consider the gene counts per cell as coordinates in a ~2k dimensional space, each point in that space a cell. Could we then just compute the "distance" between each cell in that ~2k dimensional space and look for clusters? In principle yes, but the clusters would not be biologically meaningful because of the curse of dimensionality. As the number of dimensions grows, a definition of distance like the Euclidean distance
\[ \sqrt{(x_1-y_1)^2 + (x_2-y_2)^2 + \cdots} \]
will cease to be useful because if the cluster is really defined by low variance in a small handful of dimensions (e.g. T cells defined by a few T cell specific genes), that will be drowned out by high variance in all the other dimensions.
To counteract this problem, we want to construct dimensions upon which the data spreads maximally. You can think of these new dimensions/axes as weighted combinations of genes called "principal components" (PCs), as opposed to the axes just simply being the genes themselves. They represent some kind of biological mechanism that leads to correlated gene expression, for example certain cell types are defined by genes ABC with increased expression and DEF with decreased.
A helpful analogy is imagining a dataset of people's weights and heights. That's a 2 dimensional dataset where the 2 variables are correlated: if you are taller you are more likely to weigh more and vice versa. A principal component of that dataset would be an axis representing something like "size." A weighted combination of height and weight, such that bigger people have higher values and smaller people have lower values.
Importantly, each additional axis should be orthogonal to each other otherwise we'd be adding a dimension which is at least partially capturing the same variance as another axis, wasting a costly dimension. Principal Component Analysis or PCA, is the process by which we build these axes such that \(\mathrm{PC}_2\) is orthogonal to \(\mathrm{PC}_1\), \(\mathrm{PC}_3\) is orthogonal to \(\mathrm{PC}_1\) and \(\mathrm{PC}_2\) and so on. In the height/weight analogy, \(\mathrm{PC}_1\) could represent "size" and \(\mathrm{PC}_2\) could represent "lanky/stockiness." After you account for someone's overall size, people can still vary by how lanky/stocky they are: it's an orthogonal axis of variation.
Before running PCA we also scale each gene across cells (zero mean / unit variance) so genes that just happen to have bigger numeric ranges don't dominate the components. After that, the cell data points can be redefined from \(\text{cell}_1 = (\text{gene}_1, \text{gene}_2, \ldots)\) to \(\text{cell}_1 = (\mathrm{PC}_1, \mathrm{PC}_2, \ldots)\). PCA itself can generate up to \(\min(n_{\text{cells}}, n_{\text{genes}})\) dimensions; in practice you keep a small prefix (often on the order of 10–50) that captures most of the useful variance for clustering.
Clustering
With the data now living in a much smaller number of optimally chosen dimensions ("PC space"), we can cluster the cells. There are other ways to measure the distance between cells, but the simplest and default in Seurat is the Euclidean distance as defined earlier. The k-nearest neighbours approach (KNN) says to pick a value \(k\) and look for the \(k\) closest cells to each cell. \(k\) can be tuned to see if more biologically sound (i.e. the genes expressed in a cluster corresponds to a known cell type) clusters emerge or not. Empirically a starting point is 20.
A cluster of cells can then be defined roughly as cells who share a lot of KNN defined neighbours. Given 2 cells and their \(k\) nearest neighbours, one can define the "Jaccard similarity" as the number of neighbours common to both divided by the total number of unique neighbours across the 2 cells. One then weights edges between cells by this similarity metric to build a weighted graph.
Given this weighted graph, how do we label each cell with a cluster ID? Turns out this is a surprisingly difficult problem. First we must define a score that represents how good the clustering assignment is, known as "modularity" or conceptually "clumpiness." Can we just define it as the total weight of all the edges connecting cells within a cluster? No, because then a single cluster would always have the highest score. Modularity instead compares each within-cluster edge to a random baseline: for every pair of cells \(i, j\) in the same proposed cluster, take the actual edge weight \(A_{ij}\) minus the weight you'd expect if connections were random while preserving each cell's total connection strength \(k_i\). Sum those differences over same-cluster pairs (and scale by the total edge weight \(2m\)):
\[ Q = \frac{1}{2m}\sum_{ij}\left(A_{ij} - \frac{k_i k_j}{2m}\right)\delta(c_i, c_j) \]
where \(\delta(c_i, c_j)\) is 1 only when \(i\) and \(j\) share a cluster label. Positive contributions mean "more linked than chance"; the goal is to maximize \(Q\). The randomly connected weight can be scaled by a "resolution" factor; the higher the resolution, the higher above the random baseline the proposed cluster needs to be in order to move the modularity up.
A brute force approach then could be to just try all possible assignments for increasing cluster sizes and compute the modularity for each. That process grows exponentially though because for say 4 cells and 2 clusters, there are 16 or \(2^4\) different assignments. In general, \(N_{\text{clusters}}^{N_{\text{cells}}}\), which with 2700 cells, is obviously intractable.
The Leiden algorithm's approach to the computational complexity is:
Leiden is not guaranteed to find the global optimum assignment, it instead finds a local optimum which is usually good enough. The randomness of selecting cells also means that different random seeds can give you different runs.
Note the Seurat actually defaults to Louvain, which is essentially Leiden without the refinement step (4) that avoids leaving poorly connected clusters.
With a set of clusters, we can now examine their gene expression to assign a cell type based on known biology. The pbmc3k dataset had already filtered out many types of other cells that would normally be in the blood like red blood cells, neutrophils, etc. The remaining set we'd expect to see are adaptive immune cells (T cells and B cells) and some innate immune cells (NK, DC, monocytes). Platelets are hard to fully isolate as well because they stick to other cells. Each of those cell populations have subtypes, such as whether the cell has fought a pathogen before or not (naive vs memory) or whether the cell has a specific protein on its surface known as its cluster of differentiation (CD). We may or may not be able to assign a cluster to a specific subtype. The pbmc tutorial, for example, ends up with 9 clusters:
In that particular dataset it just so happens that distinct clusters broke out for the subtypes of T cells and not for B cells. It's possible that by modifying the parameters we used in the analysis (\(k\), resolution, number of PCs etc.) or with a deeper biological understanding of what genes are expressed in which cell types that we'd be able to cluster in a more fine grained manner.
Assigning the labels is non-deterministic and somewhat of an art. For example, in the Seurat tutorial, how do they distinguish between naive CD4 T cells, memory CD4 T cells and CD8 T cells (a broader grouping of both)? They use a set of genes that are known to be expressed as part of specific processes:
Given these known gene expression associations, you can then reason what type of a cell a cluster is. High expression of {CD3D,IL7R,CCR7} is very likely a T-cell. If S100A4 is also present, that's evidence of a memory T-cell. If CD8 is present, that is evidence of a CD8 T cell type, but notably CD8 can also be expressed in NK cells.
Over time, researchers have externalized this type of reasoning into common sets of markers, but again most of it isn't deterministic because of the overlapping expression across cell types.
Summary
To recap, a scRNA-seq pipeline runs a sample of cells through a specialized machine to capture the gene expression in each cell. Determining the set of cell types in the sample from these raw gene counts per cell involves filtering out erroneous data (QC), normalizing for capture/sequencing depth, focusing on highly variable genes, reducing dimensions with PCA, clustering cells that share expression neighbourhoods (e.g. Leiden on a KNN/SNN graph), and finally biological reasoning to assign those patterns of gene expression to a known cell type.
Questions or comments? Email feedback@cstein.xyz