Cookbooks

Recipe

Cross-species label transfer with bio foundation models

Muhammad AliPublished

Most cell atlases are human or mouse. For other species, such as macaque or mouse lemur, cell types are often annotated by hand, or not at all. If a model places matching cells from different species close together, you can train a classifier on human cells and use it to annotate another primate.

Bio foundation models are trained on millions of cells, mostly from human and mouse. Comparing several of them on your own data means getting each model's weights, a GPU and the preprocessing each one expects. The Helical MCP handles that part: one prompt runs every hosted model on the same dataset.

In this cookbook, you embed a balanced four-species atlas (human, macaque, mouse and mouse lemur; 10,000 cells across eight tissues) with all 15 single-cell models on the platform. You compare them with scIB, then use the top-scoring model to transfer human cell-type labels to macaque and mouse lemur.

scGPT co-embedding of the four-species atlas, coloured by species and by tissue

  • Build a cohort with the same tissues in every species, on a shared ortholog gene space.
  • Register it and embed it with every hosted model.
  • Compare the models with scIB, against a PCA baseline.
  • Train a classifier on human cells and score its predictions on macaque and mouse lemur.
  • Compare ground truth and predictions on the same UMAP.

You need the Helical MCP connected (see Getting started) and about 5 credits.

1Load the experiment

  • Download the curated cohort into data/. The embeddings come from the prompts in step 3; running this notebook doesn't start any runs.
aws s3 cp s3://helicalpackage/cookbooks/cross-species-demo/cross_species_balanced_10000.h5ad data/ --no-sign-request
  • Keep the embeddings together with their input and cell order: a vector without its cell order can't be reused.
In [1]:
from pathlib import Path
import importlib.metadata
import json
import sys

import anndata as ad
import numpy as np
import pandas as pd
import scanpy as sc
import matplotlib.pyplot as plt
import seaborn as sns
from IPython.display import display, Markdown
from IPython import get_ipython

get_ipython().run_line_magic('matplotlib', 'inline')

DATA_DIR = Path('data')
INPUT = DATA_DIR / 'cross_species_balanced_10000.h5ad'
EMBEDDINGS = DATA_DIR / 'embeddings'
OUTPUT = Path('results')
FIGURES = OUTPUT / 'figures'
FIGURES.mkdir(parents=True, exist_ok=True)
SEED = 42
PRIMATES = ['Macaque', 'Mouse lemur']
pd.set_option('display.max_colwidth', 90)
sns.set_theme(style='white', context='notebook')

data = ad.read_h5ad(INPUT)
assert data.obs_names.is_unique, 'Repeated cell IDs invalidate alignment.'
print('Execution environment:', sys.version.split()[0])
print({package: importlib.metadata.version(package)
       for package in ['anndata', 'scanpy', 'numpy', 'pandas', 'scikit-learn', 'scib-metrics']})
Execution environment: 3.14.8
{'anndata': '0.13.4', 'scanpy': '1.12.4', 'numpy': '2.5.3', 'pandas': '3.0.6', 'scikit-learn': '1.9.1', 'scib-metrics': '0.6.1'}

2Build a comparable cohort

  • The file you downloaded in step 1 is the result of this step.

  • Start from an ortholog-mapped multi-species atlas, and add mouse lemur data.

  • Keep four species: human, macaque, mouse and mouse lemur. Rat is left out because keeping it reduces the tissues shared by every species to liver alone.

  • Harmonise tissue names, and keep only tissues present in every species. Eight tissues remain.

  • Give every species the same number of cells per tissue, from 97 in spleen to 524 in bladder and liver. Sample without replacement.

  • Keep the original annotations next to the harmonised ones. All species share one ortholog gene space.

Example prompt:

Build a cross-species cohort from the ortholog atlas and the mouse lemur data: human, macaque, mouse and mouse lemur. Harmonise tissue names, keep only tissues present in every species, and give every species the same number of cells per tissue, sampling without replacement. Keep the original annotations, and write the result to data/cross_species_balanced_10000.h5ad.
In [2]:
display(Markdown(f"**Curation:** {data.uns['curation_description']}"))
display(pd.crosstab(data.obs['source_dataset'], data.obs['species']))
<IPython.core.display.Markdown object>
species            Human  Macaque  Mouse  Mouse lemur
source_dataset                                       
monkey_atlas           0      251      0            0
mouse_atlas            0        0   2500            0
nhpca                  0     2249      0            0
tabula_microcebus      0        0      0         2500
tabula_sapiens      2500        0      0            0
In [3]:
balance = pd.crosstab(data.obs['species'], data.obs['tissue'])
display(Markdown(f'**Cohort:** {data.n_obs:,} cells × {data.n_vars:,} common features; '
                 f'{len(balance)} species and {len(balance.columns)} tissues.'))
