Fig. 7 and S7: AML mosaic integration with mutation heads#

Open in Colab

Paired AML CITE-seq (Knorr et al. 2023) trains the RNA↔protein bridge. Two cohorts that were never measured together are then projected into that frozen bridge: van Galen et al. (2019) scRNA-seq through the RNA encoder and Demaree et al. (2021) DAb-seq through the protein encoder.

This tutorial follows the manuscript analysis more closely than the earlier API draft:

  • DAb genotype truth is read from the variant-specific columns already present in the original DAb .obs (for example NPM1 W288fs, DNMT3A R882H, and FLT3-ITD) and collapsed to gene-level mutation targets.

  • van Galen truth is derived from MutTranscripts / WtTranscripts.

  • DAb contributes observed genotype labels only for the genes actually assayed there; unmeasured genes remain masked rather than being treated as wild type.

  • mutation-head train/validation/test masks are group-aware: whole van Galen patients and DAb-seq experiments are held out. The article evaluated the heads with cell-level splits (see its Discussion), so this is a stricter test of generalization to new individuals, and scores are expected to be lower than cell-level ones.

  • the CITE bridge is retained as a replay set during supervised refinement.

  • after refinement, embeddings are recomputed for all four blocks (CITE RNA, CITE ADT, van Galen RNA, DAb ADT), and mutation probabilities are projected across the complete joint latent.

The public UniVI 1.1 API is used for preprocessing, training, encoding, and refinement, so exact numerical values can differ slightly from the archived manuscript notebook.

import sys

if "google.colab" in sys.modules:
    %pip install -q "univi[tutorials]>=1.1" "pandas==2.2.3"
import re

import numpy as np
import pandas as pd
import scanpy as sc
import scipy.sparse as sp
import torch

from sklearn.metrics import average_precision_score, roc_auc_score

import univi.datasets as uds
from univi import (
    ClassHeadConfig,
    ModalityConfig,
    RefinementConfig,
    TrainingConfig,
    UniVIConfig,
    UniVIMultiModalVAE,
    UniVIRefiner,
    UniVITrainer,
    predict_heads_adata,
)
from univi.preprocessing import ADTPreprocessor, RNAPreprocessor
from univi.utils.seed import set_seed
from univi.workflows import make_loader, stack_embeddings

device = (
    "cuda"
    if torch.cuda.is_available()
    else ("mps" if torch.backends.mps.is_available() else "cpu")
)
set_seed(0)
print("device:", device)
device: cuda
C:\Users\ashfo\micromamba\envs\univi-release-050\Lib\site-packages\tqdm\auto.py:21: TqdmWarning: IProgress not found. Please update jupyter and ipywidgets. See https://ipywidgets.readthedocs.io/en/stable/user_install.html
  from .autonotebook import tqdm as notebook_tqdm
N_EPOCHS = 3000
REFINE_EPOCHS = 300

# Cross-cohort mutation vocabulary used by the manuscript analysis.
GENES = [
    "NPM1",
    "DNMT3A",
    "FLT3",
    "TP53",
    "NRAS",
    "TET2",
    "IDH2",
]

# The DAb cohort has direct genotype calls for these three genes.
# Other heads can still be trained from van Galen labels, but DAb
# contributes M=0 (missing/unmeasured) for those genes.
DAB_GENES = [
    "NPM1",
    "DNMT3A",
    "FLT3",
]

HERO = "NPM1"

# Minimum combined labeled training examples required to instantiate
# a binary head.
MIN_PER_CLASS = 20

Data and patient-level split of the bridge#

The CITE-seq bridge is split by sample (8 training, 1 validation, 2 test samples; Supplemental Table S7). All RNA genes shared with the van Galen cohort are used, and the 18 protein markers shared with DAb-seq.

data = uds.load("aml_mosaic")

cite_rna = data["cite_rna"]
cite_adt = data["cite_adt"]
vg = data["vangalen_rna"]
dab = data["dabseq_adt"]

if "split" in cite_rna.obs:
    splits = {
        key: np.flatnonzero(
            cite_rna.obs["split"].to_numpy() == key
        )
        for key in ("train", "val", "test")
    }
else:
    samples = np.random.default_rng(0).permutation(
        cite_rna.obs["sample_id"].astype(str).unique()
    )
    group = cite_rna.obs["sample_id"].astype(str).to_numpy()

    splits = {
        "test": np.flatnonzero(
            np.isin(group, samples[:2])
        ),
        "val": np.flatnonzero(
            np.isin(group, samples[2:3])
        ),
        "train": np.flatnonzero(
            np.isin(group, samples[3:])
        ),
    }

print("CITE split sizes:", {k: len(v) for k, v in splits.items()})
print("van Galen:", vg.shape)
print("DAb:", dab.shape)

mutation_like = [
    col
    for col in dab.obs.columns
    if any(
        re.search(
            rf"(?<![A-Z0-9]){re.escape(gene)}(?![A-Z0-9])",
            str(col).upper(),
        )
        for gene in DAB_GENES
    )
]

print("\nDAb mutation/genotype-like .obs columns:")
print(mutation_like if mutation_like else "NONE FOUND")

