Rhaister / scripts /eval_a_vs_b.py
Shreshth Gandhi
Import Rhaister main branch from GitHub source
5a72781
Raw
History Blame Contribute Delete
19 kB
"""Noise-ceiling calibration: score sub-sample A against sub-sample B.
Groups A and B are independent sub-samples of the same experiments, so A is an
optimal predictor of B (up to sampling noise) and vice versa. Running each as
"prediction" against the other as "truth" gives a calibration ceiling for the
eight metrics logged by eval_splits.py.
Each dataset declares its A/B paths in splits/<dataset>/dataset.toml under
[data.variants.{A,B}]. Only datasets that declare both variants are considered.
Tahoe's legacy long-format cell_eval is handled via the `cell_eval_format =
"tahoe_long"` flag on its variants; other datasets use the shared wide loader.
Usage:
# all datasets with [data.variants], all non-titration splits
uv run python scripts/eval_a_vs_b.py
# only specific datasets (all their splits)
uv run python scripts/eval_a_vs_b.py --datasets parse
# specific "<dataset>/<split>" names (overrides --datasets)
uv run python scripts/eval_a_vs_b.py --splits parse/donor_split_0 parse/donor_split_1
# merge into an existing output file instead of overwriting (preserves
# per-split entries from previous runs that aren't in this invocation)
uv run python scripts/eval_a_vs_b.py --splits parse/fewshot_donor_split_0 --merge
# override the gene list (applies to all selected datasets)
uv run python scripts/eval_a_vs_b.py --gene-list path/to/alt_genes.json
# + wandb
uv run python scripts/eval_a_vs_b.py --wandb
"""
import argparse
import glob
import json
import os
import sys
import tomllib
from collections import defaultdict
import numpy as np
import pandas as pd
from rhaister import prepare_combined as pc
METRIC_KEYS = [
"pdex_static/pearson_delta_mean",
"pdex_static/auprc_p05",
"state/pearson_delta_mean",
"state/de_overlap_mean",
"state/de_spearman_sig",
"state/pr_auc_mean",
"state/spearman_lfc_sig_mean",
"state/discrimination_mean",
]
# === variant cell_eval loaders ===
def _format_tahoe_treatment(drug: str, dose) -> str:
# pdex target column: "[('5-Azacytidine', 0.05, 'uM')]" — drug trimmed, dose as float repr.
return f"[('{drug.strip()}', {float(dose)}, 'uM')]"
def _load_cell_eval_tahoe_long(variant_cfg: dict, static_genes: list[str]) -> pd.DataFrame:
"""Load legacy long-format Tahoe cell_eval (group_{A,B}_celleval_plate*.parquet)
and pivot to the wide (cell_line, treatment, <gene_cols>) shape. Treatment is
composed from drug+dose to match the pdex `target` formatting.
"""
pattern = os.path.join(variant_cfg["cell_eval_dir"], variant_cfg["cell_eval_glob"])
files = sorted(glob.glob(pattern))
assert files, f"No cell_eval parquet files at {pattern}"
print(f" found {len(files)} cell_eval files")
gene_set = set(static_genes)
parts = []
for f in files:
df = pd.read_parquet(
f, columns=["gene_name", "log2FoldChange", "cell_line", "drug", "dose", "plate"],
)
df = df[df["gene_name"].isin(gene_set)]
parts.append(df)
long_df = pd.concat(parts, ignore_index=True)
del parts
long_df["treatment"] = [
_format_tahoe_treatment(d, s) for d, s in zip(long_df["drug"], long_df["dose"])
]
wide = long_df.pivot_table(
index=["cell_line", "treatment"],
columns="gene_name",
values="log2FoldChange",
aggfunc="mean",
)
wide.reset_index(inplace=True)
wide.columns.name = None
gene_cols = [c for c in wide.columns if c not in ("cell_line", "treatment")]
wide[gene_cols] = wide[gene_cols].fillna(0.0)
print(f" {len(wide)} (cell_line, treatment) pairs, {len(gene_cols)} genes")
return wide
def _load_cell_eval_for_variant(variant_cfg: dict, static_genes: list[str]) -> pd.DataFrame:
fmt = variant_cfg.get("cell_eval_format", "wide")
if fmt == "wide":
return pc.load_cell_eval_data(variant_cfg)
if fmt == "tahoe_long":
return _load_cell_eval_tahoe_long(variant_cfg, static_genes)
raise ValueError(f"unknown cell_eval_format: {fmt!r}")
# === per-dataset load + align ===
def load_variant(dataset: str, variant: str, gene_list_override: str | None = None) -> dict:
"""Load + align pdex and cell_eval for one dataset variant.
If `gene_list_override` is set, it replaces `cfg["gene_list"]` so that both
the pdex and cell_eval loaders read the alternate list.
"""
cfg = pc.load_variant_config(dataset, variant)
if gene_list_override is not None:
cfg["gene_list"] = gene_list_override
with open(cfg["gene_list"]) as f:
static_genes = json.load(f)
print(f" pdex path: {cfg['pdex_path']}")
print(f" cell_eval src: {cfg.get('cell_eval_path') or cfg.get('cell_eval_dir')}")
print(f" cell_eval fmt: {cfg.get('cell_eval_format', 'wide')}")
fc_df, pv_df, fdr_df, _ = pc.load_pdex_data(cfg)
pdex_genes = pc.get_gene_columns(fc_df)
print(f" pdex: {len(fc_df)} rows, {len(pdex_genes)} genes")
ce_df = _load_cell_eval_for_variant(cfg, static_genes)
ce_genes = pc.get_gene_columns(ce_df)
fc_agg = pc.aggregate_replicates(fc_df)
pv_agg = pc.aggregate_replicates(pv_df)
fdr_agg = pc.aggregate_replicates(fdr_df)
ce_agg = pc.aggregate_replicates(ce_df)
del fc_df, pv_df, fdr_df, ce_df
print(f" aggregated: pdex={len(fc_agg)}, cell_eval={len(ce_agg)}")
shared_genes = [g for g in static_genes if g in set(pdex_genes) and g in set(ce_genes)]
print(f" pdex×cell_eval genes: {len(shared_genes)}/{len(static_genes)}")
fc_agg = fc_agg[["cell_line", "treatment"] + shared_genes]
pv_agg = pv_agg[["cell_line", "treatment"] + shared_genes]
fdr_agg = fdr_agg[["cell_line", "treatment"] + shared_genes]
ce_agg = ce_agg[["cell_line", "treatment"] + shared_genes]
key_pdex = fc_agg[["cell_line", "treatment"]].copy()
key_pdex["_in_pdex"] = True
ce_agg = ce_agg.merge(key_pdex, on=["cell_line", "treatment"], how="inner").drop(columns="_in_pdex")
key_ce = ce_agg[["cell_line", "treatment"]].copy()
key_ce["_in_ce"] = True
fc_agg = fc_agg.merge(key_ce, on=["cell_line", "treatment"], how="inner").drop(columns="_in_ce")
pv_agg = pv_agg.merge(key_ce, on=["cell_line", "treatment"], how="inner").drop(columns="_in_ce")
fdr_agg = fdr_agg.merge(key_ce, on=["cell_line", "treatment"], how="inner").drop(columns="_in_ce")
for df in (fc_agg, pv_agg, fdr_agg, ce_agg):
df.sort_values(["cell_line", "treatment"], inplace=True)
df.reset_index(drop=True, inplace=True)
print(f" aligned: {len(fc_agg)} shared (cell_line, treatment) pairs")
return {"fc": fc_agg, "pv": pv_agg, "fdr": fdr_agg, "ce": ce_agg, "gene_cols": shared_genes}
def reconcile_groups(a: dict, b: dict) -> tuple[dict, dict]:
"""Restrict two loaded groups to the intersection of rows AND gene columns."""
key_a = a["fc"][["cell_line", "treatment"]].copy()
key_a["_ka"] = True
key_b = b["fc"][["cell_line", "treatment"]].copy()
key_b["_kb"] = True
shared_keys = (
key_a.merge(key_b, on=["cell_line", "treatment"], how="inner")
[["cell_line", "treatment"]]
.sort_values(["cell_line", "treatment"])
.reset_index(drop=True)
)
shared_genes = [g for g in a["gene_cols"] if g in set(b["gene_cols"])]
if len(shared_genes) != len(a["gene_cols"]) or len(shared_genes) != len(b["gene_cols"]):
print(f" A×B genes: {len(shared_genes)} (A={len(a['gene_cols'])}, B={len(b['gene_cols'])})")
if len(shared_keys) != len(a["fc"]) or len(shared_keys) != len(b["fc"]):
print(f" A×B rows: {len(shared_keys)} (A={len(a['fc'])}, B={len(b['fc'])})")
def align(group):
out = {}
for k in ("fc", "pv", "fdr", "ce"):
merged = shared_keys.merge(group[k], on=["cell_line", "treatment"], how="left")
out[k] = merged[["cell_line", "treatment"] + shared_genes].reset_index(drop=True)
out["gene_cols"] = shared_genes
return out
return align(a), align(b)
def split_to_matrices(group: dict, split_info: dict) -> dict:
_, test_fc = pc.make_splits(group["fc"], split_info)
_, test_pv = pc.make_splits(group["pv"], split_info)
_, test_fdr = pc.make_splits(group["fdr"], split_info)
_, test_ce = pc.make_splits(group["ce"], split_info)
gene_cols = group["gene_cols"]
test_cells, test_tr, Y_test = pc.to_matrices(test_fc, gene_cols)
_, _, P_test = pc.to_matrices(test_pv, gene_cols)
_, _, F_test = pc.to_matrices(test_fdr, gene_cols)
_, _, D_test = pc.to_matrices(test_ce, gene_cols)
D_test = np.nan_to_num(D_test, nan=0.0)
return {
"Y": Y_test, "P": P_test, "F": F_test, "D": D_test,
"cells": test_cells, "treatments": test_tr,
"gene_cols": gene_cols,
}
def score_pair(a_test: dict, b_test: dict) -> tuple[dict, dict]:
assert np.array_equal(a_test["cells"], b_test["cells"]), "row order mismatch"
assert np.array_equal(a_test["treatments"], b_test["treatments"]), "treatment order mismatch"
assert a_test["Y"].shape == b_test["Y"].shape, (
f"shape mismatch: A={a_test['Y'].shape}, B={b_test['Y'].shape}"
)
print(" scoring A-as-pred vs B-as-truth...")
ab = pc.evaluate(
Y_true=b_test["Y"], Y_pred=a_test["Y"],
P_true=b_test["P"], P_pred=a_test["P"],
D_true=b_test["D"], D_pred=a_test["D"],
F_true=b_test["F"], F_pred=a_test["F"],
test_cells=b_test["cells"], test_treatments=b_test["treatments"],
gene_cols=b_test["gene_cols"], compute_discrimination=True,
)
print(" scoring B-as-pred vs A-as-truth...")
ba = pc.evaluate(
Y_true=a_test["Y"], Y_pred=b_test["Y"],
P_true=a_test["P"], P_pred=b_test["P"],
D_true=a_test["D"], D_pred=b_test["D"],
F_true=a_test["F"], F_pred=b_test["F"],
test_cells=a_test["cells"], test_treatments=a_test["treatments"],
gene_cols=a_test["gene_cols"], compute_discrimination=True,
)
return ab, ba
# === discovery ===
def _has_variants(dataset: str) -> bool:
cfg_path = os.path.join(pc.SPLITS_DIR, dataset, "dataset.toml")
if not os.path.isfile(cfg_path):
return False
with open(cfg_path, "rb") as f:
raw = tomllib.load(f)
variants = raw.get("data", {}).get("variants", {})
return "A" in variants and "B" in variants
def discover_datasets() -> list[str]:
"""Datasets under splits/ that declare both A and B variants."""
out = []
for name in sorted(os.listdir(pc.SPLITS_DIR)):
if not os.path.isdir(os.path.join(pc.SPLITS_DIR, name)):
continue
if _has_variants(name):
out.append(name)
return out
def discover_splits(dataset: str) -> list[str]:
"""Subdirs of splits/<dataset>/ that contain the default_split_config file
and aren't titration variants (titrations share a ceiling with their parent)."""
cfg = pc.load_dataset_config(dataset)
dataset_dir = cfg["dataset_dir"]
default_name = cfg["default_split_config"]
out = []
for name in sorted(os.listdir(dataset_dir)):
if "titration" in name:
continue
sub = os.path.join(dataset_dir, name)
if os.path.isdir(sub) and os.path.isfile(os.path.join(sub, default_name)):
out.append(f"{dataset}/{name}")
return out
# === output assembly ===
def build_output(per_dataset: dict, *, print_summary: bool = True) -> dict:
"""Derive metadata/summary/asymmetry blocks from a per_dataset mapping.
`per_dataset[dataset]` must have shape
`{n_genes_shared, n_aligned_pairs, per_split: {split: {a_vs_b, b_vs_a, mean, n_samples}}}`.
The summary/asymmetry blocks are pure derivations, so this function is the
single place that knows how to roll them up — used both for fresh runs and
for `--merge` runs that load prior per_split entries from disk.
"""
if print_summary:
print(f"\n{'='*70}\nSUMMARY (mean of A-vs-B and B-vs-A)\n{'='*70}")
summary = {}
for dataset, res in per_dataset.items():
if print_summary:
print(f"\n{dataset}:")
split_names = list(res["per_split"].keys())
dsum = {}
for key in METRIC_KEYS:
vals = [res["per_split"][s]["mean"][key] for s in split_names if key in res["per_split"][s]["mean"]]
if not vals:
continue
dsum[key] = {"mean": float(np.mean(vals)), "std": float(np.std(vals)), "per_split": vals}
if print_summary:
per = " ".join(f"{v:.4f}" for v in vals)
print(f" {key:<42} {np.mean(vals):>8.4f} {np.std(vals):>8.4f} {per}")
summary[dataset] = dsum
asymmetry = {}
for dataset, res in per_dataset.items():
das = {}
for key in METRIC_KEYS:
diffs = [
abs(res["per_split"][s]["a_vs_b"][key] - res["per_split"][s]["b_vs_a"][key])
for s in res["per_split"]
if key in res["per_split"][s]["a_vs_b"] and key in res["per_split"][s]["b_vs_a"]
]
if diffs:
das[key] = {"max": max(diffs), "per_split": diffs}
asymmetry[dataset] = das
return {
"metadata": {
"datasets": list(per_dataset.keys()),
"splits": [s for d in per_dataset.values() for s in d["per_split"]],
},
"per_dataset": per_dataset,
"summary": summary,
"asymmetry": asymmetry,
}
def merge_per_dataset(existing: dict, fresh: dict) -> dict:
"""Merge fresh per_dataset into existing; fresh per_split entries override."""
merged = {ds: {**v, "per_split": dict(v["per_split"])} for ds, v in existing.items()}
for dataset, res in fresh.items():
if dataset in merged:
merged[dataset]["n_genes_shared"] = res["n_genes_shared"]
merged[dataset]["n_aligned_pairs"] = res["n_aligned_pairs"]
merged[dataset]["per_split"].update(res["per_split"])
else:
merged[dataset] = {**res, "per_split": dict(res["per_split"])}
return merged
# === main ===
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--datasets", nargs="+", default=None,
help="Datasets to evaluate (default: all with [data.variants])")
parser.add_argument("--splits", nargs="+", default=None,
help="'<dataset>/<split>' names; overrides --datasets")
parser.add_argument("--wandb", action="store_true", help="Log results to WandB")
parser.add_argument(
"--gene-list",
default=None,
help="Override the gene-list JSON used for all selected datasets "
"(default: each dataset's static_2k_genes.json from dataset.toml). "
"Use this for exploring alternative gene sets.",
)
parser.add_argument(
"--merge",
action="store_true",
help="Merge results into an existing --output file (per_split entries "
"from this run override matching keys; other splits/datasets are "
"preserved). Default behavior overwrites the file.",
)
parser.add_argument(
"--output",
default=os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "a_vs_b_results.json"),
help="Path to write the structured JSON result file",
)
args = parser.parse_args()
by_dataset: dict[str, list[str]] = defaultdict(list)
if args.splits is not None:
for s in args.splits:
ds, sp = pc.parse_split_name(s)
by_dataset[ds].append(f"{ds}/{sp}")
datasets = sorted(by_dataset.keys())
else:
datasets = args.datasets or discover_datasets()
for d in datasets:
by_dataset[d] = discover_splits(d)
print(f"Datasets to evaluate: {datasets}")
for d in datasets:
print(f" {d}: {by_dataset[d]}")
results = {}
for dataset in datasets:
splits = by_dataset[dataset]
if not splits:
print(f"[skip] {dataset}: no splits")
continue
if not _has_variants(dataset):
print(f"[skip] {dataset}: no [data.variants.{{A,B}}] in dataset.toml")
continue
print(f"\n{'='*70}\n{dataset.upper()}: LOADING GROUP A\n{'='*70}")
a = load_variant(dataset, "A", gene_list_override=args.gene_list)
print(f"\n{'='*70}\n{dataset.upper()}: LOADING GROUP B\n{'='*70}")
b = load_variant(dataset, "B", gene_list_override=args.gene_list)
print(f"\n{'='*70}\n{dataset.upper()}: RECONCILING\n{'='*70}")
a, b = reconcile_groups(a, b)
per_split = {}
for split in splits:
print(f"\n{'='*70}\n{split}\n{'='*70}")
split_info = pc.load_split(split)
a_test = split_to_matrices(a, split_info)
b_test = split_to_matrices(b, split_info)
print(f" test pairs: {len(a_test['cells'])}")
if len(a_test["cells"]) == 0:
print(" [skip] no test samples after applying split to A∩B")
continue
ab, ba = score_pair(a_test, b_test)
mean = {k: 0.5 * (ab[k] + ba[k]) for k in METRIC_KEYS if k in ab and k in ba}
per_split[split] = {
"a_vs_b": ab, "b_vs_a": ba, "mean": mean,
"n_samples": ab.get("n_samples", 0),
}
results[dataset] = {
"n_genes_shared": len(a["gene_cols"]),
"n_aligned_pairs": len(a["fc"]),
"per_split": per_split,
}
del a, b
if args.wandb:
import wandb
for dataset, res in results.items():
for split, r in res["per_split"].items():
wandb.init(
project="perturbation-eval", job_type="a_vs_b",
name=f"a_vs_b_{split.replace('/', '_')}",
config={"dataset": dataset, "split": split, "n_samples": r["n_samples"]},
reinit=True,
)
wandb.log({k: r["mean"][k] for k in METRIC_KEYS if k in r["mean"]})
wandb.finish()
if args.merge and os.path.isfile(args.output):
with open(args.output) as f:
prior = json.load(f)
prior_per_dataset = prior.get("per_dataset", {})
fresh_splits = [s for d in results.values() for s in d["per_split"]]
print(f"\nMerging {len(fresh_splits)} fresh split(s) into {args.output} "
f"(prior had {sum(len(v.get('per_split', {})) for v in prior_per_dataset.values())} splits)")
per_dataset = merge_per_dataset(prior_per_dataset, results)
else:
per_dataset = results
output = build_output(per_dataset)
with open(args.output, "w") as f:
json.dump(output, f, indent=2)
print(f"\nStructured results written to {args.output}")
if __name__ == "__main__":
main()