print('Every tissue present in every species:', bool((balance > 0).all().all()))
print('Identical per-tissue counts across species:', bool(balance.nunique(axis=0).eq(1).all()))
print('Smallest/largest species-tissue groups:', int(balance.min().min()), int(balance.max().max()))
fig, ax = plt.subplots(figsize=(max(7, len(balance.columns) * 0.7), max(3, len(balance) * 0.55)))
sns.heatmap(balance, annot=True, fmt='d', cmap='Blues', linewidths=0.5,
            cbar_kws={'label': 'Unique cells'}, ax=ax)
ax.set(title='Which species-tissue comparisons does the cohort support?', xlabel='Harmonized tissue', ylabel='Species')
plt.tight_layout()
plt.show()
<IPython.core.display.Markdown object>
Every tissue present in every species: True
Identical per-tissue counts across species: True
Smallest/largest species-tissue groups: 97 524
Output of cell 3

3Register the cohort and embed it with every model

Example prompt:

Register data/cross_species_balanced_10000.h5ad with Helical. Keep the original cell order.

Example prompt:

List every hosted single-cell model compatible with this dataset and quote me the cost of embedding it with each one. Run them all once I approve, with one run per model.
  • Review and approve the runs when prompted. The 15 runs were quoted at 4.7 credits in total, from 0.18 for scGPT to 0.63 for c2s_2b.

Example prompt:

When the runs finish, download every embedding to data/embeddings, one <model>.npy file per model. Tell me about any model that failed.
  • All 15 runs succeeded.

4Check the embeddings line up

  • Check each downloaded embedding has one finite row per input cell.
  • Use the input cell order for every model. Don't sort a matrix, a label table or an ID list on its own.
In [4]:
embeddings = {}
for path in sorted(EMBEDDINGS.glob('*.npy')):
    matrix = np.load(path, allow_pickle=False)
    assert matrix.ndim == 2 and matrix.shape[0] == data.n_obs, f'{path.name}: one row per input cell is required.'
    assert np.isfinite(matrix).all(), f'{path.name}: non-finite values.'
    embeddings[path.stem] = matrix.astype(np.float32)
assert embeddings, f'No embeddings found in {EMBEDDINGS}.'
display(pd.DataFrame([{'model': name, 'cells': m.shape[0], 'dimensions': m.shape[1]}
                      for name, m in embeddings.items()]))
                             model  cells  dimensions
0                           c2s_2b  10000        2304
1       gf-12L-104M-i4096-CLcancer  10000         768
2                gf-12L-104M-i4096  10000         768
3        gf-12L-38M-i4096-CLcancer  10000         512
4                 gf-12L-38M-i4096  10000         512
5   gf-12L-40M-i2048-CZI-CellxGene  10000         512
6                 gf-12L-40M-i2048  10000         512
7                gf-18L-316M-i4096  10000        1152
8                gf-20L-151M-i4096  10000         896
9                  gf-6L-10M-i2048  10000         256
10                     nicheformer  10000         512
11                           scgpt  10000         512
12                     tf_exemplar  10000        2048
13                      tf_metazoa  10000        2048
14                      tf_sapiens  10000        2048

5Compare the models with scIB

  • Set batch to species and label to tissue: a good co-embedding mixes species while keeping tissues apart.
  • Include a PCA baseline computed from the counts (3,000 batch-aware highly variable genes, 50 components).
  • Total is 0.6 × bio conservation + 0.4 × batch correction, as in the scIB paper. Isolated labels is turned off because every tissue occurs in every species.
  • Some species differences are real biology, so perfect species mixing isn't the goal.
In [5]:
from scib_metrics.benchmark import Benchmarker, BioConservation, BatchCorrection

# Unintegrated baseline: 50 PCs on 3,000 batch-aware highly variable genes.
baseline = data.copy()
sc.pp.normalize_total(baseline, target_sum=1e4)
sc.pp.log1p(baseline)
sc.pp.highly_variable_genes(baseline, n_top_genes=3000, batch_key='species')
sc.tl.pca(baseline, n_comps=50, mask_var='highly_variable', random_state=SEED)
data.obsm['PCA'] = baseline.obsm['X_pca']
for name, matrix in embeddings.items():
    data.obsm[name] = matrix