print("\nDAb grouping candidates present:")
print(
    [
        col
        for col in [
            "experiment",
            "dab_source",
            "sample_id",
            "patient",
        ]
        if col in dab.obs.columns
    ]
)
CITE split sizes: {'train': 28414, 'val': 4652, 'test': 10113}
van Galen: (37627, 21782)
DAb: (40239, 18)

DAb mutation/genotype-like .obs columns:
['DNMT3A R882H', 'NPM1 W288fs', 'FLT3-ITD']

DAb grouping candidates present:
['experiment', 'dab_source']
rna_prep = RNAPreprocessor(
    n_hvg=None,
    scale=True,
).fit(
    cite_rna[splits["train"]]
)

adt_prep = ADTPreprocessor(
    scale=True,
    clip=10.0,
).fit(
    cite_adt[splits["train"]]
)

cite = {
    "rna": rna_prep.transform(cite_rna),
    "adt": adt_prep.transform(cite_adt),
}

# van Galen is count-based, so the fitted RNA preprocessor applies
# directly.
vg_pp = rna_prep.transform(vg)
# ------------------------------------------------------------
# DAb-seq: source-provided processed continuous values
# ------------------------------------------------------------
def preprocess_dab_processed_X(
    adata,
    features,
    *,
    clip=10.0,
):
    """
    Preprocess DAb-seq when .X already contains source-processed
    continuous protein measurements.

    This intentionally does NOT:
      - look for raw counts
      - apply CLR
      - create layers['counts']

    It reproduces the Figure 7 DAb-specific path:
      processed X -> feature-wise unit standardization -> clipping
    """
    features = list(features)
    missing = [f for f in features if f not in adata.var_names]
    if missing:
        raise ValueError(
            f"DAb is missing {len(missing)} CITE reference features: "
            f"{missing[:10]}"
        )
    out = adata[:, features].copy()
    X = (
        out.X.toarray()
        if sp.issparse(out.X)
        else np.asarray(out.X)
    ).astype(np.float32)
    # DAb-specific self-standardization, as in Figure 7
    mu = np.nanmean(X, axis=0).astype(np.float32)
    sd = np.nanstd(X, axis=0).astype(np.float32)
    # Guard against invariant markers
    sd = np.where(sd < 1e-8, 1.0, sd)
    X = (X - mu) / sd
    X = np.clip(X, -clip, clip).astype(np.float32)
    out.X = X
    out.uns["adt_transform"] = {
        "type": "already_processed",
        "standardize": "unit",
        "standardize_fit": "dab_self",
        "clip": float(clip),
    }
    return out
dab_pp = preprocess_dab_processed_X(dab, features=adt_prep.features_, clip=10.0,)

cite_train = {m: a[splits["train"]].copy() for m, a in cite.items()}
cite_val = {m: a[splits["val"]].copy() for m, a in cite.items()}
cfg = UniVIConfig(
    latent_dim=30,
    beta=1.15,
    gamma=1.75,
    encoder_dropout=0.10,
    decoder_dropout=0.05,
    modalities=[
        ModalityConfig(
            "rna",
            cite["rna"].n_vars,
            [1024, 512, 256, 128, 64],
            [64, 128, 256, 512, 1024],
            likelihood="gaussian",
        ),
        ModalityConfig(
            "adt",
            cite["adt"].n_vars,
            [128, 64, 32],
            [32, 64, 128],
            likelihood="gaussian",
        ),
    ],
)

model = UniVIMultiModalVAE(
    cfg,
    loss_mode="v1",
    v1_recon="avg",
    normalize_v1_terms=True,
)

base_trainer = UniVITrainer(
    model,
    make_loader(
        cite_train,
        batch_size=256,
        shuffle=True,
        drop_last=True,
    ),
    make_loader(
        cite_val,
        batch_size=1024,
    ),
    TrainingConfig(
        n_epochs=N_EPOCHS,
        batch_size=256,
        lr=1e-4,
        weight_decay=1e-5,
        device=device,
        early_stopping=True,
        patience=150,
        log_every=100,
    ),
)

