Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

Annotation

🧠 Key takeaways
⚙️ Environment setup
Steps
yml
  1. Install conda:

    • Before creating the environment, ensure that conda is installed on your system.

  2. Save the yml content:

    • Copy the content from the yml tab into a file named environment.yml.

  3. Create the environment:

    • Open a terminal or command prompt.

    • Run the following command:

      conda env create -f environment.yml
  4. Activate the environment:

    • After the environment is created, activate it using:

      conda activate <environment_name>
    • Replace <environment_name> with the name specified in the environment.yml file. In the yml file it will look like this:

      name: <environment_name>
  5. Verify the installation:

    • Check that the environment was created successfully by running:

      conda env list
🗄️ Get data and notebooks

This book uses lamindb to store, share, and load datasets and notebooks using the theislab/sc-best-practices instance. We acknowledge free hosting from Lamin Labs.

  1. Install lamindb

    • Install the lamindb Python package:

    pip install lamindb
  2. Optionally create a lamin account

  3. Verify your setup

    • Run the lamin connect command:

    import lamindb as ln
    
    ln.Artifact.connect("theislab/sc-best-practices").df()

    You should now see up to 100 of the stored datasets.

  4. Accessing datasets (Artifacts)

    • Search for the datasets on the Artifacts page

    • Load an Artifact and the corresponding object:

    import lamindb as ln
    af = ln.Artifact.connect("theislab/sc-best-practices").get(key="key_of_dataset", is_latest=True)
    obj = af.load()

    The object is now accessible in memory and is ready for analysis. Adapt the lamindb.Artifact.connect("theislab/sc-best-practices").get("SOMEIDXXXX") suffix to get respective versions.

  5. Accessing notebooks (Transforms)

    lamin load <notebook url>

    which will download the notebook to the current working directory. Analogously to Artifacts, you can adapt the suffix ID to get older versions.

Motivation

To understand your data better and make use of existing knowledge, it is important to figure out the “cellular identity” of each of the cells in your data. The process of labeling groups of cells in your data based on known (or sometimes unknown) cellular phenotypes is called “cell annotation”. Whereas there are many ways to annotate your cells (e.g. based on batch, disease, sex and more), in this notebook we will focus on the annotation of “cell types”.

A cell type is a cellular phenotype that is robust across datasets, identifiable by specific marker genes or proteins, and tied to a distinct biological function. A classic example is the plasma B cell — a white blood cell that secretes antibodies and can be identified by characteristic markers.

However, like with any categorization the size of categories and the borders drawn between them are partly subjective and can change over time, e.g. because new technologies allow for a higher resolution view of cells, or because specific “sub-phenotypes” that were not considered biologically meaningful are found to have important biological implications (see e.g. Kadur Lakshminarasimha Murthy et al., 2022). Cell types are therefore often further classified into “subtypes” or “cell states” (e.g. activated versus resting) and some researchers use the term “cell identity” to avoid this sometimes arbitrary distinction. For a more detailed discussion of this topic, we recommend the review by Wagner et al. Wagner et al., 2016 and the recently published review by Zeng Zeng, 2022.

Similarly, multiple cell types can be part of a single continuum, where one cell type might transition or differentiate into another. For example, in hematopoiesis cells differentiate from a stem cell into a specific immune cell type. Although hard borders between early and late stages of this differentiation are often drawn, the state of these cells can more accurately be described by the differentiation coordinate between the less and more differentiated cellular phenotypes.

There are multiple ways to annotate cells. We will give an overview of the most widely used approaches below. As we are working with transcriptomic data, each of these methods is ultimately based on the expression of specific genes or gene sets, or general transcriptomic similarity between cells.

Environment setup

import shutil
import sys
from pathlib import Path

import celltypist
import lamindb as ln
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import pandas.core.indexes.base as pandas_indexes_base
import scanpy as sc
import scvi
import seaborn as sns
from cellmapper import CellMapper
from celltypist import models
from scipy.sparse import csr_matrix

ln.connect("theislab/sc-best-practices")

ln.track("BnpvfJLWjHuE")
Output
 the database (2.8.1) is ahead of your installed lamindb package (2.3.1)
 consider updating lamindb: pip install lamindb>=2.8
 connected lamindb: theislab/sc-best-practices
 loaded Transform('BnpvfJLWjHuE000A', key='annotation.ipynb'), re-started Run('fbTEuAJg0LXZL9u2') at 2026-08-06 14:20:41 UTC
 notebook imports: cellmapper==0.2.6 celltypist==1.6.3 lamindb-core==2.3.1 matplotlib==3.10.8 numba==0.64.0 numpy==2.4.3 pandas==2.3.3 scanpy==1.12 scipy==1.16.3 scvi-tools==1.3.1.post1 seaborn==0.13.2

We will continue working with the scRNA-seq dataset that we earlier preprocessed and will now annotate it.

Set figure parameters:

sc.set_figure_params(figsize=(5, 5))

Load data

Let’s read in the toy dataset we will use for this tutorial. It includes a single sample (“site4-donor8”) of the data also used in other parts of the book. Moreover, cells that didn’t pass QC have already been removed.

af = ln.Artifact.get(key="cellular_structure/s4d8_clustered.h5ad", is_latest=True)
adata = af.load(is_run_input=False)
---------------------------------------------------------------------------
ImportError                               Traceback (most recent call last)
Cell In[4], line 2
      1 af = ln.Artifact.get(key="cellular_structure/s4d8_clustered.h5ad", is_latest=True)
----> 2 adata = af.load(is_run_input=False)

File ~/miniforge3/envs/annotation-pd3-test/lib/python3.12/site-packages/lamindb/models/artifact.py:2868, in Artifact.load(self, is_run_input, mute, **kwargs)
   2866         access_memory.path = self._cache_path
   2867 else:
-> 2868     filepath, cache_key = _s().filepath_cache_key_from_artifact(
   2869         self, using_key=settings._using_key
   2870     )
   2871     cache_path = _synchronize_cleanup_on_error(
   2872         filepath, cache_key=cache_key, print_progress=not mute
   2873     )
   2874     try:
   2875         # cache_path is local so doesn't trigger any sync in load_to_memory

File ~/miniforge3/envs/annotation-pd3-test/lib/python3.12/site-packages/lamindb/models/artifact.py:111, in _s()
    109 global _storage_cache
    110 if _storage_cache is None:
--> 111     _storage_cache = _lazy_load_storage_module()
    112 return _storage_cache

File ~/miniforge3/envs/annotation-pd3-test/lib/python3.12/site-packages/lamindb/models/artifact.py:75, in _lazy_load_storage_module()
     73 def _lazy_load_storage_module():
     74     """Lazy-import storage to avoid loading pandas/anndata at package import."""
---> 75     from ..core.storage import (
     76         delete_storage,
     77         infer_suffix,
     78         write_to_disk,
     79     )
     80     from ..core.storage.paths import (
     81         AUTO_KEY_PREFIX,
     82         auto_storage_key_from_artifact,
   (...)     86         filepath_from_artifact,
     87     )
     89     return types.SimpleNamespace(
     90         delete_storage=delete_storage,
     91         infer_suffix=infer_suffix,
   (...)     98         filepath_from_artifact=filepath_from_artifact,
     99     )

File ~/miniforge3/envs/annotation-pd3-test/lib/python3.12/site-packages/lamindb/core/storage/__init__.py:16
      1 """Storage API.
      2 
      3 Valid suffixes.
   (...)     11 .. autoclass:: BackedAccessor
     12 """
     14 from lamindb_setup.core.upath import LocalPathClasses, UPath, infer_filesystem
---> 16 from ._backed_access import AnnDataAccessor, BackedAccessor, SpatialDataAccessor
     17 from ._tiledbsoma import save_tiledbsoma_experiment
     18 from ._valid_suffixes import VALID_SUFFIXES

File ~/miniforge3/envs/annotation-pd3-test/lib/python3.12/site-packages/lamindb/core/storage/_backed_access.py:10
      7 import h5py
      8 from anndata._io.specs.registry import get_spec
---> 10 from ._anndata_accessor import AnnDataAccessor, StorageType, registry
     11 from ._polars_lazy_df import POLARS_SUFFIXES, _open_polars_lazy_df
     12 from ._pyarrow_dataset import PYARROW_SUFFIXES, _open_pyarrow_dataset