benchmark = Benchmarker(
    data, batch_key='species', label_key='tissue',
    embedding_obsm_keys=['PCA', *embeddings],
    bio_conservation_metrics=BioConservation(isolated_labels=False),
    batch_correction_metrics=BatchCorrection(),
    pre_integrated_embedding_obsm_key='PCA', n_jobs=4, progress_bar=False,
)
benchmark.benchmark()
scib_results = benchmark.get_results(min_max_scale=False).drop(index='Metric Type').astype(float)
scib_results = scib_results.sort_values('Total', ascending=False).rename_axis('model').reset_index()
scib_results['n_dimensions'] = scib_results['model'].map(lambda m: data.obsm[m].shape[1])
summary_columns = {'model': 'Model', 'Total': 'scIB Total', 'Bio conservation': 'Tissue conservation',
                   'Batch correction': 'Species mixing', 'n_dimensions': 'Dimensions'}
display(scib_results[list(summary_columns)].rename(columns=summary_columns).style.format(
    {'scIB Total': '{:.3f}', 'Tissue conservation': '{:.3f}', 'Species mixing': '{:.3f}'}).hide(axis='index'))

foundation_ranking = scib_results.loc[scib_results['model'].ne('PCA')]
best_model = foundation_ranking.iloc[0]['model']
if len(foundation_ranking) >= 2:
    gap = foundation_ranking.iloc[0]['Total'] - foundation_ranking.iloc[1]['Total']
    if gap < .01:
        display(Markdown(f"**Narrow winner:** `{best_model}` leads `{foundation_ranking.iloc[1]['model']}` "
                         f'by only **{gap:.4f}** scIB points.'))

ranking = scib_results.melt(id_vars='model', value_vars=['Total', 'Bio conservation', 'Batch correction'],
                            var_name='score', value_name='value')
fig, ax = plt.subplots(figsize=(9, max(4, 0.35 * len(scib_results))))
sns.barplot(data=ranking, y='model', x='value', hue='score', ax=ax)
ax.axvline(scib_results.loc[scib_results['model'].eq('PCA'), 'Total'].item(), color='grey', linestyle='--')
ax.set(title='scIB scores; dashed line is the PCA baseline Total', xlabel='', ylabel='')
plt.tight_layout(); plt.savefig(FIGURES / 'scib_ranking.png', dpi=150); plt.show()
<pandas.io.formats.style.Styler at 0x37f0352b0>
<IPython.core.display.Markdown object>
Output of cell 5
  • scGPT (0.410) and gf-12L-40M-i2048-CZI-CellxGene (0.405) score highest on Total, 0.005 apart. scGPT keeps tissues more distinct; the CZI Geneformer mixes species more.
  • The PCA baseline scores 0.328. Only seven models score above it, most by less than 0.02. tf_sapiens (0.302) and nicheformer (0.286) score lowest.
  • Read bio conservation and batch correction separately: Total hides which one you're getting.

6Look at the top-scoring co-embedding

  • The figure colours one scGPT UMAP by species and by tissue. Neither label is used to compute it.
  • Species structure is still visible. Label transfer can still work, because mixing and transfer measure different things.
In [6]:
display(Markdown(f'**Selected foundation model:** `{best_model}`'))
sc.pp.neighbors(data, use_rep=best_model, n_neighbors=15, metric='cosine', random_state=SEED)
sc.tl.umap(data, random_state=SEED)
sc.pl.umap(data, color=['species', 'tissue'], wspace=0.35, size=8, show=False)
plt.savefig(FIGURES / 'best_model_coembedding.png', dpi=150, bbox_inches='tight')
plt.show()
<IPython.core.display.Markdown object>
Output of cell 6

7Transfer human labels to other primates

  • Train a classifier on the top-scoring model's embeddings of human cells only. Macaque and mouse lemur labels are used only for scoring.
  • Map annotations to broad cell classes shared across species; for example, T-cell subsets become "T / NKT cell". Fine subtypes aren't compared. Labels that are too vague to map, such as "Unannotated" or "Myeloid cell", are left out of scoring and count against coverage.
  • The classifier is a class-balanced logistic regression, with regularisation picked by cross-validation on human cells.
  • Tissue labels from all species were used to pick the model, so treat this as exploratory rather than a fully held-out test.
