OmicVerse Single-Cell — CellVote Multi-Annotator Consensus
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
obs columns (e.g. scsa_annotation, gpt_celltype, gbi_celltype). Either run the annotators yourself (see Branch Selection for the pre-baked cv.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 in adata.obs['CellVote_celltype'].
- Vote (offline, local majority): monkey-patch
omicverse.single._cellvote.get_cluster_celltype = local_fn before calling cv.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-line pd.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 summary obs.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; writes obs['scsa_annotation']. (Documented in single-cell-annotation skill.)
cv.gpt_anno() — gpt4celltype-style LLM annotation; writes obs['gpt_celltype'].
cv.gbi_anno() — GPTBioInsightor; writes obs['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. (Use single-popv-annotation skill 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) and single-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' with base_url) + api_key. The LLM sees the per-cluster candidate labels AND the marker genes AND the species / organization context — typically produces a more biologically informed consensus, especially when annotators disagree. Costs ~$0.001 per cluster on gpt-4o-mini; total run ~$0.01 for a 20-cluster cohort.
- Offline (local majority): monkey-patch
cvmod.get_cluster_celltype before 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. Pass api_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,
organization can 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_groups results, 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_markers is 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 same cv object.
- 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
AnnData with obs[clusters_key] populated (typically 'leiden').
- For each
key in celltype_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 in obs[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 against obs[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 many CellVote_celltype outputs, the LLM rejected the candidates (often because the cluster's marker genes don't match any of the candidate cell types). Inspect cluster_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-mini is ~$0.001/cluster); total cost is n_clusters × 0.001 USD.
Resource Map
- See
reference.md for compact copy-paste snippets per mode.
- See
references/source-grounding.md for verified CellVote constructor + method signatures and the LLM-arbitrator dispatch path.
- For individual annotators (CellTypist / SCSA / gpt4celltype), see existing
single-cell-annotation skill.
- For POPV (a different consensus tool), see existing
single-popv-annotation skill.
- 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_celltype on 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()), then cv.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.
1---2name: omicverse-single-cell-cellvote-consensus3description: Multi-annotator consensus for single-cell labels via `ov.single.CellVote`. Combine labels from any subset of SCSA / gpt4celltype / GPTBioInsightor / scMulan / PopV per cluster; resolve disagreement either by LLM arbitration (online) or local-majority voting (offline). Output is `obs['CellVote_celltype']`. Use when you have multiple annotators on the same AnnData and need a single consensus label, or when reproducing `t_cellvote_pbmc3k`.4---56# OmicVerse Single-Cell — CellVote Multi-Annotator Consensus78## Goal910Take 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).1112CellVote 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.1314## Quick Workflow15161. Ensure each candidate annotator's labels are populated in `obs` columns (e.g. `scsa_annotation`, `gpt_celltype`, `gbi_celltype`). Either run the annotators yourself (see Branch Selection for the pre-baked `cv.scsa_anno()` / `cv.gpt_anno()` / `cv.gbi_anno()` etc.) or import labels from elsewhere.172. 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.183. **Construct**: `cv = CellVote(adata)`. Stores the AnnData reference; doesn't run anything yet.194. **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 in `adata.obs['CellVote_celltype']`.205. **Vote (offline, local majority)**: monkey-patch `omicverse.single._cellvote.get_cluster_celltype = local_fn` *before* calling `cv.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-line `pd.Series.value_counts().idxmax()` implementation that's deterministic and free.216. **Inspect**: compare `obs[['leiden', 'scsa_annotation', 'gpt_celltype', 'gbi_celltype', 'CellVote_celltype']].head()` and the per-cluster summary `obs.groupby('leiden')[annot_cols].agg(lambda s: s.value_counts().index[0])`.2223## Interface Summary2425```python26ov.single.CellVote(adata: AnnData) -> None27# Constructor; stores adata reference.28```2930Per-annotator runners (each populates a different `obs` column):31- `cv.scsa_anno()` — runs the SCSA pipeline; writes `obs['scsa_annotation']`. (Documented in `single-cell-annotation` skill.)32- `cv.gpt_anno()` — gpt4celltype-style LLM annotation; writes `obs['gpt_celltype']`.33- `cv.gbi_anno()` — GPTBioInsightor; writes `obs['gbi_celltype']`.34- `cv.scMulan_anno()` — scMulan large-language model; writes the corresponding column.35- `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. (Use `single-popv-annotation` skill for stand-alone PopV.)3637Consensus voting:38```python39cv.vote(40 clusters_key: str = None,41 cluster_markers: dict[str, list[str]] = None,42 celltype_keys: list[str] = [],43 model: str = 'gpt-3.5-turbo',44 base_url: str = None,45 species: str = 'human',46 organization: str = 'stomach',47 provider: str = 'openai',48 result_key: str = 'CellVote_celltype',49) -> dict[cluster_id, label]50# Writes adata.obs[result_key]; returns the cluster → label dict.51```5253Offline arbitrator (monkey-patch pattern):54```python55import omicverse.single._cellvote as cvmod5657def local_majority_arbitration(cluster_celltypes, cluster_markers,58 species, organization, model, base_url,59 provider, api_key=None, **kwargs):60 out = {}61 for cl, cand in cluster_celltypes.items():62 if not cand:63 out[cl] = 'unknown'64 else:65 s = pd.Series(cand).str.lower()66 out[cl] = s.value_counts().idxmax()67 return out6869cvmod.get_cluster_celltype = local_majority_arbitration70# Then cv.vote(...) uses the patched function.71```7273## Boundary7475**Inside scope:**76- Multi-annotator consensus arbitration (LLM or local-majority).77- The pre-baked annotator runners (`scsa_anno`, `gpt_anno`, `gbi_anno`, `scMulan_anno`, `popv_anno`).78- Custom arbitration via the monkey-patch hook.7980**Outside scope — separate skill:**81- Running individual annotators stand-alone — see `single-cell-annotation` (CellTypist / SCSA / gpt4celltype) and `single-popv-annotation` (POPV).82- Cell Ontology mapping of the consensus labels — see `omicverse-single-cell-cellmatch-ontology`.83- MetaTiME tumour-microenvironment annotation — see `omicverse-single-cell-metatime-annotation`.84- Cross-modality label transfer — see `cross-modal-celltype-transfer`.8586## Branch Selection8788**Online vs offline arbitration**89- **Online (LLM)**: pass `provider='openai'` (or `'custom_openai'` with `base_url`) + `api_key`. The LLM sees the per-cluster candidate labels AND the marker genes AND the `species` / `organization` context — typically produces a more biologically informed consensus, especially when annotators disagree. Costs ~$0.001 per cluster on `gpt-4o-mini`; total run ~$0.01 for a 20-cluster cohort.90- **Offline (local majority)**: monkey-patch `cvmod.get_cluster_celltype` before voting. Deterministic, free, no network. Tutorial-recommended for CI / reproducibility.91- **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.9293**Annotator selection (`celltype_keys`)**94- **Two annotators**: still works — but ties are resolved alphabetically in local-majority mode (suboptimal). Add a third for tie-breaking.95- **Three annotators** (canonical): SCSA + gpt4celltype + GPTBioInsightor. Tutorial default.96- **More annotators**: monotonically improves consensus quality but increases LLM tokens and runtime.9798**`provider` and `model`**99- `provider='openai'` + `model='gpt-4o-mini'` (tutorial demo): cheap, fast, accurate enough for consensus. Pass `api_key=...` from env.100- `provider='openai'` + `model='gpt-4o-2024-11-20'`: more expensive, more accurate on edge cases.101- `provider='custom_openai'` + `base_url='https://...'`: any OpenAI-compatible endpoint (Azure, Ollama, vLLM, OhMyGPT).102103**`species` / `organization`**104- Required for the LLM context. `species='human'`, `organization='PBMC'` for the tutorial. Wrong values → LLM hallucinates non-existent cell types.105- For non-typical organisms, `organization` can be free-text (e.g. `'mouse spleen post-LPS'`) — the LLM uses it as prompt context.106107**`cluster_markers` source**108- Pass cluster-level top markers (typically 5–10 per cluster). Build from `sc.tl.rank_genes_groups` results, or from your own DEG analysis.109- For LLM arbitration: more markers (5–10) help the LLM disambiguate; fewer (2–3) underdetermine the cluster.110- For local-majority: `cluster_markers` is *not* used (the arbitration is purely candidate-frequency); pass anyway since the API requires it.111112**Annotator pre-population**113- If you haven't run annotators yet: use `cv.scsa_anno()` etc. to populate them via the same `cv` object.114- If labels come from elsewhere (a previous run, a collaborator's output): just write them into `obs[<keyname>]` manually before voting.115- 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.116117## Input Contract118119- `AnnData` with `obs[clusters_key]` populated (typically `'leiden'`).120- For each `key` in `celltype_keys`: `obs[key]` populated with cell-type strings. Empty / NaN values get treated as `'unknown'` candidate.121- `cluster_markers`: `dict[cluster_id, list[gene_str]]`. Cluster IDs must match the unique values in `obs[clusters_key]` (string types).122- For online voting: outbound HTTPS to OpenAI (or compatible); valid `api_key`. ~10s per cluster.123- For offline voting: monkey-patch in place before `cv.vote(...)`.124125## Minimal Execution Patterns126127```python128import os129import numpy as np130import pandas as pd131import anndata as ad132import scanpy as sc133import omicverse as ov134from omicverse.single import CellVote135136# 1) Load PBMC3k and preprocess137adata = sc.datasets.pbmc3k_processed()138sc.tl.rank_genes_groups(adata, 'leiden', method='wilcoxon')139140# Build marker_dict for the LLM context141marker_dict = {142 cluster: list(adata.uns['rank_genes_groups']['names'][cluster][:5])143 for cluster in adata.obs['leiden'].cat.categories144}145146# 2) Populate three annotator columns (here: simulated; in practice, run the annotators)147adata.obs['scsa_annotation'] = ... # from cv.scsa_anno() or external148adata.obs['gpt_celltype'] = ... # from cv.gpt_anno() or external149adata.obs['gbi_celltype'] = ... # from cv.gbi_anno() or external150```151152```python153# 3) Online consensus (requires API key)154cv = CellVote(adata)155final_map = cv.vote(156 clusters_key='leiden',157 cluster_markers=marker_dict,158 celltype_keys=['scsa_annotation', 'gpt_celltype', 'gbi_celltype'],159 species='human',160 organization='PBMC',161 provider='openai',162 model='gpt-4o-mini',163 # api_key='sk-...',164)165# adata.obs['CellVote_celltype'] populated; final_map is the cluster -> label dict166```167168```python169# 4) Offline local-majority arbitration (deterministic, free)170import omicverse.single._cellvote as cvmod171172def local_majority_arbitration(cluster_celltypes, cluster_markers,173 species, organization, model, base_url,174 provider, api_key=None, **kwargs):175 out = {}176 for cl, cand in cluster_celltypes.items():177 if not cand:178 out[cl] = 'unknown'179 else:180 s = pd.Series(cand).str.lower()181 out[cl] = s.value_counts().idxmax()182 return out183184cvmod.get_cluster_celltype = local_majority_arbitration185186cv = CellVote(adata)187final_map_offline = cv.vote(188 clusters_key='leiden',189 cluster_markers=marker_dict,190 celltype_keys=['scsa_annotation', 'gpt_celltype', 'gbi_celltype'],191 species='human', organization='PBMC',192 provider='openai', model='gpt-4o-mini', # ignored by the patched function193)194print(adata.obs[['leiden', 'scsa_annotation', 'gpt_celltype',195 'gbi_celltype', 'CellVote_celltype']].head())196```197198```python199# 5) Per-cluster summary table200cols = ['leiden', 'scsa_annotation', 'gpt_celltype',201 'gbi_celltype', 'CellVote_celltype']202summary = (adata.obs203 .groupby('leiden')[cols[1:]]204 .agg(lambda s: s.value_counts().index[0]))205print(summary)206```207208## Validation209210- After `cv.vote(...)`: `adata.obs['CellVote_celltype']` populated with no NaN cells (clusters with no candidate labels become `'unknown'`).211- Compare `cv.vote(...)` keys against `obs[clusters_key].unique()` — every cluster should be in the returned dict.212- 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.213- LLM arbitrator failures: if `'unknown'` appears in many `CellVote_celltype` outputs, the LLM rejected the candidates (often because the cluster's marker genes don't match any of the candidate cell types). Inspect `cluster_markers[cluster]` and the candidate set manually.214- 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.215- For online runs: log the LLM cost per call (`gpt-4o-mini` is ~$0.001/cluster); total cost is `n_clusters × 0.001` USD.216217## Resource Map218219- See [`reference.md`](reference.md) for compact copy-paste snippets per mode.220- See [`references/source-grounding.md`](references/source-grounding.md) for verified `CellVote` constructor + method signatures and the LLM-arbitrator dispatch path.221- For individual annotators (CellTypist / SCSA / gpt4celltype), see existing `single-cell-annotation` skill.222- For POPV (a different consensus tool), see existing `single-popv-annotation` skill.223- For Cell Ontology mapping of CellVote outputs (label standardisation across cohorts), see `omicverse-single-cell-cellmatch-ontology`.224225## Examples226- "Build a consensus label from `scsa_annotation` + `gpt_celltype` + `gbi_celltype` on PBMC3k via local-majority arbitration (offline)."227- "Run online CellVote with gpt-4o-mini on a 20-cluster cohort and report the cost."228- "Diagnose why CellVote keeps returning `'unknown'` for cluster 5 — likely the markers don't match any annotator's candidate."229- "Run all three annotators (`cv.scsa_anno()`, `cv.gpt_anno()`, `cv.gbi_anno()`), then `cv.vote(...)` for a one-shot consensus pipeline."230231## References232- Tutorial notebook: [`t_cellvote_pbmc3k.ipynb`](https://omicverse.readthedocs.io/en/latest/Tutorials-single/t_cellvote_pbmc3k/) — PBMC3k offline-and-online consensus.233- Live API verified — see [`references/source-grounding.md`](references/source-grounding.md).