Spaces:
Running on Zero
Running on Zero
Upload folder using huggingface_hub
Browse files- .gitattributes +2 -0
- README.md +33 -45
- __pycache__/app.cpython-311.pyc +0 -0
- app.py +256 -312
- data/frangieh_split_10.h5ad +3 -0
- data/papalexi_split_10.h5ad +3 -0
- map_pfn_torch.py +467 -0
- requirements.txt +2 -2
- weights/weights_frangieh.npz +3 -0
- weights/weights_papalexi.npz +3 -0
.gitattributes
CHANGED
|
@@ -33,3 +33,5 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
+
data/frangieh_split_10.h5ad filter=lfs diff=lfs merge=lfs -text
|
| 37 |
+
data/papalexi_split_10.h5ad filter=lfs diff=lfs merge=lfs -text
|
README.md
CHANGED
|
@@ -6,54 +6,42 @@ colorTo: red
|
|
| 6 |
sdk: gradio
|
| 7 |
sdk_version: 6.31.0
|
| 8 |
app_file: app.py
|
| 9 |
-
short_description: In-context prediction of
|
| 10 |
python_version: "3.12"
|
| 11 |
startup_duration_timeout: 30m
|
| 12 |
---
|
| 13 |
|
| 14 |
# MapPFN: Learning Causal Perturbation Maps in Context
|
| 15 |
|
| 16 |
-
Interactive demo of **MapPFN** (Sextro
|
| 17 |
-
|
| 18 |
-
**
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
**Links:** [Paper](https://arxiv.org/abs/2601.21092) · [Project page](https://marvinsxtr.github.io/MapPFN) · [Code](https://github.com/marvinsxtr/MapPFN) · [Weights](https://huggingface.co/marvinsxtr/MapPFN)
|
| 50 |
-
|
| 51 |
-
```bibtex
|
| 52 |
-
@inproceedings{sextro2026mappfn,
|
| 53 |
-
title = {Map{PFN}: Learning Causal Perturbation Maps in Context},
|
| 54 |
-
author = {Marvin Sextro and Weronika K{\l}os and Gabriel Dernbach},
|
| 55 |
-
booktitle = {Advances in Neural Information Processing Systems},
|
| 56 |
-
volume = {39},
|
| 57 |
-
year = {2026}
|
| 58 |
-
}
|
| 59 |
-
```
|
|
|
|
| 6 |
sdk: gradio
|
| 7 |
sdk_version: 6.31.0
|
| 8 |
app_file: app.py
|
| 9 |
+
short_description: In-context prediction of genetic perturbation responses
|
| 10 |
python_version: "3.12"
|
| 11 |
startup_duration_timeout: 30m
|
| 12 |
---
|
| 13 |
|
| 14 |
# MapPFN: Learning Causal Perturbation Maps in Context
|
| 15 |
|
| 16 |
+
Interactive demo of **MapPFN** (Sextro et al., 2026, [arXiv:2601.21092](https://huggingface.co/papers/2601.21092)) —
|
| 17 |
+
a flow-matching in-context model that predicts the post-perturbation single-cell
|
| 18 |
+
expression distribution for a **held-out** gene, given only demonstrations of *other*
|
| 19 |
+
perturbations in the same cell context.
|
| 20 |
+
|
| 21 |
+
- **Paper**: [MapPFN: Learning Causal Perturbation Maps in Context](https://huggingface.co/papers/2601.21092)
|
| 22 |
+
- **Code**: https://github.com/marvinsxtr/MapPFN (MIT)
|
| 23 |
+
- **Weights / data**: https://huggingface.co/marvinsxtr/MapPFN (MIT)
|
| 24 |
+
|
| 25 |
+
## What the demo does
|
| 26 |
+
|
| 27 |
+
1. Choose a dataset — Frangieh 2021 (melanoma CRISPR screens across IFN-γ /
|
| 28 |
+
co-culture contexts) or Papalexi 2021 (ECCITE-seq stimulated T cells). Each uses
|
| 29 |
+
the authors' fine-tuned checkpoint for that dataset.
|
| 30 |
+
2. Choose a cell context and a **held-out target gene** (from the paper's test split).
|
| 31 |
+
3. MapPFN samples the treated population in the paper's 50-d PCA expression space,
|
| 32 |
+
conditioning on the context's control cells plus a configurable number of
|
| 33 |
+
in-context demonstrations (other treatments' control→treated pairs).
|
| 34 |
+
4. The prediction is compared against the real held-out measurements: mean/variance
|
| 35 |
+
R², perturbation-effect correlation, and a top differentially-expressed-gene table.
|
| 36 |
+
|
| 37 |
+
## Implementation notes
|
| 38 |
+
|
| 39 |
+
The authors' reference implementation is JAX/Equinox with a cuDNN attention kernel;
|
| 40 |
+
ZeroGPU Spaces are PyTorch-first, so this Space runs a faithful PyTorch port
|
| 41 |
+
(`map_pfn_torch.py`) of the model and its exact sampling procedure (Dormand–Prince
|
| 42 |
+
5(4) ODE solver, constant step 0.01, 100 steps, classifier-free guidance 2.0, EMA
|
| 43 |
+
weights). The port was validated numerically against the reference: CFG velocity
|
| 44 |
+
fields and full trajectories match to float32 tolerance on real test queries.
|
| 45 |
+
|
| 46 |
+
Datasets and weights are redistributed under their MIT license from
|
| 47 |
+
`marvinsxtr/MapPFN`.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
__pycache__/app.cpython-311.pyc
ADDED
|
Binary file (22.2 kB). View file
|
|
|
app.py
CHANGED
|
@@ -1,367 +1,311 @@
|
|
| 1 |
"""MapPFN: Learning Causal Perturbation Maps in Context — interactive demo.
|
| 2 |
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
|
|
|
|
|
|
| 9 |
"""
|
| 10 |
|
| 11 |
-
import
|
| 12 |
-
from pathlib import Path
|
| 13 |
-
|
| 14 |
-
import spaces # MUST be imported before torch
|
| 15 |
-
import torch
|
| 16 |
|
|
|
|
| 17 |
import gradio as gr
|
| 18 |
-
|
| 19 |
-
from mappfn import MapPFN, MMDiT, load_weights, solve_ode
|
| 20 |
-
|
| 21 |
import numpy as np
|
| 22 |
import pandas as pd
|
| 23 |
-
import
|
| 24 |
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
IN_DIM = 50
|
| 28 |
-
NUM_SHOTS = 8
|
| 29 |
-
NUM_SAMPLES = 200
|
| 30 |
-
GUIDANCE = 2.0
|
| 31 |
-
STEP_SIZE = 0.01
|
| 32 |
-
|
| 33 |
-
# ---------------------------------------------------------------- model
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
def build_model() -> MapPFN:
|
| 37 |
-
decoder = MMDiT(
|
| 38 |
-
embed_dim=256,
|
| 39 |
-
cond_dim=256,
|
| 40 |
-
noise_dim=IN_DIM,
|
| 41 |
-
num_heads=4,
|
| 42 |
-
num_blocks=8,
|
| 43 |
-
use_rms_norm=True,
|
| 44 |
-
num_reg_tokens=8,
|
| 45 |
-
)
|
| 46 |
-
return MapPFN(decoder=decoder, cond_dim=256, in_dim=IN_DIM, max_num_contexts=10)
|
| 47 |
|
|
|
|
| 48 |
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
}
|
| 53 |
-
for m in MODELS.values():
|
| 54 |
-
for p in m.parameters():
|
| 55 |
-
p.requires_grad_(False)
|
| 56 |
-
|
| 57 |
-
# ---------------------------------------------------------------- data
|
| 58 |
-
|
| 59 |
-
DATASETS = {}
|
| 60 |
-
for name, label in (("papalexi", "Papalexi 2021 (CRISPRi leukemia, IFN-γ)"), ("frangieh", "Frangieh 2021 (CRISPRi melanoma)")):
|
| 61 |
-
bundle = dict(np.load(HERE / f"bundle_{name}.npz", allow_pickle=True))
|
| 62 |
-
meta = json.loads((HERE / f"bundle_{name}_meta.json").read_text())
|
| 63 |
-
DATASETS[name] = {"label": label, "bundle": bundle, "meta": meta}
|
| 64 |
|
|
|
|
| 65 |
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
|
|
|
|
|
|
|
|
|
|
| 70 |
|
| 71 |
-
def get_test_treatments(dataset: str, context: str) -> list[str]:
|
| 72 |
-
return sorted(DATASETS[dataset]["meta"]["test"].get(context, []))
|
| 73 |
|
|
|
|
| 74 |
|
| 75 |
-
|
| 76 |
-
|
|
|
|
|
|
|
|
|
|
| 77 |
|
| 78 |
|
| 79 |
-
def
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
def mean_rmse(x: np.ndarray, y: np.ndarray) -> float:
|
| 88 |
-
return float(np.sqrt(np.mean((x.mean(0) - y.mean(0)) ** 2)))
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
def var_rmse(x: np.ndarray, y: np.ndarray) -> float:
|
| 92 |
-
return float(np.sqrt(np.mean((x.var(0) - y.var(0)) ** 2)))
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
def rbf_kernel_sq(x: np.ndarray, y: np.ndarray, gamma: float) -> np.ndarray:
|
| 96 |
-
sq = ((x[:, None, :] - y[None, :, :]) ** 2).sum(-1)
|
| 97 |
-
return np.exp(-gamma * sq)
|
| 98 |
|
| 99 |
|
| 100 |
-
def
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 109 |
|
| 110 |
|
| 111 |
-
def
|
| 112 |
-
"""
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
return float(np.abs(xs - ys).mean())
|
| 116 |
|
| 117 |
|
| 118 |
# ---------------------------------------------------------------- inference
|
| 119 |
|
| 120 |
-
|
| 121 |
-
def
|
| 122 |
-
if len(x) <= n:
|
| 123 |
-
# sample with replacement (reference dataset does this too)
|
| 124 |
-
idx = rng.choice(len(x), n, replace=True)
|
| 125 |
-
else:
|
| 126 |
-
idx = rng.choice(len(x), n, replace=False)
|
| 127 |
-
return x[idx]
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
@spaces.GPU(duration=45)
|
| 131 |
-
def run_predict(
|
| 132 |
dataset: str,
|
| 133 |
context: str,
|
| 134 |
treatment: str,
|
| 135 |
-
num_shots: int =
|
| 136 |
-
num_cells: int =
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
seed: int = 42,
|
| 140 |
-
progress=gr.Progress(track_tqdm=True),
|
| 141 |
):
|
| 142 |
-
"""Predict the post-perturbation expression distribution for a held-out
|
| 143 |
-
treatment, given in-context examples of other perturbations.
|
| 144 |
|
| 145 |
Args:
|
| 146 |
-
dataset:
|
| 147 |
-
context:
|
| 148 |
-
treatment:
|
| 149 |
-
num_shots: Number of in-context
|
| 150 |
-
num_cells:
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
seed: RNG seed for cell sampling and the flow's initial noise.
|
| 154 |
|
| 155 |
Returns:
|
| 156 |
-
(
|
| 157 |
"""
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
rng = np.random.default_rng(seed)
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
num_shots = int(max(0, min(num_shots, NUM_SHOTS, len(meta["train"].get(context, [])))))
|
| 167 |
-
|
| 168 |
-
# context demonstrations: (num_shots, num_cells, dim) treated cells + control obs
|
| 169 |
-
context_treatments = list(rng.choice(meta["train"][context], size=num_shots, replace=False)) if num_shots else []
|
| 170 |
-
|
| 171 |
-
obs_list, int_list, treat_list = [], [], []
|
| 172 |
-
control_cells = bundle[f"control__{context}"]
|
| 173 |
-
for tr in context_treatments:
|
| 174 |
-
obs_list.append(sample_cells(control_cells, num_cells, rng))
|
| 175 |
-
int_list.append(sample_cells(bundle[f"train__{context}__{tr}"], num_cells, rng))
|
| 176 |
-
treat_list.append(bundle[f"treatvec__train__{context}__{tr}"])
|
| 177 |
-
|
| 178 |
-
# query last: the dataset item carries the query's own (control, treated) pair;
|
| 179 |
-
# the treated part is the ground truth and is dropped by unpack_batch before
|
| 180 |
-
# the model sees it (int_data_cond = int_data[:-1]).
|
| 181 |
-
obs_list.append(sample_cells(control_cells, num_cells, rng))
|
| 182 |
-
int_list.append(sample_cells(bundle[f"test__{context}__{treatment}"], num_cells, rng))
|
| 183 |
-
treat_list.append(bundle[f"treatvec__test__{context}__{treatment}"])
|
| 184 |
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
|
|
|
| 188 |
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
|
|
|
| 192 |
|
| 193 |
-
|
| 194 |
-
|
|
|
|
| 195 |
|
| 196 |
with torch.no_grad():
|
| 197 |
pred = solve_ode(
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
}
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
)
|
| 238 |
-
fig.update_layout(
|
| 239 |
-
title=f"Expression of top-DE genes — {treatment} in {context} ({ds['label']})",
|
| 240 |
-
yaxis_title="log1p normalized expression",
|
| 241 |
-
boxmode="group",
|
| 242 |
-
template="plotly_white",
|
| 243 |
-
height=420,
|
| 244 |
-
margin=dict(l=10, r=10, t=60, b=10),
|
| 245 |
-
)
|
| 246 |
-
|
| 247 |
-
# ---- plot 2: per-gene mean predicted vs measured
|
| 248 |
-
fig2 = go.Figure()
|
| 249 |
-
fig2.add_trace(
|
| 250 |
-
go.Scatter(
|
| 251 |
-
x=true_np.mean(0),
|
| 252 |
-
y=pred_np.mean(0),
|
| 253 |
-
mode="markers",
|
| 254 |
-
text=var_names,
|
| 255 |
-
marker=dict(size=7, color=lfc, colorscale="RdBu", showscale=True, colorbar=dict(title="Δ (true − control)")),
|
| 256 |
-
name="genes",
|
| 257 |
-
)
|
| 258 |
-
)
|
| 259 |
-
lims = [
|
| 260 |
-
min(true_np.mean(0).min(), pred_np.mean(0).min()) - 0.1,
|
| 261 |
-
max(true_np.mean(0).max(), pred_np.mean(0).max()) + 0.1,
|
| 262 |
-
]
|
| 263 |
-
fig2.add_trace(go.Scatter(x=lims, y=lims, mode="lines", line=dict(dash="dash", color="#94a3b8"), name="y = x"))
|
| 264 |
-
fig2.update_layout(
|
| 265 |
-
title=f"Per-gene mean expression — predicted vs measured (R² = {metrics['Mean R²']:.3f})",
|
| 266 |
-
xaxis_title="measured mean (log1p)",
|
| 267 |
-
yaxis_title="MapPFN predicted mean (log1p)",
|
| 268 |
-
template="plotly_white",
|
| 269 |
-
height=420,
|
| 270 |
-
margin=dict(l=10, r=10, t=60, b=10),
|
| 271 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 272 |
|
| 273 |
-
metrics_df = pd.DataFrame({"Metric": list(metrics.keys()), "Value": [round(v, 4) for v in metrics.values()]})
|
| 274 |
-
summary = (
|
| 275 |
-
f"**{treatment}** in context *{context}* — {num_shots} in-context example"
|
| 276 |
-
f"{'s' if num_shots != 1 else ''} ({num_cells} cells each), guidance {guidance}, "
|
| 277 |
-
f"ODE step {step_size}, seed {seed}. The query perturbation was **held out** from training."
|
| 278 |
-
)
|
| 279 |
-
return fig, fig2, metrics_df, summary
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
def refresh_context(dataset: str, context: str):
|
| 283 |
-
ctxs = get_contexts(dataset)
|
| 284 |
-
val = context if context in ctxs else ctxs[0]
|
| 285 |
-
return gr.update(choices=ctxs, value=val)
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
def refresh_treatments(dataset: str, context: str, treatment: str):
|
| 289 |
-
tests = get_test_treatments(dataset, context)
|
| 290 |
-
val = treatment if treatment in tests else (tests[0] if tests else None)
|
| 291 |
-
return gr.update(choices=tests, value=val)
|
| 292 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 293 |
|
| 294 |
-
CSS = """
|
| 295 |
-
#col-container { max-width: 1200px; margin: 0 auto; }
|
| 296 |
-
.dark .gradio-container { color: var(--body-text-color); }
|
| 297 |
-
"""
|
| 298 |
|
| 299 |
-
with gr.Blocks() as demo:
|
| 300 |
gr.Markdown(
|
| 301 |
-
f"""
|
| 302 |
-
# 🧬 MapPFN: Learning Causal Perturbation Maps in Context
|
| 303 |
-
Predict the **post-perturbation single-cell expression distribution** for an unseen gene knockdown —
|
| 304 |
-
in-context, from a handful of example perturbation experiments. PyTorch port of the
|
| 305 |
-
[NeurIPS 2026 paper](https://arxiv.org/abs/2601.21092) · [code](https://github.com/marvinsxtr/MapPFN) ·
|
| 306 |
-
[weights](https://huggingface.co/marvinsxtr/MapPFN) (MIT). Zero-shot on held-out test perturbations.
|
| 307 |
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 308 |
)
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
)
|
| 326 |
-
run_btn = gr.Button("Predict perturbation response", variant="primary")
|
| 327 |
-
with gr.Accordion("Advanced settings", open=False):
|
| 328 |
-
num_shots = gr.Slider(0, 8, value=NUM_SHOTS, step=1, label="In-context examples (num_shots)")
|
| 329 |
-
num_cells = gr.Slider(16, 200, value=NUM_SAMPLES, step=8, label="Cells per condition")
|
| 330 |
-
guidance = gr.Slider(1.0, 4.0, value=GUIDANCE, step=0.1, label="Guidance")
|
| 331 |
-
step_size = gr.Slider(0.005, 0.05, value=STEP_SIZE, step=0.005, label="ODE step size")
|
| 332 |
-
seed = gr.Number(value=42, precision=0, label="Seed")
|
| 333 |
-
with gr.Column(scale=2):
|
| 334 |
-
summary_md = gr.Markdown()
|
| 335 |
-
dist_plot = gr.Plot(label="Expression distributions (top-DE genes)")
|
| 336 |
-
with gr.Row():
|
| 337 |
-
scatter_plot = gr.Plot(label="Predicted vs measured per-gene mean")
|
| 338 |
-
metrics_df = gr.Dataframe(headers=["Metric", "Value"], label="Distribution metrics (vs ground truth)", interactive=False)
|
| 339 |
-
|
| 340 |
-
gr.Examples(
|
| 341 |
-
examples=[
|
| 342 |
-
["papalexi", "stimulated", "STAT1"],
|
| 343 |
-
["papalexi", "stimulated", "IFNGR1"],
|
| 344 |
-
["papalexi", "stimulated", "JAK2"],
|
| 345 |
-
["frangieh", "IFNγ", "B2M"],
|
| 346 |
-
["frangieh", "IFNγ", "TAPBP"],
|
| 347 |
-
["frangieh", "IFNγ", "STAT3"],
|
| 348 |
-
],
|
| 349 |
-
inputs=[dataset_dd, context_dd, treatment_dd],
|
| 350 |
-
fn=run_predict,
|
| 351 |
-
outputs=[dist_plot, scatter_plot, metrics_df, summary_md],
|
| 352 |
-
cache_examples=True,
|
| 353 |
-
cache_mode="lazy",
|
| 354 |
-
)
|
| 355 |
-
|
| 356 |
-
dataset_dd.change(refresh_context, inputs=[dataset_dd, context_dd], outputs=context_dd)
|
| 357 |
-
context_dd.change(
|
| 358 |
-
refresh_treatments, inputs=[dataset_dd, context_dd, treatment_dd], outputs=treatment_dd
|
| 359 |
)
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
|
|
|
| 365 |
)
|
| 366 |
|
| 367 |
-
|
|
|
|
|
|
| 1 |
"""MapPFN: Learning Causal Perturbation Maps in Context — interactive demo.
|
| 2 |
|
| 3 |
+
Predicts the post-perturbation single-cell expression distribution for a held-out
|
| 4 |
+
gene perturbation, given in-context demonstrations (control + perturbed
|
| 5 |
+
populations for OTHER treatments in the same cell context) — the in-context
|
| 6 |
+
perturbation setting of Sextro et al. (2026).
|
| 7 |
+
|
| 8 |
+
Runs the official EMA weights of MapPFN, ported 1:1 from the authors'
|
| 9 |
+
JAX/Equinox reference to PyTorch (verified numerically against the reference:
|
| 10 |
+
velocity fields and 100-step ODE trajectories match to float32 tolerance).
|
| 11 |
"""
|
| 12 |
|
| 13 |
+
import spaces # MUST be first — ZeroGPU CUDA hijack
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
|
| 15 |
+
import anndata as ad
|
| 16 |
import gradio as gr
|
| 17 |
+
import matplotlib
|
|
|
|
|
|
|
| 18 |
import numpy as np
|
| 19 |
import pandas as pd
|
| 20 |
+
import torch
|
| 21 |
|
| 22 |
+
matplotlib.use("Agg")
|
| 23 |
+
import matplotlib.pyplot as plt
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
|
| 25 |
+
from map_pfn_torch import MapPFN, load_ema_weights, solve_ode
|
| 26 |
|
| 27 |
+
IN_DIM = 50
|
| 28 |
+
DEFAULT_GUIDANCE = 2.0
|
| 29 |
+
DEFAULT_STEP = 0.01
|
| 30 |
+
|
| 31 |
+
DATASETS = {
|
| 32 |
+
"frangieh": {
|
| 33 |
+
"label": "Frangieh 2021 (melanoma CRISPR, IFN-γ contexts)",
|
| 34 |
+
"h5ad": "data/frangieh_split_10.h5ad",
|
| 35 |
+
"npz": "weights/weights_frangieh.npz",
|
| 36 |
+
},
|
| 37 |
+
"papalexi": {
|
| 38 |
+
"label": "Papalexi 2021 (ECCITE-seq, stimulated T cells)",
|
| 39 |
+
"h5ad": "data/papalexi_split_10.h5ad",
|
| 40 |
+
"npz": "weights/weights_papalexi.npz",
|
| 41 |
+
},
|
| 42 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
|
| 44 |
+
# ---------------------------------------------------------------- load eagerly
|
| 45 |
|
| 46 |
+
print("[app] loading models...", flush=True)
|
| 47 |
+
MODELS: dict[str, MapPFN] = {}
|
| 48 |
+
for _key, _cfg in DATASETS.items():
|
| 49 |
+
_m = MapPFN(cond_dim=256, in_dim=IN_DIM, max_num_contexts=10,
|
| 50 |
+
embed_dim=256, num_heads=4, num_blocks=8, num_reg_tokens=8)
|
| 51 |
+
load_ema_weights(_m, _cfg["npz"])
|
| 52 |
+
_m.eval()
|
| 53 |
+
MODELS[_key] = _m.to("cuda")
|
| 54 |
+
print("[app] models on cuda", flush=True)
|
| 55 |
|
| 56 |
+
print("[app] loading datasets...", flush=True)
|
| 57 |
+
ADATA: dict[str, ad.AnnData] = {k: ad.read_h5ad(cfg["h5ad"]) for k, cfg in DATASETS.items()}
|
| 58 |
+
print("[app] ready", flush=True)
|
| 59 |
|
|
|
|
|
|
|
| 60 |
|
| 61 |
+
# ---------------------------------------------------------------- helpers
|
| 62 |
|
| 63 |
+
def context_choices(name: str) -> list[str]:
|
| 64 |
+
"""List cell contexts that have held-out test perturbations to predict."""
|
| 65 |
+
obs = ADATA[name].obs
|
| 66 |
+
test_ctxs = obs[obs["split"] == "test"]["context"].unique().tolist()
|
| 67 |
+
return sorted(test_ctxs)
|
| 68 |
|
| 69 |
|
| 70 |
+
def treatments_for(name: str, ctx: str) -> dict[str, list[str]]:
|
| 71 |
+
"""Split a context's treatments into test (held-out query) and train (demonstrations)."""
|
| 72 |
+
sub = ADATA[name].obs[ADATA[name].obs["context"] == ctx]
|
| 73 |
+
return {
|
| 74 |
+
"test": sorted(sub[sub["split"] == "test"]["treatment"].unique().tolist()),
|
| 75 |
+
"train": sorted(sub[sub["split"] == "train"]["treatment"].unique().tolist()),
|
| 76 |
+
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 77 |
|
| 78 |
|
| 79 |
+
def _pools(name: str, ctx: str) -> tuple[np.ndarray, dict, dict]:
|
| 80 |
+
"""Return (control cells, {treatment: treated cells}, {treatment: one-hot vector})."""
|
| 81 |
+
a = ADATA[name]
|
| 82 |
+
X = np.asarray(a.X, dtype=np.float32)
|
| 83 |
+
obs = a.obs
|
| 84 |
+
sub = obs["context"] == ctx
|
| 85 |
+
ctrl = X[(sub & (obs["split"] == "control")).to_numpy()]
|
| 86 |
+
treated, onehot = {}, {}
|
| 87 |
+
for t in obs[sub & (obs["split"] != "control")]["treatment"].unique():
|
| 88 |
+
m = (sub & (obs["treatment"] == t)).to_numpy()
|
| 89 |
+
treated[str(t)] = X[m]
|
| 90 |
+
onehot[str(t)] = np.asarray(a.obsm["treatment"][m][0], dtype=np.float32)
|
| 91 |
+
return ctrl, treated, onehot
|
| 92 |
|
| 93 |
|
| 94 |
+
def _sample_cells(X: np.ndarray, n: int, rng: np.random.Generator) -> np.ndarray:
|
| 95 |
+
"""Sample n rows from X (with replacement if needed), like the reference dataset."""
|
| 96 |
+
idx = rng.choice(len(X), size=n, replace=len(X) < n)
|
| 97 |
+
return X[idx]
|
|
|
|
| 98 |
|
| 99 |
|
| 100 |
# ---------------------------------------------------------------- inference
|
| 101 |
|
| 102 |
+
@spaces.GPU(duration=120)
|
| 103 |
+
def predict(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
dataset: str,
|
| 105 |
context: str,
|
| 106 |
treatment: str,
|
| 107 |
+
num_shots: int = 8,
|
| 108 |
+
num_cells: int = 200,
|
| 109 |
+
seed: int = 0,
|
| 110 |
+
guidance: float = DEFAULT_GUIDANCE,
|
|
|
|
|
|
|
| 111 |
):
|
| 112 |
+
"""Predict the post-perturbation expression distribution for a held-out gene.
|
|
|
|
| 113 |
|
| 114 |
Args:
|
| 115 |
+
dataset: One of "frangieh" or "papalexi".
|
| 116 |
+
context: Cell context / experimental condition to predict in.
|
| 117 |
+
treatment: Held-out (test-split) gene perturbation to predict.
|
| 118 |
+
num_shots: Number of in-context demonstrations from other treatments (0-8).
|
| 119 |
+
num_cells: Number of cells per population (query and generated).
|
| 120 |
+
seed: Random seed for demonstration sampling and flow noise.
|
| 121 |
+
guidance: Classifier-free guidance weight (paper default 2.0).
|
|
|
|
| 122 |
|
| 123 |
Returns:
|
| 124 |
+
(profile plot, metrics dataframe, DE-gene table)
|
| 125 |
"""
|
| 126 |
+
name = "frangieh" if dataset.startswith("frangieh") else "papalexi"
|
| 127 |
+
if not treatment or not context:
|
| 128 |
+
raise gr.Error("Pick a cell context and a target gene perturbation first.")
|
| 129 |
rng = np.random.default_rng(seed)
|
| 130 |
+
ctrl, treated, onehot = _pools(name, context)
|
| 131 |
+
splits = treatments_for(name, context)
|
| 132 |
+
if treatment not in treated:
|
| 133 |
+
raise gr.Error(f"Treatment {treatment!r} not found in context {context!r}.")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 134 |
|
| 135 |
+
# ---- in-context demonstrations: TRAIN treatments of the same context
|
| 136 |
+
demo_pool = [t for t in splits["train"] if t != treatment]
|
| 137 |
+
shots = (rng.choice(demo_pool, size=min(num_shots, len(demo_pool)), replace=False).tolist()
|
| 138 |
+
if num_shots > 0 and demo_pool else [])
|
| 139 |
|
| 140 |
+
obs_cond = _sample_cells(ctrl, num_cells, rng)[None] # (1, n, 50) query control
|
| 141 |
+
int_cond = (np.stack([_sample_cells(treated[t], num_cells, rng) for t in shots])
|
| 142 |
+
if shots else None) # (S, n, 50) demonstrations
|
| 143 |
+
treat_vec = np.stack([onehot[t][None] for t in [*shots, treatment]])
|
| 144 |
|
| 145 |
+
to = torch.from_numpy(obs_cond).cuda()
|
| 146 |
+
ti = torch.from_numpy(int_cond).cuda() if int_cond is not None else None
|
| 147 |
+
tt = torch.from_numpy(treat_vec).cuda()
|
| 148 |
|
| 149 |
with torch.no_grad():
|
| 150 |
pred = solve_ode(
|
| 151 |
+
MODELS[name], (num_cells, IN_DIM), to, ti, tt,
|
| 152 |
+
guidance=guidance, step_size=DEFAULT_STEP,
|
| 153 |
+
generator=torch.Generator(device="cuda").manual_seed(seed),
|
| 154 |
+
).cpu().numpy()
|
| 155 |
+
|
| 156 |
+
gt = _sample_cells(treated[treatment], num_cells, rng) # held-out truth
|
| 157 |
+
obs_query = obs_cond[0]
|
| 158 |
+
|
| 159 |
+
genes = [str(g) for g in ADATA[name].var_names]
|
| 160 |
+
pred_mean, pred_std = pred.mean(0), pred.std(0)
|
| 161 |
+
gt_mean, gt_std = gt.mean(0), gt.std(0)
|
| 162 |
+
ctrl_mean = obs_query.mean(0)
|
| 163 |
+
|
| 164 |
+
delta_true = gt_mean - ctrl_mean
|
| 165 |
+
delta_pred = pred_mean - ctrl_mean
|
| 166 |
+
order = np.argsort(-np.abs(delta_true))
|
| 167 |
+
|
| 168 |
+
# ---- plot
|
| 169 |
+
fig, axes = plt.subplots(1, 2, figsize=(13, 4.6))
|
| 170 |
+
xs = np.arange(len(genes))
|
| 171 |
+
ax = axes[0]
|
| 172 |
+
ax.plot(xs, ctrl_mean[order], color="#9aa0a6", lw=1.4, label="control (observed)")
|
| 173 |
+
ax.plot(xs, gt_mean[order], color="#1f77b4", lw=1.6, label=f"{treatment} observed (held-out)")
|
| 174 |
+
ax.plot(xs, pred_mean[order], color="#d62728", lw=1.6, ls="--", label=f"{treatment} MapPFN predicted")
|
| 175 |
+
ax.set_xlabel("genes (sorted by observed effect)")
|
| 176 |
+
ax.set_ylabel("expression (50-d PCA space)")
|
| 177 |
+
ax.set_title(f"Mean expression profile — {context}")
|
| 178 |
+
ax.legend(fontsize=8)
|
| 179 |
+
ax = axes[1]
|
| 180 |
+
ax.scatter(delta_true[order], delta_pred[order], s=20, color="#d62728", alpha=0.75)
|
| 181 |
+
lim = max(np.abs(delta_true).max(), np.abs(delta_pred).max(), 1e-6) * 1.1
|
| 182 |
+
ax.plot([-lim, lim], [-lim, lim], color="#9aa0a6", lw=1)
|
| 183 |
+
ax.set_xlabel("observed Δ (treated − control)")
|
| 184 |
+
ax.set_ylabel("predicted Δ")
|
| 185 |
+
ax.set_title("Perturbation effect: predicted vs observed")
|
| 186 |
+
fig.suptitle(
|
| 187 |
+
f"MapPFN — {DATASETS[name]['label']} | context: {context} | target: {treatment} | "
|
| 188 |
+
f"{len(shots)} in-context demonstration{'s' if len(shots) != 1 else ''}",
|
| 189 |
+
fontsize=10,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 190 |
)
|
| 191 |
+
fig.tight_layout(rect=(0, 0, 1, 0.94))
|
| 192 |
+
|
| 193 |
+
# ---- metrics vs held-out ground truth
|
| 194 |
+
tgt_idx = int(np.argmax(onehot[treatment]))
|
| 195 |
+
top5 = np.argsort(-delta_pred)[:5]
|
| 196 |
+
metrics = pd.DataFrame({
|
| 197 |
+
"metric": [
|
| 198 |
+
"mean RMSE", "variance RMSE", "mean R²", "variance R²",
|
| 199 |
+
"Δ-effect Pearson r", "target gene in top-5 predicted Δ",
|
| 200 |
+
],
|
| 201 |
+
"value": [
|
| 202 |
+
f"{np.sqrt(np.mean((gt_mean - pred_mean) ** 2)):.3f}",
|
| 203 |
+
f"{np.sqrt(np.mean((gt_std - pred_std) ** 2)):.3f}",
|
| 204 |
+
f"{np.corrcoef(gt_mean, pred_mean)[0, 1]:.3f}",
|
| 205 |
+
f"{np.corrcoef(gt_std, pred_std)[0, 1]:.3f}",
|
| 206 |
+
f"{np.corrcoef(delta_true, delta_pred)[0, 1]:.3f}",
|
| 207 |
+
"yes" if tgt_idx in top5 else "no",
|
| 208 |
+
],
|
| 209 |
+
})
|
| 210 |
+
|
| 211 |
+
# ---- DE table
|
| 212 |
+
top = np.argsort(-np.abs(delta_pred))[:15]
|
| 213 |
+
de = pd.DataFrame({
|
| 214 |
+
"gene": [genes[i] for i in top],
|
| 215 |
+
"predicted Δ": delta_pred[top].round(3),
|
| 216 |
+
"observed Δ": delta_true[top].round(3),
|
| 217 |
+
"pred mean": pred_mean[top].round(3),
|
| 218 |
+
"observed mean": gt_mean[top].round(3),
|
| 219 |
+
"pred std": pred_std[top].round(3),
|
| 220 |
+
"observed std": gt_std[top].round(3),
|
| 221 |
+
})
|
| 222 |
+
return fig, metrics, de
|
| 223 |
+
|
| 224 |
+
|
| 225 |
+
# ---------------------------------------------------------------- UI wiring
|
| 226 |
+
|
| 227 |
+
def _on_dataset(name: str) -> dict:
|
| 228 |
+
"""Update context and treatment dropdowns when the dataset changes."""
|
| 229 |
+
ctxs = context_choices(name)
|
| 230 |
+
test = treatments_for(name, ctxs[0])["test"]
|
| 231 |
+
return {
|
| 232 |
+
ctx_dd: gr.update(choices=ctxs, value=ctxs[0]),
|
| 233 |
+
treat_dd: gr.update(choices=test, value=test[0] if test else None),
|
| 234 |
+
}
|
| 235 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 236 |
|
| 237 |
+
def _on_context(name: str, ctx: str) -> dict:
|
| 238 |
+
"""Update the treatment dropdown when the context changes."""
|
| 239 |
+
test = treatments_for(name, ctx)["test"]
|
| 240 |
+
return {treat_dd: gr.update(choices=test, value=test[0] if test else None)}
|
| 241 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 242 |
|
| 243 |
+
with gr.Blocks(theme=gr.themes.Citrus(), title="MapPFN") as demo:
|
| 244 |
gr.Markdown(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 245 |
"""
|
| 246 |
+
# MapPFN: Learning Causal Perturbation Maps in Context
|
| 247 |
+
**Sextro et al. (2026)** — [paper](https://huggingface.co/papers/2601.21092) ·
|
| 248 |
+
[code](https://github.com/marvinsxtr/MapPFN) · [weights](https://huggingface.co/marvinsxtr/MapPFN)
|
| 249 |
+
|
| 250 |
+
MapPFN is a flow-matching in-context model for genetic perturbation prediction:
|
| 251 |
+
given a set of *demonstration* experiments (control + perturbed populations for
|
| 252 |
+
**other** genes in the same cell context), it predicts the full
|
| 253 |
+
post-perturbation expression distribution for a **held-out** target gene — no
|
| 254 |
+
retraining per perturbation. Pick a dataset, cell context and held-out target
|
| 255 |
+
below; the prediction is scored against the real held-out measurements.
|
| 256 |
+
"""
|
| 257 |
+
)
|
| 258 |
+
with gr.Row():
|
| 259 |
+
with gr.Column():
|
| 260 |
+
dataset_dd = gr.Dropdown(
|
| 261 |
+
choices=[(cfg["label"], k) for k, cfg in DATASETS.items()],
|
| 262 |
+
value="frangieh", label="Dataset (each uses its fine-tuned checkpoint)",
|
| 263 |
+
)
|
| 264 |
+
ctx_dd = gr.Dropdown(choices=context_choices("frangieh"), value="IFNγ",
|
| 265 |
+
label="Cell context")
|
| 266 |
+
treat_dd = gr.Dropdown(choices=[], label="Target gene perturbation (held-out test split)")
|
| 267 |
+
with gr.Accordion("Inference settings", open=False):
|
| 268 |
+
num_shots = gr.Slider(0, 8, value=8, step=1,
|
| 269 |
+
label="In-context demonstrations (other treatments)")
|
| 270 |
+
num_cells = gr.Slider(32, 200, value=200, step=8, label="Cells per population")
|
| 271 |
+
seed = gr.Number(value=0, precision=0, label="Seed")
|
| 272 |
+
guidance = gr.Slider(0.0, 4.0, value=DEFAULT_GUIDANCE, step=0.1,
|
| 273 |
+
label="Classifier-free guidance")
|
| 274 |
+
run = gr.Button("Predict perturbation response", variant="primary")
|
| 275 |
+
with gr.Column():
|
| 276 |
+
plot = gr.Plot(label="Predicted vs observed expression profile")
|
| 277 |
+
metrics_df = gr.Dataframe(label="Prediction quality (vs held-out ground truth)")
|
| 278 |
+
de_df = gr.Dataframe(label="Top predicted differentially expressed genes")
|
| 279 |
+
gr.Markdown(
|
| 280 |
+
"*Expression values live in the 50-dimensional PCA space of the original publication. "
|
| 281 |
+
"Metrics: R² between per-gene means/variances of predicted and observed treated cells; "
|
| 282 |
+
"Pearson r between predicted and observed perturbation effects (Δ vs control); and "
|
| 283 |
+
"whether the knocked-out gene itself ranks among the top-5 predicted changes.*"
|
| 284 |
)
|
| 285 |
+
gr.Examples(
|
| 286 |
+
examples=[
|
| 287 |
+
["frangieh", "IFNγ", "STAT3"],
|
| 288 |
+
["frangieh", "IFNγ", "B2M"],
|
| 289 |
+
["frangieh", "IFNγ", "TAPBP"],
|
| 290 |
+
["frangieh", "IFNγ", "AHNAK"],
|
| 291 |
+
["papalexi", "stimulated", "STAT1"],
|
| 292 |
+
["papalexi", "stimulated", "JAK2"],
|
| 293 |
+
["papalexi", "stimulated", "IFNGR1"],
|
| 294 |
+
],
|
| 295 |
+
inputs=[dataset_dd, ctx_dd, treat_dd],
|
| 296 |
+
fn=predict,
|
| 297 |
+
outputs=[plot, metrics_df, de_df],
|
| 298 |
+
cache_examples=True,
|
| 299 |
+
cache_mode="lazy",
|
| 300 |
+
label="Example queries (the paper's held-out test perturbations)",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 301 |
)
|
| 302 |
+
dataset_dd.change(_on_dataset, inputs=[dataset_dd], outputs=[ctx_dd, treat_dd])
|
| 303 |
+
ctx_dd.change(_on_context, inputs=[dataset_dd, ctx_dd], outputs=[treat_dd])
|
| 304 |
+
run.click(
|
| 305 |
+
predict,
|
| 306 |
+
inputs=[dataset_dd, ctx_dd, treat_dd, num_shots, num_cells, seed, guidance],
|
| 307 |
+
outputs=[plot, metrics_df, de_df],
|
| 308 |
)
|
| 309 |
|
| 310 |
+
if __name__ == "__main__":
|
| 311 |
+
demo.launch(mcp_server=True)
|
data/frangieh_split_10.h5ad
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:103c8c4b1e44b36c2cee96ea7753d43bbc2e456a81c216cac2cd9d5ea01bcffe
|
| 3 |
+
size 51143641
|
data/papalexi_split_10.h5ad
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:e42c3becc855e2ca4b894c5f47b3c9c495ac46d68ab28ab2180b335914af821d
|
| 3 |
+
size 9829716
|
map_pfn_torch.py
ADDED
|
@@ -0,0 +1,467 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""PyTorch port of MapPFN (Sextro et al., 2026) — JAX/Equinox reference:
|
| 2 |
+
|
| 3 |
+
marvinsxtr/MapPFN | map_pfn/models/{map_pfn,mmdit}.py, map_pfn/models/utils.py
|
| 4 |
+
|
| 5 |
+
Architecture: MapPFN(cond_dim=256, in_dim=50, max_num_contexts=10) wrapping an
|
| 6 |
+
MMDiT(embed_dim=256, num_heads=4, num_blocks=8, rms_norm, 8 register tokens).
|
| 7 |
+
Sampling: flow-matching velocity field integrated t=0->1 with Dormand-Prince 5(4)
|
| 8 |
+
at a constant step of 0.01 (100 steps), classifier-free guidance weight 2.0.
|
| 9 |
+
|
| 10 |
+
Port notes (verified against reference sources and library internals):
|
| 11 |
+
- eqx.nn.Linear stores weight as (out, in) — identical to torch.nn.Linear, so
|
| 12 |
+
exported weights copy directly with NO transpose.
|
| 13 |
+
- eqx.nn.LayerNorm: population statistics over the whole array, eps=1e-5 default.
|
| 14 |
+
- eqx.nn.RMSNorm(eps=1e-8) on per-head Q/K, WITH weight and bias.
|
| 15 |
+
- jax.nn.gelu default = tanh approximation.
|
| 16 |
+
- flash_attention: pads seq to a multiple of 64 AFTER the RMS norms and computes
|
| 17 |
+
attention in bfloat16 with NO mask. Zero-padded keys get score 0 (NOT -inf), so
|
| 18 |
+
they dilute the softmax denominator (values are zero so they add no signal).
|
| 19 |
+
Replicated exactly with F.scaled_dot_product_attention on zero-padded bf16
|
| 20 |
+
tensors.
|
| 21 |
+
- solve_ode == diffrax diffeqsolve(Dopri5, ConstantStepSize, t0=0, t1=1, dt0=0.01,
|
| 22 |
+
SaveAt(t1=True)): exactly 100 fixed DP5 steps. diffrax works in k-space
|
| 23 |
+
(k = f * dt): stage states are y0 + dt * sum(a_ij f_j) and the update is
|
| 24 |
+
y1 = y0 + dt * sum(b_i f_i). Dopri5 has 7 stages with c = [0, 1/5, 3/10, 4/5,
|
| 25 |
+
8/9, 1, 1] and is FSAL: stage 6 evaluates at (t1, y1) (the tableau is SSAL), and
|
| 26 |
+
that evaluation is reused as stage 0 of the next step. Stage times are
|
| 27 |
+
t0 + c*dt, except c == 1 uses the step endpoint exactly (no float error).
|
| 28 |
+
- CFG mixes conditional and unconditional (null_cond) velocity fields:
|
| 29 |
+
guidance * v_cond + (1 - guidance) * v_uncond.
|
| 30 |
+
- Weights come from the EMA tree of the authors' Equinox checkpoints.
|
| 31 |
+
"""
|
| 32 |
+
|
| 33 |
+
from __future__ import annotations
|
| 34 |
+
|
| 35 |
+
import math
|
| 36 |
+
|
| 37 |
+
import numpy as np
|
| 38 |
+
import torch
|
| 39 |
+
import torch.nn as nn
|
| 40 |
+
import torch.nn.functional as F
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def gelu_tanh(x: torch.Tensor) -> torch.Tensor:
|
| 44 |
+
return F.gelu(x, approximate="tanh")
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
class MLP(nn.Module):
|
| 48 |
+
"""Reference: map_pfn.models.utils.MLP — Linear -> gelu(tanh) -> Linear."""
|
| 49 |
+
|
| 50 |
+
def __init__(self, input_dim: int, hidden_dim: int, output_dim: int):
|
| 51 |
+
super().__init__()
|
| 52 |
+
self.linear_1 = nn.Linear(input_dim, hidden_dim)
|
| 53 |
+
self.linear_2 = nn.Linear(hidden_dim, output_dim)
|
| 54 |
+
|
| 55 |
+
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
| 56 |
+
return self.linear_2(gelu_tanh(self.linear_1(x)))
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
class SinusoidalPosEmb(nn.Module):
|
| 60 |
+
"""Reference: map_pfn.models.utils.SinusoidalPosEmb."""
|
| 61 |
+
|
| 62 |
+
def __init__(self, dim: int):
|
| 63 |
+
super().__init__()
|
| 64 |
+
half_dim = dim // 2
|
| 65 |
+
emb = math.log(10000) / (half_dim - 1)
|
| 66 |
+
self.register_buffer("emb", torch.exp(torch.arange(half_dim, dtype=torch.float32) * -emb))
|
| 67 |
+
|
| 68 |
+
def forward(self, x: torch.Tensor) -> torch.Tensor: # x: (seq,)
|
| 69 |
+
emb = x * self.emb
|
| 70 |
+
return torch.cat([torch.sin(emb), torch.cos(emb)], dim=-1)
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
class MMDiTBlock(nn.Module):
|
| 74 |
+
"""Reference: map_pfn.models.mmdit.MMDiTBlock (FiLM-conditioned joint attention)."""
|
| 75 |
+
|
| 76 |
+
def __init__(self, embed_dim: int, num_heads: int, head_dim: int):
|
| 77 |
+
super().__init__()
|
| 78 |
+
self.embed_dim = embed_dim
|
| 79 |
+
self.num_heads = num_heads
|
| 80 |
+
self.head_dim = head_dim
|
| 81 |
+
|
| 82 |
+
self.ln_qkv_n = nn.LayerNorm(embed_dim, eps=1e-5)
|
| 83 |
+
self.ln_qkv_c = nn.LayerNorm(embed_dim, eps=1e-5)
|
| 84 |
+
self.ln_qkv_t = nn.LayerNorm(embed_dim, eps=1e-5)
|
| 85 |
+
self.ln_ff_n = nn.LayerNorm(embed_dim, eps=1e-5)
|
| 86 |
+
self.ln_ff_c = nn.LayerNorm(embed_dim, eps=1e-5)
|
| 87 |
+
self.ln_ff_t = nn.LayerNorm(embed_dim, eps=1e-5)
|
| 88 |
+
|
| 89 |
+
self.qkv_n = nn.Linear(embed_dim, embed_dim * 3, bias=False)
|
| 90 |
+
self.qkv_c = nn.Linear(embed_dim, embed_dim * 3, bias=False)
|
| 91 |
+
self.qkv_t = nn.Linear(embed_dim, embed_dim * 3, bias=False)
|
| 92 |
+
|
| 93 |
+
self.o_n = nn.Linear(embed_dim, embed_dim)
|
| 94 |
+
self.o_c = nn.Linear(embed_dim, embed_dim)
|
| 95 |
+
self.o_t = nn.Linear(embed_dim, embed_dim)
|
| 96 |
+
|
| 97 |
+
self.film_an = nn.Linear(embed_dim, embed_dim * 2)
|
| 98 |
+
self.film_ac = nn.Linear(embed_dim, embed_dim * 2)
|
| 99 |
+
self.film_at = nn.Linear(embed_dim, embed_dim * 2)
|
| 100 |
+
self.film_fn = nn.Linear(embed_dim, embed_dim * 2)
|
| 101 |
+
self.film_fc = nn.Linear(embed_dim, embed_dim * 2)
|
| 102 |
+
self.film_ft = nn.Linear(embed_dim, embed_dim * 2)
|
| 103 |
+
|
| 104 |
+
# eqx RMSNorm(shape=head_dim, eps=1e-8) with weight+bias
|
| 105 |
+
self.rms_q_w = nn.Parameter(torch.ones(head_dim))
|
| 106 |
+
self.rms_q_b = nn.Parameter(torch.zeros(head_dim))
|
| 107 |
+
self.rms_k_w = nn.Parameter(torch.ones(head_dim))
|
| 108 |
+
self.rms_k_b = nn.Parameter(torch.zeros(head_dim))
|
| 109 |
+
|
| 110 |
+
self.ff_n = MLP(embed_dim, embed_dim * 4, embed_dim)
|
| 111 |
+
self.ff_c = MLP(embed_dim, embed_dim * 4, embed_dim)
|
| 112 |
+
self.ff_t = MLP(embed_dim, embed_dim * 4, embed_dim)
|
| 113 |
+
|
| 114 |
+
@staticmethod
|
| 115 |
+
def _rms_norm(x: torch.Tensor, w: torch.Tensor, b: torch.Tensor, eps: float = 1e-8) -> torch.Tensor:
|
| 116 |
+
# x: (..., head_dim); eqx RMSNorm: x / sqrt(mean(x^2) + eps) * w + b, fp32 stats
|
| 117 |
+
x32 = x.float()
|
| 118 |
+
inv = torch.rsqrt(x32.pow(2).mean(dim=-1, keepdim=True) + eps)
|
| 119 |
+
return (inv * x32) * w + b
|
| 120 |
+
|
| 121 |
+
def _heads(self, x: torch.Tensor) -> torch.Tensor: # (seq, dim) -> (seq, H, D)
|
| 122 |
+
s = x.shape[0]
|
| 123 |
+
return x.reshape(s, self.num_heads, self.head_dim)
|
| 124 |
+
|
| 125 |
+
def forward(
|
| 126 |
+
self,
|
| 127 |
+
noise: torch.Tensor, # (seq_n, E)
|
| 128 |
+
cond: torch.Tensor, # (seq_c, E)
|
| 129 |
+
treatment: torch.Tensor, # (seq_t, E) — all treatment tokens
|
| 130 |
+
t_emb: torch.Tensor, # (1, E)
|
| 131 |
+
) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
|
| 132 |
+
seq_n, seq_c, seq_t = noise.shape[0], cond.shape[0], treatment.shape[0]
|
| 133 |
+
|
| 134 |
+
t_n = t_emb.expand(seq_n, -1)
|
| 135 |
+
t_c = t_emb.expand(seq_c, -1)
|
| 136 |
+
t_t = t_emb.expand(seq_t, -1)
|
| 137 |
+
|
| 138 |
+
gamma_n, beta_n = self.film_an(t_n).chunk(2, dim=-1)
|
| 139 |
+
gamma_c, beta_c = self.film_ac(t_c).chunk(2, dim=-1)
|
| 140 |
+
gamma_t, beta_t = self.film_at(t_t).chunk(2, dim=-1)
|
| 141 |
+
|
| 142 |
+
n = self.ln_qkv_n(noise) * (1 + gamma_n) + beta_n
|
| 143 |
+
c = self.ln_qkv_c(cond) * (1 + gamma_c) + beta_c
|
| 144 |
+
t = self.ln_qkv_t(treatment) * (1 + gamma_t) + beta_t
|
| 145 |
+
|
| 146 |
+
qkv_n = self.qkv_n(n)
|
| 147 |
+
qkv_c = self.qkv_c(c)
|
| 148 |
+
qkv_t = self.qkv_t(t)
|
| 149 |
+
|
| 150 |
+
q_n, k_n, v_n = qkv_n.chunk(3, dim=-1)
|
| 151 |
+
q_c, k_c, v_c = qkv_c.chunk(3, dim=-1)
|
| 152 |
+
q_t, k_t, v_t = qkv_t.chunk(3, dim=-1)
|
| 153 |
+
|
| 154 |
+
q = torch.cat([self._heads(q_n), self._heads(q_c), self._heads(q_t)], dim=0)
|
| 155 |
+
k = torch.cat([self._heads(k_n), self._heads(k_c), self._heads(k_t)], dim=0)
|
| 156 |
+
v = torch.cat([self._heads(v_n), self._heads(v_c), self._heads(v_t)], dim=0)
|
| 157 |
+
|
| 158 |
+
q = self._rms_norm(q, self.rms_q_w, self.rms_q_b)
|
| 159 |
+
k = self._rms_norm(k, self.rms_k_w, self.rms_k_b)
|
| 160 |
+
|
| 161 |
+
attn_out = flash_attention(q, k, v) # (seq_total, H, D)
|
| 162 |
+
|
| 163 |
+
attn_out = attn_out.reshape(seq_n + seq_c + seq_t, self.embed_dim)
|
| 164 |
+
a_n, a_c, a_t = attn_out.split([seq_n, seq_c, seq_t], dim=0)
|
| 165 |
+
|
| 166 |
+
noise = noise + self.o_n(a_n)
|
| 167 |
+
cond = cond + self.o_c(a_c)
|
| 168 |
+
treatment = treatment + self.o_t(a_t)
|
| 169 |
+
|
| 170 |
+
gamma_n2, beta_n2 = self.film_fn(t_n).chunk(2, dim=-1)
|
| 171 |
+
gamma_c2, beta_c2 = self.film_fc(t_c).chunk(2, dim=-1)
|
| 172 |
+
gamma_t2, beta_t2 = self.film_ft(t_t).chunk(2, dim=-1)
|
| 173 |
+
|
| 174 |
+
noise = noise + self.ff_n(self.ln_ff_n(noise) * (1 + gamma_n2) + beta_n2)
|
| 175 |
+
cond = cond + self.ff_c(self.ln_ff_c(cond) * (1 + gamma_c2) + beta_c2)
|
| 176 |
+
treatment = treatment + self.ff_t(self.ln_ff_t(treatment) * (1 + gamma_t2) + beta_t2)
|
| 177 |
+
|
| 178 |
+
return noise, cond, treatment
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
def flash_attention(
|
| 182 |
+
q: torch.Tensor, k: torch.Tensor, v: torch.Tensor, pad_multiple: int = 64
|
| 183 |
+
) -> torch.Tensor:
|
| 184 |
+
"""Reference: map_pfn.models.utils.flash_attention.
|
| 185 |
+
|
| 186 |
+
Pads seq_len to a multiple of 64 (post-RMS-norm tensors) and runs attention
|
| 187 |
+
in bfloat16 with no mask. Zero-padded keys get score 0 and therefore dilute
|
| 188 |
+
the softmax denominator (exactly as the JAX reference does); padded values
|
| 189 |
+
are zero so they contribute no signal. Padded query rows are discarded.
|
| 190 |
+
"""
|
| 191 |
+
seq_len = q.shape[0]
|
| 192 |
+
pad_len = (pad_multiple - seq_len % pad_multiple) % pad_multiple
|
| 193 |
+
if pad_len > 0:
|
| 194 |
+
# (D_left, D_right, H_top, H_bottom, seq_front, seq_back)
|
| 195 |
+
q = F.pad(q, (0, 0, 0, 0, 0, pad_len))
|
| 196 |
+
k = F.pad(k, (0, 0, 0, 0, 0, pad_len))
|
| 197 |
+
v = F.pad(v, (0, 0, 0, 0, 0, pad_len))
|
| 198 |
+
|
| 199 |
+
# (seq, H, D) -> (1, H, seq, D)
|
| 200 |
+
q = q.permute(1, 0, 2).unsqueeze(0).to(torch.bfloat16)
|
| 201 |
+
k = k.permute(1, 0, 2).unsqueeze(0).to(torch.bfloat16)
|
| 202 |
+
v = v.permute(1, 0, 2).unsqueeze(0).to(torch.bfloat16)
|
| 203 |
+
out = F.scaled_dot_product_attention(q, k, v)
|
| 204 |
+
out = out.squeeze(0).permute(1, 0, 2).to(torch.float32)
|
| 205 |
+
return out[:seq_len]
|
| 206 |
+
|
| 207 |
+
|
| 208 |
+
class MMDiT(nn.Module):
|
| 209 |
+
"""Reference: map_pfn.models.mmdit.MMDiT."""
|
| 210 |
+
|
| 211 |
+
def __init__(self, embed_dim: int, cond_dim: int, noise_dim: int, num_heads: int,
|
| 212 |
+
num_blocks: int, num_reg_tokens: int):
|
| 213 |
+
super().__init__()
|
| 214 |
+
self.num_reg_tokens = num_reg_tokens
|
| 215 |
+
self.noise_dim = noise_dim
|
| 216 |
+
self.embed_dim = embed_dim
|
| 217 |
+
|
| 218 |
+
self.noise_in = nn.Linear(noise_dim, embed_dim)
|
| 219 |
+
self.noise_out = nn.Linear(embed_dim, noise_dim)
|
| 220 |
+
self.cond_in = nn.Linear(cond_dim, embed_dim)
|
| 221 |
+
self.treatment_in = nn.Linear(cond_dim, embed_dim)
|
| 222 |
+
|
| 223 |
+
self.modality_emb_noise = nn.Parameter(torch.zeros(embed_dim))
|
| 224 |
+
self.modality_emb_cond = nn.Parameter(torch.zeros(embed_dim))
|
| 225 |
+
self.modality_emb_treatment = nn.Parameter(torch.zeros(embed_dim))
|
| 226 |
+
|
| 227 |
+
self.reg_tok = nn.Parameter(torch.zeros(num_reg_tokens, noise_dim))
|
| 228 |
+
|
| 229 |
+
self.time_emb = SinusoidalPosEmb(embed_dim)
|
| 230 |
+
self.time_mlp = MLP(embed_dim, embed_dim * 4, embed_dim)
|
| 231 |
+
|
| 232 |
+
self.blocks = nn.ModuleList(
|
| 233 |
+
[MMDiTBlock(embed_dim, num_heads, embed_dim // num_heads) for _ in range(num_blocks)]
|
| 234 |
+
)
|
| 235 |
+
|
| 236 |
+
def forward(self, noise: torch.Tensor, cond: torch.Tensor, treatment: torch.Tensor, t: torch.Tensor):
|
| 237 |
+
# noise: (seq_n, 50); cond: (seq_c, 256); treatment: (seq_t, 256); t: scalar tensor
|
| 238 |
+
noise = torch.cat([self.reg_tok, noise], dim=0)
|
| 239 |
+
noise = self.noise_in(noise) + self.modality_emb_noise
|
| 240 |
+
cond = self.cond_in(cond) + self.modality_emb_cond
|
| 241 |
+
treatment = self.treatment_in(treatment) + self.modality_emb_treatment
|
| 242 |
+
|
| 243 |
+
t = t.reshape(1)
|
| 244 |
+
t_pos = self.time_emb(t) # (1, E)
|
| 245 |
+
t_emb = self.time_mlp(t_pos) # (1, E)
|
| 246 |
+
|
| 247 |
+
for block in self.blocks:
|
| 248 |
+
noise, cond, treatment = block(noise, cond, treatment, t_emb)
|
| 249 |
+
|
| 250 |
+
noise = noise[self.num_reg_tokens:]
|
| 251 |
+
return self.noise_out(noise)
|
| 252 |
+
|
| 253 |
+
|
| 254 |
+
class MapPFN(nn.Module):
|
| 255 |
+
"""Reference: map_pfn.models.map_pfn.MapPFN."""
|
| 256 |
+
|
| 257 |
+
def __init__(self, cond_dim: int = 256, in_dim: int = 50, max_num_contexts: int = 10,
|
| 258 |
+
embed_dim: int = 256, num_heads: int = 4, num_blocks: int = 8,
|
| 259 |
+
num_reg_tokens: int = 8):
|
| 260 |
+
super().__init__()
|
| 261 |
+
self.max_num_contexts = max_num_contexts
|
| 262 |
+
|
| 263 |
+
self.decoder = MMDiT(embed_dim, cond_dim, in_dim, num_heads, num_blocks, num_reg_tokens)
|
| 264 |
+
|
| 265 |
+
self.null_cond = nn.Parameter(torch.zeros(cond_dim))
|
| 266 |
+
self.cond_in = nn.Linear(in_dim, cond_dim)
|
| 267 |
+
self.treatment_in = nn.Linear(in_dim, cond_dim)
|
| 268 |
+
|
| 269 |
+
self.obs_data_emb = nn.Parameter(torch.zeros(cond_dim))
|
| 270 |
+
self.int_data_emb = nn.Parameter(torch.zeros(cond_dim))
|
| 271 |
+
self.treatment_emb = nn.Parameter(torch.zeros(cond_dim))
|
| 272 |
+
self.context_emb = nn.Parameter(torch.zeros(max_num_contexts, cond_dim))
|
| 273 |
+
self.query_emb = nn.Parameter(torch.zeros(cond_dim))
|
| 274 |
+
|
| 275 |
+
def assemble_context(
|
| 276 |
+
self,
|
| 277 |
+
obs_data: torch.Tensor, # (1, num_rows, C)
|
| 278 |
+
int_data: torch.Tensor | None, # (num_ctx-1, num_rows, C) or None
|
| 279 |
+
treatment: torch.Tensor, # (num_ctx, num_treatments, C)
|
| 280 |
+
) -> tuple[torch.Tensor, torch.Tensor]:
|
| 281 |
+
num_contexts = treatment.shape[0]
|
| 282 |
+
if int_data is None:
|
| 283 |
+
int_data = self.null_cond.view(1, 1, 1).expand(num_contexts - 1, *treatment.shape[1:])
|
| 284 |
+
|
| 285 |
+
obs_data = obs_data + self.obs_data_emb.view(1, 1, -1)
|
| 286 |
+
int_data = int_data + self.int_data_emb.view(1, 1, -1)
|
| 287 |
+
treatment = treatment + self.treatment_emb.view(1, 1, -1)
|
| 288 |
+
|
| 289 |
+
obs_data = obs_data + self.context_emb[num_contexts - 1: num_contexts].view(1, 1, -1)
|
| 290 |
+
int_data = int_data + self.context_emb[: num_contexts - 1].unsqueeze(1)
|
| 291 |
+
treatment = treatment + self.context_emb[:num_contexts].unsqueeze(1)
|
| 292 |
+
|
| 293 |
+
obs_data = obs_data.clone()
|
| 294 |
+
obs_data[-1] = obs_data[-1] + self.query_emb
|
| 295 |
+
treatment = treatment.clone()
|
| 296 |
+
treatment[-1] = treatment[-1] + self.query_emb
|
| 297 |
+
|
| 298 |
+
obs_seq = obs_data.reshape(-1, obs_data.shape[-1])
|
| 299 |
+
int_seq = int_data.reshape(-1, int_data.shape[-1])
|
| 300 |
+
treat_seq = treatment.reshape(-1, treatment.shape[-1])
|
| 301 |
+
cond_seq = torch.cat([obs_seq, int_seq], dim=0)
|
| 302 |
+
return cond_seq, treat_seq
|
| 303 |
+
|
| 304 |
+
def forward(
|
| 305 |
+
self,
|
| 306 |
+
noise: torch.Tensor, # (seq, 50)
|
| 307 |
+
obs_data: torch.Tensor, # (1, num_rows, 50)
|
| 308 |
+
int_data: torch.Tensor | None, # (num_ctx-1, num_rows, 50) or None
|
| 309 |
+
treatment: torch.Tensor, # (num_ctx, num_treatments, 50)
|
| 310 |
+
t: torch.Tensor, # scalar
|
| 311 |
+
drop_cond: bool = False,
|
| 312 |
+
) -> torch.Tensor:
|
| 313 |
+
obs_data = self.cond_in(obs_data)
|
| 314 |
+
int_data = self.cond_in(int_data) if int_data is not None else None
|
| 315 |
+
treatment = self.treatment_in(treatment)
|
| 316 |
+
|
| 317 |
+
cond_seq, treat_seq = self.assemble_context(obs_data, int_data, treatment)
|
| 318 |
+
|
| 319 |
+
if drop_cond:
|
| 320 |
+
cond_seq = self.null_cond.view(1, -1).expand_as(cond_seq)
|
| 321 |
+
treat_seq = self.null_cond.view(1, -1).expand_as(treat_seq)
|
| 322 |
+
|
| 323 |
+
return self.decoder(noise, cond_seq, treat_seq, t)
|
| 324 |
+
|
| 325 |
+
|
| 326 |
+
def load_ema_weights(model: MapPFN, npz_path: str) -> MapPFN:
|
| 327 |
+
"""Load EMA weights exported from the authors' Equinox checkpoint.
|
| 328 |
+
|
| 329 |
+
eqx.nn.Linear weights are stored (out, in) exactly like torch, so every
|
| 330 |
+
weight copies directly. filter_vmap-stacked block parameters have a leading
|
| 331 |
+
block axis in the npz; index [i] recovers block i's parameter.
|
| 332 |
+
"""
|
| 333 |
+
w = np.load(npz_path)
|
| 334 |
+
t = lambda k: torch.from_numpy(np.ascontiguousarray(w[k]))
|
| 335 |
+
|
| 336 |
+
p = dict(model.named_parameters())
|
| 337 |
+
|
| 338 |
+
def set_par(name: str, key: str):
|
| 339 |
+
arr = t(key)
|
| 340 |
+
assert tuple(arr.shape) == tuple(p[name].shape), f"{name}: {arr.shape} vs {p[name].shape}"
|
| 341 |
+
p[name].data.copy_(arr)
|
| 342 |
+
|
| 343 |
+
# ---- top level (MapPFN) ----
|
| 344 |
+
for k in ["null_cond", "obs_data_emb", "int_data_emb", "treatment_emb",
|
| 345 |
+
"context_emb", "query_emb"]:
|
| 346 |
+
set_par(k, k)
|
| 347 |
+
for lyr in ["cond_in", "treatment_in"]:
|
| 348 |
+
set_par(f"{lyr}.weight", f"{lyr}.weight")
|
| 349 |
+
set_par(f"{lyr}.bias", f"{lyr}.bias")
|
| 350 |
+
|
| 351 |
+
# ---- decoder ----
|
| 352 |
+
for lyr in ["noise_in", "noise_out", "cond_in", "treatment_in"]:
|
| 353 |
+
set_par(f"decoder.{lyr}.weight", f"decoder.{lyr}.weight")
|
| 354 |
+
set_par(f"decoder.{lyr}.bias", f"decoder.{lyr}.bias")
|
| 355 |
+
for k in ["reg_tok", "modality_emb_noise", "modality_emb_cond", "modality_emb_treatment"]:
|
| 356 |
+
set_par(f"decoder.{k}", f"decoder.{k}")
|
| 357 |
+
for lyr in ["linear_1", "linear_2"]:
|
| 358 |
+
set_par(f"decoder.time_mlp.{lyr}.weight", f"decoder.time_mlp.{lyr}.weight")
|
| 359 |
+
set_par(f"decoder.time_mlp.{lyr}.bias", f"decoder.time_mlp.{lyr}.bias")
|
| 360 |
+
# time_emb.emb is a deterministic buffer
|
| 361 |
+
buf = t("decoder.time_emb.emb")
|
| 362 |
+
model.decoder.time_emb.emb.data.copy_(buf)
|
| 363 |
+
|
| 364 |
+
# ---- vmapped blocks (block index is the leading dim in the npz) ----
|
| 365 |
+
n_blocks = len(model.decoder.blocks)
|
| 366 |
+
for i in range(n_blocks):
|
| 367 |
+
b = f"decoder.blocks.{i}."
|
| 368 |
+
for src in ["ln_qkv_n", "ln_qkv_c", "ln_qkv_t", "ln_ff_n", "ln_ff_c", "ln_ff_t"]:
|
| 369 |
+
p[f"{b}{src}.weight"].data.copy_(t(f"decoder.blocks.{src}.weight")[i])
|
| 370 |
+
p[f"{b}{src}.bias"].data.copy_(t(f"decoder.blocks.{src}.bias")[i])
|
| 371 |
+
for src in ["qkv_n", "qkv_c", "qkv_t"]:
|
| 372 |
+
p[f"{b}{src}.weight"].data.copy_(t(f"decoder.blocks.{src}.weight")[i])
|
| 373 |
+
for src in ["o_n", "o_c", "o_t", "film_an", "film_ac", "film_at",
|
| 374 |
+
"film_fn", "film_fc", "film_ft"]:
|
| 375 |
+
p[f"{b}{src}.weight"].data.copy_(t(f"decoder.blocks.{src}.weight")[i])
|
| 376 |
+
p[f"{b}{src}.bias"].data.copy_(t(f"decoder.blocks.{src}.bias")[i])
|
| 377 |
+
for src in ["rms_q", "rms_k"]:
|
| 378 |
+
p[f"{b}{src}_w"].data.copy_(t(f"decoder.blocks.{src}.weight")[i])
|
| 379 |
+
p[f"{b}{src}_b"].data.copy_(t(f"decoder.blocks.{src}.bias")[i])
|
| 380 |
+
for src in ["ff_n", "ff_c", "ff_t"]:
|
| 381 |
+
for lyr in ["linear_1", "linear_2"]:
|
| 382 |
+
p[f"{b}{src}.{lyr}.weight"].data.copy_(t(f"decoder.blocks.{src}.{lyr}.weight")[i])
|
| 383 |
+
p[f"{b}{src}.{lyr}.bias"].data.copy_(t(f"decoder.blocks.{src}.{lyr}.bias")[i])
|
| 384 |
+
return model
|
| 385 |
+
|
| 386 |
+
|
| 387 |
+
# ---------------- ODE sampler: Dormand-Prince 5(4), constant step, CFG ----------------
|
| 388 |
+
# Butcher tableau, embedded exactly as diffrax's Dopri5 (7 stages, FSAL/SSAL).
|
| 389 |
+
# a_lower rows are stages 1..6; stage 0 has no coefficients (c1 = 0).
|
| 390 |
+
_DP_A_LOWER = (
|
| 391 |
+
(1 / 5,),
|
| 392 |
+
(3 / 40, 9 / 40),
|
| 393 |
+
(44 / 45, -56 / 15, 32 / 9),
|
| 394 |
+
(19372 / 6561, -25360 / 2187, 64448 / 6561, -212 / 729),
|
| 395 |
+
(9017 / 3168, -355 / 33, 46732 / 5247, 49 / 176, -5103 / 18656),
|
| 396 |
+
(35 / 384, 0.0, 500 / 1113, 125 / 192, -2187 / 6784, 11 / 84),
|
| 397 |
+
)
|
| 398 |
+
_DP_B = (35 / 384, 0.0, 500 / 1113, 125 / 192, -2187 / 6784, 11 / 84, 0.0)
|
| 399 |
+
_DP_C = (0.0, 1 / 5, 3 / 10, 4 / 5, 8 / 9, 1.0, 1.0)
|
| 400 |
+
|
| 401 |
+
|
| 402 |
+
def solve_ode(
|
| 403 |
+
model: MapPFN,
|
| 404 |
+
noise_shape: tuple[int, int], # (num_rows, dim)
|
| 405 |
+
obs_data: torch.Tensor, # (1, num_rows, dim)
|
| 406 |
+
int_data: torch.Tensor | None, # (num_shots, num_rows, dim) or None
|
| 407 |
+
treatment: torch.Tensor, # (num_shots + 1, 1, dim)
|
| 408 |
+
guidance: float = 2.0,
|
| 409 |
+
step_size: float = 0.01,
|
| 410 |
+
generator: torch.Generator | None = None,
|
| 411 |
+
x_init: torch.Tensor | None = None,
|
| 412 |
+
):
|
| 413 |
+
"""Reference: map_pfn.models.utils.solve_ode (diffrax Dopri5, ConstantStepSize).
|
| 414 |
+
|
| 415 |
+
Exactly 100 fixed DP5 steps from t=0 to t=1 with classifier-free guidance,
|
| 416 |
+
replicating diffrax's k-space arithmetic (k = f * dt) and FSAL stage reuse.
|
| 417 |
+
Times accumulate in float32 exactly as diffrax does, with the endpoint
|
| 418 |
+
clipped to t1 when within 1e-6 (diffrax's `_clip_to_end`).
|
| 419 |
+
"""
|
| 420 |
+
device = next(model.parameters()).device
|
| 421 |
+
if x_init is None:
|
| 422 |
+
x = torch.randn(noise_shape, generator=generator, device=device, dtype=torch.float32)
|
| 423 |
+
else:
|
| 424 |
+
x = x_init.to(device=device, dtype=torch.float32)
|
| 425 |
+
|
| 426 |
+
def velocity(x_t: torch.Tensor, t: float) -> torch.Tensor:
|
| 427 |
+
tt = torch.tensor(t, device=device, dtype=torch.float32)
|
| 428 |
+
v_cond = model(x_t, obs_data, int_data, treatment, tt, drop_cond=False)
|
| 429 |
+
v_uncond = model(x_t, obs_data, int_data, treatment, tt, drop_cond=True)
|
| 430 |
+
return guidance * v_cond + (1 - guidance) * v_uncond
|
| 431 |
+
|
| 432 |
+
f32 = np.float32
|
| 433 |
+
dt0 = f32(step_size)
|
| 434 |
+
t1 = f32(1.0)
|
| 435 |
+
tprev = f32(0.0)
|
| 436 |
+
tnext = f32(f32(0.0) + dt0)
|
| 437 |
+
|
| 438 |
+
f_last = None # FSAL: stage-6 evaluation of the previous step
|
| 439 |
+
n_steps = 0
|
| 440 |
+
while tprev < t1:
|
| 441 |
+
dt = f32(tnext - tprev)
|
| 442 |
+
# stage times: t1_next exactly when c == 1, else tprev + c*dt (all float32)
|
| 443 |
+
ts = [f32(tnext if c == 1.0 else tprev + f32(c) * dt) for c in _DP_C]
|
| 444 |
+
|
| 445 |
+
fs = [None] * 7
|
| 446 |
+
fs[0] = f_last if f_last is not None else velocity(x, ts[0])
|
| 447 |
+
for s in range(1, 7):
|
| 448 |
+
yi = x
|
| 449 |
+
for j, a in enumerate(_DP_A_LOWER[s - 1]):
|
| 450 |
+
if a != 0.0:
|
| 451 |
+
yi = yi + f32(a) * dt * fs[j]
|
| 452 |
+
fs[s] = velocity(yi, ts[s])
|
| 453 |
+
|
| 454 |
+
x = x + f32(_DP_B[0]) * dt * fs[0]
|
| 455 |
+
for i in range(1, 7):
|
| 456 |
+
if _DP_B[i] != 0.0:
|
| 457 |
+
x = x + f32(_DP_B[i]) * dt * fs[i]
|
| 458 |
+
|
| 459 |
+
f_last = fs[6]
|
| 460 |
+
tprev = f32(min(tnext, t1))
|
| 461 |
+
tnext = f32(tprev + dt0)
|
| 462 |
+
if tnext > f32(t1 - 1e-6):
|
| 463 |
+
tnext = t1
|
| 464 |
+
n_steps += 1
|
| 465 |
+
assert n_steps <= 102, "runaway ODE loop"
|
| 466 |
+
|
| 467 |
+
return x
|
requirements.txt
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
numpy
|
| 4 |
pandas
|
|
|
|
| 1 |
+
anndata
|
| 2 |
+
matplotlib
|
| 3 |
numpy
|
| 4 |
pandas
|
weights/weights_frangieh.npz
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:b1d9d7306f4ccd4a1434072c4d89b67df8cfe5513d86b44cbf59adac2edf4983
|
| 3 |
+
size 103894364
|
weights/weights_papalexi.npz
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:a41a0d0077ef6fe0963cfe095921cd4c47e30ce0a0c019e5a4b65271788881f8
|
| 3 |
+
size 103894364
|