File ~/miniforge3/envs/annotation-pd3-test/lib/python3.12/site-packages/lamindb/core/storage/_anndata_accessor.py:43
     41     from anndata._core.index import Index
     42 else:
---> 43     from anndata.compat import Index
     45 if anndata_version_parse < version.parse("0.10.0"):
     46     if anndata_version_parse < version.parse("0.9.1"):

ImportError: cannot import name 'Index' from 'anndata.compat' (/home/seokim/miniforge3/envs/annotation-pd3-test/lib/python3.12/site-packages/anndata/compat/__init__.py)

Manual annotation

The classical or oldest way to perform cell type annotation is based on a single or small set of marker genes known to be associated with a particular cell type. This approach dates back to “pre-scRNA-seq times”, when single-cell data was low-dimensional (e.g., FACS data with gene panels consisting of no more than 30-40 genes). However, when no unique markers exist for a specific cell type, this approach can quickly become challenging and less objective, with combinations of markers or expression thresholds necessary for proper annotation. A robust set of marker genes and prior knowledge or annotation experience can help here, but the approach comes with the risk of unclear and subjective decision-making.

For manual annotation, the data is usually clustered before annotation, so that we can annotate groups of cells instead of making a per-cell call. This is not only less laborious but also more robust to noise: a single cell might not have a count for a specific marker even if it was expressed in that cell, simply due to the inherent sparsity of single-cell data. Clustering enables the detection of cells highly similar in overall gene expression and can therefore account for drop-outs at the single-cell level.

Finally, there are two angles from which to approach the marker-gene-based annotation. One option is to work from a table of marker genes for all the cell types you expect in your data and check in which clusters those genes are expressed. The other option is to check which genes are highly expressed in the clusters you defined and then check if they are associated with known cell types or states. If necessary, one can move back and forth between those approaches. We will show examples of both below.

From markers to cluster annotation

First, we list a set of markers for cell types in the bone marrow that is based on literature: previous papers that study specific cell types and subtypes and report marker genes for those cell types. Note that markers at the protein level (e.g., used for FACS) sometimes do not work as well in transcriptomic data; hence, using markers from RNA-based papers is often more likely to work. Moreover, sometimes markers in one dataset do not turn out to work as well in other datasets. Ideally, a marker set is therefore validated across multiple datasets. It is often useful to work together with experts: as a bioinformatician, try to team up with a biologist who has more extensive knowledge of the tissue, the biology, the expected cell types and markers, etc.

# keys: cell types or populations, values: lists of marker genes
marker_genes = {
    "CD14+ Mono": ["FCN1", "CD14"],
    "CD16+ Mono": ["TCF7L2", "FCGR3A", "LYN"],
    "ID2-hi myeloid prog": [
        "CD14",
        "ID2",
        "VCAN",
        "S100A9",
        "CLEC12A",
        "KLF4",
        "PLAUR",
    ],
    "cDC1": ["CLEC9A", "CADM1"],
    "cDC2": [
        "CST3",
        "COTL1",
        "LYZ",
        "DMXL2",
        "CLEC10A",
        "FCER1A",
    ],  # Note: DMXL2 should be negative
    "Normoblast": ["SLC4A1", "SLC25A37", "HBB", "HBA2", "HBA1", "TFRC"],
    "Erythroblast": ["MKI67", "HBA1", "HBB"],
    "Proerythroblast": [
        "CDK6",
        "SYNGR1",
        "HBM",
        "GYPA",
    ],  # Note HBM and GYPA are negative markers
    "NK": ["GNLY", "NKG7", "CD247", "GRIK4", "FCER1G", "TYROBP", "KLRG1", "FCGR3A"],
    "ILC": ["ID2", "PLCG2", "GNLY", "SYNE1"],
    "Lymph prog": [
        "VPREB1",
        "MME",
        "EBF1",
        "SSBP2",
        "BACH2",
        "CD79B",
        "IGHM",
        "PAX5",
        "PRKCE",
        "DNTT",
        "IGLL1",
    ],
    "Naive CD20+ B": ["MS4A1", "IL4R", "IGHD", "FCRL1", "IGHM"],
    "B1 B": [
        "MS4A1",
        "SSPN",
        "ITGB1",
        "EPHA4",
        "COL4A4",
        "PRDM1",
        "IRF4",
        "CD38",
        "XBP1",
        "PAX5",
        "BCL11A",
        "BLK",
        "IGHD",
        "IGHM",
        "ZNF215",
    ],  # Note IGHD and IGHM are negative markers
    "Transitional B": ["MME", "CD38", "CD24", "ACSM3", "MSI2"],
    "Plasma cells": ["MZB1", "HSP90B1", "FNDC3B", "PRDM1", "IGKC", "JCHAIN"],
    "Plasmablast": ["XBP1", "RF4", "PRDM1", "PAX5"],  # Note PAX5 is a negative marker
    "CD4+ T activated": ["CD4", "IL7R", "TRBC2", "ITGB1"],
    "CD4+ T naive": ["CD4", "IL7R", "TRBC2", "CCR7"],
    "CD8+ T": ["CD8A", "CD8B", "GZMK", "GZMA", "CCL5", "GZMB", "GZMH", "GZMA"],
    "T activation": ["CD69", "CD38"],  # CD69 much better marker!
    "T naive": ["LEF1", "CCR7", "TCF7"],
    "pDC": ["GZMB", "IL3RA", "COBLL1", "TCF4"],
    "G/M prog": ["MPO", "BCL2", "KCNQ5", "CSF3R"],
    "HSC": ["NRIP1", "MECOM", "PROM1", "NKAIN2", "CD34"],
    "MK/E prog": [
        "ZNF385D",
        "ITGA2B",
        "RYR3",
        "PLCB1",
    ],  # Note PLCB1 is a negative marker
}

Subset to only the markers that were detected in our data. We will loop through all cell types and keep only the genes that we find in our adata object as markers for that cell type. This will prevent errors once we start plotting.

marker_genes_in_data = {}
for ct, markers in marker_genes.items():
    markers_found = []
    for marker in markers:
        if marker in adata.var.index:
            markers_found.append(marker)
    marker_genes_in_data[ct] = markers_found

To see where these markers are expressed, we can work with a 2-dimensional visualization of the data, such as a UMAP. We calculate such an embedding here based on the scran-normalized count data, using only the highly deviant genes. Note that we first perform a PCA on the normalized counts to reduce the dimensionality of the data before we generate the UMAP.

To start we store our raw counts in .layers['counts'], so that we will still have access to them later if needed. We then set our adata.X to the scran-normalized, log-transformed counts.

adata.layers["counts"] = adata.X
adata.X = adata.layers["scran_normalization"]

We furthermore set our adata.var.highly_variable to the highly deviant genes. Scanpy uses this var column in downstream calculations, such as the PCA below.

adata.var["highly_variable"] = adata.var["highly_deviant"]

Now perform PCA. We use the highly deviant genes (set as “highly variable” above) to reduce noise and strengthen signal in our data and set number of components to the default n=50. 50 is on the high side for data of a single sample, but it will ensure that we don’t ignore important variation in our data.

sc.tl.pca(adata, n_comps=50, use_highly_variable=True)

Calculate the neighbor graph based on the PCs:

sc.pp.neighbors(adata)

And use that neighbor graph to calculate a 2-dimensional UMAP embedding of the data:

sc.tl.umap(adata)

Now show expression of the markers using the calculated UMAP. We’ll limit ourselves to B/plasma cell subtypes for this example. Note from the marker dictionary above that there are three negative markers in our list: IGHD and IGHM for B1 B, and PAX5 for plasmablasts, meaning that this cell type is expected not to or to lowly express those markers.

Let’s list the B cell subtypes we want to show the markers for:

B_plasma_cts = [
    "Naive CD20+ B",
    "B1 B",
    "Transitional B",
    "Plasma cells",
    "Plasmablast",
]

And now plot one UMAP per marker for each of the B cell subtypes. Note that we can only plot the markers that are present in our data.

