# Bio Applied Cell Type Annotation

> 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.

- Skill: `pavel-kravchenko/bio-applied-cell-type-annotation` (Agent Skill)
- Install (CLI): `npx skillmds@latest add pavel-kravchenko/bio-applied-cell-type-annotation`
- Raw SKILL.md: https://api.skillmd.com/api/skills/pavel-kravchenko/bio-applied-cell-type-annotation/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: pavel-kravchenko (https://skillmd.com/u/pavel-kravchenko)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/pavel-kravchenko/bio-applied-cell-type-annotation

---


# 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).

```python
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.

```r
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.

```python
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.

```python
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=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

