Pretrained full-data models

Load two archived 20,000-step full-data gastrulation networks from the public COATI repository and run inference without refitting. These are not the strict leave-one-stage-out models used for the held-out benchmark.

Both models use \(C_y=0.3\) and RNA50. Their architectures and loss coefficients differ, so this notebook inspects the two fitted models rather than treating balance as the only changed parameter.

Train the two full-data configurations

The compact paired AnnData is committed at examples/gastrulation/data/gastrulation_embeddings.h5ad: RNA PCA50 and ATAC LSI14 only, with no count matrices. The TraInf workflow first applies the saved RNA and ATAC scales separately, trains the original FiLMMLP, freezes T, and only then trains the vector field. From the repository root, run:

python -m examples.gastrulation.train_film --output outputs/gastrulation-film/T_FiLM.pt
python -m examples.gastrulation.train_balanced --map-checkpoint outputs/gastrulation-film/T_FiLM.pt --output outputs/gastrulation-balanced
python -m examples.gastrulation.train_unbalanced --map-checkpoint outputs/gastrulation-film/T_FiLM.pt --output outputs/gastrulation-unbalanced

balanced.json mirrors the current TraInf FilmSync recipe and unbalanced.json mirrors UnbalancedSync_biological_num. The two checkpoints inspected below are older archived runs (including a 400-wide balanced model at \(C_y=0.3\)), so they are retained for inference but are not mislabeled as outputs of the current 800-wide FiLM recipe.

from pathlib import Path
import sys

project_dir = next((p for p in (Path.cwd(), *Path.cwd().parents)
                    if (p / "pyproject.toml").exists() and (p / "src/coati").is_dir()), None)
example_dir = project_dir / "examples" if project_dir is not None else Path.cwd()
if str(example_dir) not in sys.path:
    sys.path.insert(0, str(example_dir))

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

from gastrulation.pretrained import (
    load_gastrulation_result,
    load_gastrulation_umap,
    obtain_gastrulation_assets,
)

%matplotlib inline
plt.rcParams.update({"font.family": "Arial", "font.size": 11})

Obtain the public assets

Inside a COATI checkout the committed files are used directly. Outside the repository, the same helper downloads them from the corresponding raw GitHub directory and verifies their SHA-256 checksums.

assets, manifest = obtain_gastrulation_assets()
model_files = [
    "balanced_cy0p3_s1_iter20000.pth",
    "unbalanced_cy0p3_s0_iter20000.pth",
]
rows = []
for name in model_files:
    spec = manifest["assets"][name]
    rows.append({
        "Model": "Unbalanced" if spec["unbalanced"] else "Balanced",
        "Seed": spec["seed"], "Cy": spec["cy"], "Iterations": spec["iteration"],
        "Energy": spec["energy_coefficient"], "Density": spec["density_coefficient"],
        "PDF": spec["pdf_coefficient"], "Width": spec["hidden_dim"],
        "Layers": spec["n_layers"],
    })
pd.DataFrame(rows).set_index("Model")
Seed Cy Iterations Energy Density PDF Width Layers
Model
Balanced 1 0.3 20000 0.10 0.10 100.0 400 4
Unbalanced 0 0.3 20000 0.01 0.01 100.0 800 3

Load the networks

The compatibility loader reconstructs each exact architecture from the manifest, checks the stored iteration and returns a COATI Result. Both networks are evaluated from the same 600 E7.5 cells on the original 0.1 integration grid.

data_path = assets["rna_pca_by_time.npz"]
balanced = load_gastrulation_result(
    assets["balanced_cy0p3_s1_iter20000.pth"], data_path, manifest=manifest,
)
unbalanced = load_gastrulation_result(
    assets["unbalanced_cy0p3_s0_iter20000.pth"], data_path, manifest=manifest,
)

rng = np.random.default_rng(7)
initial_index = np.sort(rng.choice(len(balanced.data.primary[0]), 600, replace=False))
initial = balanced.data.primary[0][initial_index]
integration_times = np.linspace(0.0, 2.5, 26)
predictions = {
    "Balanced": balanced.predict_trajectory(initial, times=integration_times),
    "Unbalanced": unbalanced.predict_trajectory(initial, times=integration_times),
}
assert predictions["Balanced"]["primary"].shape == (26, 600, 50)
assert predictions["Unbalanced"]["primary"].shape == (26, 600, 50)
assert np.isfinite(predictions["Balanced"]["primary"]).all()
assert np.isfinite(predictions["Unbalanced"]["primary"]).all()
print("Loaded iterations:", balanced.metadata["checkpoint_iteration"], unbalanced.metadata["checkpoint_iteration"])
print("Unbalanced final mass ratio:", f"{np.exp(predictions['Unbalanced']['log_weights'][-1]).sum():.3f}")
Loaded iterations: 20000 20000
Unbalanced final mass ratio: 10.799

Two UMAP views

Load the original RNA UMAP model instead of fitting a new embedding. Its 35,503 fitting rows are exactly the concatenated RNA50 snapshots in rna_pca_by_time.npz; the loader checks both values and order. The gray atlas uses the reducer’s saved embedding_.

Only the predicted distributions at the four observed stages are shown. No trajectory lines are drawn.

umap_model = load_gastrulation_umap(assets["rna_umap_model.joblib"], data_path)
reference_umap = np.asarray(umap_model.embedding_)
stage_names = ["E7.5", "E8.0", "E8.5", "E8.75"]
stage_colors = ["#355F8D", "#2A9D8F", "#E9C46A", "#E76F51"]
stage_steps = [0, 10, 20, 25]
snapshot_umap = {
    name: umap_model.transform(prediction["primary"][stage_steps].reshape(-1, 50)).reshape(4, 600, 2)
    for name, prediction in predictions.items()
}
fig, axes = plt.subplots(1, 2, figsize=(14, 6.2), sharex=True, sharey=True)
for ax, name in zip(axes, ["Balanced", "Unbalanced"]):
    ax.scatter(reference_umap[:, 0], reference_umap[:, 1], s=2.2,
               color="#C8CDD2", alpha=.24, linewidths=0, rasterized=True)
    for stage, color, points in zip(stage_names, stage_colors, snapshot_umap[name]):
        ax.scatter(points[:, 0], points[:, 1], s=9, color=color, alpha=.58,
                   linewidths=0, rasterized=True, label=stage)
    ax.set_title(f"{name} · 20,000 steps", fontsize=15)
    ax.set_xlabel("UMAP 1")
    ax.set_xticks([])
    ax.set_yticks([])
    for spine in ax.spines.values():
        spine.set_visible(False)
axes[0].set_ylabel("UMAP 2")
stage_handles = [Line2D([0], [0], marker="o", linestyle="", color=color,
                        label=stage, markersize=6)
                 for stage, color in zip(stage_names, stage_colors)]
fig.legend(handles=stage_handles, loc="lower center", ncol=4, frameon=False,
           bbox_to_anchor=(.5, -.01))
fig.suptitle("Predicted RNA states in the original gastrulation UMAP", fontsize=18, y=1.01)
fig.tight_layout(rect=(0, .07, 1, 1))
plt.show()
../../_images/8c3ec31666533754e76af01a5c52ab7e99ca3118419f4756e734f2caa1aaad6e.png

The panels directly inspect the two archived full-data checkpoints. They are not a controlled balanced-versus-unbalanced ablation because the networks also differ in seed, width, depth and loss coefficients.