In [7]:
BROAD_CLASSES = {
    "T / NKT cell": ["T cell", "T cell/NKT cell", "CD4-positive, alpha-beta T cell", "CD8-positive, alpha-beta T cell", "naive thymus-derived CD4-positive, alpha-beta T cell", "naive thymus-derived CD8-positive, alpha-beta T cell", "regulatory T cell", "mature NK T cell"],
    "NK cell": ["natural killer cell"],
    "Innate lymphoid cell": ["innate lymphoid cell"],
    "B cell": ["B cell"],
    "Plasma cell": ["Plasma cell", "Plasma B cell", "plasma cell"],
    "Monocyte": ["Monocyte", "monocyte", "classical monocyte", "intermediate monocyte", "non-classical monocyte"],
    "Macrophage": ["Macrophage", "macrophage", "alveolar macrophage", "tissue-resident macrophage"],
    "Dendritic cell": ["Dendritic cell", "dendritic cell", "conventional dendritic cell", "myeloid dendritic cell"],
    "Plasmacytoid dendritic cell": ["plasmacytoid dendritic cell"],
    "Neutrophil": ["Neutrophil", "neutrophil"],
    "Mast cell / basophil": ["Mast cell", "mast cell", "basophil"],
    "Erythroid cell": ["Erythroid cell", "Erythroblast", "erythrocyte", "erythroid lineage cell", "erythroid progenitor cell"],
    "Hematopoietic precursor": ["hematopoietic precursor cell"],
    "Endothelial cell": ["Endothelial cell", "endothelial cell", "capillary endothelial cell", "cardiac endothelial cell", "endothelial cell of artery", "endothelial cell of hepatic sinusoid", "vein endothelial cell", "Liver endothelial cell 1", "Liver endothelial cell 2", "Digestive tract endothelial cell 2", "Epidermal endothelial cell"],
    "Lymphatic endothelial cell": ["endothelial cell of lymphatic vessel"],
    "Fibroblast / stromal cell": ["Fibroblast", "fibroblast", "fibroblast of cardiac tissue", "fibroblast of lung", "alveolar adventitial fibroblast", "adventitial cell", "Stromal cell", "stromal cell", "Uterus stromal cell", "Tongue stromal cell", "Digestive tract stromal cell", "Adipocyte progenitor/stromal cell", "Mesenchymal cell"],
    "Myofibroblast": ["myofibroblast cell"],
    "Pericyte": ["pericyte"],
    "Smooth muscle cell": ["Smooth muscle cell", "smooth muscle cell", "vascular associated smooth muscle cell", "bronchial smooth muscle cell", "Epidermal smooth muscle cell", "Male reproductive smooth muscle cell", "myometrial cell"],
    "Stellate cell": ["Stellate cell", "hepatic stellate cell", "pancreatic stellate cell"],
    "Mesothelial cell": ["Mesothelial cell", "mesothelial cell", "Viseral adipose mesothelial cell"],
    "Adipocyte": ["Adipocyte", "Adipocyte cell", "adipocyte"],
    "Cardiomyocyte": ["Cardiomyocyte", "cardiac muscle cell", "regular atrial cardiac myocyte", "regular ventricular cardiac myocyte", "ventricular cardiac muscle cell"],
    "Hepatocyte": ["Hepatocyte", "hepatocyte"],
    "Cholangiocyte": ["intrahepatic cholangiocyte", "Bile ductal cell"],
    "Alveolar type 1 cell": ["AT1 cell", "Alveolar epithelial type 1 cell", "pulmonary alveolar type 1 cell"],
    "Alveolar type 2 cell": ["AT2 cell", "Alveolar epithelial type 2 cell", "pulmonary alveolar type 2 cell"],
    "Ciliated cell": ["Ciliated cell", "ciliated epithelial cell", "lung multiciliated epithelial cell", "Respiratory ciliated cell", "Female reproductive ciliated cell"],
    "Club / goblet cell": ["Club cell", "club cell", "respiratory tract goblet cell", "Goblet/serous cell"],
    "Basal cell": ["Basal cell", "basal cell", "Male reproductive basal cell"],
    "Urothelial cell": ["Bladder urothelial cell", "bladder urothelial cell", "Bladder intermediate cell"],
    "Pancreatic acinar cell": ["Pancreas acinar cell", "pancreatic acinar cell", "Acinar cell"],
    "Pancreatic ductal cell": ["pancreatic ductal cell"],
    "Pancreatic endocrine cell": ["type B pancreatic cell", "pancreatic A cell", "Pancreatic endocrine cell"],
    "Uterine epithelial cell": ["epithelial cell of uterus", "Uterus luminal epithelial cell"],
    "Spermatogonium": ["Spermatogonia", "spermatogonium"],
    "Spermatocyte": ["Spermatocyte", "spermatocyte"],
    "Spermatid": ["Spermatid", "spermatid"],
    "Sertoli cell": ["Sertoli cell"],
    "Leydig cell": ["Leydig cell"],
    "Peritubular myoid cell": ["peritubular myoid cell"],
    "Neuron": ["Neuron", "neuron"],
}
LABEL_MAP = {label: broad for broad, labels in BROAD_CLASSES.items() for label in labels}
MIN_HUMAN_CELLS = 10

