Cell Type Annotation
Overview
This pipeline skill generates code for annotating cell types in single-cell RNA-seq data. It covers four complementary approaches:
- Automated — CellTypist (logistic-regression classifiers trained on curated atlases).
- Reference-based — SingleR (R) or scANVI/
sc.tl.ingest(Python) label transfer. - Marker-based — Manual assignment from canonical marker gene panels.
- Hierarchical — Coarse compartment assignment followed by specialized fine-grained annotation.
Use it when the user has a clustered AnnData (or SingleCellExperiment) and needs cell-type labels in adata.obs.
Usage
Decision Tree: Choosing an Annotation Approach
Follow this decision tree to select the right method:
- Is there a CellTypist model matching your tissue and species?
- YES → Use CellTypist (Steps 1–6 below)
- NO → Go to step 2
- Do you have a labeled reference AnnData from the same tissue?
- YES, same batch/technology → Use
sc.tl.ingest(Step 12) - YES, different batches → Use scANVI label transfer (Step 13)
- NO → Go to step 3
- YES, same batch/technology → Use
- Are you working in R with bulk or sorted-cell references?
- YES → Use SingleR (Steps 7–9)
- NO → Go to step 4
- Do you have canonical marker gene panels for expected cell types?
- YES → Use marker-based annotation (Steps 10–11)
- NO → Start with CellTypist's broadest model (
Immune_All_Low.pklorDeveloping_Human_Brain.pkl), then refine with markers
Annotation Workflow (15 Steps)
Step 1 — Confirm normalization. CellTypist requires log1p(CP10k). Check adata.uns['log1p'] exists or run normalization. If data is in adata.raw or a layer, subset appropriately.
Step 2 — Verify gene symbols. Confirm adata.var_names are HGNC (human) or MGI (mouse) symbols, not Ensembl IDs. Convert if needed with sc.queries.biomart_annotations.
Step 3 — Select CellTypist model. Match species + tissue. Use celltypist.models.models_description() to list options. If unsure, start with the broadest model for the compartment.
Step 4 — Run CellTypist annotation. Use celltypist.annotate() with majority_voting=True and appropriate p_thres (default 0.5; raise to 0.7 for stringent calls).
Step 5 — Extract results via to_adata(). Call predictions.to_adata() to get annotations in .obs. Access confidence via adata_result.obs['conf_score'].
Step 6 — Filter low-confidence cells. Mask cells with conf_score < 0.5 (or user-specified threshold) as 'Unknown'.
Step 7 — (SingleR) Install references. Use BiocManager::install("celldex") and load the appropriate reference dataset.
Step 8 — (SingleR) Run prediction. Call SingleR(test, ref, labels) with assay.type.test = "logcounts".
Step 9 — (SingleR) Prune low-quality calls. Use pruneScores(pred) to identify unreliable assignments; set those to "Unknown".
Step 10 — (Marker-based) Define panels. Provide ≥3 markers per cell type from literature. Include negative markers where informative.
Step 11 — (Marker-based) Score and assign. Use sc.pl.dotplot to visualize, then map clusters to types. Leave ambiguous clusters as 'Unknown'.
Step 12 — (Ingest) Project query onto reference. Requires shared PCA space; run sc.tl.ingest(adata_query, adata_ref, obs='cell_type'). Only works well when query and reference share the same technology and minimal batch effects.
Step 13 — (scANVI) Semi-supervised transfer. Train SCVI on reference, extend to SCANVI with labels, then load_query_data for the query. Handles batch effects natively.
Step 14 — Validate annotations. Confirm with dotplot of canonical markers grouped by assigned cell type. Check per-sample proportions for biological plausibility.
Step 15 — Hierarchical refinement. If fine labels are unstable, annotate coarse compartments first (immune/stromal/epithelial via PTPRC/EPCAM/COL1A1), subset, then re-annotate each with a specialized model or markers.
Branch Points
- At Step 4: if
>30%of cells getconf_score < 0.5, the model is a poor fit → switch to marker-based or try a different model. - At Step 6: if a single label dominates
>80%of cells unexpectedly, suspect wrong model or un-normalized data. - At Step 12: if query UMAP doesn't align with reference after ingest, batch effects are too strong → switch to scANVI (Step 13).
- At Step 14: if proportions are biologically implausible (e.g., 60% fibroblasts in PBMC), re-examine normalization and model choice.
Response Format
- Lead with the command or code the user needs — explain after.
- Structure as: confirm inputs → working code → key parameters explained → gotchas.
- One complete working example per task; do not show every alternative.
- Keep code comments minimal and functional.
- Target: 50–100 lines of code with brief surrounding explanation.
- Always include confidence score extraction and a filtering step.
Core Concepts
1. CellTypist (automated, Python)
import scanpy as sc
import celltypist
from celltypist import models
# Input: log1p-normalized to 10,000 counts/cell
sc.pp.normalize_total(adata, target_sum=1e4)
sc.pp.log1p(adata)
# Download models (first run only)
models.download_models()
print(models.models_description()) # list available models
# Load tissue-matched model
model = models.Model.load(model='Immune_All_Low.pkl')
# Annotate with majority voting
predictions = celltypist.annotate(
adata,
model=model,
majority_voting=True, # smooth labels over neighborhood graph
over_clustering='leiden', # obs key for majority voting resolution
p_thres=0.5, # minimum probability threshold (raise for stringency)
)
# Extract results — modern API (CellTypist >=1.3)
adata_result = predictions.to_adata()
# Annotations now in adata_result.obs:
# 'predicted_labels' — per-cell raw predictions
# 'majority_voting' — smoothed labels
# 'conf_score' — max probability per cell
# Transfer to original adata
adata.obs['cell_type'] = adata_result.obs['majority_voting']
adata.obs['cell_type_conf'] = adata_result.obs['conf_score']
# Filter low-confidence assignments
adata.obs.loc[adata.obs['cell_type_conf'] < 0.5, 'cell_type'] = 'Unknown'
Key parameters for celltypist.annotate():
| Parameter | Default | Effect |
|---|---|---|
majority_voting |
False |
Smooth labels over kNN graph; always enable |
over_clustering |
None |
obs key (e.g., 'leiden') for voting resolution; finer clusters → better resolution |
p_thres |
0.5 |
Min probability to assign a label; raise to 0.7 for high-confidence only |
mode |
'best match' |
Use 'prob match' for multi-label scenarios |
Common models: Immune_All_Low, Immune_All_High, Healthy_COVID19_PBMC, Human_Lung_Atlas, Cells_Intestinal_Tract, Developing_Human_Brain, Adult_Mouse_Gut. Match species and tissue.
2. SingleR (reference-based, R)
if (!requireNamespace("BiocManager", quietly = TRUE))
install.packages("BiocManager")
BiocManager::install(c("SingleR", "celldex", "SingleCellExperiment"))
library(SingleR)
library(celldex)
library(SingleCellExperiment)
# Choose reference matching species/tissue
ref <- celldex::HumanPrimaryCellAtlasData()
# ref <- celldex::MonacoImmuneData() # sorted human immune
# ref <- celldex::ImmGenData() # mouse immune
# ref <- celldex::BlueprintEncodeData() # human stromal + immune
pred <- SingleR(
test = sce,
ref = ref,
labels = ref$label.main,
assay.type.test = "logcounts"
)
sce$cell_type <- pred$labels
table(pred$labels)
# Prune low-confidence calls (delta < median - 3*MAD)
sce$cell_type[is.na(pred$pruned.labels)] <- "Unknown"
3. Marker-based (manual, Python)
import scanpy as sc
marker_genes = {
'T cells': ['CD3D', 'CD3E', 'CD2'],
'CD4 T': ['CD4', 'IL7R'],
'CD8 T': ['CD8A', 'CD8B'],
'B cells': ['CD79A', 'MS4A1', 'CD19'],
'NK cells': ['NKG7', 'GNLY', 'KLRD1'],
'Monocytes': ['CD14', 'LYZ', 'S100A8'],
'Dendritic': ['FCER1A', 'CST3', 'CLEC10A'],
'Fibroblasts': ['COL1A1', 'DCN', 'COL3A1'],
'Endothelial': ['PECAM1', 'VWF', 'CDH5'],
'Epithelial': ['EPCAM', 'KRT18', 'KRT19'],
}
sc.pl.dotplot(adata, marker_genes, groupby='leiden', standard_scale='var')
# Assign after reviewing the dotplot
cluster_to_celltype = {
'0': 'T cells', '1': 'Monocytes', '2': 'B cells',
'3': 'NK cells', '4': 'Unknown',
}
adata.obs['cell_type'] = adata.obs['leiden'].map(cluster_to_celltype).astype('category')
4. Label transfer from a reference atlas
# Quick path: sc.tl.ingest (PCA/UMAP projection, kNN labels)
# Only use when batch effects are minimal
import scanpy as sc
sc.pp.pca(adata_ref)
sc.pp.neighbors(adata_ref)
sc.tl.umap(adata_ref)
sc.tl.ingest(adata_query, adata_ref, obs='cell_type')
# Robust path: scANVI (semi-supervised, handles batch)
import scvi
scvi.model.SCVI.setup_anndata(adata_ref, batch_key='batch')
scvi_model = scvi.model.SCVI(adata_ref)
scvi_model.train()
scvi.model.SCANVI.setup_anndata(
adata_ref, labels_key='cell_type', unlabeled_category='Unknown', batch_key='batch'
)
scanvi_model = scvi.model.SCANVI.from_scvi_model(scvi_model, unlabeled_category='Unknown')
scanvi_model.train(max_epochs=20)
scanvi_model = scvi.model.SCANVI.load_query_data(adata_query, scanvi_model)
scanvi_model.train(max_epochs=100, plan_kwargs={'weight_decay': 0.0})
adata_query.obs['cell_type_pred'] = scanvi_model.predict(adata_query)
5. Validation
# Confirm assigned labels express their markers
sc.pl.dotplot(adata, marker_genes, groupby='cell_type', standard_scale='var')
sc.pl.violin(adata, ['CD3D', 'MS4A1', 'CD14'], groupby='cell_type', rotation=90)
# Sanity-check proportions per sample
adata.obs.groupby(['sample', 'cell_type']).size().unstack(fill_value=0)
Mark any cluster without clear marker support as 'Unknown' rather than forcing a label.
6. Hierarchical annotation
# Step 1: coarse compartments
compartment_markers = {
'Immune': ['PTPRC'], # CD45
'Epithelial': ['EPCAM', 'KRT8'],
'Stromal': ['COL1A1', 'PECAM1'],
}
# Step 2: subset and re-annotate each compartment
adata_immune = adata[adata.obs['compartment'] == 'Immune'].copy()
model = models.Model.load('Immune_All_Low.pkl')
preds = celltypist.annotate(adata_immune, model=model, majority_voting=True)
adata_immune_result = preds.to_adata()
adata_immune.obs['cell_type_fine'] = adata_immune_result.obs['majority_voting']
Quick Reference
| Task | Tool | Key call |
|---|---|---|
| PBMC / immune automated | CellTypist | models.Model.load('Immune_All_Low.pkl') |
| Lung atlas mapping | CellTypist | 'Human_Lung_Atlas.pkl' |
| Gut / intestine | CellTypist | 'Cells_Intestinal_Tract.pkl' |
| Broad human ref (R) | SingleR | celldex::HumanPrimaryCellAtlasData() |
| Sorted immune ref (R) | SingleR | celldex::MonacoImmuneData() |
| Mouse immune (R) | SingleR | celldex::ImmGenData() |
| Query→reference transfer | scanpy | sc.tl.ingest(adata_q, adata_ref, obs='cell_type') |
| Batch-aware transfer | scvi-tools | SCANVI.load_query_data(...) |
| Marker review | scanpy | sc.pl.dotplot(adata, markers, groupby='leiden') |
| Confidence (CellTypist) | CellTypist | predictions.to_adata().obs['conf_score'] |
| Confidence (SingleR) | SingleR | is.na(pred$pruned.labels) flags low-quality |
Required inputs:
- CellTypist:
adata.X= log1p(CP10k) normalized, genes as HGNC symbols inadata.var_names. - SingleR:
SingleCellExperimentwith alogcountsassay. - scANVI: raw counts in
adata.layers['counts'],batch_key,labels_keyon reference.
Numeric Thresholds
| Metric | Threshold | Action |
|---|---|---|
CellTypist conf_score |
< 0.5 | Label as 'Unknown' |
CellTypist conf_score |
< 0.7 | Flag for manual review |
CellTypist p_thres |
0.5 (default) | Raise to 0.7 for stringent annotation |
| SingleR delta score | < median - 3×MAD | Pruned (set to NA) |
| Cluster size | < 20 cells | Do not assign rare subtypes; use parent label |
| Marker dotplot fraction | < 0.3 | Marker not reliably expressed; do not use for assignment |
| Expected proportion deviation | > 3× literature range | Suspect wrong model or normalization error |
Common Mistakes
Wrong: Feeding raw counts to CellTypist Right: Always run
sc.pp.normalize_total(adata, target_sum=1e4)+sc.pp.log1p(adata)before annotation Why: CellTypist expects log1p of counts normalized to 10,000/cell — raw or SCTransform'd data produces nonsense labelsWrong: Using a reference model that doesn't match the tissue or species Right: Match species (
Human_*vsMouse_*) and tissue type when selecting CellTypist models or SingleR references Why: UsingImmune_All_Lowon epithelial tumor cells orHumanPrimaryCellAtlasDataon mouse data produces confident but wrong labelsWrong: Passing Ensembl IDs or mixed-case gene symbols to CellTypist/SingleR Right: Convert
adata.var_namesto HGNC (human) or MGI (mouse) symbols before annotation Why: Ensembl IDs or wrong case (Cd3dvsCD3D) silently drop features, degrading model performance without warningWrong: Using
predictions.probability_matrix.max(axis=1)to get confidence scores Right: Usepredictions.to_adata().obs['conf_score']which is the stable API since CellTypist ≥1.3 Why: The probability matrix approach is fragile and version-dependent — it may break or return incorrect values across updatesWrong: Assigning a rare subtype label to a cluster of fewer than 20 cells Right: Collapse to the parent cell type or label as
'Unknown'when cluster size is below 20 cells Why: Rare subtype assignments on tiny clusters lack statistical power and are unreliable without strong distinguishing markersWrong: Forcing a label on clusters that lack clear markers after dotplot review Right: Keep ambiguous clusters as
'Unknown'rather than picking the next-best guess Why: Forced labels propagate errors into downstream analyses (DE, trajectory) and create false biological narrativesWrong: Running CellTypist with
majority_voting=Falseand using per-cell labels directly Right: Always enablemajority_voting=Trueto smooth labels over the local neighborhood graph Why: Per-cell predictions are noisy; majority voting leverages cluster structure to produce coherent, stable annotationsWrong: Running
sc.tl.ingestfor label transfer without batch correction Right: Integrate query and reference first, or use scANVI'sload_query_datapath which handles batch natively Why:ingestassumes query and reference share the same embedding — batch effects cause misalignment and wrong label transfersWrong: Not validating cell-type proportions against biological expectations Right: Always cross-check per-sample cell-type proportions against known tissue composition Why: A PBMC sample with 80% fibroblasts is a red flag indicating wrong model, normalization error, or data quality issues
Wrong: Annotating at one resolution only without hierarchical refinement Right: Annotate coarse compartments first (immune/stromal/epithelial), then refine each with specialized models or markers Why: Fine labels on poorly separated clusters are unstable — hierarchical annotation produces more reliable results
References
- CellTypist: Dominguez Conde et al. Science 2022, https://doi.org/10.1126/science.abl5197
- SingleR: Aran et al. Nat Immunol 2019, https://doi.org/10.1038/s41590-018-0276-y
- scANVI: Xu et al. Mol Syst Biol 2021, https://doi.org/10.15252/msb.20209620
- celldex: Aran et al. 2019, https://bioconductor.org/packages/celldex