| """Summarize generated candidates and recorded routes using a common schema.""" |
|
|
| import argparse |
| import json |
| from pathlib import Path |
| import numpy as np |
| import pandas as pd |
| from rdkit import Chem, DataStructs |
| from rdkit.Chem import QED, rdFingerprintGenerator |
| from dooable.chemistry import canonical, replay |
| from dooable.metrics import hypervolume_2d |
|
|
|
|
| def main(): |
| p = argparse.ArgumentParser() |
| p.add_argument("--samples", required=True) |
| p.add_argument("--scores", required=True) |
| p.add_argument("--budget", required=True, type=int) |
| p.add_argument("--requested", required=True, type=int) |
| p.add_argument("--oracle-calls", required=True, type=int) |
| p.add_argument("--method", required=True) |
| p.add_argument("--seed", type=int, default=0) |
| p.add_argument("--target", default="BACE_public") |
| p.add_argument("--output", required=True) |
| args = p.parse_args() |
| rows = [ |
| json.loads(x) for x in Path(args.samples).read_text().splitlines() if x.strip() |
| ] |
| if args.requested < len(rows) or args.requested < 1: |
| raise ValueError( |
| "Requested count must be positive and at least the output count" |
| ) |
| scores = pd.read_csv(args.scores) |
| scores["smiles"] = scores.smiles.map(canonical) |
| if scores.smiles.duplicated().any(): |
| raise ValueError("Score rows must be unique by canonical structure") |
| required = [ |
| "smiles", |
| "bace_utility", |
| "caco2_utility", |
| "predicted_bace_pIC50", |
| "predicted_caco2_log10_cm_s", |
| ] |
| if any(x not in scores for x in required): |
| raise ValueError("Score CSV must contain the public property-score schema") |
| feasible = [r for r in rows if replay(r, args.budget)] |
| molecules = sorted({canonical(r["outcome"]) for r in feasible}) |
| measured = scores.set_index("smiles").reindex(molecules) |
| if measured[required[1:]].isna().any().any(): |
| raise ValueError("Every feasible outcome requires finite recorded scores") |
| measured["reward"] = 0.5 * (measured.bace_utility + measured.caco2_utility) |
| top = measured.sort_values("reward", ascending=False).head(100) |
| generator = rdFingerprintGenerator.GetMorganGenerator(radius=2, fpSize=1024) |
| fps = [generator.GetFingerprint(Chem.MolFromSmiles(s)) for s in top.index] |
| distances = [ |
| 1 - DataStructs.TanimotoSimilarity(fps[i], fps[j]) |
| for i in range(len(fps)) |
| for j in range(i) |
| ] |
| result = { |
| "method": args.method, |
| "target": args.target, |
| "seed": args.seed, |
| "budget": args.budget, |
| "requested": args.requested, |
| "returned": len(rows), |
| "oracle_calls": args.oracle_calls, |
| "replay_fraction": len(feasible) / args.requested, |
| "unique_feasible": len(molecules), |
| "top_count": len(top), |
| "mean_reaction_steps": ( |
| float( |
| np.mean( |
| [ |
| sum(a.get("kind") == "reaction" for a in r["actions"]) |
| for r in feasible |
| ] |
| ) |
| ) |
| if feasible |
| else None |
| ), |
| "top_mean_bace_pIC50": ( |
| float(top.predicted_bace_pIC50.mean()) if len(top) else None |
| ), |
| "top_mean_caco2_log10_cm_s": ( |
| float(top.predicted_caco2_log10_cm_s.mean()) if len(top) else None |
| ), |
| "top_mean_qed": ( |
| float(np.mean([QED.qed(Chem.MolFromSmiles(s)) for s in top.index])) |
| if len(top) |
| else None |
| ), |
| "top_internal_diversity": float(np.mean(distances)) if distances else None, |
| "hypervolume": hypervolume_2d( |
| measured[["bace_utility", "caco2_utility"]].to_numpy() |
| ), |
| } |
| out = Path(args.output) |
| out.parent.mkdir(parents=True, exist_ok=True) |
| out.write_text(json.dumps(result, indent=2, allow_nan=False)) |
| print(json.dumps(result, allow_nan=False)) |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|