from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import GridSearchCV, StratifiedKFold
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler

data.obs['cell_type_broad'] = data.obs['cell_type'].astype(str).map(LABEL_MAP)
species = data.obs['species'].astype(str).to_numpy()
broad = data.obs['cell_type_broad'].to_numpy(dtype=object)
human_counts = pd.Series(broad[(species == 'Human') & pd.notna(broad)]).value_counts()
training_classes = sorted(human_counts[human_counts >= MIN_HUMAN_CELLS].index)
train = (species == 'Human') & np.isin(broad, training_classes)
target = np.isin(species, PRIMATES)

display(Markdown(f'**Cell-type harmonization:** {data.obs["cell_type"].nunique()} source labels map to '
                 f'{len(BROAD_CLASSES)} broad classes; {len(training_classes)} have at least '
                 f'{MIN_HUMAN_CELLS} human cells and are used for training.'))
label_mapping = (data.obs.groupby(['cell_type', 'cell_type_broad'], observed=True, dropna=False)
                 .size().rename('cells').reset_index())
display(label_mapping)

coverage = pd.DataFrame({'species': species[target], 'tissue': data.obs['tissue'].astype(str).to_numpy()[target],
                         'supported': np.isin(broad[target], training_classes)})
display(Markdown('**Which target cells have a supported human reference label?**'))
display(coverage.groupby(['species', 'tissue'])['supported'].agg(['size', 'mean'])
        .rename(columns={'size': 'cells', 'mean': 'coverage'}).round(3))

x = data.obsm[best_model]
search = GridSearchCV(
    make_pipeline(StandardScaler(), LogisticRegression(class_weight='balanced', max_iter=1500, random_state=SEED)),
    {'logisticregression__C': [0.01, 0.1, 1.0]}, scoring='f1_macro',
    cv=StratifiedKFold(5, shuffle=True, random_state=SEED), n_jobs=-1,
)
search.fit(x[train], broad[train].astype(str))
classifier = search.best_estimator_
display(Markdown(f"**Human validation:** macro F1 {search.best_score_:.3f} at C = "
                 f"{search.best_params_['logisticregression__C']} (5-fold cross-validation on "
                 f'{train.sum():,} human cells; cell-level splits, so this does not test new donors).'))
classes = classifier.classes_
probabilities = classifier.predict_proba(x[target])
predicted = classes[probabilities.argmax(axis=1)]
print(f'Trained on {train.sum():,} human cells; predicted {target.sum():,} primate cells.')
<IPython.core.display.Markdown object>
                                  cell_type            cell_type_broad  cells
0                                  AT1 cell       Alveolar type 1 cell     23
1                                  AT2 cell       Alveolar type 2 cell     46
2                               Acinar cell     Pancreatic acinar cell      4
3                                 Adipocyte                  Adipocyte     20
4                            Adipocyte cell                  Adipocyte      1
..                                      ...                        ...    ...
180                  type B pancreatic cell  Pancreatic endocrine cell     18
181                                 unknown                        NaN    142
182  vascular associated smooth muscle cell         Smooth muscle cell     83
183                   vein endothelial cell           Endothelial cell     49
184         ventricular cardiac muscle cell              Cardiomyocyte     18

[185 rows x 3 columns]
<IPython.core.display.Markdown object>
                      cells  coverage
species     tissue                   
Macaque     Bladder     524     0.956
            Heart       187     0.781
            Liver       524     0.781
            Lung        475     0.844
            Pancreas    292     0.750
            Spleen       97     0.804
            Testis      129     0.636
            Uterus      272     0.816
Mouse lemur Bladder     524     0.977
            Heart       187     0.631
            Liver       524     0.910
            Lung        475     0.935
            Pancreas    292     0.901
            Spleen       97     0.856
            Testis      129     1.000
            Uterus      272     0.993
<IPython.core.display.Markdown object>
Trained on 2,465 human cells; predicted 5,000 primate cells.

