Replace the cross-modal map in scMultiSim

This notebook keeps the balanced COATI experiment fixed and changes only the frozen RNA-to-ATAC map. The two alternatives are MLP + t, which appends time to the RNA coordinates, and Ridge, a global linear map. Both maps were trained on the same paired RNA10 and ATAC8 cells used by the FiLM example.

COATI is trained for 3,000 iterations on CPU with \(C_y=0.1\). The final comparison uses the same observed-cell UMAP and the same 15-nearest-neighbor lineage test for both maps.

from pathlib import Path
from urllib.request import urlretrieve
import sys
import uuid

import matplotlib.pyplot as plt
from matplotlib.lines import Line2D
import numpy as np
import pandas as pd
import torch
from sklearn.neighbors import KNeighborsClassifier, KNeighborsRegressor
from threadpoolctl import threadpool_limits

import coati as ct

%matplotlib inline
threadpool_limits(limits=1)
torch.set_num_threads(1)
plt.rcParams.update({"font.family": "Arial", "font.size": 11})

TIME_KEYS = ("time1", "time2", "time3", "time4")
TIME_VALUES = np.arange(4, dtype=float)
RAW_DATA_BASE_URL = "https://raw.githubusercontent.com/shankswang953/COATI/main/examples/data/scmultisim"
SOURCE_FILES = (
    "all_time_scRNA_pca10.npz",
    "all_time_scATAC_pca8_no_pc2.npz",
    "all_time_celltype.npz",
    "primal_norm_params_rna10_w2.pt",
    "secondary_norm_params_atac8_no_pc2_w2.pt",
    "observed_rna10_umap.npz",
)
MAP_FILES = {
    "MLP + t": "scmultisim_rna10_atac8_mlp_t_T.pt",
    "Ridge": "scmultisim_rna10_atac8_ridge_T.pt",
}
device = "cpu"
print("Training device:", device)
Training device: cpu

Prepare the paired AnnData

RNA PCA10 is stored in obsm["X_pca"], ATAC PCA8 in obsm["X_atac"], and the four stages in obs. The UMAP coordinates are used only after training.

repo_root = next((root for root in (Path.cwd(), *Path.cwd().parents)
                  if (root / "notebooks/scMultisim/trained_maps.py").is_file()), None)
if repo_root is None:
    raise FileNotFoundError("Run this notebook from a cloned COATI repository.")
sys.path.insert(0, str(repo_root / "notebooks/scMultisim"))
from trained_maps import load_trained_map

repo_data_dir = repo_root / "examples/data/scmultisim"
data_dir = repo_data_dir if repo_data_dir.is_dir() else Path("outputs/scmultisim-data")
data_dir.mkdir(parents=True, exist_ok=True)


def obtain_data(filename):
    path = data_dir / filename
    if not path.is_file():
        print("Downloading", filename)
        urlretrieve(f"{RAW_DATA_BASE_URL}/{filename}", path)
    return path


paired = ct.TemporalData.from_npz(
    obtain_data(SOURCE_FILES[0]), times=TIME_VALUES, keys=TIME_KEYS,
    secondary_path=obtain_data(SOURCE_FILES[1]),
)
with np.load(obtain_data(SOURCE_FILES[2]), allow_pickle=False) as archive:
    celltypes = np.concatenate([archive[key].astype(str) for key in TIME_KEYS])

cells = paired.to_anndata(
    celltype=celltypes, use_rep="X_pca", secondary_key="X_atac", warn=False,
)
cells.obs["stage"] = pd.Categorical(
    np.repeat(TIME_KEYS, [len(x) for x in paired.primary]),
    categories=TIME_KEYS, ordered=True,
)
display(
    cells.obs["stage"].value_counts(sort=False).rename("cells").reset_index()
    .style.hide(axis="index")
    .set_properties(**{"text-align": "left"})
    .set_table_styles([{"selector": "th", "props": [("text-align", "left")]}])
)
stage cells
time1 2006
time2 2001
time3 3008
time4 2985

Load the two frozen maps

The notebook selects each saved map by name. Model reconstruction and checkpoint loading are kept in the shared tutorial helper rather than repeated here.

maps = {name: load_trained_map(obtain_data(filename))
        for name, filename in MAP_FILES.items()}
with torch.no_grad():
    for name, model in maps.items():
        x = torch.tensor(paired.primary[0][:16])
        probe = model(x, torch.zeros(16)) if name == "MLP + t" else model(x)
        assert probe.shape == (16, 8) and torch.isfinite(probe).all()
        print(f"{name}: RNA10 → ATAC8 | trainable during COATI: {any(p.requires_grad for p in model.parameters())}")
MLP + t: RNA10 → ATAC8 | trainable during COATI: False
Ridge: RNA10 → ATAC8 | trainable during COATI: False

