Skip to content

Marker-based cell type prediction

predictCellTypeByMarker takes a {cell type: [genes]} dictionary and labels cells by scoring each set. Three choices decide how well it works, and this page measures each rather than asserting it: which embedding the scores are smoothed in, how specific the marker genes are, and where the markers come from.

import numpy as np
import pandas as pd
import piaso, cosg
from sklearn.metrics import accuracy_score, silhouette_score
piaso.settings.set_figure_params(style="cell")

1. A reference with labels — and with batches

ref = piaso.data.load_dataset("adult_cortex_multiome_rna")
ref.shape, ref.obs["CellTypes"].nunique(), dict(ref.obs["Sample"].value_counts())
((17412, 26205), 20,
{'1c_TST_NP40_004': 5071, '1c_UC': 4920, '1c_TST': 4364,
'1c_without_permiabilization': 2139, '1c': 918})

Five preparation protocols, from 918 to 5,071 cells. Worth knowing before embedding anything.

piaso.tl.infog(ref, layer="raw", n_top_genes=3000)

layer="raw" is not optional. This dataset keeps counts in a layer and the integrated matrix in .X; check piaso.data.dataset_info(...)["counts_layer"] before normalizing anything from the registry. Getting it wrong here does not raise — it silently normalizes already-normalized values.

2. The embedding

piaso.tl.runSVD(ref, layer="infog", n_components=50, key_added="X_svd")
piaso.tl.runGDR(ref, batch_key="Sample", groupby=None,
layer="infog",
infog_layer="raw", # per-batch INFOG needs the counts
score_layer="infog",
n_gene=30, key_added="X_gdr", save_reference=True)

batch_key="Sample" makes marker selection run within each protocol, so a gene that only looks specific because of one protocol’s chemistry cannot become an axis. groupby=None lets GDR find its own groups per batch.

Note infog_layer="raw" again — GDR re-runs INFOG inside each batch, and pointing it at .X here produces NaN and a failed SVD. Same trap, second place.

Measure both embeddings on both questions:

silhouette, Sample (lower better)silhouette, CellTypes (higher better)
SVD−0.1850.199
GDR−0.0190.344

Neither embedding separates the protocols — both Sample silhouettes are negative, so there is no batch effect to correct here. What GDR buys is cell type structure: 0.344 against 0.199, a 1.7× improvement on the same cells. That is the argument for it on this dataset, and it is a different argument from batch correction.

piaso.tl.neighbors(ref, use_rep="X_gdr", n_neighbors=15)
piaso.tl.umap(ref, use_rep="X_gdr")
piaso.pl.embedding(ref, basis="X_umap", color=["Sample", "CellTypes"])
GDR embedding by sample and cell type
piaso.pl.stackedBarplot(ref, groupby="CellTypes", splitby="Sample")
Protocol composition per cell type

The stacked bars are the check the UMAP cannot give you: if one cell type came overwhelmingly from one protocol, its markers would be protocol markers.

3. Marker genes, and how specific to make them

COSG’s mu penalises genes that are expressed outside the target group. The default is permissive; raising it trades sensitivity for specificity.

for mu in (1, 10, 100):
cosg.cosg(ref, key_added=f"cosg_mu{mu}", groupby="CellTypes",
n_genes_user=30 if mu > 1 else 50, mu=mu)

Top-30 overlap between mu=1 and mu=10 is 0.87 — about one gene in eight changes. The ones that change are the point:

cell typemu=1mu=10
PV6330411D24Rik, Cemip, Pvalb, Kcnc1, Btbd11, Gpr1766330411D24Rik, Pvalb, Tac1, Cemip, Adamts15, Syt2
AstrocyteAtp1a2, Plpp3, Gja1, Ntsr2, Sparcl1, Prex2Ntsr2, Gja1, Atp1a2, Cldn10, Prex2, Slc39a12

At mu=10, PV gains Tac1 and Syt2 — both genuine parvalbumin-interneuron genes — and Pvalb itself moves up. Astrocytes gain Cldn10. The permissive setting was spending slots on genes that are merely higher in the group rather than restricted to it.

30 genes at mu=10 is the setting used below.

names = pd.DataFrame(ref.uns["cosg_mu10"]["names"])
marker_sets = {c: list(names[c]) for c in names.columns}