_base_result = base_trainer.fit()
print("Base CITE-seq bridge training complete.")
Base CITE-seq bridge training complete.
[2026-09-21 19:22:56,189] [UniVITrainer] [INFO] TrainingConfig:
[2026-09-21 19:22:56,190] [UniVITrainer] [INFO]   n_epochs: 3000
[2026-09-21 19:22:56,190] [UniVITrainer] [INFO]   batch_size: 256
[2026-09-21 19:22:56,191] [UniVITrainer] [INFO]   lr: 0.0001
[2026-09-21 19:22:56,191] [UniVITrainer] [INFO]   weight_decay: 1e-05
[2026-09-21 19:22:56,192] [UniVITrainer] [INFO]   device: 'cuda'
[2026-09-21 19:22:56,192] [UniVITrainer] [INFO]   log_every: 100
[2026-09-21 19:22:56,193] [UniVITrainer] [INFO]   grad_clip: None
[2026-09-21 19:22:56,193] [UniVITrainer] [INFO]   num_workers: 0
[2026-09-21 19:22:56,194] [UniVITrainer] [INFO]   seed: 0
[2026-09-21 19:22:56,194] [UniVITrainer] [INFO]   early_stopping: True
[2026-09-21 19:22:56,194] [UniVITrainer] [INFO]   patience: 150
[2026-09-21 19:22:56,195] [UniVITrainer] [INFO]   min_delta: 0.0
[2026-09-21 19:22:56,195] [UniVITrainer] [INFO]   best_epoch_warmup: 0
Training UniVI:   0%|          | 0/3000 [00:00<?, ?it/s][2026-09-21 19:23:01,127] [UniVITrainer] [INFO] [Epoch 001] Train loss=9768.9412 (beta=1.150, gamma=1.750)
[2026-09-21 19:23:01,462] [UniVITrainer] [INFO] [Epoch 001] Val loss=11885.9268 (beta=1.150, gamma=1.750)
Training UniVI:   0%|          | 0/3000 [00:05<?, ?it/s, beta=1.150, gamma=1.750, train_loss=9768.9412, val_loss=11885.9268][2026-09-21 19:23:01,534] [UniVITrainer] [INFO] [Epoch 001] New best val loss: 11885.9268
Training UniVI:   0%|          | 1/3000 [00:10<4:26:49,  5.34s/it, beta=1.150, gamma=1.750, train_loss=9724.2203, val_loss=11783.1396][2026-09-21 19:23:06,445] [UniVITrainer] [INFO] [Epoch 002] New best val loss: 11783.1396
Training UniVI:   0%|          | 2/3000 [00:15<4:14:08,  5.09s/it, beta=1.150, gamma=1.750, train_loss=9667.7449, val_loss=11777.2982][2026-09-21 19:23:11,398] [UniVITrainer] [INFO] [Epoch 003] New best val loss: 11777.2982
Training UniVI:   0%|          | 3/3000 [00:20<4:11:00,  5.03s/it, beta=1.150, gamma=1.750, train_loss=9647.0745, val_loss=11766.0029][2026-09-21 19:23:16,320] [UniVITrainer] [INFO] [Epoch 004] New best val loss: 11766.0029
Training UniVI:   0%|          | 4/3000 [00:25<4:08:54,  4.98s/it, beta=1.150, gamma=1.750, train_loss=9623.8065, val_loss=11728.0707][2026-09-21 19:23:21,298] [UniVITrainer] [INFO] [Epoch 005] New best val loss: 11728.0707
Training UniVI:   0%|          | 5/3000 [00:30<4:08:41,  4.98s/it, beta=1.150, gamma=1.750, train_loss=9604.3125, val_loss=11711.2176][2026-09-21 19:23:26,241] [UniVITrainer] [INFO] [Epoch 006] New best val loss: 11711.2176
Training UniVI:   0%|          | 6/3000 [00:34<4:07:57,  4.97s/it, beta=1.150, gamma=1.750, train_loss=9596.8990, val_loss=11702.3797][2026-09-21 19:23:31,191] [UniVITrainer] [INFO] [Epoch 007] New best val loss: 11702.3797
Training UniVI:   0%|          | 7/3000 [00:39<4:07:33,  4.96s/it, beta=1.150, gamma=1.750, train_loss=9580.4027, val_loss=11686.2570][2026-09-21 19:23:36,098] [UniVITrainer] [INFO] [Epoch 008] New best val loss: 11686.2570
Training UniVI:   0%|          | 8/3000 [00:44<4:06:34,  4.94s/it, beta=1.150, gamma=1.750, train_loss=9566.2973, val_loss=11670.4402][2026-09-21 19:23:41,174] [UniVITrainer] [INFO] [Epoch 009] New best val loss: 11670.4402
Training UniVI:   0%|          | 9/3000 [00:49<4:08:32,  4.99s/it, beta=1.150, gamma=1.750, train_loss=9557.6824, val_loss=11665.2561][2026-09-21 19:23:46,039] [UniVITrainer] [INFO] [Epoch 010] New best val loss: 11665.2561
Training UniVI:   0%|          | 10/3000 [00:54<4:06:36,  4.95s/it, beta=1.150, gamma=1.750, train_loss=9547.1020, val_loss=11661.9186][2026-09-21 19:23:50,995] [UniVITrainer] [INFO] [Epoch 011] New best val loss: 11661.9186
Training UniVI:   0%|          | 11/3000 [00:59<4:06:38,  4.95s/it, beta=1.150, gamma=1.750, train_loss=9544.9322, val_loss=11658.4572][2026-09-21 19:23:55,967] [UniVITrainer] [INFO] [Epoch 012] New best val loss: 11658.4572
Training UniVI:   0%|          | 12/3000 [01:04<4:06:52,  4.96s/it, beta=1.150, gamma=1.750, train_loss=9542.3379, val_loss=11655.5514][2026-09-21 19:24:00,910] [UniVITrainer] [INFO] [Epoch 013] New best val loss: 11655.5514
Training UniVI:   1%|          | 19/3000 [01:35<3:38:22,  4.40s/it, beta=1.150, gamma=1.750, train_loss=9489.5652, val_loss=11649.1766][2026-09-21 19:24:32,199] [UniVITrainer] [INFO] [Epoch 020] New best val loss: 11649.1766
Training UniVI:   1%|          | 20/3000 [01:40<3:34:52,  4.33s/it, beta=1.150, gamma=1.750, train_loss=9491.9438, val_loss=11645.8553][2026-09-21 19:24:36,440] [UniVITrainer] [INFO] [Epoch 021] New best val loss: 11645.8553
Training UniVI:   1%|          | 21/3000 [01:44<3:33:30,  4.30s/it, beta=1.150, gamma=1.750, train_loss=9488.6097, val_loss=11639.3750][2026-09-21 19:24:40,627] [UniVITrainer] [INFO] [Epoch 022] New best val loss: 11639.3750
Training UniVI:   1%|          | 22/3000 [01:48<3:31:45,  4.27s/it, beta=1.150, gamma=1.750, train_loss=9489.5619, val_loss=11633.7492][2026-09-21 19:24:44,770] [UniVITrainer] [INFO] [Epoch 023] New best val loss: 11633.7492
Training UniVI:   1%|          | 23/3000 [01:52<3:29:50,  4.23s/it, beta=1.150, gamma=1.750, train_loss=9482.1914, val_loss=11632.4666][2026-09-21 19:24:48,826] [UniVITrainer] [INFO] [Epoch 024] New best val loss: 11632.4666
Training UniVI:   1%|          | 24/3000 [01:56<3:27:11,  4.18s/it, beta=1.150, gamma=1.750, train_loss=9483.5007, val_loss=11626.7492][2026-09-21 19:24:52,911] [UniVITrainer] [INFO] [Epoch 025] New best val loss: 11626.7492
Training UniVI:   1%|          | 26/3000 [02:04<3:24:32,  4.13s/it, beta=1.150, gamma=1.750, train_loss=9466.6209, val_loss=11623.2975][2026-09-21 19:25:01,122] [UniVITrainer] [INFO] [Epoch 027] New best val loss: 11623.2975
Training UniVI:   2%|▏         | 49/3000 [03:49<3:15:12,  3.97s/it, beta=1.150, gamma=1.750, train_loss=9436.4224, val_loss=11622.6316][2026-09-21 19:26:46,032] [UniVITrainer] [INFO] [Epoch 050] New best val loss: 11622.6316
Training UniVI:   2%|▏         | 55/3000 [04:14<3:24:45,  4.17s/it, beta=1.150, gamma=1.750, train_loss=9436.8196, val_loss=11622.4635][2026-09-21 19:27:11,058] [UniVITrainer] [INFO] [Epoch 056] New best val loss: 11622.4635
Training UniVI:   2%|▏         | 59/3000 [04:30<3:12:29,  3.93s/it, beta=1.150, gamma=1.750, train_loss=9432.7048, val_loss=11619.8607][2026-09-21 19:27:26,503] [UniVITrainer] [INFO] [Epoch 060] New best val loss: 11619.8607
Training UniVI:   3%|▎         | 99/3000 [07:06<3:18:49,  4.11s/it, beta=1.150, gamma=1.750, train_loss=9410.4060, val_loss=11629.4254][2026-09-21 19:30:06,653] [UniVITrainer] [INFO] [Epoch 100] Train loss=9411.6608 (beta=1.150, gamma=1.750)
[2026-09-21 19:30:06,910] [UniVITrainer] [INFO] [Epoch 100] Val loss=11628.7229 (beta=1.150, gamma=1.750)
Training UniVI:   5%|▍         | 138/3000 [09:58<3:31:22,  4.43s/it, beta=1.150, gamma=1.750, train_loss=9397.9880, val_loss=11618.8162][2026-09-21 19:32:54,632] [UniVITrainer] [INFO] [Epoch 139] New best val loss: 11618.8162
Training UniVI:   7%|▋         | 199/3000 [14:51<3:28:52,  4.47s/it, beta=1.150, gamma=1.750, train_loss=9373.2198, val_loss=11629.7609][2026-09-21 19:37:51,809] [UniVITrainer] [INFO] [Epoch 200] Train loss=9379.6845 (beta=1.150, gamma=1.750)
[2026-09-21 19:37:52,060] [UniVITrainer] [INFO] [Epoch 200] Val loss=11636.9398 (beta=1.150, gamma=1.750)
Training UniVI:   7%|▋         | 215/3000 [16:16<4:36:50,  5.96s/it, beta=1.150, gamma=1.750, train_loss=9367.5445, val_loss=11618.7789][2026-09-21 19:39:12,352] [UniVITrainer] [INFO] [Epoch 216] New best val loss: 11618.7789
Training UniVI:  10%|▉         | 299/3000 [22:40<3:34:07,  4.76s/it, beta=1.150, gamma=1.750, train_loss=9352.8864, val_loss=11624.8104][2026-09-21 19:45:41,317] [UniVITrainer] [INFO] [Epoch 300] Train loss=9357.5087 (beta=1.150, gamma=1.750)
[2026-09-21 19:45:41,592] [UniVITrainer] [INFO] [Epoch 300] Val loss=11629.1664 (beta=1.150, gamma=1.750)
Training UniVI:  12%|█▏        | 365/3000 [28:04<3:32:30,  4.84s/it, beta=1.150, gamma=1.750, train_loss=9341.2945, val_loss=11628.6625][2026-09-21 19:51:00,280] [UniVITrainer] [INFO] Early stopping at epoch 366 (best val loss=11618.7789, best epoch=216)
Training UniVI:  12%|█▏        | 365/3000 [28:04<3:22:37,  4.61s/it, beta=1.150, gamma=1.750, train_loss=9341.2945, val_loss=11628.6625]
[2026-09-21 19:51:00,316] [UniVITrainer] [INFO] Restored best model from epoch 216 (val loss=11618.7789)

