Goal
Take an annotated AnnData that already has labels from two or more annotators (e.g. obs['scsa_annotation'], obs['gpt_celltype'], obs['gbi_celltype']) and produce a per-cluster consensus label in obs['CellVote_celltype']. The class wraps the upstream annotators (so you can populate them via the same object) and arbitrates by either calling an LLM with the per-cluster candidate set + the cluster's marker genes (online; requires API key) or running a deterministic local-majority vote on lower-cased candidates (offline; tutorial-canonical demo).
CellVote is OmicVerse's own consensus layer; it's distinct from single-popv-annotation (POPV is a separate Bayesian voting tool from a different group). CellVote can include POPV as one of its inputs but is not a wrapper for POPV.
Quick Workflow
- Ensure each candidate annotator's labels are populated in
obscolumns (e.g.scsa_annotation,gpt_celltype,gbi_celltype). Either run the annotators yourself (see Branch Selection for the pre-bakedcv.scsa_anno()/cv.gpt_anno()/cv.gbi_anno()etc.) or import labels from elsewhere. - Compute cluster-level marker genes —
marker_dict[cluster_id] = [top_genes...]. CellVote uses these to give the LLM (or the local arbitrator) biological context per cluster. - Construct:
cv = CellVote(adata). Stores the AnnData reference; doesn't run anything yet. - Vote (online, LLM):
final_map = cv.vote(clusters_key='leiden', cluster_markers=marker_dict, celltype_keys=['scsa_annotation', 'gpt_celltype', 'gbi_celltype'], species='human', organization='PBMC', provider='openai', model='gpt-4o-mini', api_key='sk-...', result_key='CellVote_celltype'). Calls the LLM once per cluster with the candidate labels + markers + species/organization context; LLM returns a single arbitrated label. Result lands inadata.obs['CellVote_celltype']. - Vote (offline, local majority): monkey-patch
omicverse.single._cellvote.get_cluster_celltype = local_fnbefore callingcv.vote(...). The local function takes the same(cluster_celltypes, cluster_markers, species, organization, model, base_url, provider, api_key)signature and returns a dict mapping cluster → consensus label. The tutorial provides a one-linepd.Series.value_counts().idxmax()implementation that's deterministic and free. - Inspect: compare
obs[['leiden', 'scsa_annotation', 'gpt_celltype', 'gbi_celltype', 'CellVote_celltype']].head()and the per-cluster summaryobs.groupby('leiden')[annot_cols].agg(lambda s: s.value_counts().index[0]).
Interface Summary
ov.single.CellVote(adata: AnnData) -> None
# Constructor; stores adata reference.
Per-annotator runners (each populates a different obs column):
cv.scsa_anno()— runs the SCSA pipeline; writesobs['scsa_annotation']. (Documented insingle-cell-annotationskill.)cv.gpt_anno()— gpt4celltype-style LLM annotation; writesobs['gpt_celltype'].cv.gbi_anno()— GPTBioInsightor; writesobs['gbi_celltype'].cv.scMulan_anno()— scMulan large-language model; writes the corresponding column.cv.popv_anno(ref_adata, ref_labels_key, ref_batch_key, query_batch_key=None, cl_obo_folder=None, save_path='tmp', prediction_mode='fast', methods=None, methods_kwargs=None)— PopV consensus from a reference; writes its own column. (Usesingle-popv-annotationskill for stand-alone PopV.)
Consensus voting:
cv.vote(
clusters_key: str = None,
cluster_markers: dict[str, list[str]] = None,
celltype_keys: list[str] = [],
model: str = 'gpt-3.5-turbo',
base_url: str = None,
species: str = 'human',
organization: str = 'stomach',
provider: str = 'openai',
result_key: str = 'CellVote_celltype',
) -> dict[cluster_id, label]
# Writes adata.obs[result_key]; returns the cluster → label dict.
Offline arbitrator (monkey-patch pattern):
import omicverse.single._cellvote as cvmod
def local_majority_arbitration(cluster_celltypes, cluster_markers,
species, organization, model, base_url,
provider, api_key=None, **kwargs):
out = {}
for cl, cand in cluster_celltypes.items():
if not cand:
out[cl] = 'unknown'
else:
s = pd.Series(cand).str.lower()
out[cl] = s.value_counts().idxmax()
return out
cvmod.get_cluster_celltype = local_majority_arbitration
# Then cv.vote(...) uses the patched function.
Boundary
Inside scope:
- Multi-annotator consensus arbitration (LLM or local-majority).
- The pre-baked annotator runners (
scsa_anno,gpt_anno,gbi_anno,scMulan_anno,popv_anno). - Custom arbitration via the monkey-patch hook.
Outside scope — separate skill:
- Running individual annotators stand-alone — see
single-cell-annotation(CellTypist / SCSA / gpt4celltype) andsingle-popv-annotation(POPV). - Cell Ontology mapping of the consensus labels — see
omicverse-single-cell-cellmatch-ontology. - MetaTiME tumour-microenvironment annotation — see
omicverse-single-cell-metatime-annotation. - Cross-modality label transfer — see
cross-modal-celltype-transfer.
Branch Selection
Online vs offline arbitration
- Online (LLM): pass
provider='openai'(or'custom_openai'withbase_url) +api_key. The LLM sees the per-cluster candidate labels AND the marker genes AND thespecies/organizationcontext — typically produces a more biologically informed consensus, especially when annotators disagree. Costs ~$0.001 per cluster ongpt-4o-mini; total run ~$0.01 for a 20-cluster cohort. - Offline (local majority): monkey-patch
cvmod.get_cluster_celltypebefore voting. Deterministic, free, no network. Tutorial-recommended for CI / reproducibility. - Hybrid pattern: run offline first to get a deterministic baseline; run online on a copy of the AnnData; compare. Disagreements highlight where biology is ambiguous.
Annotator selection (celltype_keys)
- Two annotators: still works — but ties are resolved alphabetically in local-majority mode (suboptimal). Add a third for tie-breaking.
- Three annotators (canonical): SCSA + gpt4celltype + GPTBioInsightor. Tutorial default.
- More annotators: monotonically improves consensus quality but increases LLM tokens and runtime.
provider and model
provider='openai'+model='gpt-4o-mini'(tutorial demo): cheap, fast, accurate enough for consensus. Passapi_key=...from env.provider='openai'+model='gpt-4o-2024-11-20': more expensive, more accurate on edge cases.provider='custom_openai'+base_url='https://...': any OpenAI-compatible endpoint (Azure, Ollama, vLLM, OhMyGPT).
species / organization
- Required for the LLM context.
species='human',organization='PBMC'for the tutorial. Wrong values → LLM hallucinates non-existent cell types. - For non-typical organisms,
organizationcan be free-text (e.g.'mouse spleen post-LPS') — the LLM uses it as prompt context.
cluster_markers source
- Pass cluster-level top markers (typically 5–10 per cluster). Build from
sc.tl.rank_genes_groupsresults, or from your own DEG analysis. - For LLM arbitration: more markers (5–10) help the LLM disambiguate; fewer (2–3) underdetermine the cluster.
- For local-majority:
cluster_markersis not used (the arbitration is purely candidate-frequency); pass anyway since the API requires it.
Annotator pre-population
- If you haven't run annotators yet: use
cv.scsa_anno()etc. to populate them via the samecvobject. - If labels come from elsewhere (a previous run, a collaborator's output): just write them into
obs[<keyname>]manually before voting. - Heterogeneous label vocabularies (e.g. one annotator says
'CD8 T', another says'CD8+ T cell'): standardise first with the cellmatch skill, then vote. Otherwise the local-majority arbitrator counts these as different votes.
Input Contract
AnnDatawithobs[clusters_key]populated (typically'leiden').- For each
keyincelltype_keys:obs[key]populated with cell-type strings. Empty / NaN values get treated as'unknown'candidate. cluster_markers:dict[cluster_id, list[gene_str]]. Cluster IDs must match the unique values inobs[clusters_key](string types).- For online voting: outbound HTTPS to OpenAI (or compatible); valid
api_key. ~10s per cluster. - For offline voting: monkey-patch in place before
cv.vote(...).
Minimal Execution Patterns
import os
import numpy as np
import pandas as pd
import anndata as ad
import scanpy as sc
import omicverse as ov
from omicverse.single import CellVote
# 1) Load PBMC3k and preprocess
adata = sc.datasets.pbmc3k_processed()
sc.tl.rank_genes_groups(adata, 'leiden', method='wilcoxon')
# Build marker_dict for the LLM context
marker_dict = {
cluster: list(adata.uns['rank_genes_groups']['names'][cluster][:5])
for cluster in adata.obs['leiden'].cat.categories
}
# 2) Populate three annotator columns (here: simulated; in practice, run the annotators)
adata.obs['scsa_annotation'] = ... # from cv.scsa_anno() or external
adata.obs['gpt_celltype'] = ... # from cv.gpt_anno() or external
adata.obs['gbi_celltype'] = ... # from cv.gbi_anno() or external
# 3) Online consensus (requires API key)
cv = CellVote(adata)
final_map = cv.vote(
clusters_key='leiden',
cluster_markers=marker_dict,
celltype_keys=['scsa_annotation', 'gpt_celltype', 'gbi_celltype'],
species='human',
organization='PBMC',
provider='openai',
model='gpt-4o-mini',
# api_key='sk-...',
)
# adata.obs['CellVote_celltype'] populated; final_map is the cluster -> label dict
# 4) Offline local-majority arbitration (deterministic, free)
import omicverse.single._cellvote as cvmod
def local_majority_arbitration(cluster_celltypes, cluster_markers,
species, organization, model, base_url,
provider, api_key=None, **kwargs):
out = {}
for cl, cand in cluster_celltypes.items():
if not cand:
out[cl] = 'unknown'
else:
s = pd.Series(cand).str.lower()
out[cl] = s.value_counts().idxmax()
return out
cvmod.get_cluster_celltype = local_majority_arbitration
cv = CellVote(adata)
final_map_offline = cv.vote(
clusters_key='leiden',
cluster_markers=marker_dict,
celltype_keys=['scsa_annotation', 'gpt_celltype', 'gbi_celltype'],
species='human', organization='PBMC',
provider='openai', model='gpt-4o-mini', # ignored by the patched function
)
print(adata.obs[['leiden', 'scsa_annotation', 'gpt_celltype',
'gbi_celltype', 'CellVote_celltype']].head())
# 5) Per-cluster summary table
cols = ['leiden', 'scsa_annotation', 'gpt_celltype',
'gbi_celltype', 'CellVote_celltype']
summary = (adata.obs
.groupby('leiden')[cols[1:]]
.agg(lambda s: s.value_counts().index[0]))
print(summary)
Validation
- After
cv.vote(...):adata.obs['CellVote_celltype']populated with no NaN cells (clusters with no candidate labels become'unknown'). - Compare
cv.vote(...)keys againstobs[clusters_key].unique()— every cluster should be in the returned dict. - Disagreement diagnostic: for each cluster, count candidate labels — if all 3 annotators agree, consensus is trivially that label. Where they disagree, the LLM (online) typically picks the most marker-supported one; the local-majority (offline) just picks the most-frequent.
- LLM arbitrator failures: if
'unknown'appears in manyCellVote_celltypeoutputs, the LLM rejected the candidates (often because the cluster's marker genes don't match any of the candidate cell types). Inspectcluster_markers[cluster]and the candidate set manually. - Heterogeneous vocabulary: if
'CD8 T'and'CD8+ T cell'co-exist, local-majority sees them as different votes and may pick the third (different) annotator's answer. Standardise via the cellmatch skill before voting. - For online runs: log the LLM cost per call (
gpt-4o-miniis ~$0.001/cluster); total cost isn_clusters × 0.001USD.
Resource Map
- See
reference.mdfor compact copy-paste snippets per mode. - See
references/source-grounding.mdfor verifiedCellVoteconstructor + method signatures and the LLM-arbitrator dispatch path. - For individual annotators (CellTypist / SCSA / gpt4celltype), see existing
single-cell-annotationskill. - For POPV (a different consensus tool), see existing
single-popv-annotationskill. - For Cell Ontology mapping of CellVote outputs (label standardisation across cohorts), see
omicverse-single-cell-cellmatch-ontology.
Examples
- "Build a consensus label from
scsa_annotation+gpt_celltype+gbi_celltypeon PBMC3k via local-majority arbitration (offline)." - "Run online CellVote with gpt-4o-mini on a 20-cluster cohort and report the cost."
- "Diagnose why CellVote keeps returning
'unknown'for cluster 5 — likely the markers don't match any annotator's candidate." - "Run all three annotators (
cv.scsa_anno(),cv.gpt_anno(),cv.gbi_anno()), thencv.vote(...)for a one-shot consensus pipeline."
References
- Tutorial notebook:
t_cellvote_pbmc3k.ipynb— PBMC3k offline-and-online consensus. - Live API verified — see
references/source-grounding.md.