Rhaister / scripts /baseline_sensitivity.py
Shreshth Gandhi
Import Rhaister main branch from GitHub source
5a72781
Raw
History Blame Contribute Delete
13.2 kB
"""Baselines for scalar-target sensitivity datasets (RIFIVDU and future
drug-sensitivity datasets of the same modality).
Baselines (all scalar targets):
global_mean — mu (overall train mean)
cell_mean — mu + cell_eff[c]
treatment_mean — mu + treat_eff[t]
additive — mu + cell_eff[c] + treat_eff[t] (ALS)
svd_residual — additive + low-rank SVD completion of (cell × treatment)
residual matrix on the train mask
Metrics: MSE, MAE (declared in splits/<dataset>/dataset.toml [metrics].applicable).
Usage:
uv run python scripts/baseline_sensitivity.py --split EmeraldBay/split_0
"""
import argparse
import os
import sys
import time
import numpy as np
from rhaister import prepare_sensitivity
BASELINE_NAMES = ["global_mean", "cell_mean", "treatment_mean", "additive", "svd_residual"]
A_VS_B_BASELINE = "a_vs_b"
PRIMARY_BASELINE = "primary_to_secondary"
ADJACENT_BASELINE = "adjacent_dose"
_CONDITION_RE = __import__("re").compile(
r"^\[\('(.+)',\s*([\-\d\.eE\+]+),\s*'([^']+)'\)\]$"
)
def _parse_condition(s):
"""Return (drug, dose) for a single-drug condition string, else (None, None)."""
m = _CONDITION_RE.match(s)
if m is None:
return None, None
return m.group(1), float(m.group(2))
def _als_decomposition(y, c_idx, t_idx, n_cell, n_treat, n_iter=30):
"""Scalar ALS: y ~= mu + cell_eff[c] + treat_eff[t].
Vectorized via np.bincount — O(n_train) per pass instead of O(n_cell+n_treat)
Python-level loops, which matters at prism scale (n_treat ~ 12K).
"""
mu = float(np.mean(y))
cell_eff = np.zeros(n_cell)
treat_eff = np.zeros(n_treat)
cell_count = np.bincount(c_idx, minlength=n_cell).astype(np.float64)
treat_count = np.bincount(t_idx, minlength=n_treat).astype(np.float64)
safe_cell = np.maximum(cell_count, 1.0)
safe_treat = np.maximum(treat_count, 1.0)
for _ in range(n_iter):
resid = y - mu - cell_eff[c_idx]
treat_sum = np.bincount(t_idx, weights=resid, minlength=n_treat)
treat_eff = np.where(treat_count > 0, treat_sum / safe_treat, 0.0)
resid = y - mu - treat_eff[t_idx]
cell_sum = np.bincount(c_idx, weights=resid, minlength=n_cell)
cell_eff = np.where(cell_count > 0, cell_sum / safe_cell, 0.0)
return mu, cell_eff, treat_eff
def _svd_completion(R_obs, mask, rank, n_iter):
"""Iterative truncated-SVD completion of a 2-D matrix.
R_obs: (n_cell, n_treat) residuals at observed positions, 0 elsewhere.
mask: bool (n_cell, n_treat), True at observed positions.
Uses sklearn's randomized_svd for the truncated factorization — at prism
scale (481 x 12392) this is far faster than np.linalg.svd's full SVD.
"""
from sklearn.utils.extmath import randomized_svd
R = R_obs.copy()
k = min(rank, *R.shape)
rng = np.random.default_rng(0)
for _ in range(n_iter):
U, S, Vt = randomized_svd(R, n_components=k, random_state=rng.integers(2**31))
R_recon = (U * S) @ Vt
R = np.where(mask, R_obs, R_recon)
return R
def _a_vs_b_pairs(dataset, test_cells, test_treatments):
"""Look up A and B growth_rate at each test (cell, cond) pair.
Returns (a_vals, b_vals, valid_mask) — float arrays plus a bool mask of
pairs where BOTH A and B have a non-null aggregated value.
"""
A = prepare_sensitivity.load_variant_data(dataset, "A").set_index(["cell_line", "condition"])["growth_rate"]
B = prepare_sensitivity.load_variant_data(dataset, "B").set_index(["cell_line", "condition"])["growth_rate"]
keys = list(zip(test_cells, test_treatments))
a_vals = np.array([A.get(k, np.nan) for k in keys], dtype=np.float64)
b_vals = np.array([B.get(k, np.nan) for k in keys], dtype=np.float64)
valid = np.isfinite(a_vals) & np.isfinite(b_vals)
return a_vals, b_vals, valid
def _primary_to_secondary_pred(dataset, test_cells, test_treatments, y_test):
"""Predict secondary growth_rate from the primary-screen measurement at the
matched (cell, drug, dose). Only ~12% of test rows have a matching dose —
primary parquet's dose_secondary covers 121 of the 939 secondary doses, all
clustered near the primary's 2.5 μM dose. Returns (pred, y_truth,
valid_mask) where valid_mask flags rows that have a match.
"""
primary = prepare_sensitivity.load_primary_screen(dataset)
pred = np.full(len(test_cells), np.nan, dtype=np.float64)
for i, (cell, cond) in enumerate(zip(test_cells, test_treatments)):
drug, dose = _parse_condition(cond)
if drug is None:
continue
v = primary.get((cell, drug, dose))
if v is not None:
pred[i] = v
valid = np.isfinite(pred)
return pred, np.asarray(y_test, dtype=np.float64), valid
def _adjacent_dose_pred(test_cells, test_treatments, y_test):
"""Predict each test row's growth_rate from the nearest other dose of the
SAME (cell, drug), measured in log-dose space, using that neighbour's
growth_rate verbatim.
Held-out splits hold out whole drugs within held-out cells, so the entire
dose-response curve of a held-out (cell, drug) lands in the test set
(median 8 doses, up to 16). This is therefore an oracle / titration noise
ceiling — like the a_vs_b ceiling it reads held-out labels — answering "if
you already knew the adjacent dose on this curve, how well would copying it
do?". valid_mask flags rows that have at least one same-(cell, drug)
neighbour (essentially all of them; singletons have none).
Returns (pred, y_truth, valid_mask).
"""
y_test = np.asarray(y_test, dtype=np.float64)
pred = np.full(len(test_cells), np.nan, dtype=np.float64)
# Group row indices by (cell, drug); doses on the same curve are neighbours.
groups = {}
for i, (cell, cond) in enumerate(zip(test_cells, test_treatments)):
drug, dose = _parse_condition(cond)
if drug is None or not np.isfinite(y_test[i]):
continue
groups.setdefault((cell, drug), []).append((dose, i))
for members in groups.values():
if len(members) < 2:
continue
members.sort(key=lambda t: t[0])
doses = np.array([d for d, _ in members], dtype=np.float64)
idxs = [i for _, i in members]
log_doses = np.log(doses) # secondary doses are strictly positive
for j in range(len(members)):
# nearest neighbour in log-dose, excluding self
gaps = np.abs(log_doses - log_doses[j])
gaps[j] = np.inf
nn = int(np.argmin(gaps))
pred[idxs[j]] = y_test[idxs[nn]]
valid = np.isfinite(pred)
return pred, y_test, valid
def compute_baselines(split_name, rank, iters):
data = prepare_sensitivity.prepare_all(split_name)
cell_to_idx = data["cell_to_idx"]
treat_to_idx = data["treat_to_idx"]
n_cell = data["n_cells"]
n_treat = data["n_treatments"]
train_c = np.array([cell_to_idx[c] for c in data["train_cells"]])
train_t = np.array([treat_to_idx[t] for t in data["train_treatments"]])
test_c = np.array([cell_to_idx[c] for c in data["test_cells"]])
test_t = np.array([treat_to_idx[t] for t in data["test_treatments"]])
y_train = data["y_train"]
y_test = data["y_test"]
mu, cell_eff, treat_eff = _als_decomposition(
y_train, train_c, train_t, n_cell, n_treat, n_iter=iters
)
additive_test = mu + cell_eff[test_c] + treat_eff[test_t]
# Residual matrix on train mask
R_obs = np.zeros((n_cell, n_treat), dtype=np.float64)
mask = np.zeros((n_cell, n_treat), dtype=bool)
additive_train = mu + cell_eff[train_c] + treat_eff[train_t]
R_obs[train_c, train_t] = y_train - additive_train
mask[train_c, train_t] = True
R_completed = _svd_completion(R_obs, mask, rank=rank, n_iter=iters)
svd_test = additive_test + R_completed[test_c, test_t]
preds = {
"global_mean": np.full_like(y_test, mu),
"cell_mean": mu + cell_eff[test_c],
"treatment_mean": mu + treat_eff[test_t],
"additive": additive_test,
"svd_residual": svd_test,
}
return preds, y_test, data
def _metrics(y_pred, y_true):
return prepare_sensitivity.compute_metrics(y_pred, y_true)
def main():
p = argparse.ArgumentParser()
p.add_argument("--split", default="EmeraldBay/split_0")
p.add_argument("--rank", type=int, default=int(os.environ.get("HP_SVD_RANK", "5")))
p.add_argument("--iters", type=int, default=int(os.environ.get("HP_SVD_ITERS", "30")))
p.add_argument("--wandb", action="store_true", help="Opt in to WandB logging")
args = p.parse_args()
t0 = time.time()
preds, y_test, data = compute_baselines(args.split, rank=args.rank, iters=args.iters)
n_train = data["y_train"].size
n_test = y_test.size
def _row(name, m, n_test_used):
return {
"baseline": name,
"split": args.split,
"mse": m["sensitivity/mse"],
"mae": m["sensitivity/mae"],
"r2": m["sensitivity/r2"],
"pearson": m["sensitivity/pearson"],
"n_train": int(n_train),
"n_test": int(n_test_used),
"rank": args.rank,
"iters": args.iters,
}
print()
print(f"Split: {args.split} | n_train={n_train} n_test={n_test} rank={args.rank} iters={args.iters}")
print(f"y_test mean={float(y_test.mean()):+.4f} std={float(y_test.std()):.4f}")
print()
print(f"{'baseline':<16} {'MSE':>10} {'MAE':>10} {'R^2':>10} {'Pearson':>10}")
print("-" * 64)
rows = []
for name in BASELINE_NAMES:
m = _metrics(preds[name], y_test)
print(f"{name:<16} {m['sensitivity/mse']:>10.4f} {m['sensitivity/mae']:>10.4f} "
f"{m['sensitivity/r2']:>10.4f} {m['sensitivity/pearson']:>10.4f}")
rows.append(_row(name, m, n_test))
# A-vs-B noise ceiling — only when the dataset declares A/B variants.
dataset, _ = prepare_sensitivity.parse_split_name(args.split)
if prepare_sensitivity.has_variants(dataset):
a_vals, b_vals, valid = _a_vs_b_pairs(dataset, data["test_cells"], data["test_treatments"])
n_pairs = int(valid.sum())
if n_pairs == 0:
print(f"{A_VS_B_BASELINE:<16} (no valid A/B pairs on test set)")
else:
m = _metrics(a_vals[valid], b_vals[valid])
print(f"{A_VS_B_BASELINE:<16} {m['sensitivity/mse']:>10.4f} {m['sensitivity/mae']:>10.4f} "
f"{m['sensitivity/r2']:>10.4f} {m['sensitivity/pearson']:>10.4f} "
f"(n_pairs={n_pairs}/{n_test})")
rows.append(_row(A_VS_B_BASELINE, m, n_pairs))
# Primary-screen reference — only when the dataset declares an external primary screen.
if prepare_sensitivity.has_primary_screen(dataset):
p_pred, y_truth, valid = _primary_to_secondary_pred(
dataset, data["test_cells"], data["test_treatments"], y_test,
)
n_pairs = int(valid.sum())
if n_pairs == 0:
print(f"{PRIMARY_BASELINE:<16} (no valid primary matches on test set)")
else:
m = _metrics(p_pred[valid], y_truth[valid])
print(f"{PRIMARY_BASELINE:<16} {m['sensitivity/mse']:>10.4f} {m['sensitivity/mae']:>10.4f} "
f"{m['sensitivity/r2']:>10.4f} {m['sensitivity/pearson']:>10.4f} "
f"(n_pairs={n_pairs}/{n_test})")
rows.append(_row(PRIMARY_BASELINE, m, n_pairs))
# Adjacent-dose ceiling — copy the nearest dose on the same (cell, drug)
# curve. Self-skips when no (cell, drug) has >=2 test doses.
a_pred, y_truth, valid = _adjacent_dose_pred(
data["test_cells"], data["test_treatments"], y_test,
)
n_pairs = int(valid.sum())
if n_pairs == 0:
print(f"{ADJACENT_BASELINE:<16} (no multi-dose (cell, drug) groups on test set)")
else:
m = _metrics(a_pred[valid], y_truth[valid])
print(f"{ADJACENT_BASELINE:<16} {m['sensitivity/mse']:>10.4f} {m['sensitivity/mae']:>10.4f} "
f"{m['sensitivity/r2']:>10.4f} {m['sensitivity/pearson']:>10.4f} "
f"(n_pairs={n_pairs}/{n_test})")
rows.append(_row(ADJACENT_BASELINE, m, n_pairs))
runtime = time.time() - t0
for r in rows:
r["runtime_seconds"] = round(runtime, 3)
prepare_sensitivity.log_result(r)
print()
print(f"Wrote {len(rows)} rows to {prepare_sensitivity.RESULTS_FILE} (runtime {runtime:.2f}s)")
if args.wandb:
import wandb
for r in rows:
wandb.init(
project="rhaister-sensitivity",
name=f"{r['baseline']}_{args.split.replace('/', '_')}",
config={k: v for k, v in r.items() if k != "baseline"},
reinit=True,
)
wandb.log({
"sensitivity/mse": r["mse"],
"sensitivity/mae": r["mae"],
"sensitivity/r2": r["r2"],
"sensitivity/pearson": r["pearson"],
})
wandb.finish()
if __name__ == "__main__":
main()