for ct in B_plasma_cts:
    print(f"{ct.upper()}:")  # print cell subtype name
    sc.pl.umap(
        adata,
        color=marker_genes_in_data[ct],
        vmin=0,
        vmax="p99",  # set vmax to the 99th percentile of the gene count instead of the maximum, to prevent outliers from making expression in other cells invisible. Note that this can cause problems for extremely lowly expressed genes.
        sort_order=False,  # do not plot highest expression on top, to not get a biased view of the mean expression among cells
        frameon=False,
        cmap="Reds",  # or choose another color map e.g. from here: https://matplotlib.org/stable/tutorials/colors/colormaps.html
    )
    print("\n\n\n")  # print white space for legibility
NAIVE CD20+ B:
<Figure size 1872x800 with 10 Axes>




B1 B:
<Figure size 1872x1600 with 28 Axes>




TRANSITIONAL B:
<Figure size 1872x800 with 10 Axes>




PLASMA CELLS:
<Figure size 1872x800 with 12 Axes>




PLASMABLAST:
<Figure size 1404x400 with 6 Axes>




Even markers for a single cell type are often expressed in different subsets of the data, i.e. individual markers are often not uniquely expressed in a single cell type. Rather, it is the intersection of those subsets that will tell you where your cell type of interest is.

Of note is that markers are often sparsely expressed, i.e., it is often only a subset of cells of a cell type in which a marker was detected. This is due to the nature of scRNA-seq data: we only sequence a small subset of the total amount of RNA molecules in the cell, and due to this subsampling, we will sometimes not sample transcripts from specific genes in a cell even if they were expressed in that cell. Therefore, we do not annotate single cells based on a minimum expression threshold of, e.g., a set of markers. Instead, we first subdivide the data into groups of similar cells (i.e., “partition” the data) by clustering, thereby accounting for “missing transcripts” of single genes and rather grouping based on overall transcriptomic similarity. We can then annotate those clusters based on their overall marker expression patterns.

Let us cluster our data now. We will use the Leiden algorithm Traag et al., 2019 as discussed in the Clustering chapter to define a grouping of our data into similar subsets of cells:

sc.tl.leiden(adata, resolution=1, key_added="leiden_1")
sc.pl.umap(adata, color="leiden_1")
<Figure size 400x400 with 1 Axes>

In case you want the partitioning to be finer, you can change the resolution to a higher level by changing the resolution parameter of the clustering:

sc.tl.leiden(adata, resolution=2, key_added="leiden_2")

You can also add the cluster numbers to the UMAP.

sc.pl.umap(adata, color="leiden_2", legend_loc="on data")
<Figure size 400x400 with 1 Axes>

This clustering is much finer and, in some cases, will help you annotate the data with more detail. You can play around with the resolution parameter to find the setting that best captures the marker expression patterns you observe. In our case, we will stick to the original resolution:

sc.pl.umap(adata, color="leiden_1", legend_loc="on data")
<Figure size 400x400 with 1 Axes>

Scrolling back up, you will see that cluster 3 consistently expresses Naive CD20+ B cell markers, while cluster 6 consistently expresses Transitional B cell markers.

We can also visualize this using a dotplot:

B_plasma_markers = {
    ct: [m for m in ct_markers if m in adata.var.index]
    for ct, ct_markers in marker_genes.items()
    if ct in B_plasma_cts
}
sc.pl.dotplot(
    adata,
    groupby="leiden_1",
    var_names=B_plasma_markers,
    standard_scale="var",  # standard scale: normalize each gene to range from 0 to 1
)
<Figure size 1136.8x416 with 5 Axes>

Using a combination of visual inspection of the UMAPs and the dotplot above we can now start annotating the clusters:

cl_annotation = {
    "3": "Naive CD20+ B",
    "6": "Transitional B",
}

You might notice that the annotation of B1 B cells is difficult, with none of the clusters expressing all the B1 B markers and several clusters expressing some of the markers. We often see that markers that work for one dataset do not work as well for others. This can be due to differences in sequencing depth, but also due to other sources of variation between datasets or samples.

Let’s visualize our annotations so far:

adata.obs["manual_celltype_annotation"] = adata.obs.leiden_1.map(cl_annotation)
sc.pl.umap(adata, color=["manual_celltype_annotation"])
... storing 'manual_celltype_annotation' as categorical
<Figure size 400x400 with 1 Axes>

From cluster differentially expressed genes to cluster annotation

Conversely, we can calculate marker genes per cluster and then look up whether we can link those marker genes to any known biology, such as cell types and/or states. For marker gene calculation of clusters, simple methods such as the Wilcoxon rank-sum test are thought to perform best Pullin & McCarthy, 2022. Importantly, as the definition of the clusters is based on the same data as used for these statistical tests, the p-values of these tests will be inflated as also described here Zhang et al., 2019.

Let’s calculate the differentially expressed genes for every cluster, compared to the rest of the cells in our adata:

sc.tl.rank_genes_groups(
    adata, groupby="leiden_1", method="wilcoxon", key_added="dea_leiden_1"
)

We can visualize expression of the top differentially expressed genes per cluster with a standard scanpy dotplot:

sc.tl.dendrogram(
    adata,
    groupby="leiden_1",
)

sc.pl.rank_genes_groups_dotplot(
    adata, groupby="leiden_1", standard_scale="var", n_genes=5, key="dea_leiden_1"
)
<Figure size 2000x416 with 6 Axes>

As you can see above, a lot of the differentially expressed genes are highly expressed in multiple clusters. We can filter the differentially expressed genes to select for more cluster-specific differentially expressed genes:

sc.tl.filter_rank_genes_groups(
    adata,
    min_in_group_fraction=0.2,
    max_out_group_fraction=0.2,
    key="dea_leiden_1",
    key_added="dea_leiden_1_filtered",
)
---------------------------------------------------------------------------
IndexError                                Traceback (most recent call last)
Cell In[26], line 1
----> 1 sc.tl.filter_rank_genes_groups(
      2     adata,
      3     min_in_group_fraction=0.2,
      4     max_out_group_fraction=0.2,
      5     key="dea_leiden_1",
      6     key_added="dea_leiden_1_filtered",
      7 )

    [... skipping hidden 1 frame]

File ~/miniforge3/envs/annotation-pd3-test/lib/python3.12/site-packages/scanpy/tools/_rank_genes_groups.py:900, in filter_rank_genes_groups(adata, key, groupby, use_raw, key_added, min_in_group_fraction, min_fold_change, max_out_group_fraction, compare_abs)
    897 var_names = gene_names[cluster].values
    899 if not use_logfolds or not use_fraction:
--> 900     sub_x = adata.raw[:, var_names].X if use_raw else adata[:, var_names].X
    901     in_group = (adata.obs[groupby] == cluster).to_numpy()
    902     x_in = sub_x[in_group]

File ~/miniforge3/envs/annotation-pd3-test/lib/python3.12/site-packages/anndata/_core/anndata.py:1011, in AnnData.__getitem__(self, index)
   1009 def __getitem__(self, index: Index) -> AnnData:
   1010     """Returns a sliced view of the object."""
-> 1011     oidx, vidx = self._normalize_indices(index)
   1012     return AnnData(self, oidx=oidx, vidx=vidx, asview=True)

File ~/miniforge3/envs/annotation-pd3-test/lib/python3.12/site-packages/anndata/_core/anndata.py:992, in AnnData._normalize_indices(self, index)
    991 def _normalize_indices(self, index: Index | None) -> tuple[slice, slice]:
--> 992     return _normalize_indices(index, self.obs_names, self.var_names)

File ~/miniforge3/envs/annotation-pd3-test/lib/python3.12/site-packages/anndata/_core/index.py:33, in _normalize_indices(index, names0, names1)
     31 ax0, ax1 = unpack_index(index)
     32 ax0 = _normalize_index(ax0, names0)
---> 33 ax1 = _normalize_index(ax1, names1)
     34 return ax0, ax1

File ~/miniforge3/envs/annotation-pd3-test/lib/python3.12/site-packages/anndata/_core/index.py:114, in _normalize_index(indexer, index)
    112         return positions  # np.ndarray[int]
    113 msg = f"Unknown indexer {indexer!r} of type {type(indexer)}"
--> 114 raise IndexError(msg)