Zero-shot projection before mutation refinement#

All four blocks are embedded before mutation supervision so the effect of refinement can be compared against the original bridge geometry.

bridge_blocks = [
    ("CITE-seq RNA", "rna", cite["rna"]),
    ("CITE-seq ADT", "adt", cite["adt"]),
    ("van Galen RNA", "rna", vg_pp),
    ("DAb-seq ADT", "adt", dab_pp),
]

joint_pre = stack_embeddings(
    model,
    bridge_blocks,
    device=device,
)

sc.pp.neighbors(
    joint_pre,
    use_rep="X_univi",
    n_neighbors=30,
)
sc.tl.umap(
    joint_pre,
    random_state=0,
)

sc.pl.umap(
    joint_pre,
    color=["cohort"],
    legend_fontsize=8,
)
../../_images/fbb5962be7617411c508011bb4ab58b740d7883c1b2ead20563f91df9e265a70.png

Mutation labels from the original cohort metadata#

van Galen: mutation truth is reconstructed from MutTranscripts and WtTranscripts. A cell is labeled mutant when the mutant transcript string contains the gene and the wild-type string does not; wild type is assigned when the reverse is true; otherwise the gene is masked for that cell.

DAb-seq: the original Demaree .h5ad objects do not use synthetic mut_<GENE> columns. Their genotype calls are stored in variant-specific .obs columns such as NPM1 W288fs, DNMT3A R882H, and FLT3-ITD. The code below discovers those columns and collapses them to gene-level 0/1/NA targets.