8Score the transfer

  • The main score is macro one-vs-rest AUROC: one AUROC per cell class, averaged with equal weight.
  • Read it with coverage: the share of target cells whose label exists in the human training set.
  • The tissue-only baseline predicts cell types from human cell-type frequencies within each tissue, without embeddings. It shows how much of the score tissue identity alone explains.
In [8]:
from sklearn.metrics import roc_auc_score, balanced_accuracy_score, f1_score

truth = broad[target]
target_obs = data.obs.loc[target, ['species', 'tissue']].astype(str).reset_index(drop=True)

# Tissue-only baseline: human cell-type frequencies within each tissue, no embeddings.
human_tissue = pd.crosstab(data.obs['tissue'][train], broad[train]).reindex(columns=classes, fill_value=0) + 1
tissue_prior = (human_tissue.div(human_tissue.sum(axis=1), axis=0)
                .reindex(target_obs['tissue']).to_numpy())


def macro_auc(y, scores):
    aucs = [roc_auc_score(y == label, scores[:, k]) for k, label in enumerate(classes)
            if (y == label).any() and (y != label).any()]
    return float(np.mean(aucs)) if aucs else np.nan


def score_scope(mask):
    supported = mask & np.isin(truth, classes)
    y = truth[supported].astype(str)
    return {'n_target': int(mask.sum()), 'n_evaluable': int(supported.sum()),
            'label_coverage': supported.sum() / max(mask.sum(), 1),
            'auc_macro_ovr': macro_auc(y, probabilities[supported]),
            'tissue_prior_auc_macro_ovr': macro_auc(y, tissue_prior[supported]),
            'balanced_accuracy': balanced_accuracy_score(y, predicted[supported]) if supported.any() else np.nan,
            'macro_f1': f1_score(y, predicted[supported], average='macro') if supported.any() else np.nan}


rows = [{'scope': 'all_primates', 'species': 'All primates', 'tissue': 'All',
         **score_scope(np.ones(len(truth), bool))}]
for name in PRIMATES:
    rows.append({'scope': 'species', 'species': name, 'tissue': 'All',
                 **score_scope(target_obs['species'].eq(name).to_numpy())})
    for tissue in sorted(target_obs['tissue'].unique()):
        rows.append({'scope': 'species_tissue', 'species': name, 'tissue': tissue,
                     **score_scope((target_obs['species'].eq(name) & target_obs['tissue'].eq(tissue)).to_numpy())})
transfer_metrics = pd.DataFrame(rows)
metric_names = {'species': 'Species', 'tissue': 'Tissue', 'n_target': 'Target cells', 'n_evaluable': 'Scored cells',
                'label_coverage': 'Coverage', 'auc_macro_ovr': 'Macro AUC', 'tissue_prior_auc_macro_ovr': 'Tissue-only AUC',
                'balanced_accuracy': 'Balanced accuracy', 'macro_f1': 'Macro F1'}
formats = {'Coverage': '{:.1%}', 'Macro AUC': '{:.3f}', 'Tissue-only AUC': '{:.3f}',
           'Balanced accuracy': '{:.3f}', 'Macro F1': '{:.3f}'}
for scopes, title in [(['all_primates', 'species'], 'Overall and species-specific transfer'),
                      (['species_tissue'], 'Within matched tissues')]:
    display(Markdown('**' + title + '**'))
    display(transfer_metrics.loc[transfer_metrics['scope'].isin(scopes), list(metric_names)]
            .rename(columns=metric_names).style.format(formats, na_rep='Undefined').hide(axis='index'))

supported = np.isin(truth, classes)
per_class_auc = pd.DataFrame([
    {'cell_class': label, 'positives': int((truth[supported] == label).sum()),
     'auroc': roc_auc_score(truth[supported] == label, probabilities[supported, k])}
    for k, label in enumerate(classes)
    if (truth[supported] == label).any() and (truth[supported] != label).any()]).sort_values('auroc')
display(Markdown('**Per-class discrimination and support**'))
display(per_class_auc.round(3))

confusion = pd.crosstab(pd.Series(truth[supported], name='Ground truth'),
                        pd.Series(predicted[supported], name='Prediction'), normalize='index')
