| """Compute naive baselines with all six State metrics. |
| |
| Baselines (applied to FC, deltas, and NLP independently): |
| global_mean — predict global mean per gene |
| cell_mean — predict per-cell-line mean per gene |
| treatment_mean — predict per-treatment mean per gene |
| additive — predict mu + treat_effect + cell_effect (ALS) |
| |
| Usage: |
| python scripts/baselines.py |
| """ |
| import json |
| import sys |
| import os |
|
|
| import numpy as np |
| import torch |
| from rhaister.prepare_combined import prepare_all, evaluate, pvalues_to_fdr_bh, parse_split_name, CACHE_DIR |
|
|
| REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) |
| RESULTS_FILE = os.path.join(REPO_ROOT, "baseline_results.json") |
| DEFAULT_PREDICTIONS_DIR = os.path.join(REPO_ROOT, "predictions") |
|
|
|
|
| BASELINE_NAMES = ["global_mean", "cell_mean", "treatment_mean", "additive", "svd_residual"] |
| ALS_BASELINE_NAMES = ["global_mean", "cell_mean", "treatment_mean", "additive"] |
|
|
| SVD_RANK = int(os.environ.get("HP_SVD_RANK", "10")) |
| SVD_ITERS = int(os.environ.get("HP_SVD_ITERS", "20")) |
|
|
|
|
| def _als_decomposition(Y, c_idx, t_idx, n_cell, n_treat, n_iter=5): |
| """ALS decomposition: mu + cell_effect + treatment_effect.""" |
| n_genes = Y.shape[1] |
| mu = Y.mean(axis=0) |
| cell_eff = np.zeros((n_cell, n_genes)) |
| treat_eff = np.zeros((n_treat, n_genes)) |
| for _ in range(n_iter): |
| for ti in range(n_treat): |
| mask = t_idx == ti |
| if mask.sum() > 0: |
| treat_eff[ti] = (Y[mask] - mu - cell_eff[c_idx[mask]]).mean(axis=0) |
| for ci in range(n_cell): |
| mask = c_idx == ci |
| if mask.sum() > 0: |
| cell_eff[ci] = (Y[mask] - mu - treat_eff[t_idx[mask]]).mean(axis=0) |
| return mu, cell_eff, treat_eff |
|
|
|
|
| def _build_predictions(mu, cell_eff, treat_eff, test_c, test_t, n_test): |
| """Build the four additive baseline predictions from ALS components.""" |
| return { |
| "global_mean": np.tile(mu, (n_test, 1)), |
| "cell_mean": mu + cell_eff[test_c], |
| "treatment_mean": mu + treat_eff[test_t], |
| "additive": mu + treat_eff[test_t] + cell_eff[test_c], |
| } |
|
|
|
|
| def _svd_residual_predictions(Y_train, c_idx, t_idx, mu, cell_eff, treat_eff, |
| n_cell, n_treat, test_c, test_t, |
| rank=SVD_RANK, n_iter=SVD_ITERS): |
| """SVD matrix-completion baseline on mean-subtracted residuals. |
| |
| Subtracts additive ALS prediction from observed Y_train to form residuals, |
| lays them onto a (n_cell, n_treat, n_genes) grid with test positions masked |
| as missing, and iteratively fills the missing entries with per-gene truncated |
| SVD (rank k) of the (n_cell, n_treat) residual matrix. Final prediction for |
| each test (c, t) is mu + cell_eff[c] + treat_eff[t] + completed_residual. |
| This is a fair head-to-head with the full model's ridge-regression residual |
| predictor — both operate in residual space after ALS mean effects. |
| """ |
| n_train, n_genes = Y_train.shape |
| device = torch.device("cuda" if torch.cuda.is_available() else "cpu") |
|
|
| residuals = ( |
| Y_train.astype(np.float32) |
| - mu.astype(np.float32) |
| - cell_eff[c_idx].astype(np.float32) |
| - treat_eff[t_idx].astype(np.float32) |
| ) |
|
|
| |
| R = torch.zeros(n_cell, n_treat, n_genes, device=device, dtype=torch.float32) |
| mask = torch.zeros(n_cell, n_treat, device=device, dtype=torch.bool) |
| c_idx_t = torch.from_numpy(c_idx).long().to(device) |
| t_idx_t = torch.from_numpy(t_idx).long().to(device) |
| R[c_idx_t, t_idx_t] = torch.from_numpy(residuals).to(device) |
| mask[c_idx_t, t_idx_t] = True |
|
|
| |
| R_gnt = R.permute(2, 0, 1).contiguous() |
| R_obs = R_gnt.clone() |
| mask_gnt = mask.unsqueeze(0).expand_as(R_gnt) |
| k = min(rank, n_cell, n_treat) |
| for _ in range(n_iter): |
| U, S, Vh = torch.linalg.svd(R_gnt, full_matrices=False) |
| S_trunc = S.clone() |
| S_trunc[:, k:] = 0 |
| R_recon = U @ torch.diag_embed(S_trunc) @ Vh |
| R_gnt = torch.where(mask_gnt, R_obs, R_recon) |
|
|
| R_completed = R_gnt.permute(1, 2, 0).cpu().numpy() |
| test_c_arr = np.asarray(test_c) |
| test_t_arr = np.asarray(test_t) |
| residual_test = R_completed[test_c_arr, test_t_arr] |
| additive_test = mu + cell_eff[test_c_arr] + treat_eff[test_t_arr] |
| return additive_test + residual_test |
|
|
|
|
| def _write_baseline_predictions_parquets( |
| split_name, predictions_dir, |
| fc_preds_test, d_preds_test, fc_preds_train, d_preds_train, |
| Y_test, D_test, Y_train, D_train, |
| test_cells, test_treatments, train_cells, train_treatments, gene_cols, |
| ): |
| """Write per-baseline (cell, treatment, gene) prediction parquets for one split. |
| |
| Output schema matches `train.py`'s full-model parquets minus FDR columns: |
| cell_line, treatment, gene, split, y_true, y_pred, d_true, d_pred. Two files |
| per baseline per split (`_train.parquet` and `_test.parquet`); train rows use |
| train-side ground truth and the same ALS fit evaluated at training indices. |
| """ |
| import pandas as pd |
|
|
| out_dir = predictions_dir or DEFAULT_PREDICTIONS_DIR |
| os.makedirs(out_dir, exist_ok=True) |
| safe_split = split_name.replace("/", "_") |
|
|
| genes = np.asarray(gene_cols) |
| n_genes = genes.shape[0] |
|
|
| Y_test_arr = np.asarray(Y_test, dtype=np.float32) |
| D_test_arr = np.asarray(D_test, dtype=np.float32) |
| Y_train_arr = np.asarray(Y_train, dtype=np.float32) |
| D_train_arr = np.asarray(D_train, dtype=np.float32) |
| test_cells_arr = np.asarray(test_cells) |
| test_treatments_arr = np.asarray(test_treatments) |
| train_cells_arr = np.asarray(train_cells) |
| train_treatments_arr = np.asarray(train_treatments) |
|
|
| n_test = Y_test_arr.shape[0] |
| n_train = Y_train_arr.shape[0] |
| assert Y_test_arr.shape == (n_test, n_genes), f"Y_test shape mismatch: {Y_test_arr.shape}" |
| assert Y_train_arr.shape == (n_train, n_genes), f"Y_train shape mismatch: {Y_train_arr.shape}" |
|
|
| passes = [ |
| ("test", test_cells_arr, test_treatments_arr, n_test, Y_test_arr, D_test_arr, |
| fc_preds_test, d_preds_test), |
| ("train", train_cells_arr, train_treatments_arr, n_train, Y_train_arr, D_train_arr, |
| fc_preds_train, d_preds_train), |
| ] |
|
|
| written = [] |
| for name in ALS_BASELINE_NAMES: |
| for pass_tag, cells, treats, n_obs, y_true, d_true, fc_preds, d_preds in passes: |
| y_pred = np.asarray(fc_preds[name], dtype=np.float32) |
| d_pred = np.asarray(d_preds[name], dtype=np.float32) |
| assert y_pred.shape == (n_obs, n_genes), f"{name}/{pass_tag} y_pred shape: {y_pred.shape}" |
| assert d_pred.shape == (n_obs, n_genes), f"{name}/{pass_tag} d_pred shape: {d_pred.shape}" |
|
|
| df = pd.DataFrame({ |
| "cell_line": pd.Categorical(np.repeat(cells, n_genes)), |
| "treatment": pd.Categorical(np.repeat(treats, n_genes)), |
| "gene": pd.Categorical(np.tile(genes, n_obs)), |
| "split": pd.Categorical([pass_tag] * (n_obs * n_genes)), |
| "y_true": y_true.ravel(), |
| "y_pred": y_pred.ravel(), |
| "d_true": d_true.ravel(), |
| "d_pred": d_pred.ravel(), |
| }) |
| out = os.path.join(out_dir, f"baseline_{name}__{safe_split}_{pass_tag}.parquet") |
| df.to_parquet(out, index=False) |
| print(f"Saved baseline predictions: {out} ({len(df)} rows)") |
| written.append(out) |
| return written |
|
|
|
|
| def compute_baselines(split_name="tahoe_5_holdout", save_predictions=False, predictions_dir=None): |
| """Compute baselines for a single split, evaluating all six State metrics. |
| |
| When `save_predictions=True`, additionally write per-baseline (cell, treatment, |
| gene) parquets for the four ALS baselines and skip the SVD-residual + NLP |
| paths (only `d_pred` / `y_pred` are needed downstream). Metrics are not |
| computed in that mode and the function returns None. |
| """ |
| data = prepare_all(split_name) |
| Y_train = np.array(data["Y_train"], dtype=np.float64) |
| D_train = np.array(data["D_train"], dtype=np.float64) |
| n_test = data["n_test"] |
| test_cells = data["test_cells"] |
| test_treatments = data["test_treatments"] |
| gene_cols = data["gene_cols"] |
|
|
| |
| dataset, split = parse_split_name(split_name) |
| cache_dir = os.path.join(CACHE_DIR, dataset, split) |
| Y_test = np.load(os.path.join(cache_dir, "Y_test.npy"), mmap_mode="r") |
| D_test = np.load(os.path.join(cache_dir, "D_test.npy"), mmap_mode="r") |
|
|
| |
| unique_cells = sorted(set(data["train_cells"])) |
| unique_treats = sorted(set(data["train_treatments"])) |
| cell_map = {c: i for i, c in enumerate(unique_cells)} |
| treat_map = {t: i for i, t in enumerate(unique_treats)} |
| c_idx = np.array([cell_map[c] for c in data["train_cells"]]) |
| t_idx = np.array([treat_map[t] for t in data["train_treatments"]]) |
| test_c = np.array([cell_map[c] for c in test_cells]) |
| test_t = np.array([treat_map[t] for t in test_treatments]) |
| n_cell, n_treat = len(unique_cells), len(unique_treats) |
| n_train = Y_train.shape[0] |
|
|
| |
| mu_fc, cell_fc, treat_fc = _als_decomposition(Y_train, c_idx, t_idx, n_cell, n_treat) |
| fc_preds = _build_predictions(mu_fc, cell_fc, treat_fc, test_c, test_t, n_test) |
|
|
| mu_d, cell_d, treat_d = _als_decomposition(D_train, c_idx, t_idx, n_cell, n_treat) |
| d_preds = _build_predictions(mu_d, cell_d, treat_d, test_c, test_t, n_test) |
|
|
| if save_predictions: |
| |
| |
| fc_preds_train = _build_predictions(mu_fc, cell_fc, treat_fc, c_idx, t_idx, n_train) |
| d_preds_train = _build_predictions(mu_d, cell_d, treat_d, c_idx, t_idx, n_train) |
| _write_baseline_predictions_parquets( |
| split_name, predictions_dir, |
| fc_preds, d_preds, fc_preds_train, d_preds_train, |
| Y_test, D_test, Y_train, D_train, |
| test_cells, test_treatments, data["train_cells"], data["train_treatments"], |
| gene_cols, |
| ) |
| return None |
|
|
| |
| P_train = np.array(data["P_train"], dtype=np.float64) |
| P_test = np.load(os.path.join(cache_dir, "P_test.npy"), mmap_mode="r") |
| F_test = np.load(os.path.join(cache_dir, "F_test.npy"), mmap_mode="r") |
|
|
| fc_preds["svd_residual"] = _svd_residual_predictions( |
| Y_train, c_idx, t_idx, mu_fc, cell_fc, treat_fc, |
| n_cell, n_treat, test_c, test_t, |
| ) |
| d_preds["svd_residual"] = _svd_residual_predictions( |
| D_train, c_idx, t_idx, mu_d, cell_d, treat_d, |
| n_cell, n_treat, test_c, test_t, |
| ) |
|
|
| NLP_train = -np.log10(np.clip(P_train, 1e-30, 1.0)) |
| mu_nlp, cell_nlp, treat_nlp = _als_decomposition(NLP_train, c_idx, t_idx, n_cell, n_treat) |
| nlp_preds = _build_predictions(mu_nlp, cell_nlp, treat_nlp, test_c, test_t, n_test) |
| nlp_preds["svd_residual"] = _svd_residual_predictions( |
| NLP_train, c_idx, t_idx, mu_nlp, cell_nlp, treat_nlp, |
| n_cell, n_treat, test_c, test_t, |
| ) |
|
|
| |
| results = {} |
| for name in BASELINE_NAMES: |
| Y_pred = fc_preds[name] |
| D_pred = d_preds[name] |
| NLP_pred = np.clip(nlp_preds[name], 0, 30) |
| P_pred = np.power(10.0, -NLP_pred) |
| F_pred = pvalues_to_fdr_bh(P_pred) |
|
|
| metrics = evaluate( |
| Y_test, Y_pred, P_test, P_pred, |
| D_test, D_pred, F_test, F_pred, |
| test_cells, test_treatments, gene_cols, |
| compute_discrimination=True, |
| ) |
| results[name] = metrics |
|
|
| return results |
|
|
|
|
| SPLITS = [f"tahoe_{i}_holdout" for i in range(5, 10)] |
|
|
| METRIC_KEYS = [ |
| "pdex_static/pearson_delta_mean", |
| "pdex_static/auprc_p05", |
| "state/pearson_delta_mean", |
| "state/spearman_lfc_sig_mean", |
| "state/pr_auc_mean", |
| "state/de_overlap_mean", |
| "state/de_spearman_sig", |
| "state/discrimination_mean", |
| ] |
| SHORT_NAMES = ["fc_pearson", "auprc_p05", "delta_pearson", |
| "spearman_lfc", "pr_auc", "de_overlap", "spearman_sig", "discrim"] |
|
|
|
|
| def main(): |
| split_name = None |
| log_wandb = "--wandb" in sys.argv |
| save_predictions = "--save-predictions" in sys.argv |
| predictions_dir = None |
| args = list(sys.argv[1:]) |
| i = 0 |
| while i < len(args): |
| arg = args[i] |
| if arg in ("--wandb", "--save-predictions"): |
| i += 1 |
| continue |
| if arg == "--predictions-dir": |
| predictions_dir = args[i + 1] |
| i += 2 |
| continue |
| |
| split_name = arg |
| i += 1 |
|
|
| if save_predictions: |
| |
| |
| targets = [split_name] if split_name else SPLITS |
| for split in targets: |
| print(f"Saving baseline predictions for {split}...") |
| compute_baselines(split, save_predictions=True, predictions_dir=predictions_dir) |
| return |
|
|
| if split_name: |
| |
| results = compute_baselines(split_name) |
| _print_table(f"Baselines ({split_name})", results) |
| _save_results({split_name: results}) |
| if log_wandb: |
| _log_wandb(split_name, results) |
| else: |
| |
| all_results = {} |
| for split in SPLITS: |
| print(f"Computing baselines for {split}...") |
| all_results[split] = compute_baselines(split) |
| if log_wandb: |
| _log_wandb(split, all_results[split]) |
|
|
| _save_results(all_results) |
|
|
| for split in SPLITS: |
| _print_table(split, all_results[split]) |
|
|
| |
| print(f"\n{'='*60}") |
| print("SUMMARY (mean ± std across splits)") |
| print(f"{'='*60}") |
| print(f"\n{'Baseline':<18}" + "".join(f"{s:>14}" for s in SHORT_NAMES)) |
| print("-" * (18 + 14 * len(METRIC_KEYS))) |
| for name in BASELINE_NAMES: |
| parts = [] |
| for k in METRIC_KEYS: |
| vals = [all_results[s][name].get(k, float('nan')) for s in SPLITS] |
| parts.append(f"{np.mean(vals):8.4f}±{np.std(vals):.3f}") |
| print(f"{name:<18}" + "".join(f"{p:>14}" for p in parts)) |
| print() |
|
|
|
|
| def _save_results(new_results): |
| """Merge new results into baseline_results.json, preserving other splits.""" |
| existing = {} |
| if os.path.exists(RESULTS_FILE): |
| with open(RESULTS_FILE) as f: |
| existing = json.load(f) |
| for split, split_results in new_results.items(): |
| existing[split] = { |
| name: {k: float(v) for k, v in metrics.items() if isinstance(v, (int, float, np.floating))} |
| for name, metrics in split_results.items() |
| } |
| with open(RESULTS_FILE, "w") as f: |
| json.dump(existing, f, indent=2, sort_keys=True) |
| print(f"Saved results to {RESULTS_FILE}") |
|
|
|
|
| def _log_wandb(split, results): |
| import wandb |
| split_config = f"configs/{split}/generalization_converted_cell_lines_3b.toml" |
| for name in BASELINE_NAMES: |
| metrics = {k: results[name].get(k, float('nan')) for k in METRIC_KEYS} |
| wandb.init( |
| project="perturbation-eval", |
| job_type="baseline", |
| name=f"baseline_{name}", |
| config={"split_config": split_config, "baseline": name}, |
| reinit=True, |
| ) |
| wandb.log(metrics) |
| wandb.finish() |
|
|
|
|
| def _print_table(title, results): |
| print(f"\n{'Baseline':<18}" + "".join(f"{s:>14}" for s in SHORT_NAMES) + f" [{title}]") |
| print("-" * (18 + 14 * len(METRIC_KEYS))) |
| for name in BASELINE_NAMES: |
| m = results[name] |
| vals = "".join(f"{m.get(k, float('nan')):14.4f}" for k in METRIC_KEYS) |
| print(f"{name:<18}{vals}") |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|