DAb directly contributes labels only for NPM1, DNMT3A, and FLT3; the remaining genes stay masked for DAb rather than being interpreted as wild type.

def vg_labels(obs, gene):
    gene_u = str(gene).upper()

    mut = (
        obs["MutTranscripts"]
        .fillna("")
        .astype(str)
        .str.upper()
        .str.contains(gene_u, regex=False)
    )
    wt = (
        obs["WtTranscripts"]
        .fillna("")
        .astype(str)
        .str.upper()
        .str.contains(gene_u, regex=False)
    )

    y = np.full(len(obs), np.nan, dtype=np.float32)
    y[(mut & ~wt).to_numpy()] = 1.0
    y[(wt & ~mut).to_numpy()] = 0.0
    return y


def mutation_columns_for_gene(obs, gene):
    """Find DAb variant columns containing the gene as a token."""
    gene = str(gene).upper()
    pattern = re.compile(
        rf"(?<![A-Z0-9]){re.escape(gene)}(?![A-Z0-9])",
        flags=re.IGNORECASE,
    )

    hits = [
        col
        for col in obs.columns
        if pattern.search(str(col).upper())
    ]

    # Prefer the manuscript variants when several annotations exist.
    preference = {
        "NPM1": ["W288"],
        "DNMT3A": ["R882"],
        "FLT3": ["ITD"],
    }.get(gene, [])

    preferred = [
        col
        for col in hits
        if any(
            token in str(col).upper()
            for token in preference
        )
    ]

    return preferred if preferred else hits


def coerce_variant_call(series):
    """
    DAb mutation columns are treated as:
      missing -> NA
      zero/false/WT -> 0
      nonzero/true/mutant -> 1
    """
    s = pd.Series(series, index=series.index)

    if pd.api.types.is_bool_dtype(s):
        return s.astype(float).to_numpy()

    numeric = pd.to_numeric(s, errors="coerce")

    out = np.full(len(s), np.nan, dtype=np.float32)
    numeric_ok = numeric.notna().to_numpy()

    if numeric_ok.any():
        values = numeric.to_numpy(dtype=float)
        out[numeric_ok] = (
            values[numeric_ok] != 0
        ).astype(np.float32)

    # Fill still-unparsed string labels.
    unresolved = ~numeric_ok & s.notna().to_numpy()
    if unresolved.any():
        text = (
            s.astype("string")
            .str.strip()
            .str.lower()
        )

        wt_tokens = {
            "wt",
            "wildtype",
            "wild-type",
            "false",
            "negative",
            "neg",
            "no",
        }
        mut_tokens = {
            "mut",
            "mutant",
            "true",
            "positive",
            "pos",
            "yes",
        }

        for i in np.flatnonzero(unresolved):
            value = text.iloc[i]
            if value in wt_tokens:
                out[i] = 0.0
            elif value in mut_tokens:
                out[i] = 1.0

    return out


def dab_gene_label(obs, gene):
    cols = mutation_columns_for_gene(obs, gene)

    if not cols:
        return np.full(len(obs), np.nan, dtype=np.float32), []

    calls = np.column_stack(
        [
            coerce_variant_call(obs[col])
            for col in cols
        ]
    )

    labeled = np.isfinite(calls).any(axis=1)
    positive = np.nan_to_num(
        calls,
        nan=0.0,
    ).max(axis=1) > 0

    y = np.full(len(obs), np.nan, dtype=np.float32)
    y[labeled] = positive[labeled].astype(np.float32)

    return y, cols


