multimodalart HF Staff commited on
Commit
c4b3eb0
·
verified ·
1 Parent(s): 55194b9

Upload folder using huggingface_hub

Browse files
.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 single-cell 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, Klos & Dernbach, NeurIPS 2026) — a prior-data fitted
17
- network that predicts the post-perturbation single-cell expression distribution for an
18
- **unseen** gene knockdown, using a handful of example perturbation experiments as in-context
19
- conditioning.
20
-
21
- This Space is a faithful PyTorch port of the reference JAX/Equinox implementation
22
- ([github.com/marvinsxtr/MapPFN](https://github.com/marvinsxtr/MapPFN), MIT license), running
23
- the released EMA weights from [marvinsxtr/MapPFN](https://huggingface.co/marvinsxtr/MapPFN):
24
-
25
- - `model_finetuned_papalexi.ckpt` — Papalexi et al. 2021 (CRISPRi Perturb-seq, leukemia, IFN-γ)
26
- - `model_finetuned_frangieh.ckpt` — Frangieh et al. 2021 (CRISPRi Perturb-seq, melanoma)
27
-
28
- ## What it does
29
-
30
- 1. Pick a dataset and a **held-out test perturbation** (a gene knockdown the model never saw).
31
- 2. MapPFN samples `num_shots` *other* perturbations from the same cell context as in-context
32
- examples (control cells → treated cells, with one-hot treatment vectors).
33
- 3. A classifier-free-guided ODE flow (Dopri5, step 0.01, guidance 2.0) generates the predicted
34
- post-perturbation population of cells.
35
- 4. The prediction is compared against the real measured Perturb-seq data for that knockdown:
36
- expression distributions of the top differentially-expressed genes, per-gene mean scatter,
37
- and distribution metrics (R², RMSE, multiscale MMD).
38
-
39
- Because the query perturbations come from the papers' test splits, every example is a genuine
40
- zero-shot prediction with ground truth available — the metrics panel is computed live.
41
-
42
- ## Data & license
43
-
44
- Bundled expression matrices are derived from the
45
- [marvinsxtr/MapPFN dataset](https://huggingface.co/datasets/marvinsxtr/MapPFN) (MIT), which
46
- packages the published Perturb-seq datasets of Papalexi et al. 2021 and Frangieh et al. 2021.
47
- Expression values are normalize-total + log1p, 50 genes per dataset.
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
- Faithful PyTorch port of the JAX/Equinox reference
4
- (https://github.com/marvinsxtr/MapPFN, Sextro et al., NeurIPS 2026).
5
- Given a set of in-context perturbation experiments (control cells, treated
6
- cells, one-hot treatments), MapPFN predicts the post-perturbation expression
7
- distribution for a held-out query treatment by integrating a classifier-free
8
- guided ODE flow (Dopri5, step 0.01, guidance 2.0).
 
 
9
  """
10
 
11
- import json
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 plotly.graph_objects as go
24
 
25
- HERE = Path(__file__).resolve().parent
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
- MODELS = {
50
- "frangieh": load_weights(build_model(), HERE / "weights_ema_frangieh.npz").to("cuda").eval(),
51
- "papalexi": load_weights(build_model(), HERE / "weights_ema_papalexi.npz").to("cuda").eval(),
 
 
 
 
 
 
 
 
 
 
 
 
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
- def get_contexts(dataset: str) -> list[str]:
67
- # only contexts that actually have held-out test perturbations
68
- return [c for c in DATASETS[dataset]["meta"]["contexts"] if DATASETS[dataset]["meta"]["test"].get(c)]
 
 
 
 
 
 
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
- # ---------------------------------------------------------------- metrics
76
- # Ported from map_pfn/eval/metrics.py (numpy, no jax/ott/scanpy needed).
 
 
 
77
 
78
 
79
- def mean_r2(x: np.ndarray, y: np.ndarray) -> float:
80
- return float(np.corrcoef(np.stack([x.mean(0), y.mean(0)]))[0, 1])
81
-
82
-
83
- def var_r2(x: np.ndarray, y: np.ndarray) -> float:
84
- return float(np.corrcoef(np.stack([x.var(0), y.var(0)]))[0, 1])
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 multiscale_mmd(x: np.ndarray, y: np.ndarray) -> float:
101
- gammas = np.logspace(1, -3, 5)
102
- total = 0.0
103
- for g in gammas:
104
- xx = rbf_kernel_sq(x, x, g).mean()
105
- yy = rbf_kernel_sq(y, y, g).mean()
106
- xy = rbf_kernel_sq(x, y, g).mean()
107
- total += xx + yy - 2 * xy
108
- return float(total / len(gammas))
 
 
 
 
109
 
110
 
111
- def wasserstein_1d(x: np.ndarray, y: np.ndarray) -> float:
112
- """Mean per-gene 1-Wasserstein distance (equal-sample empirical quantiles)."""
113
- xs = np.sort(x, axis=0)
114
- ys = np.sort(y, axis=0)
115
- return float(np.abs(xs - ys).mean())
116
 
117
 
118
  # ---------------------------------------------------------------- inference
119
 
120
-
121
- def sample_cells(x: np.ndarray, n: int, rng: np.random.Generator) -> np.ndarray:
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 = NUM_SHOTS,
136
- num_cells: int = NUM_SAMPLES,
137
- guidance: float = GUIDANCE,
138
- step_size: float = STEP_SIZE,
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: Which fine-tuned MapPFN + dataset to use.
147
- context: Biological context (cell type / condition).
148
- treatment: The query perturbation (gene knockdown), held out at train time.
149
- num_shots: Number of in-context perturbation experiments (max 8).
150
- num_cells: Cells per condition to sample / predict.
151
- guidance: Classifier-free guidance weight.
152
- step_size: ODE solver step size.
153
- seed: RNG seed for cell sampling and the flow's initial noise.
154
 
155
  Returns:
156
- (distribution plot, mean-scatter plot, metrics dataframe, summary md)
157
  """
158
- if treatment not in DATASETS[dataset]["meta"]["test"].get(context, []):
159
- raise gr.Error(f"Treatment '{treatment}' is not a held-out test perturbation of {context} in {dataset}.")
160
-
161
  rng = np.random.default_rng(seed)
162
- ds = DATASETS[dataset]
163
- bundle, meta = ds["bundle"], ds["meta"]
164
- var_names = meta["var_names"]
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
- obs_data = np.stack(obs_list) # (num_shots+1, num_cells, dim)
186
- int_data_all = np.stack(int_list) # (num_shots+1, num_cells, dim)
187
- treatment_arr = np.stack(treat_list)[:, None, :] # (num_shots+1, 1, dim)
 
188
 
189
- # solve_ode inputs (unpack_batch): obs_data_cond = obs[-1:], int_data_cond = int[:-1]
190
- obs_data_cond = obs_data[-1:]
191
- int_data_cond = int_data_all[:-1] if int_data_all.shape[0] > 1 else None
 
192
 
193
- model = MODELS[dataset]
194
- gen = torch.Generator(device="cuda").manual_seed(int(seed))
 
195
 
196
  with torch.no_grad():
197
  pred = solve_ode(
198
- model,
199
- noise_shape=(num_cells, IN_DIM), # per-item noise; solve_ode runs a single item
200
- obs_data=torch.from_numpy(obs_data_cond).to("cuda"),
201
- int_data=torch.from_numpy(int_data_cond).to("cuda") if int_data_cond is not None else None,
202
- treatment=torch.from_numpy(treatment_arr).to("cuda"),
203
- guidance=float(guidance),
204
- step_size=float(step_size),
205
- generator=gen,
206
- )
207
- pred_np = pred.float().cpu().numpy() # (num_cells, dim) — solve_ode runs one un-batched item
208
-
209
- # ground truth for the held-out query treatment
210
- true_np = int_data_all[-1]
211
- ctrl_np = sample_cells(control_cells, num_cells, rng)
212
-
213
- # metrics vs ground truth
214
- metrics = {
215
- "Mean R²": mean_r2(true_np, pred_np),
216
- "Var R²": var_r2(true_np, pred_np),
217
- "Mean RMSE": mean_rmse(true_np, pred_np),
218
- "Var RMSE": var_rmse(true_np, pred_np),
219
- "MMD (multiscale)": multiscale_mmd(true_np, pred_np),
220
- "Wasserstein (1d)": wasserstein_1d(true_np, pred_np),
221
- }
222
-
223
- # ---- plot 1: distribution of top DE genes (violin-style box plots)
224
- lfc = true_np.mean(0) - ctrl_np.mean(0)
225
- top_genes = np.argsort(-np.abs(lfc))[:8]
226
- fig = go.Figure()
227
- for g in top_genes:
228
- gene = var_names[g]
229
- fig.add_trace(
230
- go.Box(y=ctrl_np[:, g], name=f"{gene}<br>control", marker_color="#94a3b8", showlegend=False)
231
- )
232
- fig.add_trace(
233
- go.Box(y=true_np[:, g], name=f"{gene}<br>measured", marker_color="#0f766e", showlegend=False)
234
- )
235
- fig.add_trace(
236
- go.Box(y=pred_np[:, g], name=f"{gene}<br>MapPFN", marker_color="#e11d48", showlegend=False)
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
- with gr.Column(elem_id="col-container"):
310
- with gr.Row():
311
- with gr.Column(scale=1):
312
- dataset_dd = gr.Dropdown(
313
- choices=[("Papalexi 2021 (CRISPRi leukemia, IFN-γ)", "papalexi"), ("Frangieh 2021 (CRISPRi melanoma)", "frangieh")],
314
- value="papalexi",
315
- label="Dataset / fine-tuned checkpoint",
316
- )
317
- context_dd = gr.Dropdown(
318
- choices=get_contexts("papalexi"), value="stimulated", label="Context (cell condition)", allow_custom_value=True
319
- )
320
- treatment_dd = gr.Dropdown(
321
- choices=get_test_treatments("papalexi", "stimulated"),
322
- value="STAT1",
323
- label="Query perturbation (held out at train time)",
324
- allow_custom_value=True,
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
- run_btn.click(
361
- run_predict,
362
- inputs=[dataset_dd, context_dd, treatment_dd, num_shots, num_cells, guidance, step_size, seed],
363
- outputs=[dist_plot, scatter_plot, metrics_df, summary_md],
364
- api_name="predict",
 
365
  )
366
 
367
- demo.launch(theme=gr.themes.Citrus(), css=CSS, mcp_server=True)
 
 
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
- torch
2
- plotly
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