Fig. 6 and S6: trimodal TEA-seq with a held-out well#

Open in Colab

RNA, surface protein, and chromatin accessibility measured in the same cells (Swanson et al. 2021). UniVI is trained on wells 3, 4 and 6 and evaluated on well 5, with every preprocessing step fit on the training wells. Model settings follow the archived notebook UniVI_manuscript_GR-Figure__6__TEA-seq_tri-modal.ipynb; preprocessing uses UniVI’s fitted preprocessors, so results will be close to, not identical with, the published ones.

import sys

if "google.colab" in sys.modules:
    %pip install -q "univi[tutorials]>=1.1" "pandas==2.2.3"
import numpy as np
import pandas as pd
import scanpy as sc
import torch

import univi.datasets as uds
from univi import ModalityConfig, TrainingConfig, UniVIConfig, UniVIMultiModalVAE, UniVITrainer
from univi.evaluation import compute_foscttm, encode_adata, encode_fused_adata_pair
from univi.preprocessing import ADTPreprocessor, ATACPreprocessor, 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)
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       # archived notebook: 3000 with early stopping (patience 200)
N_HVG = 2000
N_LSI = 100
HOLDOUT = "GSM5123953_X066-MP0C1W5_leukopak_perm-cells_tea"   # well 5
LEIDEN_RESOLUTION = 0.8

Data and well hold-out#

The ATAC modality is a genome-wide tile matrix (500 bp tiles). Tiles open in 0.5–80% of training cells are kept. A random 10% of training-well cells is used for early stopping.

