| name | bio-applied-cell-type-annotation |
| description | Annotate scRNA-seq Leiden/Louvain clusters into cell types via canonical marker scoring, SingleR reference correlation, and CellTypist logistic-regression classification on an AnnData/SingleCellExperiment object. Use when doing cell type annotation, labeling clusters, scoring marker genes on a UMAP, running SingleR or CellTypist, or resolving ambiguous/NA-labeled clusters in PBMC or other scRNA-seq data. |
| tool_type | python |
| primary_tool | scanpy |
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) or BiocManager::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 — see bio-applied-scrna-preprocessing and bio-applied-dimensionality-reduction for 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()
for cid in cluster_ids:
row = score_df.loc[cid].sort_values(ascending=False)
if len(row) >= 2 and row.iloc[] > * row.iloc[]:
(
)
score_df, annotation
marker_dict = {
: [, , ],
: [, ],
: [, ],
: [, ],
: [, ],
}
score_df, cluster_annotation = annotate_clusters_by_markers(adata, marker_dict)
adata.obs[] = adata.obs[].(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)
ref <- MonacoImmuneData()
pred <- SingleR(test = sce, ref = ref, labels = ref$label.main)
plotScoreHeatmap(pred)
table(pred$pruned.labels)
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)
model = models.Model.load(model="Immune_All_Low.pkl")
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)
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=1e4 log1p 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/annotation
bio-applied-dimensionality-reduction — PCA/UMAP/Leiden clustering that produces the clusters annotated here
bio-applied-single-cell-scanpy — general scanpy workflow this skill plugs into
bio-applied-cite-seq-integration — protein-level markers (CITE-seq) to corroborate RNA-based annotation