IndexError: Unknown indexer <ArrowStringArray>
[    'IL7R',    'CAMK4',   'INPP4B', 'ARHGAP15',     'RORA',     'ANK3',
     'TC2N',   'BCL11B',    'PDE3B',    'NELL2',
 ...
     'TCF4',    'HDAC9',    'MEF2C', 'HLA-DRB1',     'VAV3',    'JAZF1',
     'CD74',     'AFF3',      'LYN',     'ZEB2']
Length: 12850, dtype: str of type <class 'pandas.arrays.ArrowStringArray'>

Visualize the filtered genes :

sc.pl.rank_genes_groups_dotplot(
    adata,
    groupby="leiden_1",
    standard_scale="var",
    n_genes=5,
    key="dea_leiden_1_filtered",
)
<Figure size 2000x416 with 6 Axes>

Let’s take a look at cluster 9, which seems to have a set of relatively unique markers including CD247, MOM2, KLRD1, PRF1, and KLRF1. Some googling tells us that e.g. KLRD1 is a marker for NK cells Isola et al., 2021. In the UMAP we can see that these genes are expressed throughout cluster 9:

sc.pl.umap(
    adata,
    color=["CD247", "MYOM2", "KLRD1", "PRF1", "KLRF1", "leiden_1"],
    vmax="p99",
    legend_loc="on data",
    frameon=False,
    cmap="Reds",
)
<Figure size 1872x800 with 11 Axes>

However, marker-based annotation can be sensitive to the cluster resolution you choose, the robustness and uniqueness of the marker sets you have, and your knowledge of the cell types to be expected in your data. Therefore, cluster annotations are not guaranteed to be definitive and should be interpreted with caution.

For this reason, the field is partly trying to move away from manual cluster annotation and rather moving towards automated annotation algorithms instead. The rest of this tutorial will focus on those options.

Before we move on, store the final bit of annotation information in our adata:

cl_annotation["8"] = "NK cells (?)"
adata.obs["manual_celltype_annotation"] = adata.obs.leiden_1.map(cl_annotation)

Automated annotation

General remarks

The remainder of the discussed methods will be methods for automated, rather than manual annotation of your data. Automated approaches are based on different principles, sometimes requiring pre-defined sets of markers, other times trained on pre-existing full scRNA-seq datasets. As discussed below, the resulting annotations can be of varying quality. It is therefore important to regard these methods as a starting point rather than an end-point of the annotation process. See also several reviews Pasquini et al., 2021, Abdelaal et al., 2019 for a more elaborate discussion of automated annotation methods.

The quality of automatically generated annotations can vary substantially. More specifically, the quality of the annotations depends on:

  1. The type of classifier chosen: Previous benchmark studies have shown that different types of classifiers often perform comparably, with neural network-based methods generally not outperforming general-purpose models such as support vector machines or linear regression modelsAbdelaal et al., 2019, Pasquini et al., 2021, Huang & Zhang, 2021.

  2. The quality of the data that the classifier was trained on. If the training data was not well annotated or annotated at low resolution, the classifier will do the same. Similarly, if the training data and/or its annotation was noisy, the classifier might not perform well.

  3. The similarity of your own data to the data that the classifier was trained on. For example, if the classifier was trained on a drop-seq single cell dataset and your data is 10X single nucleus rather than single cell drop-seq, this might worsen the quality of the annotation. Classifiers trained on cross-dataset atlases including a diversity of datasets might give more robust and better quality annotations than classifiers trained on a single dataset. An example is the CellTypist (an automated annotation method that will be discussed more extensively below) classifier trained on the Human Lung Cell Atlas Sikkema et al., 2023 which includes 14 different lung datasets. This model is likely to perform better on new lung data than a model that was trained on a single lung dataset.

The aforementioned points highlight possible disadvantages of using classifiers, depending on the training data and model type. Nonetheless, there are several important advantages of using pre-trained classifiers to annotate your data. First, it is a fast and easy way to annotate your data. The annotation does not require the downloading nor preprocessing of the training data and sometimes merely involves the upload of your data to an online webpage. Second, these methods don’t rely on a partitioning of your data into clusters, as the manual annotation does. Third, pre-trained classifiers enable you to directly leverage the knowledge and information from previous studies, such as a high quality annotation. And finally, using such classifiers can help with harmonizing cell-type definitions across a field, thereby clearing the path towards a field-wide consensus on these definitions.

Finally, as these classifiers are often less transparent than e.g. manual marker-based annotation, a good uncertainty measure quantifying annotation uncertainty will improve the quality and usability of the method. We will discuss this more extensively further down.

Marker gene-based classifiers

One class of automated cell type annotation methods relies on a predefined set of marker genes. Cells are classified into cell types based on their expression levels of these marker genes. Examples of such methods are Garnett Pliner et al., 2019 and CellAssign Zhang et al., 2019. The more robust and generalizable the set of marker genes these models are based on, the better the model will perform. However, like with other models they are likely to be affected by batch effect-related differences between the data the model was trained on and the data that needs to be labeled. One of the advantages of these methods compared to models based on larger gene sets (see below) is that they are more transparent: we know on the basis of which genes the classification is done.
We will not show an example of marker-based classifiers in this notebook, but encourage you to explore these yourself if you are interested.

Classifiers based on a wider set of genes

