scRNA-seq: Cell Type Annotation
When to Use
- You have Leiden/Louvain clusters (e.g. from
sc.tl.leiden) and need biological labels, not just cluster numbers. - You want to score canonical marker genes (CD3D, MS4A1, LYZ, ...) per cluster and assign the best-matching cell type.
- You need automated, reference-based annotation with SingleR (R/Bioconductor) or CellTypist (Python) instead of manual marker curation.
- Clusters come back ambiguous, mixed, or
NA/low-confidence and you need a strategy to resolve them. - You need to subcluster a broad population (e.g. "T cells") to resolve finer subtypes (CD4/CD8/Treg).
Version Compatibility
- scanpy ≥ 1.10, anndata ≥ 0.10, Python ≥ 3.10
- celltypist ≥ 1.6 (model files
Immune_All_Low.pkl,Immune_All_High.pkl,Pan_Fetal_Human.pkl) - R ≥ 4.3, SingleR ≥ 2.4, celldex ≥ 1.12 (Bioconductor 3.18+)
Prerequisites
pip install scanpy anndata celltypist(Python) orBiocManager::install(c("SingleR", "celldex", "SingleCellExperiment"))(R)- Data must already be QC'd, normalized (
sc.pp.normalize_total(target_sum=1e4)+sc.pp.log1p), and clustered — seebio-applied-scrna-preprocessingandbio-applied-dimensionality-reductionfor those steps.
Canonical PBMC Markers
| Cell Type | Marker Genes | Notes |
|---|---|---|
| T cells | CD3D, CD3E, CD3G | Pan-T marker, present in all T subsets |
| CD4+ T cells | CD4, IL7R | Helper T |
| CD8+ T cells | CD8A, CD8B | Cytotoxic T |
| B cells | CD79A, CD79B, MS4A1 (CD20) | MS4A1 is the rituximab target |
| NK cells | GNLY, NKG7, KLRD1 | No CD3 — distinguishes from T cells |
| Monocytes (classical) | LYZ, S100A8, S100A9, CST3 | High LYZ = phagocytic |
| Monocytes (non-classical) | FCGR3A (CD16), MS4A7 | Patrolling |
| Dendritic cells | FCER1A, CLEC10A | Low count, high HLA-DR |
| Platelets | PPBP, PF4 | Often ambient/contaminant signal |
Manual Marker-Based Annotation
Goal: map each Leiden cluster to a cell type using mean expression of canonical marker sets.
Approach: for every candidate cell type, take the marker genes present in adata.var_names, average their log-normalized expression within each cluster, and assign the cell type with the highest score. Requires ≥2 concordant markers to trust a call; flag clusters where the top two scores are close (ambiguous / possible doublet population).
import numpy as np
import pandas as pd
def annotate_clusters_by_markers(adata, marker_dict, cluster_key="leiden"):
"""Score each cluster against marker gene sets and assign the top-scoring cell type.
Parameters
----------
adata : AnnData, log1p-normalized (target_sum=1e4)
marker_dict : dict[str, list[str]] mapping cell type -> marker gene names
cluster_key : obs column with cluster labels
Returns
-------
score_df : clusters x cell types mean-expression table
annotation : dict mapping cluster id -> assigned cell type
"""
X = adata.X if not hasattr(adata.X, "toarray") else adata.X.toarray()
gene_names = adata.var_names.tolist()
clusters = adata.obs[cluster_key].values
cluster_ids = sorted(adata.obs[cluster_key].unique())
scores = {}
for cell_type, markers in marker_dict.items():
valid = [m for m in markers if m in gene_names]
if not valid:
continue
marker_idx = [gene_names.index(m) for m in valid]
scores[cell_type] = {cid: X[clusters == cid][:, marker_idx].mean() for cid in cluster_ids}
score_df = pd.DataFrame(scores, index=cluster_ids)
annotation = score_df.idxmax(axis=1).to_dict()
# flag ambiguous clusters: top two scores within 15% of each other
for cid in cluster_ids:
row = score_df.loc[cid].sort_values(ascending=False)
if len(row) >= 2 and row.iloc[1] > 0.85 * row.iloc[0]:
print(f"Cluster {cid}: ambiguous ({row.index[0]}={row.iloc[0]:.2f} vs "
f"{row.index[1]}={row.iloc[1]:.2f}) — inspect manually")
return score_df, annotation
marker_dict = {
"T_cell": ["CD3D", "CD3E", "CD3G"],
"B_cell": ["CD79A", "MS4A1"],
"Monocyte": ["LYZ", "S100A8"],
"NK_cell": ["GNLY", "NKG7"],
"Dendritic": ["FCER1A", "CLEC10A"],
}
score_df, cluster_annotation = annotate_clusters_by_markers(adata, marker_dict)
adata.obs["cell_type_annotated"] = adata.obs["leiden"].map(cluster_annotation)
SingleR (R, Reference-Based)
Goal: assign per-cell labels by Spearman-correlating each cell's expression profile against reference cell-type profiles, with iterative fine-tuning.
Approach: load a curated reference from celldex, run SingleR() against your SingleCellExperiment, then inspect pruned.labels for low-confidence (NA) calls.
library(SingleR)
library(celldex)
library(SingleCellExperiment)
# Reference options:
# HumanPrimaryCellAtlasData() - 37 cell types, diverse human tissues
# MonacoImmuneData() - 29 immune subtypes, best for PBMC
# BlueprintEncodeData() - hematopoietic and epithelial lineages
ref <- MonacoImmuneData()
pred <- SingleR(test = sce, ref = ref, labels = ref$label.main)
plotScoreHeatmap(pred)
table(pred$pruned.labels) # NA = low confidence -- inspect these cells
sce$cell_type <- pred$pruned.labels
pred$pruned.labels NA cells may be novel cell types not in the reference, transitional/intermediate states, or poor-quality cells that survived QC.
CellTypist (Python, Automated Classification)
Goal: classify cells with a logistic-regression model pretrained on a curated multi-million-cell human atlas.
Approach: normalize exactly as CellTypist expects (target_sum=1e4, log1p), load a model, and run annotate with majority_voting=True to pool per-cluster predictions and suppress single-cell noise.
import celltypist
from celltypist import models
models.download_models(force_update=False)
# Immune_All_Low.pkl -- 36 immune subtypes (fine)
# Immune_All_High.pkl -- 9 broad immune categories
# Pan_Fetal_Human.pkl -- fetal cell types across organs
model = models.Model.load(model="Immune_All_Low.pkl")
# IMPORTANT: expects log1p-normalized data with target_sum=1e4
# (i.e. sc.pp.normalize_total(adata, target_sum=1e4); sc.pp.log1p(adata))
predictions = celltypist.annotate(adata, model=model, majority_voting=True)
adata = predictions.to_adata()
print(adata.obs["majority_voting"].value_counts())
majority_voting=True pools per-cell predictions within each Leiden cluster and assigns the plurality label to the whole cluster, reducing noise from individual misclassified cells.
When CellTypist fails: non-immune tissue (neurons, hepatocytes) needs a tissue-specific model; non-human species need retraining; if every cluster gets the same label, check normalization (target_sum=1e4 + log1p, not raw counts).
Subclustering for Refined Annotation
Goal: resolve subtypes (e.g. CD4/CD8/Treg) hidden inside one broad annotated population. Approach: subset to one annotated cell type, rerun neighbors/leiden/umap on just that subset, then re-annotate with subtype-specific markers. Never compare subcluster UMAP coordinates to the full-dataset UMAP — they were computed on different variance.
import scanpy as sc
t_cells = adata[adata.obs["cell_type_annotated"] == "T_cell"].copy()
sc.pp.neighbors(t_cells, n_neighbors=10, n_pcs=10)
sc.tl.leiden(t_cells, resolution=0.8, key_added="leiden_tcell")
sc.tl.umap(t_cells)
# CD4+ naive: CD4, CCR7, SELL | Treg: CD4, FOXP3, IL2RA
# CD8+ cytotoxic: CD8A, GZMB, PRF1 | CD8+ exhausted: CD8A, PDCD1, HAVCR2
sc.pl.umap(t_cells, color=["CD4", "CD8A", "FOXP3", "leiden_tcell"])
Pitfalls
- Trusting a single marker: require ≥2 concordant markers per cluster; one gene can be noisy or ambient RNA contamination.
- Wrong normalization for CellTypist: it expects
target_sum=1e4log1p data — raw counts or a different target_sum collapse all clusters to one prediction. - Ignoring
pruned.labels == NA(SingleR): these are not errors to discard, they flag novel types, transitional states, or low-quality cells worth inspecting. - Comparing UMAPs across subclustering runs: a subcluster's PCA/UMAP is fit on different variance than the full dataset; coordinates are not comparable.
- Batch effects masquerading as cell types: always confirm a cluster isn't just a batch before assigning a novel biological label — see
bio-single-cell-batch-integration. - Doublet clusters: a cluster co-expressing two unrelated marker sets (e.g. T-cell and monocyte markers) is often a doublet population, not a real hybrid cell type.
See Also
bio-applied-scrna-preprocessing— QC and normalization steps required before clustering/annotationbio-applied-dimensionality-reduction— PCA/UMAP/Leiden clustering that produces the clusters annotated herebio-applied-single-cell-scanpy— general scanpy workflow this skill plugs intobio-applied-cite-seq-integration— protein-level markers (CITE-seq) to corroborate RNA-based annotation