File size: 15,870 Bytes
3f98d52 | 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 | """Calibration, retrospective evaluation, and catalog generation with locked inputs."""
from pathlib import Path
import json
import time
import joblib
import numpy as np
import pandas as pd
from .io import write_json, file_hash
from .models import check_feature_space, predict
from .uncertainty import target_draws, joint_losses, cross_block_losses, panel_score, calibration_radius
from .optimization import solve_regret, generate_baseline
from .metrics import intervention_metrics, grouped_summary, paired_difference
from .chemistry import verify_catalog, fingerprints, max_tanimoto
BASELINES = ["uniform", "mean", "gibbs", "marginal", "absolute", "thompson", "finite-minimax"]
def action_groups(data, indices, min_blocks=1):
rows, groups = [], []
for _, g in data.obs.loc[indices].groupby(["molecule_id", "dose_um", "context"], sort=True):
g = g.sort_values("block").drop_duplicates("block")
if len(g) < min_blocks: continue
rows.append(g.iloc[0][["molecule_id", "smiles", "dose_um", "context"]].to_dict())
groups.append(g.index.to_list())
return pd.DataFrame(rows), groups
def calibrate(data, model, output, coverage=.9, panel_molecules=6, scenarios=32, seed=0,
ablation="none", embeddings=None):
"""Estimate beta from held-out compound panels with two measurement blocks.
Pool repeated contexts by their maximum panel score within each compound
group. Save the panel scores and the associated model identity.
"""
check_feature_space(model, data)
obs = data.obs
cal = obs[obs.split == "calibration"]
molecules = np.sort(cal.molecule_id.unique())
if set(molecules) & set(model["train_molecules"]): raise ValueError("Training/calibration leakage")
if len(molecules) < 3: raise ValueError("Calibration needs at least three held-out molecules")
rng = np.random.default_rng(seed)
rng.shuffle(molecules)
records, skips = [], []
# A group is disjoint in molecule identity; repeated contexts contribute one maximum score.
for group_id, start in enumerate(range(0, len(molecules), panel_molecules)):
group = molecules[start:start+panel_molecules]
if len(group) < 3: continue
for context in sorted(cal.context.unique()):
panel = cal[(cal.molecule_id.isin(group)) & (cal.context == context)]
if panel.molecule_id.nunique() < 3: continue
target_molecule = panel.molecule_id.iloc[0]
target_rows = panel[panel.molecule_id == target_molecule]
# One dose per group/context prevents dependent target-dose panels being counted independently.
available_doses = np.sort(target_rows.dose_um.unique())
dose = available_doses[len(available_doses)//2]
target_rows = target_rows[target_rows.dose_um == dose].sort_values("block").drop_duplicates("block")
actions, groups = action_groups(data, panel[panel.molecule_id != target_molecule].index, 2)
if len(actions) < 2 or len(target_rows) < 2: continue
try:
observed = cross_block_losses(data, groups, target_rows.index.to_list(), model["weight"])
except ValueError as e:
skips.append({"group": group_id, "context": context, "reason": str(e)}); continue
targets, controls = target_draws(data, target_rows.index[:2], scenarios, rng)
joint = joint_losses(model, actions, targets, controls, seed+group_id,
embeddings, floor=ablation != "no-floor", ablation=ablation)
records.append({"group": group_id, "context": context, "score": panel_score(joint, observed),
"actions": len(actions), "target_molecule": target_molecule})
if not records:
raise ValueError("No calibration panel has independent target/candidate blocks and distinct control pools")
frame = pd.DataFrame(records)
scores = frame.groupby("group").score.max().to_numpy()
beta = calibration_radius(scores, coverage)
output = Path(output); output.mkdir(parents=True, exist_ok=True)
frame.to_csv(output/"calibration_panels.csv", index=False)
result = {"beta": beta, "coverage": coverage, "independent_groups": len(scores),
"scores": scores.tolist(), "ablation": ablation, "seed": seed,
"calibration_molecules": molecules.tolist(), "feature_space_id": model["feature_space_id"],
"model_training_molecules": model["train_molecules"], "model_digest": joblib.hash(model),
"status": "empirical_panel_quantile",
"latent_ellipsoid_coverage_guaranteed": False, "skipped": skips}
write_json(output/"calibration.json", result)
return result
def evaluate(data, model, calibration, output, split="test", scenarios=32, tau=.05,
tolerance=1., seed=0, max_queries=30, methods=None, embeddings=None,
metric_data=None, ablation="none", external_track="none"):
"""Evaluate action distributions against separate measured response blocks.
Each query excludes its source molecule at every dose from the candidate
pool. Return per-query metrics and save probabilities and grouped summaries.
"""
check_feature_space(model, data)
held = data.obs[data.obs.split == split]
protected = set(model["train_molecules"]) | set(calibration.get("calibration_molecules", []))
if external_track == "none" and set(held.molecule_id) & protected:
raise ValueError("Evaluation molecules overlap training or calibration")
if external_track != "none":
source = data.metadata.get("study")
if not source or not model.get("study") or source == model["study"]:
raise ValueError("External transfer requires distinct recorded study identifiers")
overlap = set(held.molecule_id) & set(model["train_molecules"])
if external_track == "unseen" and overlap:
raise ValueError("Unseen external track includes trained molecular structures")
if external_track == "shared" and set(held.molecule_id) != overlap:
raise ValueError("Shared external track includes structures absent from response-model training")
if calibration.get("model_digest") != joblib.hash(model):
raise ValueError("Calibration was produced by a different fitted model")
if calibration.get("feature_space_id") != model["feature_space_id"]:
raise ValueError("Calibration feature space differs from model")
methods = methods or BASELINES+["remedi"]
metric_data = data if metric_data is None else metric_data
keys = ["molecule_id", "dose_um", "context", "block", "control_id"]
if not data.obs[keys].equals(metric_data.obs[keys]): raise ValueError("Evaluation representation must preserve condition alignment")
if metric_data is data: weight = model["weight"]
else:
train = metric_data.response[metric_data.obs.split.to_numpy() == "train"]
if not len(train): raise ValueError("Independent evaluation space needs training-derived scaling")
weight = 1/np.maximum(train.var(0), 1e-6); weight /= weight.sum()
rng = np.random.default_rng(seed)
query_groups = list(held.groupby(["molecule_id", "dose_um", "context"], sort=True))
rng.shuffle(query_groups)
rows, choices, skipped = [], [], []
query_count = 0
for key, target in query_groups:
if query_count >= max_queries: break
target = target.sort_values("block").drop_duplicates("block")
if len(target) < 2: continue
query_idx, eval_idx = int(target.index[0]), int(target.index[1])
if target.iloc[0].control_id == target.iloc[1].control_id:
skipped.append({"target": key, "reason": "query/evaluation controls shared"}); continue
pool = held[(held.context == key[2]) & (held.molecule_id != key[0])]
pool = pool[pool.control_id != data.obs.iloc[query_idx].control_id]
actions, groups = action_groups(data, pool.index)
if len(actions) < 2: continue
# All candidate responses below remain evaluation-only; no observed value enters joint_losses.
targets, controls = target_draws(data, [query_idx], scenarios, rng,
sampling=ablation != "no-target-sampling")
joint = joint_losses(model, actions, targets, controls, seed+query_count,
embeddings, floor=ablation != "no-floor", ablation=ablation)
observed = np.array([np.sum((metric_data.response[g].mean(0)-metric_data.response[eval_idx])**2*weight) for g in groups])
for name in methods:
begin = time.perf_counter()
diagnostics = {}
if name == "remedi":
decision = solve_regret(joint.mean, joint.factor, joint.diagonal,
calibration["beta"], tau)
p = decision.probabilities
diagnostics = {"solver_status": decision.status, "solver_violation": decision.constraint_violation,
"estimated_worst_regret": decision.worst_regret}
else:
p = generate_baseline(name, joint.mean, joint.factor, joint.diagonal, joint.scenarios,
calibration["beta"], tau, seed+query_count)
scores = intervention_metrics(p, observed, tolerance, seed+query_count)
molecule_p = pd.Series(p).groupby(actions.molecule_id.to_numpy()).sum().to_numpy()
scores["unique_molecules_10"] = float(np.sum(1-(1-molecule_p)**10))
dose_regret = np.zeros(len(actions))
for _, g in actions.groupby("molecule_id"):
ii = g.index.to_numpy(); dose_regret[ii] = observed[ii]-observed[ii].min()
row = {"query_id": query_count, "target_molecule": key[0], "target_dose_um": key[1],
"context": key[2], "method": name, "ablation": ablation, "candidates": len(actions),
"dose_regret": float(p@dose_regret), "seconds": time.perf_counter()-begin,
**scores, **diagnostics}
rows.append(row)
for i in np.flatnonzero(p > 1e-8):
choices.append({"query_id": query_count, "method": name, **actions.iloc[i].to_dict(),
"probability": float(p[i]), "observed_loss": float(observed[i])})
query_count += 1
if not rows: raise ValueError("No independent query/evaluation pairs with eligible alternatives")
output = Path(output); output.mkdir(parents=True, exist_ok=True)
frame = pd.DataFrame(rows); frame.to_csv(output/"per_query.csv", index=False)
pd.DataFrame(choices).to_csv(output/"action_probabilities.csv", index=False)
summary = pd.concat([grouped_summary(frame, m, seed=seed) for m in ["endpoint_loss", "regret", "success", "best_of_10", "dose_regret", "entropy", "unique_molecules_10"]])
summary.to_csv(output/"summary.csv", index=False)
differences = [paired_difference(frame, "remedi", n, seed=seed) for n in methods if n != "remedi"] if "remedi" in methods else []
write_json(output/"evaluation.json", {"queries": query_count, "target_molecules": frame.target_molecule.nunique(),
"candidate_split": split, "external_track": external_track, "seed": seed, "tau": tau, "tolerance": tolerance,
"beta": calibration["beta"], "scenarios": scenarios, "paired_differences": differences,
"metric_space": metric_data.metadata.get("cell_features"), "skipped": skipped,
"biological_validation": "retrospective measured endpoints; prototype unless complete registered benchmark"})
return frame
def generate_catalog(data, model, calibration, catalog, target_indices, output, doses=(.05,.5,5.),
scenarios=32, tau=.05, shortlist=512, draws=20, seed=0, embeddings=None):
"""Sample catalog IDs, structures, and concentrations for one target endpoint.
target_indices identify replicates of one condition in data.obs. Expand
catalog records over doses in uM, shortlist if needed, then optimize and
save the distribution together with the sampled molecule-dose pairs.
"""
check_feature_space(model, data)
if calibration.get("model_digest") != joblib.hash(model):
raise ValueError("Calibration was produced by a different fitted model")
if not target_indices or min(target_indices) < 0 or max(target_indices) >= len(data.obs):
raise ValueError("Target rows must be valid condition indices")
query = data.obs.iloc[target_indices]
if len(query[["molecule_id", "dose_um", "context"]].drop_duplicates()) != 1:
raise ValueError("Target rows must describe one molecule, dose, and context")
if shortlist < 2 or draws < 1 or scenarios < 2:
raise ValueError("Require shortlist >= 2, draws >= 1, and scenarios >= 2")
catalog = verify_catalog(catalog)
if not len(catalog): raise ValueError("Verified catalog is empty")
doses = np.asarray(doses, float)
if not np.isfinite(doses).all() or (doses <= 0).any(): raise ValueError("Doses must be finite positive values")
actions = catalog.merge(pd.DataFrame({"dose_um": np.unique(doses)}), how="cross")
targets, controls = target_draws(data, target_indices, scenarios, np.random.default_rng(seed))
source_count = len(actions)
# Shared bounded shortlist. This stage is explicitly outside the finite-pool regret guarantee.
if len(actions) > shortlist:
mean_target, mean_control = targets.mean(0), controls.mean(0)
rough = []
for start in range(0, len(actions), 1024):
a = actions.iloc[start:start+1024]
f = predict(model, a.smiles, a.dose_um, np.repeat(mean_control[None, :], len(a), axis=0), embeddings).mean(0)
rough.extend(((f-mean_target)**2*model["weight"]).sum(1))
rank = np.argsort(rough, kind="stable")
low = rank[:max(1, shortlist*3//4)]
remaining = np.setdiff1d(np.arange(len(actions)), low)
extra = []
candidate_fp = fingerprints(actions.iloc[remaining].smiles)
similarity = max_tanimoto(candidate_fp, fingerprints(actions.iloc[low].smiles))
for _ in range(min(shortlist-len(low), len(remaining))):
j = int(np.argmin(similarity))
extra.append(remaining[j])
similarity = np.maximum(similarity, max_tanimoto(candidate_fp, candidate_fp[j:j+1]))
similarity[j] = np.inf
extra = np.asarray(extra, dtype=int)
actions = actions.iloc[np.concatenate([low, extra])].reset_index(drop=True)
joint = joint_losses(model, actions, targets, controls, seed, embeddings)
result = solve_regret(joint.mean, joint.factor, joint.diagonal, calibration["beta"], tau)
frame = actions.copy(); frame["probability"] = result.probabilities; frame["predicted_loss"] = joint.mean
frame["chemical_distance"] = 1-max_tanimoto(fingerprints(frame.smiles), model["train_fingerprints"])
selected = np.random.default_rng(seed).choice(len(frame), draws, p=result.probabilities)
output = Path(output); output.mkdir(parents=True, exist_ok=True)
frame.to_csv(output/"distribution.csv", index=False)
frame.iloc[selected].to_csv(output/"generated_pairs.csv", index=False)
write_json(output/"generation.json", {"source_actions": source_count, "shortlist_actions": len(frame),
"catalog_membership": "exact canonical structure match to supplied verified records",
"seconds": result.seconds, "solver_status": result.status, "constraint_violation": result.constraint_violation,
"estimated_worst_regret": result.worst_regret, "biological_effect_measured": False,
"dose_um": doses.tolist(), "seed": seed, "tau": tau, "beta": calibration["beta"]})
return frame
|