fig, ax = plt.subplots(figsize=(14, 11))
sns.heatmap(confusion, cmap='Blues', vmin=0, vmax=1, ax=ax)
ax.set_title(f'{best_model}: human-trained predictions on macaque and mouse lemur (row-normalised)')
plt.tight_layout(); plt.savefig(FIGURES / 'label_transfer_confusion.png', dpi=150); plt.show()
<IPython.core.display.Markdown object>
<pandas.io.formats.style.Styler at 0x15d206210>
<IPython.core.display.Markdown object>
<pandas.io.formats.style.Styler at 0x15d206210>
<IPython.core.display.Markdown object>
                    cell_class  positives  auroc
3                   Basal cell         22  0.690
9               Erythroid cell         17  0.772
28               Stellate cell         43  0.847
24                 Plasma cell         82  0.892
21      Pancreatic ductal cell          7  0.917
8             Endothelial cell        454  0.926
26                   Spermatid        108  0.928
16                    Monocyte        105  0.937
25          Smooth muscle cell        150  0.937
2                       B cell        207  0.942
10   Fibroblast / stromal cell        674  0.950
15            Mesothelial cell        123  0.954
18                     NK cell         71  0.958
5                Cholangiocyte         91  0.962
23                    Pericyte         16  0.964
31     Uterine epithelial cell         54  0.969
4                Cardiomyocyte         41  0.975
13                  Macrophage        495  0.976
1         Alveolar type 2 cell         68  0.980
29                T / NKT cell        648  0.981
0         Alveolar type 1 cell         51  0.984
17               Myofibroblast         68  0.985
12  Lymphatic endothelial cell         13  0.985
7           Club / goblet cell         26  0.986
30             Urothelial cell        113  0.988
6                Ciliated cell         35  0.989
19                  Neutrophil        353  0.993
11                  Hepatocyte         77  0.997
27                Spermatocyte         49  0.997
20      Pancreatic acinar cell         90  1.000
22   Pancreatic endocrine cell          2  1.000
14        Mast cell / basophil          1  1.000
Output of cell 8
  • Across 5,000 primate cells, macro AUROC is 0.949, against 0.724 for the tissue-only baseline. Coverage is 87.1% (4,354 cells scored).
  • Mouse lemur: AUROC 0.979 with 91.8% coverage. Macaque: AUROC 0.923 with 82.3% coverage, lower because more macaque labels are too vague to map.
  • Within tissues, macaque testis (0.645) and spleen (0.815) are the hardest transfers.

9Compare ground truth and predictions on one UMAP

  • Each pair of panels shows the same primate cells and coordinates: curated labels on the left, predictions on the right, with shared colours.
  • Cells without a supported label stay visible in grey.
In [9]:
predictions = pd.DataFrame({'cell_id': data.obs_names[target], **target_obs,
                            'cell_type_harmonized': truth, 'prediction': predicted})
palette = dict(zip(classes, sns.color_palette('husl', len(classes)).as_hex()))
coords = data.obsm['X_umap'][target]
fig, axes = plt.subplots(len(PRIMATES), 2, figsize=(16, 7 * len(PRIMATES)), squeeze=False)
for row, name in zip(axes, PRIMATES):
    in_species = predictions['species'].eq(name).to_numpy()
    for ax, column in zip(row, ['cell_type_harmonized', 'prediction']):
        values = predictions.loc[in_species, column].to_numpy(dtype=object)
        colours = [palette.get(v, '#d0d0d0') for v in values]
        ax.scatter(coords[in_species, 0], coords[in_species, 1], c=colours, s=6, linewidths=0)
        ax.set_title(f'{name}: {"ground truth" if column == "cell_type_harmonized" else "prediction"}')
        ax.set_aspect('equal'); ax.axis('off')
fig.legend(handles=[plt.Line2D([], [], marker='o', linestyle='', color=c, label=l) for l, c in palette.items()],
           loc='lower center', ncol=4, frameon=False)
fig.tight_layout(rect=(0, 0.12, 1, 1))
plt.savefig(FIGURES / 'label_transfer_umap.png', dpi=150, bbox_inches='tight'); plt.show()
display(Markdown(f'**Predictions:** {len(predictions):,} target cells; grey cells have no supported label.'))
display(predictions.head(12))
Output of cell 9
<IPython.core.display.Markdown object>
                                            cell_id  species   tissue  \