labels = {
    "vg": {
        gene: vg_labels(vg_pp.obs, gene)
        for gene in GENES
    },
    "dab": {},
}

dab_gene_columns = {}

for gene in GENES:
    if gene in DAB_GENES:
        y, cols = dab_gene_label(
            dab_pp.obs,
            gene,
        )
        labels["dab"][gene] = y
        dab_gene_columns[gene] = cols

        # Canonical convenience column for downstream API code.
        dab_pp.obs[f"mut_{gene}"] = y
    else:
        labels["dab"][gene] = np.full(
            dab_pp.n_obs,
            np.nan,
            dtype=np.float32,
        )
        dab_gene_columns[gene] = []


print("DAb genotype source columns:")
for gene in DAB_GENES:
    print(
        f"  {gene:7s}: "
        f"{dab_gene_columns[gene] or 'NOT FOUND'}"
    )

missing_dab_genes = [
    gene
    for gene in DAB_GENES
    if not dab_gene_columns[gene]
]

if missing_dab_genes:
    candidates = [
        col
        for col in dab_pp.obs.columns
        if any(
            token in str(col).upper()
            for token in [
                "NPM1",
                "DNMT3A",
                "FLT3",
                "ITD",
                "R882",
                "W288",
            ]
        )
    ]

    raise KeyError(
        "The hosted DAb object is missing genotype columns "
        f"for {missing_dab_genes}. This is a data-release problem, "
        "not a modeling problem. Mutation-like columns currently "
        f"visible are: {candidates}"
    )


counts = pd.DataFrame(
    {
        (cohort, state): {
            gene: int(
                np.nansum(
                    labels[cohort][gene] == value
                )
            )
            for gene in GENES
        }
        for cohort in labels
        for state, value in [
            ("mut", 1.0),
            ("wt", 0.0),
        ]
    }
)

print("\nObserved mutation-label counts:")
display(counts)
DAb genotype source columns:
  NPM1   : ['NPM1 W288fs']
  DNMT3A : ['DNMT3A R882H']
  FLT3   : ['FLT3-ITD']

Observed mutation-label counts:
vg dab
mut wt mut wt
NPM1 415 1503 24272 15967
DNMT3A 145 420 25895 14344
FLT3 38 128 10279 8008
TP53 52 26 0 0
NRAS 36 117 0 0
TET2 17 31 0 0
IDH2 3 237 0 0

Group-aware mutation-head refinement#

The article evaluated mutation heads with cell-level splits because of metadata constraints, and noted that patient-level claims would need patient-held-out evaluation. Here, van Galen is split by patient/sample identity and DAb by experiment/source identity, so the test groups are never seen by the refiner. This asks more than the published analysis did. The test groups are never supplied to the refiner.

Each binary mutation head is warmed up with frozen encoders and then refined with the RNA and ADT encoders at a small learning rate. The CITE bridge training set is replayed during refinement to preserve the original cross-modal geometry.

def first_existing_column(obs, candidates):
    for col in candidates:
        if col in obs.columns:
            return col
    return None

VG_GROUP_COL = first_existing_column(
    vg_pp.obs,
    [
        "patient",
        "orig.ident",
        "sample_id",
        "sample",
        "donor",
    ],
)

DAB_GROUP_COL = first_existing_column(
    dab_pp.obs,
    [
        "experiment",
        "dab_source",
        "sample_id",
        "patient",
    ],
)

print("van Galen grouping column:", VG_GROUP_COL)
print("DAb grouping column:", DAB_GROUP_COL)

def group_split_masks(
    adata,
    group_col,
    *,
    seed=42,
    fractions=(0.70, 0.15, 0.15),
):
    """
    Deterministic group-level 70/15/15 split.
    Falls back to cell-level only if no grouping column exists.
    """
    n = adata.n_obs
    if group_col is None:
        rng = np.random.default_rng(seed)
        idx = rng.permutation(n)
        n_train = int(fractions[0] * n)
        n_val = int(fractions[1] * n)
        masks = {
            "train": np.zeros(n, dtype=bool),
            "val": np.zeros(n, dtype=bool),
            "test": np.zeros(n, dtype=bool),
        }
        masks["train"][idx[:n_train]] = True
        masks["val"][idx[n_train:n_train + n_val]] = True
        masks["test"][idx[n_train + n_val:]] = True
        print(
            "WARNING: no grouping column available; "
            "using a cell-level split."
        )
        return masks

    groups = (
        adata.obs[group_col]
        .astype(str)
        .fillna("NA")
        .to_numpy()
    )
    unique = np.unique(groups)
    if len(unique) < 3:
        raise ValueError(
            f"{group_col!r} contains only {len(unique)} groups; "
            "cannot form leakage-aware train/val/test splits."
        )
    rng = np.random.default_rng(seed)
    unique = rng.permutation(unique)
    n_groups = len(unique)
    n_test = max(1, int(round(fractions[2] * n_groups)))
    n_val = max(1, int(round(fractions[1] * n_groups)))
    # Always leave at least one training group.
    if n_test + n_val >= n_groups:
        n_test = 1
        n_val = 1
    test_groups = unique[:n_test]
    val_groups = unique[n_test:n_test + n_val]
    train_groups = unique[n_test + n_val:]
    masks = {
        "train": np.isin(groups, train_groups),
        "val": np.isin(groups, val_groups),
        "test": np.isin(groups, test_groups),
    }
    print(
        f"{group_col}: "
        f"{len(train_groups)} train groups, "
        f"{len(val_groups)} val groups, "
        f"{len(test_groups)} test groups"
    )
    return masks