Train COATI with each map

The vector-field architecture, data, normalization, seed-controlled training procedure, and all loss coefficients are identical. Only T_model changes.

def make_config():
    return ct.Config(
        multimodal=True,
        balanced=True,
        iterations=3000,
        batch_size=128,
        learning_rate=1e-4,
        hidden_dim=400,
        n_layers=3,
        activation="leaky_relu",
        secondary_weight=0.1,
        manifold_weight=10.0,
        energy_weight=1.0,
        matching_weight=100.0,
        median_n_sample=128,
        floor_n_sample=128,
        dt=0.1,
        blur_coeff=0.05,
        floor_buffer_std=1.0,
        density_topk=25,
        density_hinge=0.01,
        terminal_density_weight=1.0,
        sync_density_loss=True,
        sync_pdf_loss=True,
        adaptive_lambda=True,
        lambda_min_pri=5.0,
        lambda_min_sec=10.0,
        lambda_max_pri=100.0,
        lambda_max_sec=100.0,
        eta_lambda_pri=10.0,
        eta_lambda_sec=10.0,
        dual_warmup=1000,
        dual_decay=1.0,
        device=device,
        extra={
            "pdf_coefficient_pri": 100.0,
            "pdf_coefficient_sec": 100.0,
            "lambda_init": 100.0,
            "lambda_init_pri": 100.0,
            "lambda_init_sec": 100.0,
        },
    )


results = {}
run_dirs = {}
for name, model in maps.items():
    run_dir = Path("outputs") / (
        "scmultisim-balanced-cy0p1-" + name.lower().replace(" + ", "-").replace(" ", "-")
        + "-" + uuid.uuid4().hex[:8]
    )
    print(f"Training with {name} ...")
    results[name] = ct.train_vector_field(
        cells,
        config=make_config(),
        output_dir=run_dir,
        T_model=model,
        use_rep="X_pca",
        secondary_obsm_key="X_atac",
        primal_norm_path=obtain_data(SOURCE_FILES[3]),
        sec_norm_path=obtain_data(SOURCE_FILES[4]),
        verbose=False,
    )
    run_dirs[name] = run_dir
    print("Completed:", run_dir)
Training with MLP + t ...
Completed: outputs/scmultisim-balanced-cy0p1-mlp-t-342fd4a8
Training with Ridge ...
Completed: outputs/scmultisim-balanced-cy0p1-ridge-3a12eadc
training_quality = pd.concat(
    [pd.Series(result.evaluate(), name="value").rename_axis("metric").reset_index()
     .assign(map=name)
     for name, result in results.items()],
    ignore_index=True,
)[["map", "metric", "value"]]
display(
    training_quality.style.hide(axis="index")
    .format({"value": "{:.4f}"})
    .set_properties(**{"text-align": "left"})
    .set_table_styles([{"selector": "th", "props": [("text-align", "left")]}])
)
map metric value
MLP + t endpoint_sinkhorn_before 1.2191
MLP + t endpoint_sinkhorn_after 0.0209
MLP + t kinetic_energy_mean 1.2929
MLP + t endpoint_sampling_floor 0.0265
MLP + t secondary_endpoint_sinkhorn 0.0252
Ridge endpoint_sinkhorn_before 1.2191
Ridge endpoint_sinkhorn_after 0.0289
Ridge kinetic_energy_mean 1.3052
Ridge endpoint_sampling_floor 0.0265
Ridge secondary_endpoint_sinkhorn 0.0508

Generated snapshots in the reference UMAP

All initial cells are evolved to the four observed times. Every generated cell keeps the color of its initial lineage; lines are intentionally omitted. The left panel is the fixed UMAP of all observed cells.

with np.load(obtain_data(SOURCE_FILES[5]), allow_pickle=False) as archive:
    observed_umap = archive["observed_umap"]
    observed_population = archive["observed_population"].astype(str)
np.testing.assert_array_equal(observed_population, celltypes)

reference_projector = KNeighborsRegressor(n_neighbors=25, weights="distance", n_jobs=-1)
reference_projector.fit(np.concatenate(paired.primary), observed_umap)
initial_lineages = celltypes[:len(paired.primary[0])]
palette = dict(zip(sorted(np.unique(celltypes)), ["#0072B2", "#E69F00", "#009E73", "#CC79A7"]))