4. Predict, and check against held-out truth

rng = np.random.default_rng(0)
query = ref[rng.random(ref.n_obs) >= 0.5].copy()
piaso.tl.predictCellTypeByMarker(query, marker_gene_set=marker_sets,
score_layer="infog", use_rep="X_gdr",
key_added="predicted")
accuracy_score(query.obs["CellTypes"].astype(str),
query.obs["predicted"].astype(str))
0.954

95.4% across 8,738 held-out cells and 20 types. For comparison, the same data with an SVD embedding and 50 markers at mu=1 gives 0.915 — the embedding and the marker specificity are worth about four points together.

per_type = (pd.DataFrame({"t": query.obs["CellTypes"].astype(str),
"p": query.obs["predicted"].astype(str)})
.groupby("t").apply(lambda d: (d.t == d.p).mean()).sort_values())
worstbest
L4-5 IT0.578Macrophage1.000
L6b0.779Astrocyte0.997
L6 IT0.812OPC0.995
L2-3 IT0.943Microglia0.992
L5 IT0.945VIP0.991

The pattern from before survives — every remaining error is an excitatory neuron confused with an adjacent cortical layer, and every glial, vascular and inhibitory type is near-perfect — but it is much weaker. L6b went from 0.30 to 0.78. L4-5 IT is the residue: a type defined as the boundary between two others is the hardest thing to call, and no embedding fixes that.

piaso.pl.plotConfusionMatrix(query, groupby_query="predicted",
groupby_reference="CellTypes")
Confusion matrix

The confusion matrix says it in one look: everything is on the diagonal except a block among the IT layers.

piaso.pl.sankey(query, left="CellTypes", right="predicted")
Sankey of true to predicted labels

A Sankey answers a different question from a confusion matrix — it shows where the cells went, weighted by how many there are, so a 58% accurate type with few cells does not dominate the picture.

metrics = pd.DataFrame({"accuracy": per_type,
"n_cells": query.obs["CellTypes"].value_counts()}).dropna()
piaso.pl.plotGroupMetrics(metrics)
Accuracy and size per type

Plotting accuracy against group size together is the honest summary: it shows at a glance that the poor calls are not the rare types.

5. The same call against PIASOmarkerDB

Nothing changes except where the dictionary comes from:

markers_df, db = piaso.tl.getMarkers(study="AllenWholeMouseBrain_isocortex",
as_dict=True)
covered = {k: [g for g in v if g in query.var_names] for k, v in db.items()}
covered = {k: v for k, v in covered.items() if len(v) >= 5}
piaso.tl.predictCellTypeByMarker(query, marker_gene_set=covered,
score_layer="infog", use_rep="X_gdr",
key_added="predicted_db")

The database uses the Allen whole-mouse-brain taxonomy, whose names share nothing with this reference’s. Cross-tabulating asks whether two independently annotated datasets agree about the same cells:

reference labelPIASOmarkerDB labelcells
Oligodendrocyte327 Oligo NN2,132
Astrocyte319 Astro-TE NN959
L6 CT030 L6 CT CTX Glut887
L2-3 IT007 L2/3 IT CTX Glut801
Microglia334 Microglia NN476
OPC326 OPC NN433
PV052 Pvalb Gaba278

Every row is the correct correspondence. That is the check worth doing before trusting an annotation — not “does the UMAP look right”, but “do two independent sources agree about these cells”.

PIASOmarkerDB labels

Which source to use

  • Your own reference, when you have one from the same tissue and protocol — better matched, and the vocabulary your collaborators use.
  • PIASOmarkerDB, when you do not: 36 studies across human and mouse. See the API client page, which also covers annotating clusters rather than cells.
  • Match the developmental stage. An adult taxonomy asked to label embryonic tissue gives confident, wrong answers — worked through in cells vs nuclei.

Parameters worth knowing

parameterwhat it changes
use_repthe embedding smoothing runs in. X_gdr beat X_svd by ~4 points here.
score_layerwhich matrix the marker sets are scored on. Defaults to infog.
smooth_predictionaverage each cell’s scores over its neighbours. On by default; needs use_rep.
COSG’s muspecificity penalty on the markers. 10 was better than 1 here.
  • projectGDR — reuse this reference’s GDR space for a new dataset, without recomputing it.