split_masks = {
    "vg": group_split_masks(
        vg_pp,
        VG_GROUP_COL,
        seed=42,
    ),
    "dab": group_split_masks(
        dab_pp,
        DAB_GROUP_COL,
        seed=43,
    ),
}

def labeled_counts_for_mask(cohort, gene, mask):
    y = labels[cohort][gene]
    usable = mask & np.isfinite(y)
    return (
        int(np.sum(y[usable] == 0)),
        int(np.sum(y[usable] == 1)),
    )

# Instantiate heads only when the combined TRAINING data have
# sufficient wild-type and mutant examples.
heads = []

for gene in GENES:
    train_wt = 0
    train_mut = 0
    for cohort in ("vg", "dab"):
        wt, mut = labeled_counts_for_mask(
            cohort,
            gene,
            split_masks[cohort]["train"],
        )
        train_wt += wt
        train_mut += mut
    if (
        train_wt >= MIN_PER_CLASS
        and train_mut >= MIN_PER_CLASS
    ):
        heads.append(gene)
print("heads trained for:", heads)

coverage_rows = []
for cohort in ("vg", "dab"):
    for split_name in ("train", "val", "test"):
        for gene in GENES:
            wt, mut = labeled_counts_for_mask(
                cohort,
                gene,
                split_masks[cohort][split_name],
            )
            coverage_rows.append(
                {
                    "cohort": cohort,
                    "split": split_name,
                    "gene": gene,
                    "wt": wt,
                    "mut": mut,
                    "labeled": wt + mut,
                }
            )

coverage = pd.DataFrame(coverage_rows)
display(
    coverage.pivot_table(
        index=["cohort", "gene"],
        columns="split",
        values=["wt", "mut"],
        aggfunc="sum",
        fill_value=0,
    )
)

def subset_with_labels(
    adata,
    modality,
    cohort,
    split_name,
):
    mask = split_masks[cohort][split_name]
    idx = np.flatnonzero(mask)
    subset = adata[idx].copy()
    y = {
        f"mut_{gene}": np.where(
            np.isfinite(labels[cohort][gene][idx]),
            labels[cohort][gene][idx],
            -1,
        ).astype(np.float32)
        for gene in heads
    }
    return subset, y

vg_train, y_vg_train = subset_with_labels(vg_pp, "rna", "vg", "train",)
vg_val, y_vg_val = subset_with_labels(vg_pp, "rna", "vg", "val",)
dab_train, y_dab_train = subset_with_labels(dab_pp, "adt", "dab", "train",)
dab_val, y_dab_val = subset_with_labels(dab_pp, "adt", "dab", "val",)

for gene in heads:
    model.add_classification_head(
        ClassHeadConfig(
            f"mut_{gene}",
            n_classes=2,
            head_type="binary",
            hidden_dims=[64, 32],
            dropout=0.1,
            batchnorm=False,
            layernorm=True,
        ),
        label_names=["wt", "mut"],
    )

refiner = (
    UniVIRefiner(
        model,
        [
            make_loader(
                {"rna": vg_train},
                labels=y_vg_train,
                batch_size=256,
                shuffle=True,
            ),
            make_loader(
                {"adt": dab_train},
                labels=y_dab_train,
                batch_size=256,
                shuffle=True,
            ),
        ],
        [
            make_loader(
                {"rna": vg_val},
                labels=y_vg_val,
                batch_size=1024,
            ),
            make_loader(
                {"adt": dab_val},
                labels=y_dab_val,
                batch_size=1024,
            ),
        ],
        device=device,
        encoder_modalities=["rna", "adt"],
        replay_loader=make_loader(
            cite_train,
            batch_size=256,
            shuffle=True,
            drop_last=True,
        ),
        config=RefinementConfig(
            max_epochs=REFINE_EPOCHS,
            warmup_epochs=min(
                50,
                REFINE_EPOCHS,
            ),
            lr_head=2e-4,
            lr_encoder=1e-5,
            latent_weight=1.0,
            replay_weight=1.0,
            patience=40,
            log_every=50,
        ),
    )
    if heads
    else None
)

if refiner is not None:
    # Capture the return value so the full history dictionary is not
    # rendered into the ReadTheDocs page.
    refinement_result = refiner.fit()
    history = (
        refinement_result.get("history", [])
        if isinstance(refinement_result, dict)
        else []
    )
    if history:
        last = history[-1]
        print(
            "Mutation refinement complete: "
            f"{len(history)} epochs; "
            f"final stage={last.get('stage')}; "
            f"final val loss="
            f"{last.get('val_supervised_loss', float('nan')):.4f}"
        )
    else:
        print("Mutation refinement complete.")
