Spaces:
Running on Zero
Running on Zero
File size: 16,400 Bytes
c8dd3aa f8bf379 c8dd3aa c4b3eb0 c8dd3aa f8bf379 c8dd3aa f8bf379 c8dd3aa f8bf379 c8dd3aa c4b3eb0 c8dd3aa c4b3eb0 c8dd3aa c4b3eb0 f8bf379 c8dd3aa 5917990 c8dd3aa 5917990 f8bf379 c8dd3aa 5917990 c8dd3aa f8bf379 c8dd3aa f8bf379 c8dd3aa f8bf379 c8dd3aa f8bf379 c8dd3aa f8bf379 c8dd3aa 5917990 c8dd3aa f8bf379 c4b3eb0 f8bf379 c8dd3aa c4b3eb0 c8dd3aa c4b3eb0 f8bf379 c8dd3aa f8bf379 c8dd3aa c4b3eb0 c8dd3aa f8bf379 c8dd3aa f8bf379 c8dd3aa 5917990 c8dd3aa c4b3eb0 c8dd3aa f8bf379 c8dd3aa c4b3eb0 c8dd3aa 5917990 c8dd3aa 5917990 c8dd3aa f8bf379 c4b3eb0 c8dd3aa | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 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 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 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 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 | import os
os.environ.setdefault("JAX_PLATFORMS", "cpu") # JAX is only used on CPU to deserialise the Equinox checkpoints
import spaces # noqa: E402 (must precede torch)
import torch # noqa: E402
import anndata as ad # noqa: E402
import gradio as gr # noqa: E402
import numpy as np # noqa: E402
import pandas as pd # noqa: E402
import plotly.graph_objects as go # noqa: E402
from huggingface_hub import hf_hub_download # noqa: E402
import mappfn_jax # noqa: E402
import mappfn_torch # noqa: E402
REPO = "marvinsxtr/MapPFN"
DEVICE = os.environ.get("MAPPFN_DEVICE", "cuda")
NUM_CELLS = 200 # cells per population, as in the paper's evaluation config
MAX_SHOTS = 8 # num_shots used for fine-tuning / evaluation
DATASETS = {
"Frangieh et al. 2021 (melanoma, CRISPR KO)": {
"key": "frangieh",
"default_context": "IFNγ",
"default_gene": "B2M",
},
"Papalexi et al. 2021 (THP-1, ECCITE-seq)": {
"key": "papalexi",
"default_context": "stimulated",
"default_gene": "IFNGR1",
},
}
# ---------------------------------------------------------------------------
# Data
# ---------------------------------------------------------------------------
DATA = {}
for label, meta in DATASETS.items():
path = hf_hub_download(REPO, f"{meta['key']}/split_10.h5ad", repo_type="dataset")
a = ad.read_h5ad(path)
X = np.asarray(a.X, dtype=np.float32)
obs = a.obs[["context", "treatment", "split"]].astype(str).reset_index(drop=True)
groups = obs.groupby(["context", "treatment", "split"]).indices
by_ct = obs.groupby(["context", "treatment"]).indices
tvec = {}
for (ctx, tr), idx in by_ct.items():
if tr != "control":
tvec[tr] = np.asarray(a.obsm["treatment"][idx[0]], dtype=np.float32)
split_of = {(c, t): s for (c, t, s) in groups}
contexts = sorted(obs["context"].unique())
perts = {c: sorted(t for (cc, t) in by_ct if cc == c and t != "control") for c in contexts}
DATA[meta["key"]] = dict(
X=X, obs=obs, groups=groups, by_ct=by_ct, tvec=tvec, split_of=split_of,
contexts=contexts, perts=perts, genes=list(a.var_names),
)
del a
# ---------------------------------------------------------------------------
# Model: Equinox checkpoint -> PyTorch (EMA weights, as used by the authors for inference).
# The released *fine-tuned* checkpoints were trained on a different gene panel/ordering than the released
# h5ad splits (their marginals only match the data after sorting), so we serve the pre-trained checkpoint,
# which is gene-agnostic and reproduces the paper's "MapPFN (pre-trained)" numbers.
# ---------------------------------------------------------------------------
_eqx = mappfn_jax.load_ema_model(hf_hub_download(REPO, "model.ckpt"), in_dim=50)
MODEL = mappfn_torch.from_equinox(_eqx).eval().to(DEVICE)
del _eqx
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
SPLIT_NOTE = {
"test": "held-out test condition of the paper's split",
"val": "validation condition of the paper's split",
"train": "training condition of the paper's split (excluded from the in-context examples when queried)",
}
def _gene_label(d, ctx, gene):
s = d["split_of"].get((ctx, gene))
return SPLIT_NOTE.get(s, "no measured cells in this context")
def _cells(d, ctx, tr, splits, n, rng):
idx = np.concatenate([d["groups"][(ctx, tr, s)] for s in splits if (ctx, tr, s) in d["groups"]])
sel = rng.choice(idx, size=n, replace=len(idx) < n)
return d["X"][sel]
def update_choices(dataset, context=None, gene=None):
d = DATA[DATASETS[dataset]["key"]]
ctx = context if context in d["contexts"] else DATASETS[dataset]["default_context"]
genes = d["perts"][ctx]
g = gene if gene in genes else (DATASETS[dataset]["default_gene"] if DATASETS[dataset]["default_gene"] in genes else genes[0])
return gr.update(choices=d["contexts"], value=ctx), gr.update(choices=genes, value=g)
def update_genes(dataset, context, gene=None):
d = DATA[DATASETS[dataset]["key"]]
if context not in d["perts"]:
return gr.update()
genes = d["perts"][context]
return gr.update(choices=genes, value=gene if gene in genes else genes[0])
C_CTRL, C_OBS, C_PRED = "#8a8985", "#2a78d6", "#eb6834"
def _layout(fig, title, xlab, ylab, height=460):
fig.update_layout(
title=dict(text=title, x=0.01, font=dict(size=15)),
xaxis_title=xlab, yaxis_title=ylab, height=height, template="plotly_white",
margin=dict(l=60, r=20, t=60, b=50),
legend=dict(orientation="h", yanchor="bottom", y=1.0, xanchor="right", x=1),
hoverlabel=dict(font_size=12),
)
fig.update_xaxes(gridcolor="rgba(0,0,0,0.06)", zerolinecolor="rgba(0,0,0,0.25)")
fig.update_yaxes(gridcolor="rgba(0,0,0,0.06)", zerolinecolor="rgba(0,0,0,0.25)")
return fig
def _delta_plot(genes, ctrl, pred, obs, gene, top_k=15):
d_pred = pred.mean(0) - ctrl.mean(0)
d_obs = obs.mean(0) - ctrl.mean(0) if obs is not None else None
score = np.abs(d_obs) + np.abs(d_pred) if d_obs is not None else np.abs(d_pred)
order = np.argsort(-score)[:top_k][::-1]
names = [genes[i] for i in order]
fig = go.Figure()
if d_obs is not None:
fig.add_bar(y=names, x=d_obs[order], orientation="h", name="Observed", marker_color=C_OBS,
hovertemplate="%{y}<br>observed Δ = %{x:.3f}<extra></extra>")
fig.add_bar(y=names, x=d_pred[order], orientation="h", name="MapPFN prediction", marker_color=C_PRED,
hovertemplate="%{y}<br>predicted Δ = %{x:.3f}<extra></extra>")
fig.update_layout(barmode="group", bargap=0.25, bargroupgap=0.08)
return _layout(fig, f"Mean expression change vs. control after {gene} perturbation (top {top_k} genes)",
"Δ mean log-expression (perturbed − control)", None, height=520)
def _pca_plot(ctrl, pred, obs):
ref = np.concatenate([ctrl] + ([obs] if obs is not None else [pred]))
mu = ref.mean(0)
_, _, vt = np.linalg.svd(ref - mu, full_matrices=False)
proj = lambda x: (x - mu) @ vt[:2].T # noqa: E731
fig = go.Figure()
series = [("Control cells", ctrl, C_CTRL)]
if obs is not None:
series.append(("Observed perturbed cells", obs, C_OBS))
series.append(("MapPFN predicted cells", pred, C_PRED))
for name, x, c in series:
p = proj(x)
fig.add_scatter(x=p[:, 0], y=p[:, 1], mode="markers", name=name,
marker=dict(size=8, color=c, opacity=0.6, line=dict(width=1, color="white")),
hovertemplate=name + "<br>PC1 %{x:.2f}, PC2 %{y:.2f}<extra></extra>")
return _layout(fig, "Cell-level distributions (PCA of the 50-gene panel)", "PC1", "PC2")
def _energy(x, y):
def md(a, b):
return np.sqrt(((a[:, None, :] - b[None, :, :]) ** 2).sum(-1)).mean()
return float(2 * md(x, y) - md(x, x) - md(y, y))
# ---------------------------------------------------------------------------
# Inference
# ---------------------------------------------------------------------------
@spaces.GPU(duration=15)
def _run_model(obs_q, intv, treat, guidance, seed):
model = MODEL
dev = DEVICE
obs_t = torch.from_numpy(obs_q).to(dev)[None, None]
intv_t = torch.from_numpy(intv).to(dev)[None] if intv is not None else None
tr_t = torch.from_numpy(treat).to(dev)[None]
gen = torch.Generator(device=dev).manual_seed(int(seed))
out = mappfn_torch.sample(model, obs_t, intv_t, tr_t, guidance=float(guidance), step_size=0.01, generator=gen)
return out[0].float().cpu().numpy()
def predict(
dataset: str,
context: str,
gene: str,
num_shots: int = 8,
guidance: float = 2.0,
seed: int = 0,
):
"""Predict the single-cell expression distribution after perturbing a gene with MapPFN.
MapPFN conditions on control cells of the chosen biological context plus `num_shots` other
perturbation experiments measured in the same context (in-context examples), and generates
200 post-perturbation cells over the 50-gene panel via flow matching.
Args:
dataset: Single-cell perturbation dataset (Frangieh or Papalexi).
context: Biological context / condition (e.g. "IFNγ" or "stimulated").
gene: Gene whose perturbation (knockout) should be predicted.
num_shots: Number of in-context perturbation experiments (0-8).
guidance: Classifier-free guidance weight (paper default 2.0).
seed: Random seed for cell sub-sampling, context selection and ODE noise.
Returns:
Summary markdown, mean-shift bar chart, PCA scatter, per-gene table, CSV of predicted cells.
"""
if dataset not in DATASETS:
raise gr.Error("Unknown dataset.")
ds_key = DATASETS[dataset]["key"]
d = DATA[ds_key]
if context not in d["contexts"]:
raise gr.Error(f"Unknown context '{context}' for this dataset.")
if gene not in d["tvec"]:
raise gr.Error(f"'{gene}' is not a perturbation target in this dataset.")
num_shots = int(max(0, min(MAX_SHOTS, int(num_shots))))
rng = np.random.default_rng(int(seed))
# Query: control cells of the context (the "control" split, as in the authors' test datasets)
obs_q = _cells(d, context, "control", ["control"], NUM_CELLS, rng)
# In-context experiments: training-split perturbations of the same context, excluding the query
pool = [t for t in d["perts"][context] if t != gene and (context, t, "train") in d["groups"]]
n = min(num_shots, len(pool))
shots = list(rng.choice(pool, size=n, replace=False)) if n else []
intv = np.stack([_cells(d, context, t, ["train"], NUM_CELLS, rng) for t in shots]) if shots else None
treat = np.stack([d["tvec"][t][None] for t in [*shots, gene]]).astype(np.float32)
# NB: context control cells for each shot are drawn from the same control pool; only the query's
# control population enters the model (obs_data[:, -1:]), exactly as in `unpack_batch`.
pred = _run_model(obs_q, intv, treat, guidance, seed)
obs_cells = None
if (context, gene) in d["by_ct"]:
idx = d["by_ct"][(context, gene)]
obs_cells = d["X"][rng.choice(idx, size=NUM_CELLS, replace=len(idx) < NUM_CELLS)]
genes = d["genes"]
ctrl_mean = obs_q.mean(0)
table = pd.DataFrame({
"gene": genes,
"control mean": ctrl_mean,
"predicted mean": pred.mean(0),
"predicted Δ": pred.mean(0) - ctrl_mean,
})
lines = [
f"### {gene} perturbation · {context}",
f"**Status of this perturbation:** {_gene_label(d, context, gene)} ",
f"**In-context experiments ({len(shots)}):** {', '.join(shots) if shots else 'none (zero-shot)'}",
]
if obs_cells is not None:
table["observed mean"] = obs_cells.mean(0)
table["observed Δ"] = obs_cells.mean(0) - ctrl_mean
dp, do = table["predicted Δ"].to_numpy(), table["observed Δ"].to_numpy()
r = float(np.corrcoef(dp, do)[0, 1])
e_pred = _energy(pred, obs_cells)
e_ctrl = _energy(obs_q, obs_cells)
lines += [
"",
"| metric | MapPFN | no-change baseline (control cells) |",
"|---|---|---|",
f"| Pearson r of Δ vs observed Δ | **{r:.3f}** | – |",
f"| MSE of mean expression | **{float(((pred.mean(0) - obs_cells.mean(0)) ** 2).mean()):.4f}** | "
f"{float(((ctrl_mean - obs_cells.mean(0)) ** 2).mean()):.4f} |",
f"| Energy distance to observed cells | **{e_pred:.3f}** | {e_ctrl:.3f} |",
]
table = table.reindex(table["predicted Δ"].abs().sort_values(ascending=False).index).round(4)
csv_path = f"/tmp/mappfn_{ds_key}_{gene}_{context}_{seed}.csv".replace(" ", "_")
pd.DataFrame(pred, columns=genes).to_csv(csv_path, index_label="cell")
return (
"\n".join(lines),
_delta_plot(genes, obs_q, pred, obs_cells, gene),
_pca_plot(obs_q, pred, obs_cells),
table,
csv_path,
)
# ---------------------------------------------------------------------------
# UI
# ---------------------------------------------------------------------------
CSS = """
#col-container { max-width: 1200px; margin: 0 auto; }
.dark .gradio-container { color: var(--body-text-color); }
"""
DEFAULT_DS = list(DATASETS)[0]
_d0 = DATA[DATASETS[DEFAULT_DS]["key"]]
with gr.Blocks() as demo:
with gr.Column(elem_id="col-container"):
gr.Markdown(
"""
# MapPFN: Learning Causal Perturbation Maps in Context
A prior-data fitted network that predicts **single-cell post-perturbation expression distributions** in an
unseen biological context by reading a handful of other perturbation experiments *in context*.
Pick a dataset, a cell context and a gene to knock out: MapPFN generates 200 perturbed cells from the
context's control cells and compares them with the measured cells (when available).
[Paper](https://huggingface.co/papers/2601.21092) · [Code](https://github.com/marvinsxtr/MapPFN) ·
[Model](https://huggingface.co/marvinsxtr/MapPFN) · [Data](https://huggingface.co/datasets/marvinsxtr/MapPFN)
<sub>Runs the **pre-trained** checkpoint, trained only on the synthetic causal prior, with no fine-tuning on real cells.
As the paper reports for this setting, it gets the direction of effects right but often overshoots their size,
so on mean error it can score worse than the "no change" baseline. The released fine-tuned checkpoints don't match
the gene panel of the released data splits, so they are not used here.</sub>
"""
)
with gr.Row():
with gr.Column(scale=1):
dataset = gr.Dropdown(list(DATASETS), value=DEFAULT_DS, label="Dataset")
context = gr.Dropdown(_d0["contexts"], value=DATASETS[DEFAULT_DS]["default_context"],
label="Biological context", allow_custom_value=True)
gene = gr.Dropdown(_d0["perts"][DATASETS[DEFAULT_DS]["default_context"]],
value=DATASETS[DEFAULT_DS]["default_gene"],
label="Gene to perturb", allow_custom_value=True)
with gr.Accordion("Advanced settings", open=False):
num_shots = gr.Slider(0, MAX_SHOTS, value=8, step=1, label="In-context experiments (shots)")
guidance = gr.Slider(1.0, 4.0, value=2.0, step=0.1, label="Classifier-free guidance")
seed = gr.Number(value=0, precision=0, label="Seed")
run = gr.Button("Predict perturbation", variant="primary")
summary = gr.Markdown()
with gr.Column(scale=2):
delta_plot = gr.Plot(label="Mean shift per gene")
pca_plot = gr.Plot(label="Cell distributions")
with gr.Accordion("Per-gene table & predicted cells", open=False):
table = gr.Dataframe(label="Per-gene means (log-normalised expression)", wrap=True)
csv_out = gr.File(label="Predicted cells (CSV, 200 cells × 50 genes)")
outputs = [summary, delta_plot, pca_plot, table, csv_out]
inputs = [dataset, context, gene, num_shots, guidance, seed]
gr.Examples(
examples=[
["Frangieh et al. 2021 (melanoma, CRISPR KO)", "IFNγ", "B2M"],
["Frangieh et al. 2021 (melanoma, CRISPR KO)", "IFNγ", "STAT3"],
["Frangieh et al. 2021 (melanoma, CRISPR KO)", "IFNγ", "CD58"],
["Papalexi et al. 2021 (THP-1, ECCITE-seq)", "stimulated", "IFNGR1"],
["Papalexi et al. 2021 (THP-1, ECCITE-seq)", "stimulated", "JAK2"],
["Papalexi et al. 2021 (THP-1, ECCITE-seq)", "stimulated", "STAT1"],
],
inputs=[dataset, context, gene],
outputs=outputs,
fn=predict,
cache_examples=True,
cache_mode="lazy",
)
dataset.change(update_choices, [dataset, context, gene], [context, gene], api_visibility="private")
context.change(update_genes, [dataset, context, gene], [gene], api_visibility="private")
run.click(predict, inputs, outputs)
if __name__ == "__main__":
demo.launch(theme=gr.themes.Citrus(), css=CSS, mcp_server=True)
|