Train T

This tutorial uses the paired 5-dimensional RNA and ATAC PCA representations produced from the scMultiSim R workflow and used by TraInf/Synthetic/data/MLPVisual.ipynb. The prepared data files download automatically when they are not present in a local COATI checkout.

No pretrained map is loaded. We first assemble paired AnnData, evaluate a time-conditioned FiLM map by five-fold testing, and then fit the final \(T(\mathrm{RNA}, t)\rightarrow\mathrm{ATAC}\) on all supplied cells.

from pathlib import Path
from shutil import copy2
from urllib.request import urlretrieve
import json
import uuid

import matplotlib.pyplot as plt
from matplotlib.lines import Line2D
import numpy as np
import pandas as pd
import torch
from threadpoolctl import threadpool_limits

import coati as ct
from coati.maps import load_map, train_map

%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_pca.npz",
    "all_time_scATAC_pca.npz",
    "all_time_celltype.npz",
)
run_dir = Path("outputs") / ("scmultisim-map-" + uuid.uuid4().hex[:8])
device = torch.device("cuda" if torch.cuda.is_available() else
                      "mps" if torch.backends.mps.is_available() else "cpu")
print("Map training device:", device)
Map training device: mps

Prepare paired AnnData

Rows are paired within each time point. RNA is stored in obsm["X_pca"], ATAC in obsm["X_atac"], and the R-generated population labels are stored in canonical obs["celltype"] together with time and barcode.

The generated AnnData is written to a fixed scmultisim.h5ad path. Re-running this cell intentionally replaces that file.

repo_data_dir = next((
    root / "examples/data/scmultisim" for root in (Path.cwd(), *Path.cwd().parents)
    if (root / "examples/data/scmultisim").is_dir()
), None)
data_dir = repo_data_dir or 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,
)
anndata_path = data_dir / "scmultisim.h5ad"
cells.write_h5ad(anndata_path, compression="gzip")
print(cells)
display_path = anndata_path.relative_to(repo_data_dir.parents[2]) if repo_data_dir else anndata_path
print("Wrote:", display_path)

counts_table = cells.obs["stage"].value_counts(sort=False).rename("cells").reset_index()
display(
    counts_table.style.hide(axis="index")
    .set_properties(**{"text-align": "left"})
    .set_table_styles([{"selector": "th", "props": [("text-align", "left")]}])
)
AnnData object with n_obs × n_vars = 10000 × 5
    obs: 'time', 'celltype', 'barcode', 'stage'
    uns: 'coati_input'
    obsm: 'X_pca', 'X_atac'
Wrote: examples/data/scmultisim/scmultisim.h5ad
stage cells
time1 2006
time2 2001
time3 3008
time4 2985
stage_palette = dict(zip(TIME_KEYS, ["#0072B2", "#009E73", "#E69F00", "#CC79A7"]))
fig, axes = plt.subplots(1, 2, figsize=(10, 4.2), layout="constrained")
for ax, values, title in zip(
    axes, (cells.obsm["X_pca"], cells.obsm["X_atac"]),
    ("RNA PCA input", "ATAC PCA target"),
):
    for stage in TIME_KEYS:
        mask = cells.obs["stage"].to_numpy() == stage
        ax.scatter(*values[mask, :2].T, s=3, alpha=.45, linewidths=0,
                   color=stage_palette[stage], label=stage, rasterized=True)
    ax.set(title=title, xlabel="Component 1", ylabel="Component 2")
    ax.spines[["top", "right"]].set_visible(False)
axes[1].legend(frameon=False, title="Time", markerscale=3)
plt.show()
../../_images/0c902f4579ad3e9656477c728322d1b0214c5457416f37a2b217c117fa922404.png

5-fold test

Cells are assigned to five folds separately within every time point, so each test fold contains all four stages. For each fold, COATI trains on the other four folds, uses an internal validation split for checkpoint selection, and evaluates only the held-out fold.

The map uses FiLM time conditioning with width 512. COATI adds feature standardization, early stopping, checkpoint reload support, and per-stage diagnostics.

times = cells.obs["time"].to_numpy()
fold_id = np.empty(cells.n_obs, dtype=int)
rng = np.random.default_rng(17)
for time in TIME_VALUES:
    rows = rng.permutation(np.flatnonzero(times == time))
    fold_id[rows] = np.arange(len(rows)) % 5