data = uds.load("teaseq_pbmc")
rna, adt, atac = data["rna"], data["adt"], data["atac"]
well = rna.obs["sample_id"].astype(str).to_numpy()
held = np.flatnonzero(well == HOLDOUT)
rest = np.random.default_rng(0).permutation(np.flatnonzero(well != HOLDOUT))
splits = {"train": np.sort(rest[len(rest) // 10:]), "val": np.sort(rest[: len(rest) // 10]), "test": held}
print({k: len(v) for k, v in splits.items()})

rna_prep = RNAPreprocessor(n_hvg=N_HVG, scale=True).fit(rna[splits["train"]])
adt_prep = ADTPreprocessor(scale=True).fit(adt[splits["train"]])
atac_prep = ATACPreprocessor(n_components=N_LSI, drop_first=False, scale=True,
                             min_fraction=0.005, max_fraction=0.80).fit(atac[splits["train"]])
parts = {k: {"rna": rna_prep.transform(rna[i]), "adt": adt_prep.transform(adt[i]), "atac": atac_prep.transform(atac[i])}
         for k, i in splits.items()}
{'train': 20226, 'val': 2247, 'test': 7421}
teaseq_pbmc_rna.h5ad: 100%|██████████| 76.5M/76.5M [00:46<00:00, 1.64MB/s]
teaseq_pbmc_adt.h5ad: 100%|██████████| 4.67M/4.67M [00:06<00:00, 682kB/s]
teaseq_pbmc_atac.h5ad: 100%|██████████| 339M/339M [03:05<00:00, 1.82MB/s]
cfg = UniVIConfig(
    latent_dim=30, beta=1.15,
    gamma=1.45,  # value in the archived notebook; Supplemental Table S8 lists 1.35
    encoder_dropout=0.10, decoder_dropout=0.0,
    kl_anneal_start=0, kl_anneal_end=50, align_anneal_start=25, align_anneal_end=75,
    modalities=[
        ModalityConfig("rna", parts["train"]["rna"].n_vars, [512, 256, 128], [128, 256, 512], likelihood="gaussian"),
        ModalityConfig("adt", parts["train"]["adt"].n_vars, [128, 64], [64, 128], likelihood="gaussian"),
        ModalityConfig("atac", parts["train"]["atac"].n_vars, [128, 64], [64, 128], likelihood="gaussian"),
    ],
)
model = UniVIMultiModalVAE(cfg, loss_mode="v1", v1_recon="avg", normalize_v1_terms=True)
UniVITrainer(
    model, make_loader(parts["train"], batch_size=256, shuffle=True, drop_last=True),
    make_loader(parts["val"], batch_size=1024),
    TrainingConfig(n_epochs=N_EPOCHS, batch_size=256, lr=1e-4, weight_decay=1e-4, device=device,
                   early_stopping=True, patience=200, best_epoch_warmup=75, log_every=100),
).fit();
[2026-09-21 16:17:05,767] [UniVITrainer] [INFO] TrainingConfig:
[2026-09-21 16:17:05,768] [UniVITrainer] [INFO]   n_epochs: 3000
[2026-09-21 16:17:05,768] [UniVITrainer] [INFO]   batch_size: 256
[2026-09-21 16:17:05,768] [UniVITrainer] [INFO]   lr: 0.0001
[2026-09-21 16:17:05,769] [UniVITrainer] [INFO]   weight_decay: 0.0001
[2026-09-21 16:17:05,769] [UniVITrainer] [INFO]   device: 'cuda'
[2026-09-21 16:17:05,770] [UniVITrainer] [INFO]   log_every: 100
[2026-09-21 16:17:05,770] [UniVITrainer] [INFO]   grad_clip: None
[2026-09-21 16:17:05,770] [UniVITrainer] [INFO]   num_workers: 0
[2026-09-21 16:17:05,771] [UniVITrainer] [INFO]   seed: 0
[2026-09-21 16:17:05,771] [UniVITrainer] [INFO]   early_stopping: True
[2026-09-21 16:17:05,772] [UniVITrainer] [INFO]   patience: 200
[2026-09-21 16:17:05,772] [UniVITrainer] [INFO]   min_delta: 0.0
[2026-09-21 16:17:05,773] [UniVITrainer] [INFO]   best_epoch_warmup: 75
Training UniVI:   0%|          | 0/3000 [00:00<?, ?it/s][2026-09-21 16:17:09,793] [UniVITrainer] [INFO] [Epoch 001] Train loss=716.3032 (beta=0.023, gamma=0.000)
[2026-09-21 16:17:09,949] [UniVITrainer] [INFO] [Epoch 001] Val loss=715.1627 (beta=1.150, gamma=1.450)
Training UniVI:   2%|▏         | 73/3000 [04:42<3:01:05,  3.71s/it, beta=1.150, gamma=1.421, train_loss=671.0216, val_loss=646.0187][2026-09-21 16:21:47,857] [UniVITrainer] [INFO] [Epoch 074] Best tracking warmup ends next epoch (best_epoch_warmup=75).
Training UniVI:   2%|▏         | 74/3000 [04:45<3:00:34,  3.70s/it, beta=1.150, gamma=1.450, train_loss=671.1755, val_loss=646.3227][2026-09-21 16:21:51,573] [UniVITrainer] [INFO] [Epoch 075] New best val loss: 646.3227
Training UniVI:   3%|▎         | 76/3000 [04:53<3:00:02,  3.69s/it, beta=1.150, gamma=1.450, train_loss=670.8795, val_loss=646.1594][2026-09-21 16:21:58,955] [UniVITrainer] [INFO] [Epoch 077] New best val loss: 646.1594
Training UniVI:   3%|▎         | 78/3000 [05:00<2:59:41,  3.69s/it, beta=1.150, gamma=1.450, train_loss=670.6944, val_loss=646.0969][2026-09-21 16:22:06,365] [UniVITrainer] [INFO] [Epoch 079] New best val loss: 646.0969
Training UniVI:   3%|▎         | 80/3000 [05:07<2:58:56,  3.68s/it, beta=1.150, gamma=1.450, train_loss=670.5961, val_loss=646.0242][2026-09-21 16:22:13,661] [UniVITrainer] [INFO] [Epoch 081] New best val loss: 646.0242
Training UniVI:   3%|▎         | 81/3000 [05:11<2:59:03,  3.68s/it, beta=1.150, gamma=1.450, train_loss=670.3775, val_loss=645.8127][2026-09-21 16:22:17,394] [UniVITrainer] [INFO] [Epoch 082] New best val loss: 645.8127
Training UniVI:   3%|▎         | 86/3000 [05:30<2:58:55,  3.68s/it, beta=1.150, gamma=1.450, train_loss=670.1635, val_loss=645.6289][2026-09-21 16:22:35,852] [UniVITrainer] [INFO] [Epoch 087] New best val loss: 645.6289
Training UniVI:   3%|▎         | 87/3000 [05:33<2:58:37,  3.68s/it, beta=1.150, gamma=1.450, train_loss=670.1406, val_loss=645.5928][2026-09-21 16:22:39,580] [UniVITrainer] [INFO] [Epoch 088] New best val loss: 645.5928
Training UniVI:   3%|▎         | 92/3000 [05:52<2:58:30,  3.68s/it, beta=1.150, gamma=1.450, train_loss=669.5410, val_loss=645.5178][2026-09-21 16:22:57,989] [UniVITrainer] [INFO] [Epoch 093] New best val loss: 645.5178
Training UniVI:   3%|▎         | 93/3000 [05:55<2:58:34,  3.69s/it, beta=1.150, gamma=1.450, train_loss=669.7000, val_loss=645.4531][2026-09-21 16:23:01,731] [UniVITrainer] [INFO] [Epoch 094] New best val loss: 645.4531
Training UniVI:   3%|▎         | 95/3000 [06:03<2:59:33,  3.71s/it, beta=1.150, gamma=1.450, train_loss=669.7251, val_loss=645.3406][2026-09-21 16:23:09,104] [UniVITrainer] [INFO] [Epoch 096] New best val loss: 645.3406
Training UniVI:   3%|▎         | 99/3000 [06:14<2:59:04,  3.70s/it, beta=1.150, gamma=1.450, train_loss=669.4687, val_loss=645.6017][2026-09-21 16:23:23,799] [UniVITrainer] [INFO] [Epoch 100] Train loss=669.4638 (beta=1.150, gamma=1.450)
[2026-09-21 16:23:23,926] [UniVITrainer] [INFO] [Epoch 100] Val loss=645.4751 (beta=1.150, gamma=1.450)
Training UniVI:   3%|▎         | 100/3000 [06:21<2:58:55,  3.70s/it, beta=1.150, gamma=1.450, train_loss=669.3533, val_loss=645.3172][2026-09-21 16:23:27,622] [UniVITrainer] [INFO] [Epoch 101] New best val loss: 645.3172
Training UniVI:   3%|▎         | 101/3000 [06:25<2:58:45,  3.70s/it, beta=1.150, gamma=1.450, train_loss=669.3846, val_loss=645.2324][2026-09-21 16:23:31,398] [UniVITrainer] [INFO] [Epoch 102] New best val loss: 645.2324
Training UniVI:   4%|▎         | 106/3000 [06:43<2:56:59,  3.67s/it, beta=1.150, gamma=1.450, train_loss=669.1889, val_loss=645.1819][2026-09-21 16:23:49,712] [UniVITrainer] [INFO] [Epoch 107] New best val loss: 645.1819
Training UniVI:   4%|▎         | 107/3000 [06:47<2:57:45,  3.69s/it, beta=1.150, gamma=1.450, train_loss=669.0001, val_loss=645.1483][2026-09-21 16:23:53,550] [UniVITrainer] [INFO] [Epoch 108] New best val loss: 645.1483
Training UniVI:   4%|▎         | 109/3000 [06:55<2:58:41,  3.71s/it, beta=1.150, gamma=1.450, train_loss=669.0559, val_loss=645.1373][2026-09-21 16:24:00,989] [UniVITrainer] [INFO] [Epoch 110] New best val loss: 645.1373
Training UniVI:   4%|▎         | 111/3000 [07:02<2:58:20,  3.70s/it, beta=1.150, gamma=1.450, train_loss=668.8252, val_loss=644.9153][2026-09-21 16:24:08,300] [UniVITrainer] [INFO] [Epoch 112] New best val loss: 644.9153
Training UniVI:   4%|▍         | 115/3000 [07:17<2:56:24,  3.67s/it, beta=1.150, gamma=1.450, train_loss=668.7074, val_loss=644.7350][2026-09-21 16:24:22,954] [UniVITrainer] [INFO] [Epoch 116] New best val loss: 644.7350
Training UniVI:   4%|▍         | 120/3000 [07:34<2:45:04,  3.44s/it, beta=1.150, gamma=1.450, train_loss=668.5257, val_loss=644.7209][2026-09-21 16:24:39,923] [UniVITrainer] [INFO] [Epoch 121] New best val loss: 644.7209
Training UniVI:   4%|▍         | 132/3000 [08:15<2:39:24,  3.33s/it, beta=1.150, gamma=1.450, train_loss=667.9547, val_loss=644.4647][2026-09-21 16:25:20,862] [UniVITrainer] [INFO] [Epoch 133] New best val loss: 644.4647
Training UniVI:   5%|▌         | 156/3000 [09:33<2:34:31,  3.26s/it, beta=1.150, gamma=1.450, train_loss=667.2689, val_loss=644.3986][2026-09-21 16:26:39,485] [UniVITrainer] [INFO] [Epoch 157] New best val loss: 644.3986
Training UniVI:   5%|▌         | 161/3000 [09:50<2:34:55,  3.27s/it, beta=1.150, gamma=1.450, train_loss=667.0680, val_loss=644.3930][2026-09-21 16:26:55,825] [UniVITrainer] [INFO] [Epoch 162] New best val loss: 644.3930
Training UniVI:   5%|▌         | 164/3000 [10:00<2:41:42,  3.42s/it, beta=1.150, gamma=1.450, train_loss=666.9480, val_loss=644.3839][2026-09-21 16:27:06,378] [UniVITrainer] [INFO] [Epoch 165] New best val loss: 644.3839
Training UniVI:   6%|▌         | 167/3000 [10:10<2:38:01,  3.35s/it, beta=1.150, gamma=1.450, train_loss=666.9287, val_loss=644.2520][2026-09-21 16:27:16,221] [UniVITrainer] [INFO] [Epoch 168] New best val loss: 644.2520
Training UniVI:   6%|▌         | 173/3000 [10:30<2:37:06,  3.33s/it, beta=1.150, gamma=1.450, train_loss=666.6891, val_loss=644.2466][2026-09-21 16:27:36,281] [UniVITrainer] [INFO] [Epoch 174] New best val loss: 644.2466
Training UniVI:   6%|▌         | 180/3000 [10:53<2:34:33,  3.29s/it, beta=1.150, gamma=1.450, train_loss=666.6179, val_loss=644.1902][2026-09-21 16:27:59,499] [UniVITrainer] [INFO] [Epoch 181] New best val loss: 644.1902
Training UniVI:   6%|▋         | 190/3000 [11:26<2:33:26,  3.28s/it, beta=1.150, gamma=1.450, train_loss=666.1468, val_loss=644.1394][2026-09-21 16:28:32,210] [UniVITrainer] [INFO] [Epoch 191] New best val loss: 644.1394
Training UniVI:   6%|▋         | 191/3000 [11:29<2:31:53,  3.24s/it, beta=1.150, gamma=1.450, train_loss=666.3388, val_loss=644.0842][2026-09-21 16:28:35,466] [UniVITrainer] [INFO] [Epoch 192] New best val loss: 644.0842
Training UniVI:   7%|▋         | 199/3000 [11:52<2:32:06,  3.26s/it, beta=1.150, gamma=1.450, train_loss=665.7939, val_loss=644.3318][2026-09-21 16:29:01,177] [UniVITrainer] [INFO] [Epoch 200] Train loss=665.8931 (beta=1.150, gamma=1.450)
[2026-09-21 16:29:01,313] [UniVITrainer] [INFO] [Epoch 200] Val loss=644.0813 (beta=1.150, gamma=1.450)
Training UniVI:   7%|▋         | 199/3000 [11:55<2:32:06,  3.26s/it, beta=1.150, gamma=1.450, train_loss=665.8931, val_loss=644.0813][2026-09-21 16:29:01,322] [UniVITrainer] [INFO] [Epoch 200] New best val loss: 644.0813
Training UniVI:   7%|▋         | 200/3000 [11:58<2:31:57,  3.26s/it, beta=1.150, gamma=1.450, train_loss=665.8760, val_loss=644.0324][2026-09-21 16:29:04,540] [UniVITrainer] [INFO] [Epoch 201] New best val loss: 644.0324
Training UniVI:   7%|▋         | 204/3000 [12:11<2:32:55,  3.28s/it, beta=1.150, gamma=1.450, train_loss=665.7469, val_loss=643.9356][2026-09-21 16:29:17,694] [UniVITrainer] [INFO] [Epoch 205] New best val loss: 643.9356
Training UniVI:   7%|▋         | 214/3000 [12:44<2:30:23,  3.24s/it, beta=1.150, gamma=1.450, train_loss=665.4751, val_loss=643.8322][2026-09-21 16:29:50,121] [UniVITrainer] [INFO] [Epoch 215] New best val loss: 643.8322
Training UniVI:   7%|▋         | 216/3000 [12:50<2:29:30,  3.22s/it, beta=1.150, gamma=1.450, train_loss=665.4741, val_loss=643.8173][2026-09-21 16:29:56,606] [UniVITrainer] [INFO] [Epoch 217] New best val loss: 643.8173
Training UniVI:   8%|▊         | 227/3000 [13:26<2:31:18,  3.27s/it, beta=1.150, gamma=1.450, train_loss=665.0639, val_loss=643.8075][2026-09-21 16:30:32,561] [UniVITrainer] [INFO] [Epoch 228] New best val loss: 643.8075
Training UniVI:   8%|▊         | 231/3000 [13:39<2:29:49,  3.25s/it, beta=1.150, gamma=1.450, train_loss=664.8827, val_loss=643.7928][2026-09-21 16:30:45,513] [UniVITrainer] [INFO] [Epoch 232] New best val loss: 643.7928
Training UniVI:   8%|▊         | 241/3000 [14:17<2:54:04,  3.79s/it, beta=1.150, gamma=1.450, train_loss=664.6049, val_loss=643.5679][2026-09-21 16:31:23,367] [UniVITrainer] [INFO] [Epoch 242] New best val loss: 643.5679
Training UniVI:   9%|▊         | 259/3000 [15:16<2:23:15,  3.14s/it, beta=1.150, gamma=1.450, train_loss=664.1696, val_loss=643.5619][2026-09-21 16:32:21,882] [UniVITrainer] [INFO] [Epoch 260] New best val loss: 643.5619
Training UniVI:   9%|▉         | 264/3000 [15:31<2:23:20,  3.14s/it, beta=1.150, gamma=1.450, train_loss=664.0607, val_loss=643.3774][2026-09-21 16:32:37,596] [UniVITrainer] [INFO] [Epoch 265] New best val loss: 643.3774
Training UniVI:   9%|▉         | 275/3000 [16:08<2:23:04,  3.15s/it, beta=1.150, gamma=1.450, train_loss=663.6574, val_loss=643.3462][2026-09-21 16:33:14,072] [UniVITrainer] [INFO] [Epoch 276] New best val loss: 643.3462
Training UniVI:   9%|▉         | 277/3000 [16:14<2:20:37,  3.10s/it, beta=1.150, gamma=1.450, train_loss=663.5388, val_loss=643.2396][2026-09-21 16:33:20,242] [UniVITrainer] [INFO] [Epoch 278] New best val loss: 643.2396
Training UniVI:   9%|▉         | 282/3000 [16:30<2:21:09,  3.12s/it, beta=1.150, gamma=1.450, train_loss=663.4960, val_loss=643.1107][2026-09-21 16:33:36,022] [UniVITrainer] [INFO] [Epoch 283] New best val loss: 643.1107
Training UniVI:   9%|▉         | 283/3000 [16:33<2:19:51,  3.09s/it, beta=1.150, gamma=1.450, train_loss=663.5777, val_loss=643.0530][2026-09-21 16:33:39,089] [UniVITrainer] [INFO] [Epoch 284] New best val loss: 643.0530
Training UniVI:  10%|▉         | 295/3000 [17:10<2:17:43,  3.05s/it, beta=1.150, gamma=1.450, train_loss=663.0957, val_loss=642.9470][2026-09-21 16:34:16,134] [UniVITrainer] [INFO] [Epoch 296] New best val loss: 642.9470
Training UniVI:  10%|▉         | 297/3000 [17:16<2:18:22,  3.07s/it, beta=1.150, gamma=1.450, train_loss=663.0351, val_loss=642.9253][2026-09-21 16:34:22,376] [UniVITrainer] [INFO] [Epoch 298] New best val loss: 642.9253
Training UniVI:  10%|▉         | 299/3000 [17:19<2:20:08,  3.11s/it, beta=1.150, gamma=1.450, train_loss=663.0094, val_loss=643.3835][2026-09-21 16:34:28,510] [UniVITrainer] [INFO] [Epoch 300] Train loss=662.9435 (beta=1.150, gamma=1.450)
[2026-09-21 16:34:28,654] [UniVITrainer] [INFO] [Epoch 300] Val loss=643.3714 (beta=1.150, gamma=1.450)
Training UniVI:  10%|█         | 305/3000 [17:41<2:21:11,  3.14s/it, beta=1.150, gamma=1.450, train_loss=662.7196, val_loss=642.7023][2026-09-21 16:34:47,480] [UniVITrainer] [INFO] [Epoch 306] New best val loss: 642.7023
Training UniVI:  11%|█         | 326/3000 [18:51<2:30:09,  3.37s/it, beta=1.150, gamma=1.450, train_loss=662.0349, val_loss=642.6286][2026-09-21 16:35:57,661] [UniVITrainer] [INFO] [Epoch 327] New best val loss: 642.6286
Training UniVI:  11%|█         | 329/3000 [19:02<2:37:25,  3.54s/it, beta=1.150, gamma=1.450, train_loss=661.9268, val_loss=642.3970][2026-09-21 16:36:08,438] [UniVITrainer] [INFO] [Epoch 330] New best val loss: 642.3970
Training UniVI:  11%|█         | 334/3000 [19:20<2:38:46,  3.57s/it, beta=1.150, gamma=1.450, train_loss=661.7969, val_loss=642.3644][2026-09-21 16:36:26,430] [UniVITrainer] [INFO] [Epoch 335] New best val loss: 642.3644
Training UniVI:  11%|█▏        | 344/3000 [19:57<2:42:38,  3.67s/it, beta=1.150, gamma=1.450, train_loss=661.6026, val_loss=642.1798][2026-09-21 16:37:03,060] [UniVITrainer] [INFO] [Epoch 345] New best val loss: 642.1798
Training UniVI:  12%|█▏        | 348/3000 [20:11<2:40:30,  3.63s/it, beta=1.150, gamma=1.450, train_loss=661.5320, val_loss=642.0118][2026-09-21 16:37:17,221] [UniVITrainer] [INFO] [Epoch 349] New best val loss: 642.0118
Training UniVI:  13%|█▎        | 380/3000 [21:53<2:11:42,  3.02s/it, beta=1.150, gamma=1.450, train_loss=660.6777, val_loss=641.9172][2026-09-21 16:38:59,348] [UniVITrainer] [INFO] [Epoch 381] New best val loss: 641.9172
Training UniVI:  13%|█▎        | 395/3000 [22:39<2:16:05,  3.13s/it, beta=1.150, gamma=1.450, train_loss=660.0608, val_loss=641.8553][2026-09-21 16:39:45,154] [UniVITrainer] [INFO] [Epoch 396] New best val loss: 641.8553
Training UniVI:  13%|█▎        | 397/3000 [22:45<2:14:50,  3.11s/it, beta=1.150, gamma=1.450, train_loss=660.0953, val_loss=641.8245][2026-09-21 16:39:51,304] [UniVITrainer] [INFO] [Epoch 398] New best val loss: 641.8245
Training UniVI:  13%|█▎        | 399/3000 [22:48<2:14:35,  3.10s/it, beta=1.150, gamma=1.450, train_loss=660.1598, val_loss=642.4406][2026-09-21 16:39:57,308] [UniVITrainer] [INFO] [Epoch 400] Train loss=660.3156 (beta=1.150, gamma=1.450)
[2026-09-21 16:39:57,439] [UniVITrainer] [INFO] [Epoch 400] Val loss=642.0370 (beta=1.150, gamma=1.450)
Training UniVI:  14%|█▍        | 423/3000 [24:07<2:17:14,  3.20s/it, beta=1.150, gamma=1.450, train_loss=659.2115, val_loss=641.6327][2026-09-21 16:41:12,885] [UniVITrainer] [INFO] [Epoch 424] New best val loss: 641.6327
Training UniVI:  14%|█▍        | 430/3000 [24:29<2:17:55,  3.22s/it, beta=1.150, gamma=1.450, train_loss=659.0405, val_loss=641.6027][2026-09-21 16:41:35,454] [UniVITrainer] [INFO] [Epoch 431] New best val loss: 641.6027
Training UniVI:  14%|█▍        | 435/3000 [24:44<2:10:53,  3.06s/it, beta=1.150, gamma=1.450, train_loss=658.9521, val_loss=641.5104][2026-09-21 16:41:50,690] [UniVITrainer] [INFO] [Epoch 436] New best val loss: 641.5104
Training UniVI:  16%|█▌        | 486/3000 [27:32<2:11:15,  3.13s/it, beta=1.150, gamma=1.450, train_loss=657.5944, val_loss=641.4186][2026-09-21 16:44:37,858] [UniVITrainer] [INFO] [Epoch 487] New best val loss: 641.4186
Training UniVI:  17%|█▋        | 499/3000 [28:07<2:05:54,  3.02s/it, beta=1.150, gamma=1.450, train_loss=657.4353, val_loss=641.7952][2026-09-21 16:45:16,718] [UniVITrainer] [INFO] [Epoch 500] Train loss=657.3714 (beta=1.150, gamma=1.450)
[2026-09-21 16:45:16,851] [UniVITrainer] [INFO] [Epoch 500] Val loss=642.3189 (beta=1.150, gamma=1.450)
Training UniVI:  20%|█▉        | 599/3000 [33:23<2:18:50,  3.47s/it, beta=1.150, gamma=1.450, train_loss=655.0741, val_loss=642.5529][2026-09-21 16:50:32,866] [UniVITrainer] [INFO] [Epoch 600] Train loss=654.7128 (beta=1.150, gamma=1.450)
[2026-09-21 16:50:33,012] [UniVITrainer] [INFO] [Epoch 600] Val loss=641.6089 (beta=1.150, gamma=1.450)
Training UniVI:  21%|██        | 621/3000 [34:40<2:15:35,  3.42s/it, beta=1.150, gamma=1.450, train_loss=654.4082, val_loss=641.2729][2026-09-21 16:51:46,477] [UniVITrainer] [INFO] [Epoch 622] New best val loss: 641.2729
Training UniVI:  23%|██▎       | 676/3000 [37:47<2:08:48,  3.33s/it, beta=1.150, gamma=1.450, train_loss=652.7708, val_loss=641.2381][2026-09-21 16:54:53,576] [UniVITrainer] [INFO] [Epoch 677] New best val loss: 641.2381
Training UniVI:  23%|██▎       | 699/3000 [39:01<2:10:20,  3.40s/it, beta=1.150, gamma=1.450, train_loss=651.8999, val_loss=641.5966][2026-09-21 16:56:10,542] [UniVITrainer] [INFO] [Epoch 700] Train loss=651.8528 (beta=1.150, gamma=1.450)
[2026-09-21 16:56:10,658] [UniVITrainer] [INFO] [Epoch 700] Val loss=642.5086 (beta=1.150, gamma=1.450)
Training UniVI:  24%|██▎       | 708/3000 [39:35<2:09:58,  3.40s/it, beta=1.150, gamma=1.450, train_loss=651.9442, val_loss=641.0636][2026-09-21 16:56:41,421] [UniVITrainer] [INFO] [Epoch 709] New best val loss: 641.0636
Training UniVI:  25%|██▍       | 738/3000 [41:06<1:47:05,  2.84s/it, beta=1.150, gamma=1.450, train_loss=651.1563, val_loss=641.0210][2026-09-21 16:58:12,417] [UniVITrainer] [INFO] [Epoch 739] New best val loss: 641.0210
Training UniVI:  26%|██▋       | 794/3000 [43:56<2:03:25,  3.36s/it, beta=1.150, gamma=1.450, train_loss=649.6361, val_loss=640.8471][2026-09-21 17:01:02,327] [UniVITrainer] [INFO] [Epoch 795] New best val loss: 640.8471
Training UniVI:  27%|██▋       | 799/3000 [44:10<2:08:03,  3.49s/it, beta=1.150, gamma=1.450, train_loss=649.6590, val_loss=641.6116][2026-09-21 17:01:19,645] [UniVITrainer] [INFO] [Epoch 800] Train loss=649.4795 (beta=1.150, gamma=1.450)
[2026-09-21 17:01:19,772] [UniVITrainer] [INFO] [Epoch 800] Val loss=641.9411 (beta=1.150, gamma=1.450)
Training UniVI:  30%|██▉       | 899/3000 [49:37<1:41:39,  2.90s/it, beta=1.150, gamma=1.450, train_loss=647.3629, val_loss=641.8805][2026-09-21 17:06:46,304] [UniVITrainer] [INFO] [Epoch 900] Train loss=647.4621 (beta=1.150, gamma=1.450)
[2026-09-21 17:06:46,427] [UniVITrainer] [INFO] [Epoch 900] Val loss=641.7296 (beta=1.150, gamma=1.450)
Training UniVI:  33%|███▎      | 994/3000 [54:15<1:36:11,  2.88s/it, beta=1.150, gamma=1.450, train_loss=646.1260, val_loss=641.8454][2026-09-21 17:11:21,071] [UniVITrainer] [INFO] Early stopping at epoch 995 (best val loss=640.8471, best epoch=795)
Training UniVI:  33%|███▎      | 994/3000 [54:15<1:49:29,  3.27s/it, beta=1.150, gamma=1.450, train_loss=646.1260, val_loss=641.8454]
[2026-09-21 17:11:21,083] [UniVITrainer] [INFO] Restored best model from epoch 795 (val loss=640.8471)

Held-out well: alignment between all three modalities#

test = parts["test"]
z = {m: encode_adata(model, a, modality=m, device=device, latent="modality_mean") for m, a in test.items()}
pd.Series({f"FOSCTTM {a}–{b}": compute_foscttm(z[a], z[b])
           for a, b in [("rna", "adt"), ("rna", "atac"), ("adt", "atac")]}).round(3)
FOSCTTM rna–adt     0.075
FOSCTTM rna–atac    0.049
FOSCTTM adt–atac    0.081
dtype: float64
joint = stack_embeddings(model, [("well5", m, a) for m, a in test.items()], device=device)
sc.pp.neighbors(joint, use_rep="X_univi", n_neighbors=30)
sc.tl.leiden(joint, resolution=LEIDEN_RESOLUTION, key_added="leiden", flavor="igraph", n_iterations=2)
sc.tl.umap(joint, random_state=0)
sc.pl.umap(joint, color=["modality", "leiden"], wspace=0.35)
pd.crosstab(joint.obs["leiden"], joint.obs["modality"], normalize="index").round(2)
../../_images/f838255aeacb3e80ad879268080440a33f780607d4ee4550a6b1e1bb55fc2b84.png
modality adt atac rna
leiden
0 0.43 0.07 0.50
1 0.51 0.22 0.27
2 0.30 0.35 0.34
3 0.72 0.13 0.15
4 0.38 0.32 0.29
5 0.31 0.32 0.36
6 0.07 0.52 0.41
7 0.11 0.43 0.46
8 0.48 0.26 0.26
9 0.13 0.46 0.41
10 0.21 0.41 0.38
11 0.33 0.37 0.30
12 0.34 0.29 0.37
13 0.31 0.36 0.33
14 0.29 0.34 0.37
15 0.40 0.28 0.32
16 0.32 0.32 0.36

Marker concordance on the fused embedding (Supplemental Fig. S6)#

encode_fused_adata_pair(model, adata_by_mod=test, device=device, fused_obsm_key="X_univi_fused")
r, p = test["rna"], test["adt"]
sc.pp.neighbors(r, use_rep="X_univi_fused", n_neighbors=30)
sc.tl.umap(r, random_state=0)
p.obsm["X_umap"] = r.obsm["X_umap"]
genes = [g for g in ["MS4A1", "BANK1", "PAX5", "CD74", "LEF1", "BCL11B", "LYZ", "ITGAX"] if g in r.var_names]
proteins = [q for q in ["CD19", "IgD", "IgM", "CD3", "CD4", "CD45RA", "CD14", "CD11c", "HLA-DR"] if q in p.var_names]
sc.pl.umap(r, color=genes, ncols=4, vmax="p99", cmap="viridis")
sc.pl.umap(p, color=proteins, ncols=4, vmax="p99", cmap="viridis")
../../_images/9e0d540fc5b4b2214bcf552bc8db24bfd45c11540a6b7267980dde684ed041ec.png ../../_images/8c9df91d0cc989a2aac487cffee750ffb7b28dc37ad0e69a7b7d308393a58eec.png