def plot_generated_snapshots(name, result):
    prediction = result.predict(initial=paired.primary[0], times=TIME_VALUES, raw=True)
    projected = reference_projector.predict(prediction["primary"].reshape(-1, 10))
    snapshot_umap = projected.reshape(len(TIME_VALUES), len(paired.primary[0]), 2)

    fig, axes = plt.subplots(2, 3, figsize=(13.5, 8.5), sharex=True, sharey=True,
                             layout="constrained")
    reference_ax = axes[0, 0]
    snapshot_axes = (axes[0, 1], axes[0, 2], axes[1, 0], axes[1, 1])
    for label in sorted(palette):
        mask = celltypes == label
        reference_ax.scatter(*observed_umap[mask].T, s=5, alpha=.30, linewidths=0,
                             color=palette[label], rasterized=True)
    for time, points, ax in zip(TIME_VALUES, snapshot_umap, snapshot_axes):
        for label in sorted(np.unique(initial_lineages)):
            mask = initial_lineages == label
            ax.scatter(*points[mask].T, s=6, alpha=.38, linewidths=0,
                       color=palette[label], rasterized=True)
        ax.set_title(f"Generated snapshot · t={time:g}")
    observed_handles = [Line2D([], [], marker="o", linestyle="", color=palette[label],
                               label=label, markersize=6) for label in sorted(palette)]
    reference_ax.legend(handles=observed_handles, frameon=False, ncol=2)
    reference_ax.set_title("Observed cells · reference UMAP")
    initial_handles = [Line2D([], [], marker="o", linestyle="", color=palette[label],
                              label=f"Initial {label}", markersize=7)
                       for label in sorted(np.unique(initial_lineages))]
    axes[1, 2].legend(handles=initial_handles, frameon=False, loc="center")
    axes[1, 2].axis("off")
    for ax in (reference_ax, *snapshot_axes):
        ax.set(xlabel="UMAP 1", ylabel="UMAP 2")
        ax.spines[["top", "right"]].set_visible(False)
    fig.suptitle(name, fontsize=15)
    plt.show()


for name, result in results.items():
    plot_generated_snapshots(name, result)
../../_images/a6f5b3e8fa613e23e34194c7c74654c0fe60c6051b5e4f149a6e4c290fa0a189.png ../../_images/3f5bc9cfcfaad855fff7375aa35509b597abd0cf6fdf0ac7ba38e3dc504f32ce.png

Lineage preservation

A distance-weighted 15-nearest-neighbor classifier assigns generated RNA10 states to observed branches. Terminal error uses the endpoint assignment. Global error marks a cell incorrect if it leaves its initial branch at any of 60 post-initial evaluation times.

snapshot_labels = np.split(
    celltypes, np.cumsum([len(snapshot) for snapshot in paired.primary])[:-1]
)
source_lineage = snapshot_labels[0]
evaluation_times = np.linspace(TIME_VALUES[0], TIME_VALUES[-1], 61)

terminal_classifier = KNeighborsClassifier(
    n_neighbors=15, weights="distance", n_jobs=-1
).fit(paired.primary[-1], snapshot_labels[-1])
observed_rna = np.concatenate(paired.primary)
observed_lineage = np.where(celltypes == "4_1", "4_1", "4_5")
global_classifier = KNeighborsClassifier(
    n_neighbors=15, weights="distance", n_jobs=-1
).fit(observed_rna, observed_lineage)


def lineage_errors(name, result):
    dense = result.predict(
        initial=paired.primary[0], times=evaluation_times, raw=True
    )["primary"]
    terminal_population = terminal_classifier.predict(dense[-1]).astype(str)
    terminal_lineage = np.where(terminal_population == "4_1", "4_1", "4_5")
    terminal_error = terminal_lineage != source_lineage
    assigned = global_classifier.predict(
        dense.reshape(-1, dense.shape[-1])
    ).reshape(len(evaluation_times), len(source_lineage))
    global_error = (assigned[1:] != source_lineage[None, :]).any(axis=0)
    return pd.DataFrame({
        "map": name,
        "metric": ["Terminal lineage error", "Global lineage error"],
        "errors": [terminal_error.sum(), global_error.sum()],
        "cells": len(source_lineage),
        "rate": [terminal_error.mean(), global_error.mean()],
    })


lineage_metrics = pd.concat(
    [lineage_errors(name, result) for name, result in results.items()],
    ignore_index=True,
)
display(
    lineage_metrics.style.hide(axis="index")
    .format({"rate": "{:.2%}"})
    .set_properties(**{"text-align": "left"})
    .set_table_styles([{"selector": "th", "props": [("text-align", "left")]}])
)
map metric errors cells rate
MLP + t Terminal lineage error 378 2006 18.84%
MLP + t Global lineage error 553 2006 27.57%
Ridge Terminal lineage error 490 2006 24.43%
Ridge Global lineage error 592 2006 29.51%
for name, path in run_dirs.items():
    print(f"{name}: {path}")
MLP + t: outputs/scmultisim-balanced-cy0p1-mlp-t-342fd4a8
Ridge: outputs/scmultisim-balanced-cy0p1-ridge-3a12eadc