0   Macaque|Bladder_AAACCCAAGGCCCGTT-1-monkey_atlas  Macaque  Bladder   
1   Macaque|Bladder_AAACGAAAGAGCCATG-1-monkey_atlas  Macaque  Bladder   
2   Macaque|Bladder_AACCACACACATAACC-1-monkey_atlas  Macaque  Bladder   
3   Macaque|Bladder_AAGGTAACAGTGGGTA-1-monkey_atlas  Macaque  Bladder   
4   Macaque|Bladder_AAGTTCGCAGACACAG-1-monkey_atlas  Macaque  Bladder   
5   Macaque|Bladder_ACAACCACATTGCCGG-1-monkey_atlas  Macaque  Bladder   
6   Macaque|Bladder_AGAGAATCATCCGTTC-1-monkey_atlas  Macaque  Bladder   
7   Macaque|Bladder_AGATCCACAAGGTCTT-1-monkey_atlas  Macaque  Bladder   
8   Macaque|Bladder_AGGAGGTGTCTACAAC-1-monkey_atlas  Macaque  Bladder   
9   Macaque|Bladder_AGTAGTCGTTAAGGGC-1-monkey_atlas  Macaque  Bladder   
10  Macaque|Bladder_AGTCAACGTTCTATCT-1-monkey_atlas  Macaque  Bladder   
11  Macaque|Bladder_CACGAATTCAAAGGAT-1-monkey_atlas  Macaque  Bladder   

         cell_type_harmonized                 prediction  
0                T / NKT cell               T / NKT cell  
1                T / NKT cell               T / NKT cell  
2                   Spermatid    Uterine epithelial cell  
3                         NaN                 Macrophage  
4                      B cell                 Macrophage  
5                 Plasma cell                 Macrophage  
6                      B cell           Endothelial cell  
7   Fibroblast / stromal cell  Fibroblast / stromal cell  
8                         NaN               T / NKT cell  
9                         NaN                 Macrophage  
10                        NaN               T / NKT cell  
11                        NaN           Endothelial cell  

10Summarise the results

  • The summary below is generated from the results above.
In [10]:
pooled = transfer_metrics.query("scope == 'all_primates'").iloc[0]
by_species = transfer_metrics.query("scope == 'species'").set_index('species')
pca_total = scib_results.loc[scib_results['model'].eq('PCA'), 'Total'].item()
best_total = scib_results.loc[scib_results['model'].eq(best_model), 'Total'].item()
findings = (
    f'**{best_model}** scores highest on scIB Total ({best_total:.3f}, against {pca_total:.3f} for the PCA baseline). '
    f"Trained on human cells only, it reaches a macro AUROC of **{pooled['auc_macro_ovr']:.3f}** on "
    f"{int(pooled['n_evaluable']):,} of {int(pooled['n_target']):,} primate cells "
    f"({pooled['label_coverage']:.1%} coverage), against {pooled['tissue_prior_auc_macro_ovr']:.3f} for the tissue-only baseline. "
    + ' '.join(f"{name}: AUROC {by_species.loc[name, 'auc_macro_ovr']:.3f} with "
               f"{by_species.loc[name, 'label_coverage']:.1%} coverage." for name in PRIMATES)
)
display(Markdown(findings))
(OUTPUT / 'findings.md').write_text(findings + '\n')
<IPython.core.display.Markdown object>
333

11Save the results

  • Save the scIB scores, transfer metrics, per-class AUROC, predictions and label mapping as CSV, next to the figures.
  • Keep the downloaded embeddings: repeating the analysis needs no new run.
In [11]:
scib_results.to_csv(OUTPUT / 'scib_metrics.csv', index=False)
transfer_metrics.to_csv(OUTPUT / 'transfer_metrics.csv', index=False)
per_class_auc.to_csv(OUTPUT / 'per_class_auc.csv', index=False)
predictions.to_csv(OUTPUT / 'transfer_predictions.csv.gz', index=False)
label_mapping.to_csv(OUTPUT / 'label_mapping.csv', index=False)
(OUTPUT / 'analysis_settings.json').write_text(json.dumps({
    'seed': SEED, 'best_model': best_model, 'models': list(embeddings), 'training_classes': list(classes),
    'min_human_cells_per_class': MIN_HUMAN_CELLS, 'regularization_C': search.best_params_['logisticregression__C'],
}, indent=2))
print(f'Results and figures saved to {OUTPUT}')
Results and figures saved to results

Takeaways

  • One prompt ran all 15 hosted single-cell models on the same cells. You choose the cohort, the labels and how to score the results.
  • The workflow is model-agnostic: compare every model on your own data, then pick one for the next step.
  • Score baselines next to the models (PCA for integration, tissue-only for transfer), so each number has a reference point.
  • Report coverage with any transfer score: an AUROC only describes the cells that could be scored.
  • This analysis is one of many computational experiments you can run in Helical, the virtual AI lab for biology.

References