PIVOT / src /pivot /evaluation /runner.py
pranamanam's picture
Upload 176 files
6fa9282 verified
Raw
History Blame Contribute Delete
9.68 kB
"""Evaluate fixed populations and intervention catalogs with condition-level rows."""
from __future__ import annotations
import json, time
from pathlib import Path
import numpy as np
import torch
from pivot.evaluation.metrics import (
correlation,
mmd2,
sliced_wasserstein,
energy_distance,
retrieval_metrics,
bootstrap,
)
from pivot.evaluation.rewards import Reward
from pivot.evaluation.inference import (
encode_label,
forward_predict,
reward_guidance,
project_and_rerank,
)
def save_json(path, value):
"""Write strict JSON. Undefined numerical statistics become null."""
def clean(x):
if isinstance(x, dict):
return {str(k): clean(v) for k, v in x.items()}
if isinstance(x, (list, tuple)):
return [clean(v) for v in x]
if isinstance(x, (float, np.floating)):
return float(x) if np.isfinite(x) else None
if isinstance(x, np.integer):
return int(x)
return x
p = Path(path)
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text(json.dumps(clean(value), indent=2, allow_nan=False))
def evaluate(
data,
predict,
output,
method="PIVOT",
partition="test",
catalog="single",
reward_kind="cosine",
n_cells=128,
seed=0,
model=None,
device="cpu",
guidance_steps=25,
step_size=0.5,
k_nearest=10,
initialization="best",
max_targets=None,
):
"""Forward and inverse evaluation on disjoint query and reference cells.
Feature errors use normalized expression, with each target's observed top-20
effect genes selected once. Population metrics use PCA coordinates. Inverse
query populations are disjoint from the reserved candidate-outcome cells.
The target condition is the aggregation and bootstrap unit.
"""
rng = np.random.default_rng(seed)
ci = data.indices(partition, True)
if not len(ci):
raise ValueError("Evaluation needs controls in the requested partition")
ci = rng.choice(ci, min(n_cells, len(ci)), replace=False)
c0 = data.emb[ci]
control_expr = np.asarray(data.Xhvg[ci].mean(0)).ravel()
control_latent = c0.mean(0)
candidates = (
data.singles
if catalog == "single"
else data.combos if catalog == "combination" else data.perturbations
)
if not candidates:
raise ValueError("Candidate catalog is empty")
labels = data.labels(partition)
if max_targets:
labels = sorted(
rng.choice(labels, min(max_targets, len(labels)), replace=False)
)
# Cache endpoint predictions once for each candidate. Count all candidate-cell
# predictions, including those used to choose a warm-start embedding.
t0 = time.perf_counter()
predictions = {p: np.asarray(predict(c0, p)) for p in candidates}
catalog_seconds = time.perf_counter() - t0
query_ids = data.indices(partition, False)
refids = data.indices("reference", False)
reference = {
p: data.emb[np.intersect1d(refids, data.pert_to_idx[p])] for p in candidates
}
if any(len(v) == 0 for v in reference.values()):
raise ValueError("Every candidate needs independent reference outcomes")
forward = []
inverse = []
for p in labels:
ids = np.intersect1d(query_ids, data.pert_to_idx[p])
ids = rng.choice(ids, min(n_cells, len(ids)), replace=False)
target = data.emb[ids]
pred = predictions.get(p)
if pred is None:
pred = np.asarray(predict(c0, p))
expression = np.asarray(data.Xhvg[ids].mean(0)).ravel()
pred_expr = data.decode_to_genes(pred).mean(0)
effect = expression - control_expr
pred_effect = pred_expr - control_expr
de = np.argsort(-np.abs(effect))[: min(20, len(effect))]
train_genes = {g for q in data.labels("train") for g in data.parse(q)}
forward.append(
{
"target": p,
"query_cells": len(ids),
"genes_observed_in_training": all(
g in train_genes for g in data.parse(p)
),
"mse_expression": float(np.mean((pred_expr - expression) ** 2)),
"effect_pearson": correlation(pred_effect, effect),
"effect_pearson_de20": correlation(pred_effect[de], effect[de]),
"mmd2": mmd2(pred, target, data.meta["mmd_gamma"]),
"sliced_wasserstein": sliced_wasserstein(pred, target, seed=seed),
"energy": energy_distance(pred, target),
"variance_ratio": float(
np.var(pred, axis=0).sum()
/ max(np.var(target, axis=0).sum(), 1e-12)
),
}
)
if p not in candidates:
continue
reward = Reward(
reward_kind,
target_sample=target,
control_ref=control_latent,
gamma=data.meta["mmd_gamma"],
device=device,
)
score = lambda pop: float(
reward(torch.as_tensor(pop, dtype=torch.float32, device=device))
.mean()
.detach()
.cpu()
)
measured = {q: score(v) for q, v in reference.items()}
scores = {q: score(v) for q, v in predictions.items()}
ranked = sorted(candidates, key=lambda q: (-scores[q], q))
tied = sum(abs(scores[q] - scores[ranked[0]]) < 1e-10 for q in candidates)
def row(name, ranking, cost, seconds, continuous=None):
selected = ranking[0]
out = {
"target": p,
"search": name,
**retrieval_metrics(ranking, p),
"selected": selected,
"predicted_reward": scores.get(selected),
"measured_reward": measured[selected],
"measured_regret": max(measured.values()) - measured[selected],
"selected_gene_overlap": len(
set(data.parse(selected)) & set(data.parse(p))
)
/ max(len(set(data.parse(p))), 1),
"catalog_size": len(candidates),
"candidate_cell_evaluations": cost,
"seconds": seconds,
"top_score_ties": tied,
}
if continuous is not None:
out["continuous_reward"] = continuous
return out
inverse.append(
row(
"exhaustive",
ranked,
len(candidates) * len(c0),
catalog_seconds / len(labels),
)
)
if model is not None and guidance_steps > 0:
# A best-candidate initialization uses the complete ranked catalog.
# Random initialization avoids this search but still projects over all embeddings.
init = (
ranked[0] if initialization == "best" else str(rng.choice(candidates))
)
e = encode_label(model, data, init, device)
ct = torch.as_tensor(c0, device=device)
t1 = time.perf_counter()
es = reward_guidance(model, ct, reward, e, guidance_steps, step_size)
guided = project_and_rerank(
model, data, candidates, es, ct, reward, k_nearest, k_nearest, device
)
continuous = score(forward_predict(model, ct, es).cpu().numpy())
names = [q for q, _ in guided]
cost = (
(len(candidates) if initialization == "best" else 0)
+ guidance_steps
+ min(k_nearest, len(candidates))
)
gr = row(
"guidance_" + initialization,
names,
cost * len(c0),
time.perf_counter()
- t1
+ (catalog_seconds / len(labels) if initialization == "best" else 0),
continuous,
)
gr["initial_candidate"] = init
gr["predicted_reward"] = guided[0][1]
gr["projection_reward_change"] = guided[0][1] - continuous
inverse.append(gr)
summary = {
"forward": {
k: bootstrap([r[k] for r in forward], seed)
for k in (
"mse_expression",
"effect_pearson",
"effect_pearson_de20",
"mmd2",
"sliced_wasserstein",
"energy",
"variance_ratio",
)
},
"inverse": {},
}
for search in sorted({r["search"] for r in inverse}):
rows = [r for r in inverse if r["search"] == search]
summary["inverse"][search] = {
k: bootstrap([r[k] for r in rows], seed)
for k in (
"top1",
"top5",
"ndcg10",
"measured_reward",
"measured_regret",
"selected_gene_overlap",
)
}
result = {
"protocol": "split-first-v1",
"method": method,
"data_meta": data.meta,
"partition": partition,
"catalog": catalog,
"reward": reward_kind,
"seed": seed,
"n_controls": len(c0),
"query_cell_ids": data.obs.iloc[query_ids].cell_id.tolist(),
"guidance": {
"steps": guidance_steps,
"step_size": step_size,
"k_nearest": k_nearest,
"initialization": initialization,
},
"summary": summary,
"forward": forward,
"inverse": inverse,
}
save_json(output, result)
return result