cells.obs["test_fold"] = fold_id + 1

fold_counts = pd.crosstab(cells.obs["test_fold"], cells.obs["stage"])
assert fold_counts.shape == (5, 4)
fold_counts
stage time1 time2 time3 time4
test_fold
1 402 401 602 597
2 401 400 602 597
3 401 400 602 597
4 401 400 601 597
5 401 400 601 597
map_config = ct.MapConfig(
    hidden_dim=512, n_layers=5,
    epochs=400, batch_size=1024, lr=2e-3, weight_decay=0.0,
    validation_fraction=.20, patience=50,
    eval_samples=256, eval_repeats=5,
)


def map_points(model, points, time, batch_size=1024):
    model.eval()
    with torch.no_grad():
        return np.concatenate([
            model(
                torch.as_tensor(points[start:start + batch_size], dtype=torch.float32, device=device),
                torch.as_tensor(time[start:start + batch_size], dtype=torch.float32, device=device),
            ).cpu().numpy()
            for start in range(0, len(points), batch_size)
        ])


x, y = cells.obsm["X_pca"], cells.obsm["X_atac"]
fold_results = []
for fold in range(5):
    train_rows = np.flatnonzero(fold_id != fold)
    test_rows = np.flatnonzero(fold_id == fold)
    training = ct.TemporalData.from_anndata(
        cells[train_rows].copy(), obsm_key="X_pca", secondary_obsm_key="X_atac"
    )
    fold_map, fold_report = train_map(
        training, kind="film", config=map_config,
        directory=run_dir / f"fold_{fold + 1}" / "map",
        device=device, seed=fold, verbose=False,
    )
    prediction = map_points(fold_map, x[test_rows], times[test_rows])
    mse = float(np.mean((prediction - y[test_rows]) ** 2))
    baseline_error = 0.0
    for time in TIME_VALUES:
        test_mask = times[test_rows] == time
        reference_mean = y[train_rows][times[train_rows] == time].mean(0)
        baseline_error += float(np.sum((reference_mean - y[test_rows][test_mask]) ** 2))
    fold_results.append({
        "fold": fold + 1,
        "test_cells": len(test_rows),
        "best_epoch": fold_report["best_epoch"],
        "paired_rmse": np.sqrt(mse),
        "normalized_mse": mse / (baseline_error / y[test_rows].size),
    })
    print(f"Fold {fold + 1}/5 complete")

fold_quality = pd.DataFrame(fold_results)
display(fold_quality.style.hide(axis="index").format({
    "paired_rmse": "{:.4f}", "normalized_mse": "{:.4f}"
}).set_properties(**{"text-align": "left"}).set_table_styles([
    {"selector": "th", "props": [("text-align", "left")]}
]))
Fold 1/5 complete
Fold 2/5 complete
Fold 3/5 complete
Fold 4/5 complete
Fold 5/5 complete
fold test_cells best_epoch paired_rmse normalized_mse
1 2002 106 0.6978 0.0443
2 2000 92 0.6964 0.0439
3 2000 91 0.7049 0.0451
4 1999 85 0.6991 0.0454
5 1999 103 0.6961 0.0447
all_paired = ct.TemporalData.from_anndata(
    cells, obsm_key="X_pca", secondary_obsm_key="X_atac"
)
T, map_report = train_map(
    all_paired, kind="film", config=map_config,
    directory=run_dir / "final_map", device=device, seed=0, verbose=True,
)
history = pd.read_json(run_dir / "final_map" / "history.json")

fig, ax = plt.subplots(figsize=(7.2, 3.8), layout="constrained")
ax.plot(history["epoch"], history["train_standardized_mse"], label="Training")
ax.plot(history["epoch"], history["validation_standardized_mse"], label="Validation")
ax.axvline(map_report["best_epoch"], color="#D55E00", linestyle="--",
           linewidth=1.2, label="Selected checkpoint")
ax.set(xlabel="Epoch", ylabel="Standardized MSE",
       title="Final RNA → ATAC map", yscale="log")
ax.spines[["top", "right"]].set_visible(False)
ax.legend(frameon=False)
plt.show()
print("Best epoch:", map_report["best_epoch"],
      "| completed:", map_report["epochs_completed"],
      "| validation:", map_report["status"])