van Galen grouping column: orig.ident
DAb grouping column: experiment
orig.ident: 29 train groups, 6 val groups, 6 test groups
experiment: 6 train groups, 1 val groups, 1 test groups
heads trained for: ['NPM1', 'DNMT3A', 'FLT3', 'NRAS']
Refinement 0: heads, validation=0.56055
Refinement 50: encoders+heads, validation=0.53104
Mutation refinement complete: 90 epochs; final stage=encoders+heads; final val loss=0.7042
mut wt
split test train val test train val
cohort gene
dab DNMT3A 4227 21204 464 795 11712 1837
FLT3 4678 5601 0 344 5363 2301
IDH2 0 0 0 0 0 0
NPM1 3952 20305 15 1070 12611 2286
NRAS 0 0 0 0 0 0
TET2 0 0 0 0 0 0
TP53 0 0 0 0 0 0
vg DNMT3A 5 122 18 174 223 23
FLT3 0 12 26 14 10 104
IDH2 0 3 0 189 46 2
NPM1 0 336 79 115 1246 142
NRAS 0 36 0 5 112 0
TET2 0 17 0 0 31 0
TP53 23 8 21 8 18 0
def positive_probability(p):
    p = np.asarray(p)
    return p.reshape(len(p), -1)[:, -1]

cohort_objects = {
    "vg": (vg_pp, "rna"),
    "dab": (dab_pp, "adt"),
    "cite_rna": (cite["rna"], "rna"),
    "cite_adt": (cite["adt"], "adt"),
}
probabilities = {}

for cohort, (adata, modality) in cohort_objects.items():
    out = (
        predict_heads_adata(
            model,
            adata,
            modality,
            device=device,
        )
        if heads
        else {}
    )
    for gene in heads:
        probabilities[(cohort, gene)] = positive_probability(
            out[f"mut_{gene}"]
        )

# Evaluate only on held-out labeled cells from the two cohorts
# that contain mutation truth.
rows = []
for cohort in ("vg", "dab"):
    for gene in heads:
        y = labels[cohort][gene]
        test = (
            split_masks[cohort]["test"]
            & np.isfinite(y)
        )
        if (
            test.sum() > 0
            and len(np.unique(y[test])) == 2
        ):
            p = probabilities[(cohort, gene)]
            rows.append(
                {
                    "cohort": cohort,
                    "gene": gene,
                    "n_test": int(test.sum()),
                    "AUC": roc_auc_score(
                        y[test],
                        p[test],
                    ),
                    "AP": average_precision_score(
                        y[test],
                        p[test],
                    ),
                }
            )

metrics = pd.DataFrame(rows).sort_values(
    ["gene", "cohort"]
)
display(metrics.round(3))
cohort gene n_test AUC AP
2 dab DNMT3A 5022 0.566 0.859
0 vg DNMT3A 179 0.749 0.066
3 dab FLT3 5022 0.706 0.957
1 dab NPM1 5022 0.535 0.801

Refined joint latent and mutation probabilities#

After mutation refinement, the embeddings are recomputed for all four blocks. Head probabilities are shown across CITE RNA, CITE ADT, van Galen RNA, and DAb ADT.

For the NPM1 example, observed source-cohort mutation truth is also overlaid where available; CITE remains unlabeled.

joint_post = stack_embeddings(
    model,
    bridge_blocks,
    device=device,
)

# stack_embeddings follows bridge_blocks order:
# CITE RNA, CITE ADT, van Galen RNA, DAb ADT.
block_sizes = [
    cite["rna"].n_obs,
    cite["adt"].n_obs,
    vg_pp.n_obs,
    dab_pp.n_obs,
]

for gene in heads:
    joint_post.obs[f"p_{gene}"] = np.concatenate(
        [
            probabilities[("cite_rna", gene)],
            probabilities[("cite_adt", gene)],
            probabilities[("vg", gene)],
            probabilities[("dab", gene)],
        ]
    )

# Observed HERO truth exists only for van Galen and DAb.
hero_truth = np.concatenate(
    [
        np.full(cite["rna"].n_obs, np.nan),
        np.full(cite["adt"].n_obs, np.nan),
        labels["vg"][HERO],
        labels["dab"][HERO],
    ]
)

hero_label = np.full(
    len(hero_truth),
    "Unlabeled",
    dtype=object,
)
hero_label[np.isfinite(hero_truth) & (hero_truth == 0)] = "WT"
hero_label[np.isfinite(hero_truth) & (hero_truth == 1)] = "Mut"

joint_post.obs[f"actual_{HERO}"] = pd.Categorical(
    hero_label,
    categories=["WT", "Mut", "Unlabeled"],
)

sc.pp.neighbors(
    joint_post,
    use_rep="X_univi",
    n_neighbors=30,
)
sc.tl.umap(
    joint_post,
    random_state=0,
)
sc.pl.umap(
    joint_post,
    color=[
        "cohort",
        f"actual_{HERO}",
        f"p_{HERO}",
    ],
    wspace=0.45,
    legend_fontsize=8,
    cmap="viridis",
)

if heads:
    sc.pl.umap(
        joint_post,
        color=[f"p_{gene}" for gene in heads],
        ncols=3,
        cmap="viridis",
        vmin=0,
        vmax=1,
    )
../../_images/59845caf5d0cf21b7e91adb41c3742fc862db80ad6076adaed22c996ac4a9496.png ../../_images/76a250b8dcccf7073a6a96b7a0e56fc95fea0138d68a374b6b2bd877d7b8113c.png