It is worth noting that the methods discussed so far use only a small subset of the genes detected in the data: often a set of only 1 to ~10 marker genes per cell type is used. An alternative approach is to use a classifier that takes as input a larger set of genes (several thousands or more), thereby making more use of the breadth of scRNA-seq data. Such classifiers are trained on previously annotated datasets or atlases. Examples of these are CellTypist Conde et al., 2022 (see also https://www.celltypist.org, where data can be uploaded to a portal to get automated cell annotations) and Clustifyr Fu et al., 2020.

Let’s try out CellTypist on our data. Based on the CellTypist tutorial (https://www.celltypist.org/tutorials) we know we need to prepare our data so that counts are normalized to 10,000 counts per cell, then log1p-transformed:

adata_celltypist = adata.copy()  # make a copy of our adata
adata_celltypist.X = adata.layers["counts"]  # set adata.X to raw counts
sc.pp.normalize_total(
    adata_celltypist, target_sum=10**4
)  # normalize to 10,000 counts per cell
sc.pp.log1p(adata_celltypist)  # log-transform
# make .X dense instead of sparse, for compatibility with celltypist:
adata_celltypist.X = adata_celltypist.X.toarray()

We’ll now download the celltypist models for immune cells:

models.download_models(
    force_update=True, model=["Immune_All_Low.pkl", "Immune_All_High.pkl"]
)
Output
📜 Retrieving model list from server https://celltypist.cog.sanger.ac.uk/models/models.json
📚 Total models in list: 61
📂 Storing models in /home/seokim/.celltypist/data/models
💾 Total models to download: 2
💾 Downloading model [1/2]: Immune_All_Low.pkl
💾 Downloading model [2/2]: Immune_All_High.pkl

Let’s try out both the Immune_All_Low and Immune_All_High models (these annotate immune cell types finer annotation level (low) and coarser (high)):

model_low = models.Model.load(model="Immune_All_Low.pkl")
model_high = models.Model.load(model="Immune_All_High.pkl")

For each of these, we can see which cell types it includes to see if bone marrow cell types are included:

model_high.cell_types
array(['B cells', 'B-cell lineage', 'Cycling cells', 'DC', 'DC precursor', 'Double-negative thymocytes', 'Double-positive thymocytes', 'ETP', 'Early MK', 'Endothelial cells', 'Epithelial cells', 'Erythrocytes', 'Erythroid', 'Fibroblasts', 'Granulocytes', 'HSC/MPP', 'ILC', 'ILC precursor', 'MNP', 'Macrophages', 'Mast cells', 'Megakaryocyte precursor', 'Megakaryocytes/platelets', 'Mono-mac', 'Monocyte precursor', 'Monocytes', 'Myelocytes', 'Plasma cells', 'Promyelocytes', 'T cells', 'pDC', 'pDC precursor'], dtype=object)
model_low.cell_types
array(['Age-associated B cells', 'Alveolar macrophages', 'B cells', 'CD16+ NK cells', 'CD16- NK cells', 'CD8a/a', 'CD8a/b(entry)', 'CMP', 'CRTAM+ gamma-delta T cells', 'Classical monocytes', 'Cycling B cells', 'Cycling DCs', 'Cycling NK cells', 'Cycling T cells', 'Cycling gamma-delta T cells', 'Cycling monocytes', 'DC', 'DC precursor', 'DC1', 'DC2', 'DC3', 'Double-negative thymocytes', 'Double-positive thymocytes', 'ELP', 'ETP', 'Early MK', 'Early erythroid', 'Early lymphoid/T lymphoid', 'Endothelial cells', 'Epithelial cells', 'Erythrocytes', 'Erythrophagocytic macrophages', 'Fibroblasts', 'Follicular B cells', 'Follicular helper T cells', 'GMP', 'Germinal center B cells', 'Granulocytes', 'HSC/MPP', 'Hofbauer cells', 'ILC', 'ILC precursor', 'ILC1', 'ILC2', 'ILC3', 'Intermediate macrophages', 'Intestinal macrophages', 'Kidney-resident macrophages', 'Kupffer cells', 'Large pre-B cells', 'Late erythroid', 'MAIT cells', 'MEMP', 'MNP', 'Macrophages', 'Mast cells', 'Megakaryocyte precursor', 'Megakaryocyte-erythroid-mast cell progenitor', 'Megakaryocytes/platelets', 'Memory B cells', 'Memory CD4+ cytotoxic T cells', 'Mid erythroid', 'Migratory DCs', 'Mono-mac', 'Monocyte precursor', 'Monocytes', 'Myelocytes', 'NK cells', 'NKT cells', 'Naive B cells', 'Neutrophil-myeloid progenitor', 'Neutrophils', 'Non-classical monocytes', 'Plasma cells', 'Plasmablasts', 'Pre-pro-B cells', 'Pro-B cells', 'Proliferative germinal center B cells', 'Promyelocytes', 'Regulatory T cells', 'Small pre-B cells', 'T(agonist)', 'Tcm/Naive cytotoxic T cells', 'Tcm/Naive helper T cells', 'Tem/Effector helper T cells', 'Tem/Effector helper T cells PD1+', 'Tem/Temra cytotoxic T cells', 'Tem/Trm cytotoxic T cells', 'Transitional B cells', 'Transitional DC', 'Transitional NK', 'Treg(diff)', 'Trm cytotoxic T cells', 'Type 1 helper T cells', 'Type 17 helper T cells', 'gamma-delta T cells', 'pDC', 'pDC precursor'], dtype=object)

Looks like the models include many different immune cell type progenitors!

Now let’s run the models. First the coarse one:

predictions_high = celltypist.annotate(
    adata_celltypist, model=model_high, majority_voting=True
)
🔬 Input data has 8874 cells and 12850 genes
🔗 Matching reference genes in the model
🧬 4237 features used for prediction
⚖️ Scaling input data
🖋️ Predicting labels
✅ Prediction done!
👀 Detected a neighborhood graph in the input object, will run over-clustering on the basis of it
⛓️ Over-clustering input data with resolution set to 10
🗳️ Majority voting the predictions
✅ Majority voting done!

Transform the predictions to adata to get the full output...

predictions_high_adata = predictions_high.to_adata()

...and copy the results to our original AnnData object:

adata.obs["celltypist_cell_label_coarse"] = predictions_high_adata.obs.loc[
    adata.obs.index, "majority_voting"
]
adata.obs["celltypist_conf_score_coarse"] = predictions_high_adata.obs.loc[
    adata.obs.index, "conf_score"
]

Now the same for the finer annotations:

predictions_low = celltypist.annotate(
    adata_celltypist, model=model_low, majority_voting=True
)
🔬 Input data has 8874 cells and 12850 genes
🔗 Matching reference genes in the model
🧬 4237 features used for prediction
⚖️ Scaling input data
🖋️ Predicting labels
✅ Prediction done!
👀 Detected a neighborhood graph in the input object, will run over-clustering on the basis of it
⛓️ Over-clustering input data with resolution set to 10
🗳️ Majority voting the predictions
✅ Majority voting done!
predictions_low_adata = predictions_low.to_adata()
adata.obs["celltypist_cell_label_fine"] = predictions_low_adata.obs.loc[
    adata.obs.index, "majority_voting"
]
adata.obs["celltypist_conf_score_fine"] = predictions_low_adata.obs.loc[
    adata.obs.index, "conf_score"
]

Now plot:

sc.pl.umap(
    adata,
    color=["celltypist_cell_label_coarse", "celltypist_conf_score_coarse"],
    frameon=False,
    sort_order=False,
    wspace=1,
)
... storing 'manual_celltype_annotation' as categorical
<Figure size 1600x400 with 3 Axes>
sc.pl.umap(
    adata,
    color=["celltypist_cell_label_fine", "celltypist_conf_score_fine"],
    frameon=False,
    sort_order=False,
    wspace=1,
)
<Figure size 1600x400 with 3 Axes>

One way of getting a feeling for the quality of these annotations is by looking if the observed cell type similarities correspond to our expectations:

sc.tl.dendrogram(
    adata,
    groupby="celltypist_cell_label_fine",
)

sc.pl.dendrogram(adata, groupby="celltypist_cell_label_fine")
<Figure size 400x400 with 1 Axes>
<Axes: >

This dendrogram shows us that the cells are well clustered with each other (e.g., B cells largely clustering together). It is very important to check the automated annotation manually before simply taking them!

Annotation by mapping to a reference

A final way to annotate your data is based on mapping your data to an existing, annotated single-cell reference and then performing label transfer using the resulting joint embedding. This reference can for example be a single sample that you annotated manually before, after which you would like to transfer those annotations to the rest of your dataset. Alternatively, it can be a published and ideally well-curated existing reference. In this context we refer to the “new data”, i.e. the data to be mapped and annotated, as the “query”.

There are multiple existing methods that perform such “query-to-reference mapping”, including scArches Lotfollahi et al., 2022, Symphony Kang et al., 2021, and Azimuth (Seurat) Hao et al., 2021. All of these methods enable you to map a new dataset to an existing reference without needing to reintegrate the data from the reference and without needing access to the full reference data.

As query-to-reference mapping involves embedding new data into an existing low-dimensional representation of the reference data, the dimensions and axes of that low-dimensional representation are largely pre-defined before learning from the query. Therefore, learning and incorporating unseen variation that might be present in the query (both new biological variation, e.g. unseen cell types or states and new technical variation, i.e. unseen batch effects that need to be removed) can be a challenge for these models. As a result, integration of the query data with the reference might not always be optimal and batch effects might not be fully removed from the joint query-reference embedding. However, as cell type label transfer does not necessarily require perfect integration but merely close proximity of identical cell types in the embedding, even an imperfect mapping can still be extremely helpful in annotating your data.

We will be using cellmapper Lange, 2025, specifically with scArches as the kernel method for reference-mapping-based label transfer to the new data ()“query”). This takes as its basis an existing (variational autoencoder-based) model that embeds the reference data in a low-dimensional, batch-corrected space. It then slightly extends that model to enable the mapping of an unseen dataset into the same “latent space” (i.e. the low-dimensional embedding). This model extension also enables the learning and removal of batch effects present in the mapped dataset.

Let’s start by preparing our data for the mapping to a reference. scArches, the method that enables us to adapt an existing reference model to new data, requires raw, non-normalized counts. We will therefore keep our counts layer and remove all other layers from our adata to map. We will set our .X to those raw counts as well.

adata_to_map = adata.copy()
for layer in list(adata_to_map.layers.keys()):
    if layer != "counts":
        del adata_to_map.layers[layer]
adata_to_map.X = adata_to_map.layers["counts"]

Moreover, it is important that we use the same input features (i.e. genes) as were used for training our reference model and that we put those features in the same order. The reference model’s feature information is stored together with the model. Let’s load the feature table.

af = ln.Artifact.connect("theislab/sc-best-practices").get(
    key="cellular_structure/annotation_reference_features.csv", is_latest=True
)
reference_model_features = af.load()
 the database (2.8.1) is ahead of your installed lamindb package (2.3.1)
 consider updating lamindb: pip install lamindb>=2.8

The table has both gene names and gene IDs. As gene IDs are usually less subject to change over genome annotation versions, we will use those to subset our data. We will therefore set our row names for both our adata and the reference model features to gene_ids. Importantly, we have to make sure to also store the gene names for later use: these are much easier to understand than the gene IDs.

adata_to_map.var["gene_names"] = adata_to_map.var.index
adata_to_map.var.set_index("gene_id", inplace=True)
reference_model_features["gene_names"] = reference_model_features.index
reference_model_features.set_index("gene_ids", inplace=True)

Now, let’s check if we have all the genes we need in our query data:

print("Total number of genes needed for mapping:", reference_model_features.shape[0])
Total number of genes needed for mapping: 4000
print(
    "Number of genes found in query dataset:",
    adata_to_map.var.index.isin(reference_model_features.index).sum(),
)
Number of genes found in query dataset: 2725

We are missing some genes. We will manually add those and set their counts to 0, as it seems like these genes were not detected in our data. Let’s create an AnnData object for those missing genes with only zero values (including our raw counts layer, which will be used for the mapping). We will concatenate that to our own AnnData objects afterwards.

missing_genes = [
    gene_id
    for gene_id in reference_model_features.index
    if gene_id not in adata_to_map.var.index
]
missing_gene_adata = sc.AnnData(
    X=csr_matrix(np.zeros(shape=(adata.n_obs, len(missing_genes))), dtype="float32"),
    obs=adata.obs.iloc[:, :1],
    var=reference_model_features.loc[missing_genes, :],
)
missing_gene_adata.layers["counts"] = missing_gene_adata.X

Concatenate our original adata to the missing genes adata. To make sure we can do this concatenation without errors, we’ll remove the PCA matrix from varm.

if "PCs" in adata_to_map.varm.keys():
    del adata_to_map.varm["PCs"]
adata_to_map_augmented = sc.concat(
    [adata_to_map, missing_gene_adata],
    axis=1,
    join="outer",
    index_unique=None,
    merge="unique",
)

Now subset to the genes used in the model and order correctly:

adata_to_map_augmented = adata_to_map_augmented[
    :, reference_model_features.index
].copy()

Check if our adata gene names correspond exactly to the required gene order:

bool((adata_to_map_augmented.var.index == reference_model_features.index).all())
True

We can now set the gene indices back to gene names for easy interpretation:

adata_to_map_augmented.var["gene_ids"] = adata_to_map_augmented.var.index
adata_to_map_augmented.var.set_index("gene_names", inplace=True)

Finally, this reference model used adata.obs['batch'] as our batch variable. We will therefore check that we have this set to one value for our entire sample:

adata_to_map_augmented.obs.batch.unique()
['s4d8'] Categories (1, object): ['s4d8']

Now let’s talk about our reference model. The better our reference model, the better our label transfer will perform. Using well-annotated reference that integrates many different datasets and that matches your data well (same organ, same single-cell technology etc.) is ideal: such models are trained on a variety of datasets and batches and are therefore expected to be more robust to batch effects. However, such references do not exist yet for all tissues. For this tutorial we will use a reference model trained on the bone marrow samples that we have been using throughout the book, excluding the sample that we will be mapping. The reference model is an scvi-model (used for data integration) that generates a low-dimensional, integrated embedding of the input data, see also the scvi-publication Lopez et al., 2018. Note that this is a toy model generated for this tutorial and it should not be used in other contexts.

We will start by patching things up for compatibility, because this model was built on previous version of pandas.

sys.modules["pandas.core.indexes.numeric"] = pandas_indexes_base
pandas_indexes_base.Int64Index = pd.Index
pandas_indexes_base.Float64Index = pd.Index

Now, let’s load the model and pass it the adata which we want to map.

af = ln.Artifact.connect("theislab/sc-best-practices").get(
    key="cellular_structure/annotation_reference_model.pt", is_latest=True
)
annotation_ref_model_path = af.cache()

model_dir = Path("./reference_model")
model_dir.mkdir(parents=True, exist_ok=True)

shutil.copy(annotation_ref_model_path, model_dir / "model.pt")
 the database (2.8.1) is ahead of your installed lamindb package (2.3.1)
 consider updating lamindb: pip install lamindb>=2.8
PosixPath('reference_model/model.pt')
query_model = scvi.model.SCVI.load_query_data(
    adata=adata_to_map_augmented,
    reference_model=str(model_dir),
    freeze_dropout=True,
)
INFO     File reference_model/model.pt already downloaded                                                          

We will now update this reference model so that we can embed our own data (the “query”) in the same latent space as the reference. This requires training on our query data:

query_model.train(max_epochs=500, plan_kwargs={"weight_decay": 0.0})
INFO: GPU available: False, used: False
GPU available: False, used: False
INFO: TPU available: False, using: 0 TPU cores
TPU available: False, using: 0 TPU cores
INFO: 💡 Tip: For seamless cloud logging and experiment tracking, try installing [litlogger](https://pypi.org/project/litlogger/) to enable LitLogger, which logs metrics and artifacts automatically to the Lightning Experiments platform.
💡 Tip: For seamless cloud logging and experiment tracking, try installing [litlogger](https://pypi.org/project/litlogger/) to enable LitLogger, which logs metrics and artifacts automatically to the Lightning Experiments platform.
Epoch 500/500: 100%|██████████| 500/500 [22:44<00:00,  3.42s/it, v_num=1, train_loss_step=1.02e+3, train_loss_epoch=1e+3]
INFO: `Trainer.fit` stopped: `max_epochs=500` reached.
`Trainer.fit` stopped: `max_epochs=500` reached.
Epoch 500/500: 100%|██████████| 500/500 [22:44<00:00,  2.73s/it, v_num=1, train_loss_step=1.02e+3, train_loss_epoch=1e+3]

Now that we have updated the model, we can calculate the (ideally batch-corrected) latent representation of our query:

adata.obsm["X_scVI"] = query_model.get_latent_representation()

We can now use this newly calculated low-dimensional embedding as a basis for visualization and clustering. Let’s calculate the new UMAP using the scVI-based representation of the data.

sc.pp.neighbors(adata, use_rep="X_scVI")
sc.tl.umap(adata)

To see if the mapping-based UMAP makes general sense, let’s look at a few markers and if their expression is localized to specific parts of the UMAP:

sc.pl.umap(
    adata,
    color=["IGHD", "IGHM", "PRDM1"],
    vmin=0,
    vmax="p99",  # set vmax to the 99th percentile of the gene count instead of the maximum, to prevent outliers from making expression in other cells invisible. Note that this can cause problems for extremely lowly expressed genes.
    sort_order=False,  # do not plot highest expression on top, to not get a biased view of the mean expression among cells
    frameon=False,
    cmap="Reds",  # or choose another color map e.g. from here: https://matplotlib.org/stable/tutorials/colors/colormaps.html
)
<Figure size 1404x400 with 6 Axes>

Now the essential step is that we can combine the inferred latent space embedding of our query data with the existing reference embedding. Using this joint embedding, we will not only be able to e.g., visualize and cluster the two together, but we can also do label transfer from the query to the reference.

Let’s load the reference embedding: this is often made publicly available with existing atlases.

af = ln.Artifact.get(
    key="cellular_structure/annotation_reference_embedding.h5ad", is_latest=True
)
ref_emb = af.load(is_run_input=False)

We’ll store a variable specifying that these cells are from the reference.

ref_emb.obs["reference_or_query"] = "reference"

Let’s see what’s in this reference object:

ref_emb
AnnData object with n_obs × n_vars = 86332 × 10 obs: 'donor', 'batch', 'site', 'cell_type', 'reference_or_query' uns: 'neighbors', 'umap' obsm: 'X_umap' obsp: 'connectivities', 'distances'

As you can see it has only 10 dimensions (in .X) which together represent the latent space embedding of the reference cells. Our query embedding that we calculated for our own data also has 10 dimensions. The 10 dimensions of the reference and query are the same and can be combined!

Moreover, it has cell type labels in .obs['cell_type']. We will use these labels to annotate our own data.

To perform the label transfer, we will first concatenate the reference and query data using the 10-dimensional embedding. To get there, we will create the same type of AnnData object from our query data as we have from the reference (with the embedding under .X) and concatenate the two. With that, we can jointly analyze reference and query including doing transfer from one to the other.

adata_emb = sc.AnnData(X=adata.obsm["X_scVI"], obs=adata.obs)
adata_emb.obs["reference_or_query"] = "query"

We will set the cell type labels in .obs["cell_type"] as None, so that we can see how we can annotate cell types based on the surroundings.

adata_emb.obs["cell_type"] = None
emb_ref_query = sc.concat(
    [ref_emb, adata_emb],
    axis=0,
    join="outer",
    index_unique=None,
    merge="unique",
)

Let’s visualize the joint embedding with a UMAP.

sc.pp.neighbors(emb_ref_query)
sc.tl.umap(emb_ref_query)

We can visually get a first impression of whether the reference and query integrated well based on the UMAP:

sc.pl.umap(
    emb_ref_query,
    color=["reference_or_query"],
    sort_order=False,
    frameon=False,
)
... storing 'reference_or_query' as categorical
<Figure size 400x400 with 1 Axes>

The (partial) mixing of query and reference in this UMAP is a good sign! When mapping completely fails, you will often see a full separation of query and reference in the UMAP.

Now let’s look at the cell type annotations from the reference. All cells from the query are set to NA here as they don’t have annotations yet and shown in black.

We’ll make this figure a bit bigger so that we can read the legend well:

sc.set_figure_params(figsize=(8, 8))
sc.pl.umap(
    emb_ref_query,
    color=["cell_type"],
    sort_order=False,
    frameon=False,
    legend_loc="on data",
    legend_fontsize=10,
    na_color="black",
)
<Figure size 640x640 with 1 Axes>

As you can already tell from the UMAP, we can guess the cell type of each of our own cells (in black) by looking at which cell types from the reference surrounding it. This is exactly what a nearest-neighbor-graph-based label transfer approach does: for each query cell it checks what is the most common cell type among its neighboring reference cells. The higher the fraction of reference cells coming from a single cell type, the more confident the label transfer is.

Let’s perform the KNN-based label transfer.

First we set up the label transfer model:

cmapper = CellMapper(query=adata_emb, reference=ref_emb)
cmapper.compute_neighbors(
    use_rep="X",  # location of our joint embedding
    n_neighbors=15,
)
INFO     Initialized CellMapper with 8874 query cells and 86332 reference cells.                                   
WARNING  Using sklearn for neighbor search with large dataset (86332 cells). Consider using approximate k-NN search
         (e.g. pynndescent) or GPU acceleration (e.g. faiss or rapids)                                             
INFO     Using sklearn to compute 15 neighbors.                                                                    

Now we perform the label transfer:

cmapper.compute_mapping_matrix(kernel_method="scarches")
cmapper.map_obs(
    key="cell_type",  # obs column name for which to transfer labels
    prediction_postfix="_pred",
    confidence_postfix="_conf",
)
INFO     Computing mapping matrix using kernel method 'scarches'.                                                  
INFO     Mapping categorical data for key 'cell_type' using direct multiplication.                                 
INFO     Categorical data mapped and stored in query.obs['cell_type_pred'].                                        

And store the results in our adata:

adata_emb.obs["transf_cell_type"] = adata_emb.obs["cell_type_pred"]
adata_emb.obs["transf_cell_type_unc"] = 1 - adata_emb.obs["cell_type_conf"]

Let’s transfer the results to our query adata object which also has our UMAP and gene counts, so that we can visualize all of those together.

adata.obs.loc[adata_emb.obs.index, "transf_cell_type"] = adata_emb.obs[
    "transf_cell_type"
]
adata.obs.loc[adata_emb.obs.index, "transf_cell_type_unc"] = adata_emb.obs[
    "transf_cell_type_unc"
]
adata.obs["transf_cell_type_unc"] = adata.obs["transf_cell_type_unc"].astype(
    float
)  # ensure uncertainty is float, for compatibility with downstream plotting functions

We can now visualize the transferred labels in our previously calculated UMAP of our own data:

Let’s set the figure size smaller again:

sc.set_figure_params(figsize=(5, 5))
sc.pl.umap(adata, color="transf_cell_type", frameon=False)
<Figure size 400x400 with 1 Axes>

Based on the neighbors of each of our query cells we can not only guess the cell type these cells belong to, but also generate a measure for certainty of that label: if a cell has neighbors from several different cell types, our guess will be highly uncertain. This is relevant to assess to what extent we can “trust” the transferred labels! Let’s visualize the uncertainty scores:

sc.pl.umap(adata, color="transf_cell_type_unc", frameon=False)
<Figure size 400x400 with 2 Axes>

Let’s check for each cell type label how high the label transfer uncertainty levels were. This gives us a first impression of which annotations are more contentious/need more manual checks.

fig, ax = plt.subplots(figsize=(8, 3))
ct_order = (
    adata.obs.groupby("transf_cell_type")
    .agg({"transf_cell_type_unc": "median"})
    .sort_values(by="transf_cell_type_unc", ascending=False)
)
sns.boxplot(
    adata.obs,
    x="transf_cell_type",
    y="transf_cell_type_unc",
    color="grey",
    ax=ax,
    order=ct_order.index,
)
ax.tick_params(rotation=90, axis="x")
<Figure size 640x240 with 1 Axes>

You’ll notice that e.g. progenitor cells are often more difficult to distinguish than other cell types. Same for the rather unspecific category “Other T” cells in our annotations. We can see that pDC, a cell type that is known to be quite transcriptionally distinct and therefore easier to recognize and label, spreads from 0.0 to 0.5.

To incorporate this uncertainty information in our transferred labels, we can set cells with an uncertainty score above e.g. 0.2 to “unknown”:

adata.obs["transf_cell_type_certain"] = adata.obs.transf_cell_type.tolist()
adata.obs.loc[adata.obs.transf_cell_type_unc > 0.2, "transf_cell_type_certain"] = (
    "Unknown"
)

Let’s see what our annotations look like after this filtering. Note the Unknown color in the legend and the UMAP.

sc.pl.umap(adata, color="transf_cell_type_certain", frameon=False)
... storing 'transf_cell_type_certain' as categorical
<Figure size 400x400 with 1 Axes>

To ease legibility, we can color only the “unknown” cells. This will make it easier for us to see how many of those there are. You can do the same with any of the other cell type labels.

sc.pl.umap(adata, color="transf_cell_type_certain", groups="Unknown")
<Figure size 400x400 with 1 Axes>

There are quite many of them! These cells will need particularly careful manual reviewing. However, the low-uncertainty annotations surrounding the “unknown cells” will already give us a first idea of what cell type we can expect each cell to belong to.

Now let’s take a look at our more certain annotations. We will check for a few cell types (chosen at random here) to what extent the reference-transferred annotation matches our known marker genes from above. In reality, this should be done systematically for all annotations!

cell_types_to_check = [
    "CD14+ Mono",
    "cDC2",
    "NK",
    "B1 B",
    "CD4+ T activated",
    "T naive",
    "MK/E prog",
]

Conveniently, for each of these cell types we have markers in our dictionary. Let’s plot marker expression for all our newly annotated cell types. You will notice that marker expression generally corresponds to the automated annotations, a good sign!

sc.pl.dotplot(
    adata,
    var_names={
        ct: marker_genes_in_data[ct] for ct in cell_types_to_check
    },  # gene names grouped by cell type in a dictionary
    groupby="transf_cell_type_certain",
    standard_scale="var",  # normalize gene scores from 0 to 1
)
<Figure size 1314.4x640 with 5 Axes>

As you can see, the marker groups are generally most highly expressed in the cells annotated with the matching label. This means these labels are likely (at least partially) correct!

Let’s go back one more time to our UMAP colored by uncertainty:

sc.pl.umap(
    adata, color=["transf_cell_type_unc", "transf_cell_type_certain"], frameon=False
)
<Figure size 936x400 with 3 Axes>

The uncertainty not only helps us identify regions where the algorithm is unsure which cell type a cell belongs to (e.g., because it falls between two annotated phenotypes), but can also highlight unseen cell types or new cell states. For example, your reference might consist of healthy cells while your query could be from a diseased sample. The uncertainty score can then highlight disease-specific cell states, as they might not have neighbors from the reference that consistently come from a single cell type. Especially when your reference is based on a large dataset, the uncertainty score is useful for flagging parts of the query data that could be worth investigating. Reference-based label transfer not only helps you annotate your data but can also speed up its exploration and interpretation. However, as with any metric, these uncertainty scores are often imperfect and, in some cases, fail to highlight new cell types or states. For a more extensive discussion of uncertainty metrics, see e.g. Engelmann et al., 2022.

Like with any of the methods discussed in this notebook, the quality of the transferred annotations depends on the quality of the “training data” (in this case the reference) and its annotations, the quality of the model, and the match of your own data with the training data!

The quality of the transferred annotations should therefore always be validated with manual inspection using marker gene expression and refinement of the initial annotations might be needed.

# formatting the 'names' column as string, to prevent problems with saving to h5ad format
fields = adata.uns["dea_leiden_1_filtered"]["names"].dtype.names
for field in fields:
    adata.uns["dea_leiden_1_filtered"]["names"][field] = adata.uns[
        "dea_leiden_1_filtered"
    ]["names"][field].astype(str)


# pandas >=3.0 defaults to Arrow-backed strings, which anndata's h5ad writer
# does not yet support; cast back to plain numpy object strings before saving
dfs = [adata.obs, adata.var] + [
    v
    for m in (adata.obsm, adata.varm)
    for v in m.values()
    if isinstance(v, pd.DataFrame)
]
for df in dfs:
    df.index = df.index.astype(object)
    for col in df.columns:
        if pd.api.types.is_string_dtype(df[col]) and not isinstance(
            df[col].dtype, pd.CategoricalDtype
        ):
            df[col] = df[col].astype(object)
af = ln.Artifact.from_anndata(
    adata,
    key="cellular_structure/s4d8_annotated.h5ad",
    description="anndata after annotation",
).save()
af
 creating new artifact version for key 'cellular_structure/s4d8_annotated.h5ad' in storage 's3://lamin-eu-central-1/VPwcjx3CDAa2'
... uploading 7d6LdOoS4ZSxqQPw0004.h5ad: 100.0%
 replacing the existing cache path /home/seokim/.cache/lamindb/lamin-eu-central-1/VPwcjx3CDAa2/cellular_structure/s4d8_annotated.h5ad
Artifact(uid='7d6LdOoS4ZSxqQPw0004', key='cellular_structure/s4d8_annotated.h5ad', description='anndata after annotation', suffix='.h5ad', kind='dataset', otype='AnnData', size=394361264, hash='mTDzlHtwrYu0CZQrdC9so_', n_files=None, n_observations=8874, extra_data=None, branch_id=1, created_on_id=1, space_id=1, storage_id=1, run_id=115, schema_id=None, created_by_id=5, created_at=2026-08-06 15:01:44 UTC, is_locked=False, version_tag=None, is_latest=True)

Contributors

We gratefully acknowledge the contributions of:

Authors

  • Lisa Sikkema

  • Maren Büttner

  • Seo H. Kim

Reviewers

  • Lukas Heumos

References
  1. Kadur Lakshminarasimha Murthy, P., Sontake, V., Tata, A., Kobayashi, Y., Macadlo, L., Okuda, K., Conchola, A. S., Nakano, S., Gregory, S., Miller, L. A., Spence, J. R., Engelhardt, J. F., Boucher, R. C., Rock, J. R., Randell, S. H., & Tata, P. R. (2022). Human distal lung maps and lineage hierarchies reveal a bipotent progenitor. Nature, 604(7904), 111–119. 10.1038/s41586-022-04541-3
  2. Wagner, A., Regev, A., & Yosef, N. (2016). Revealing the vectors of cellular identity with single-cell genomics. Nature Biotechnology, 34(11), 1145–1160. 10.1038/nbt.3711
  3. Zeng, H. (2022). What is a cell type and how to define it? Cell, 185(15), 2739–2755. https://doi.org/10.1016/j.cell.2022.06.031
  4. Traag, V. A., Waltman, L., & van Eck, N. J. (2019). From Louvain to Leiden: guaranteeing well-connected communities. Scientific Reports, 9(1), 5233. 10.1038/s41598-019-41695-z
  5. Pullin, J. M., & McCarthy, D. J. (2022). A comparison of marker gene selection methods for single-cell RNA sequencing data. bioRxiv. 10.1101/2022.05.09.490241
  6. Zhang, J. M., Kamath, G. M., & Tse, D. N. (2019). Valid Post-clustering Differential Analysis for Single-Cell RNA-Seq. Cell Systems, 9(4), 383-392.e6. https://doi.org/10.1016/j.cels.2019.07.012
  7. Isola, I., Brasó-Maristany, F., Moreno, D. F., Mena, M.-P., Oliver-Calders, A., Paré, L., Rodríguez-Lobato, L. G., Martin-Antonio, B., Cibeira, M. T., Bladé, J., Rosiñol, L., Prat, A., Lozano, E., & Fernández De Larrea, C. (2021). Gene Expression Analysis of the Bone Marrow Microenvironment Reveals Distinct Immunotypes in Smoldering Multiple Myeloma Associated to Progression to Symptomatic Disease. Frontiers in Immunology, 12, 792609. 10.3389/fimmu.2021.792609
  8. Pasquini, G., Rojo Arias, J. E., Schäfer, P., & Busskamp, V. (2021). Automated methods for cell type annotation on scRNA-seq data. Computational and Structural Biotechnology Journal, 19, 961–969. https://doi.org/10.1016/j.csbj.2021.01.015
  9. Abdelaal, T., Michielsen, L., Cats, D., Hoogduin, D., Mei, H., Reinders, M. J. T., & Mahfouz, A. (2019). A comparison of automatic cell identification methods for single-cell RNA sequencing data. Genome Biology, 20(1), 194. 10.1186/s13059-019-1795-z
  10. Huang, Y., & Zhang, P. (2021). Evaluation of machine learning approaches for cell-type identification from single-cell transcriptomics data. Briefings in Bioinformatics. 10.1093/bib/bbab035
  11. Sikkema, L., Ramı́rez-Suástegui, C., Strobl, D. C., Gillett, T. E., Zappia, L., Madissoon, E., Markov, N. S., Zaragosi, L.-E., Ji, Y., Ansari, M., Arguel, M.-J., Apperloo, L., Banchero, M., Bécavin, C., Berg, M., Chichelnitskiy, E., i Mei-Chung, Collin, A., Gay, A. C. A., … and, F. J. T. (2023). An integrated cell atlas of the lung in health and disease. Nature Medicine. 10.1038/s41591-023-02327-2
  12. Pliner, H. A., Shendure, J., & Trapnell, C. (2019). Supervised classification enables rapid annotation of cell atlases. Nature Methods, 16(10), 983–986. 10.1038/s41592-019-0535-3
  13. Zhang, A. W., O’Flanagan, C., Chavez, E. A., Lim, J. L. P., Ceglia, N., McPherson, A., Wiens, M., Walters, P., Chan, T., Hewitson, B., Lai, D., Mottok, A., Sarkozy, C., Chong, L., Aoki, T., Wang, X., Weng, A. P., McAlpine, J. N., Aparicio, S., … Shah, S. P. (2019). Probabilistic cell-type assignment of single-cell RNA-seq for tumor microenvironment profiling. Nature Methods, 16(10), 1007–1015. 10.1038/s41592-019-0529-1
  14. Conde, C. D., Xu, C., Jarvis, L. B., Rainbow, D. B., Wells, S. B., Gomes, T., Howlett, S. K., Suchanek, O., Polanski, K., King, H. W., Mamanova, L., Huang, N., Szabo, P. A., Richardson, L., Bolt, L., Fasouli, E. S., Mahbubani, K. T., Prete, M., Tuck, L., … S. A. Teichmann. (2022). Cross-tissue immune cell analysis reveals tissue-specific features in humans. Science, 376(6594), eabl5197. 10.1126/science.abl5197
  15. Fu, R., Gillen, A. E., Sheridan, R. M., Tian, C., Daya, M., Hao, Y., Hesselberth, J. R., & Riemondy, K. A. (2020). clustifyr: an R package for automated single-cell RNA sequencing cluster classification. F1000Research, 9, 223. 10.12688/f1000research.22969.2