[COATI map] Epoch 1/400, standardized validation MSE 0.257758.
[COATI map] Epoch 25/400, standardized validation MSE 0.0579193.
[COATI map] Epoch 50/400, standardized validation MSE 0.0502487.
[COATI map] Epoch 75/400, standardized validation MSE 0.0498727.
[COATI map] Epoch 100/400, standardized validation MSE 0.0518822.
[COATI map] Epoch 125/400, standardized validation MSE 0.0520628.
../../_images/0f2cf9f38b7f51ed872ac594475e831fcc4c533b0c4d92ad7e7e8065d2c5202d.png
Best epoch: 78 | completed: 128 | validation: passed

Map all RNA cells to ATAC

The final fitted map is applied to every RNA cell. Observed and mapped cells are shown directly in the same ATAC PCA coordinates, without an additional embedding fit. The final checkpoint is written to the public data folder as scmultisim_film_T.pt; re-running the notebook replaces it.

mapped_atac = map_points(T, x, times)
checkpoint_path = data_dir / "scmultisim_film_T.pt"
copy2(run_dir / "final_map/model.pt", checkpoint_path)
restored = load_map(checkpoint_path, device=device)
np.testing.assert_array_equal(mapped_atac, map_points(restored, x, times))
assert mapped_atac.shape == y.shape and np.isfinite(mapped_atac).all()
checkpoint_display = checkpoint_path.relative_to(repo_data_dir.parents[2]) if repo_data_dir else checkpoint_path
print("Saved T:", checkpoint_display)
print("Mapped RNA cells:", mapped_atac.shape)
print("Exact checkpoint reload: passed")
Saved T: examples/data/scmultisim/scmultisim_film_T.pt
Mapped RNA cells: (10000, 5)
Exact checkpoint reload: passed
coordinates = [x[:, :2], y[:, :2], mapped_atac[:, :2]]
titles = ["All RNA PCA", "Observed ATAC PCA", "T(RNA, time) → ATAC PCA"]
celltype_names = sorted(np.unique(celltypes))
celltype_palette = dict(zip(celltype_names, ["#0072B2", "#009E73", "#E69F00", "#CC79A7"]))
atac_limits = np.vstack(coordinates[1:])
low, high = atac_limits.min(0), atac_limits.max(0)
pad = .05 * np.maximum(high-low, 1.)

fig, axes = plt.subplots(1, 3, figsize=(14, 4.4))
for panel, (ax, points, title) in enumerate(zip(axes, coordinates, titles)):
    for celltype in celltype_names:
        mask = celltypes == celltype
        ax.scatter(*points[mask].T, s=4, alpha=.5, linewidths=0,
                   color=celltype_palette[celltype], rasterized=True)
    ax.set(title=title, xlabel="Component 1", ylabel="Component 2")
    ax.set_aspect("equal", adjustable="box")
    ax.spines[["top", "right"]].set_visible(False)
    if panel:
        ax.set(xlim=(low[0]-pad[0], high[0]+pad[0]),
               ylim=(low[1]-pad[1], high[1]+pad[1]))
handles = [Line2D([], [], marker="o", linestyle="", color=celltype_palette[name],
                  label=name, markersize=6) for name in celltype_names]
fig.legend(handles=handles, loc="lower center", ncol=4, frameon=False, title="Cell type")
fig.suptitle(f"All paired cells (n={cells.n_obs:,})", fontsize=15, y=.98)
fig.subplots_adjust(left=.04, right=.99, bottom=.22, top=.84, wspace=.20)
plt.show()
../../_images/084bf80a9a8ad3d11f8e1ff06b5c6cbe37c9b73f21912f587ed44835cb479dca.png
summary = {
    "dataset": "scMultiSim",
    "source": "R-generated paired RNA and ATAC; prepared 5D PCA views from MLPVisual.ipynb",
    "cells": int(cells.n_obs),
    "anndata": str(anndata_path),
    "map_checkpoint": str(checkpoint_path),
    "map_kind": "film",
    "map_config": map_config.to_dict(),
    "five_fold_test": fold_quality.to_dict(orient="records"),
    "final_map_report": map_report,
    "mapped_all_cells": True,
    "exact_checkpoint_reload": True,
}
(run_dir / "map_report.json").write_text(json.dumps(summary, indent=2) + "\n")
print("AnnData:", display_path)
print("Final map and reports:", run_dir)
AnnData: examples/data/scmultisim/scmultisim.h5ad
Final map and reports: outputs/scmultisim-map-3ec40d35