""" DeFoG guided PROTAC linker generation. Local-edit loop: each step picks one graph position (atom or edge pair), queries DeFoG for its predicted clean distribution, tries all candidate types, and commits the best. Improvements persist: every step builds on all prior commits. Objectives (fast_scoring layout): PROTACClassifier is the hard validity gate (structural checks + alert mask + >=2 SMARTS sites). It drives the validity mask and is NOT a term in the weighted sum, so it takes no weight. Soft scorers, in --weights positional order (paper Eq. 6): [0] PROTACLinkerSAScorer — S1 synthetic accessibility in [0,1] w=4.0 [1] PROTACLinkerChemScorer — S2 logP/MW/RotB/ether chemotype mean w=2.0 [2] PROTACLinkerConnectivityScorer — S3 connectivity (always runs) w=4.0 PreassembledPROTACScorer (Morgan-FP linker quality) was dropped after the weight ablation (paper Appendix J.5) showed that raising its weight lowered both assembly validity and DegradeMaster score. Aggregated score = Σ weights[k] · soft_scores[k]. Candidates rejected by the validity gate are masked to zero probability before exponential tilting. """ import argparse import os import sys import json import csv from pathlib import Path import random from datetime import datetime from enum import Enum from typing import Optional, Tuple, Dict import numpy as np import torch import torch.nn.functional as F from tqdm import tqdm # Add DeFoG to path so its modules resolve at import time. # DeFoG's internal code does `from flow_matching import ...` (needs src/defog/src) # and also `from src import utils` (needs src/defog/, so `src` resolves to defog's src). _DEFOG_ROOT = os.path.normpath( os.path.join(os.path.dirname(os.path.abspath(__file__)), "../src/defog") ) _DEFOG_SRC = os.path.join(_DEFOG_ROOT, "src") for _p in (_DEFOG_ROOT, _DEFOG_SRC): if _p not in sys.path: sys.path.insert(0, _p) # Add PROTAC sources (classifier + builders) to path. # _REPO_SRC itself lets us do `from protac. import ...`, # and the subdirs let us do `from protac_classifier import ...`, etc. _REPO_SRC = os.path.normpath( os.path.join(os.path.dirname(os.path.abspath(__file__)), "../src") ) if _REPO_SRC not in sys.path: sys.path.insert(0, _REPO_SRC) for _sub in ("classifier", "protac"): _p = os.path.join(_REPO_SRC, _sub) if _p not in sys.path: sys.path.insert(0, _p) from flow_matching import flow_matching_utils # noqa: E402 from utils import PlaceHolder # noqa: E402 class GuidanceMethod(Enum): BEST_OF_K = "bok" EXPONENTIAL_TILT = "exp_tilt" HYBRID = "hybrid" # --------------------------------------------------------------------------- # Generation tracker (same structure as cupofmoca_guided.py) # --------------------------------------------------------------------------- class GenerationTracker: def __init__(self, output_dir="./generation_logs"): self.output_dir = output_dir os.makedirs(output_dir, exist_ok=True) self.run_id = datetime.now().strftime("%Y%m%d_%H%M%S") self.step_logs = [] self.final_results = [] def log_step(self, step: int, scores, detailed_results=None, improved_indices=None, guidance_info: Optional[Dict] = None): step_info = { "step": step, "scores": scores.cpu().numpy().tolist() if isinstance(scores, torch.Tensor) else scores, "num_valid": int((scores > 0).sum()) if hasattr(scores, "__iter__") else 0, "improved_indices": improved_indices or [], "guidance_info": guidance_info or {}, } self.step_logs.append(step_info) def log_final(self, molecule_list, detailed_results): for i, (_, result) in enumerate(zip(molecule_list, detailed_results)): self.final_results.append({ "sample_idx": i, "valid": result.get("valid", 0), "smiles": result.get("smiles", ""), "checks": result.get("checks", {}), "properties": result.get("properties", {}), }) def save(self, filename_prefix="defog_guided"): step_path = os.path.join(self.output_dir, f"{filename_prefix}_{self.run_id}_steps.json") with open(step_path, "w") as f: json.dump(self.step_logs, f, indent=2, default=str) final_path = os.path.join(self.output_dir, f"{filename_prefix}_{self.run_id}_final.json") with open(final_path, "w") as f: json.dump(self.final_results, f, indent=2, default=str) csv_path = os.path.join(self.output_dir, f"{filename_prefix}_{self.run_id}_summary.csv") with open(csv_path, "w", newline="") as f: writer = csv.writer(f) writer.writerow(["sample_idx", "valid", "smiles", "mw", "logp", "tpsa", "hbd", "rotbonds"]) for r in self.final_results: props = r.get("properties", {}) writer.writerow([ r["sample_idx"], r["valid"], r["smiles"], props.get("mw", ""), props.get("xlogp", ""), props.get("polararea", ""), props.get("hbonddonor", ""), props.get("rotbonds", ""), ]) print(f"\n=== Generation logs saved ===") print(f" Step logs: {step_path}") print(f" Final: {final_path}") print(f" CSV: {csv_path}") return step_path, final_path, csv_path def print_step_summary(self, step, scores, detailed_results=None, guidance_method=None): scores_list = scores.cpu().numpy().tolist() if isinstance(scores, torch.Tensor) else list(scores) # PROTACClassifier returns binary 0/1: 1 = passed all PROTACAnalyzer structural # checks + alert mask + has ≥2 SMARTS attachment sites; 0 = failed any. # Final assembly (actual RDKit reaction trial) is checked separately at the end. num_valid = sum(1 for s in scores_list if s > 0) print(f"\n--- Step {step} ({guidance_method or 'unknown'}) --- classifier-valid: {num_valid}/{len(scores_list)}") def print_final_summary(self): total = len(self.final_results) num_valid = sum(1 for r in self.final_results if r.get("valid", 0) > 0) print("\n" + "=" * 60) print("FINAL GENERATION SUMMARY") print("=" * 60) print(f"Total molecules: {total} Valid: {num_valid}") for r in self.final_results: tag = "✓" if r.get("valid") == 1 else "✗" smi = r.get("smiles", "") or "INVALID (sanitization failed)" print(f" [{tag}] {r['sample_idx']:3d} {smi[:60]}") # --------------------------------------------------------------------------- # DeFoGGenerator (mirrors CupOfMoCAGenerator) # --------------------------------------------------------------------------- class DeFoGGenerator: """ DeFoG-based guided PROTAC linker generator. Parameters ---------- model : GraphDiscreteFlowModel Pre-loaded DeFoG model (pytorch_lightning module). atom_decoder : list[str] Maps atom-type index → element symbol (from dataset_infos.atom_decoder). objectives : list Soft scorers used in the weighted sum S(z). Final entry is treated as the connectivity scorer (always runs even when validity_fn = 0). validity_fn : callable | None Binary 0/1 hard gate (PROTACClassifier). Output drives the validity mask in exp_tilt sampling. Does not contribute to S(z). args : argparse.Namespace tracker : GenerationTracker | None """ def __init__(self, model, atom_decoder: list, objectives: list, args, validity_fn=None, tracker=None): self.model = model self.atom_decoder = atom_decoder self.objectives = objectives self.validity_fn = validity_fn self.args = args self.num_objectives = len(objectives) self.tracker = tracker self.device = next(model.parameters()).device self.guidance_method = GuidanceMethod(args.guidance_method) # Vocab mask: restrict to atom types seen in PROTAC linker training data. # Zeroes out I, P, Cl, Br from pred_X before guidance — model can never pick them. _LINKER_ATOMS = {"C", "N", "O", "S", "F"} self.vocab_mask = torch.tensor( [1.0 if sym in _LINKER_ATOMS else 0.0 for sym in atom_decoder], device=self.device, ) # [d_X] self.sample_T = getattr(args, 'num_steps', None) or model.sample_T self.node_dist = model.node_dist self.time_distorter = model.time_distorter # ------------------------------------------------------------------------- # Initial graph generation (x0 from prior μ₀) # ------------------------------------------------------------------------- def _generate_single_x0(self) -> Tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]: """ Sample initial noisy graphs from the limit distribution. Returns X, E, y, node_mask — all on self.device. """ batch_size = self.args.num_samples # Sample number of nodes per graph if self.args.num_nodes is not None: n_nodes = self.args.num_nodes * torch.ones( batch_size, device=self.device, dtype=torch.int ) else: n_nodes = self.node_dist.sample_n(batch_size, self.device) # Clamp to PROTAC linker atom range n_nodes = n_nodes.clamp(self.args.min_nodes, self.args.max_nodes) n_max = n_nodes.max().item() arange = torch.arange(n_max, device=self.device).unsqueeze(0).expand(batch_size, -1) node_mask = arange < n_nodes.unsqueeze(1) z_T = flow_matching_utils.sample_discrete_feature_noise( limit_dist=self.model.limit_dist, node_mask=node_mask ) X, E, y = z_T.X, z_T.E, z_T.y return X, E, y, node_mask, n_nodes # Per-type terminus geometry configs. # 'cooh' — two COOH groups at nodes 0-5; anchor carbons at 0 and 3 # 'methyl' — CH3(0)–CH2(1)–[generated]–CH2(2)–CH3(3) scaffold; # terminal CH3s (0, 3) bond only to their inner CH2 anchor _TERMINUS_CONFIGS = { 'cooh': { 'n_terminus_nodes': 6, 'min_total_nodes': 8, # 6 terminus + at least 2 free bridge atoms 'anchor_nodes': (0, 3), 'fixed_atom_pos': frozenset({0, 1, 2, 3, 4, 5}), 'fixed_o_nodes': frozenset({1, 2, 4, 5}), 'fixed_edge_pos': frozenset({ (0, 1), (1, 0), (0, 2), (2, 0), # terminus-1: C=O, C-OH (3, 4), (4, 3), (3, 5), (5, 3), # terminus-2: C=O, C-OH (0, 3), (3, 0), # no direct C–C between anchor atoms }), }, 'methyl': { 'n_terminus_nodes': 4, 'min_total_nodes': 6, # 4 terminus + at least 2 free bridge atoms 'anchor_nodes': (1, 2), 'fixed_atom_pos': frozenset({0, 1, 2, 3}), 'fixed_o_nodes': frozenset({0, 3}), # terminal CH3s — bond only to their CH2 'fixed_edge_pos': frozenset({ (0, 1), (1, 0), # CH3–CH2 bond at warhead end (2, 3), (3, 2), # CH2–CH3 bond at E3 end (1, 2), (2, 1), # no direct anchor–anchor bond (force bridge through free atoms) }), }, 'amine': { 'n_terminus_nodes': 2, 'min_total_nodes': 4, # 2 terminus N + at least 2 free bridge atoms 'anchor_nodes': (0, 1), 'fixed_atom_pos': frozenset({0, 1}), 'fixed_o_nodes': frozenset(), # no O nodes to isolate 'fixed_edge_pos': frozenset({ (0, 1), (1, 0), # no direct N–N bond between the two terminus atoms }), }, 'amino_acid': { # Asymmetric: N(0) on warhead end, COOH(1-3) on VHL end. # Used for BRD4-VHL and any PROTAC where warhead provides COOH # and E3 provides NH2 (so linker must bridge with amine + acid termini). 'n_terminus_nodes': 4, 'min_total_nodes': 6, # 4 terminus + at least 2 free bridge atoms 'anchor_nodes': (0, 1), 'fixed_atom_pos': frozenset({0, 1, 2, 3}), 'fixed_o_nodes': frozenset({2, 3}), # O atoms of the COOH group 'fixed_edge_pos': frozenset({ (1, 2), (2, 1), # C=O (double, carbonyl) (1, 3), (3, 1), # C-OH (single, hydroxyl) (0, 1), (1, 0), # no direct N–C(cooh) bond between anchors }), }, } @staticmethod def _limit_anchor_bonds(E: torch.Tensor, i: int, n: int, anchor: int, blocked: set, max_extra: int = 1) -> None: """Cap the number of bonds from `anchor` to free atoms (in-place on E). For atoms outside the pinned `blocked` set, count bonds and zero out all but the first `max_extra` of them. Keeps the lowest-indexed bond (deterministic). Prevents the anchor from being absorbed into a ring (e.g., the pinned NH2 nitrogen ending up as part of a pyrazole) which would break the terminal-NH2 / single-bridge-bond invariant required for amide-bond assembly with the warhead/E3. """ free_bonds = [nb for nb in range(n) if nb != anchor and nb not in blocked and E[i, anchor, nb].argmax().item() > 0] if len(free_bonds) > max_extra: for nb in free_bonds[max_extra:]: E[i, anchor, nb] = 0.0; E[i, anchor, nb, 0] = 1.0 E[i, nb, anchor] = 0.0; E[i, nb, anchor, 0] = 1.0 def _fix_termini(self, X: torch.Tensor, E: torch.Tensor, n_nodes: torch.Tensor) -> Tuple[torch.Tensor, torch.Tensor]: """Hard-pin terminus groups after every step so guidance can never revert them. 'cooh' — nodes 0-5: two COOH groups (carbonyl-C + C=O + C-OH each). Anchor carbons (0, 3) connect freely to bridge atoms (6+). 'methyl' — nodes 0-3: CH3(0)–CH2(1) ... CH2(2)–CH3(3) scaffold. Terminal CH3s (0, 3) locked to only bond their inner CH2 (1 or 2). 'amine' — nodes 0-1: two N atoms (nucleophiles for warhead/E3 COOH). 'amino_acid' — nodes 0-3: N(0) warhead-side amine + COOH(1-3) VHL-side acid. Asymmetric — use for BRD4-VHL where warhead provides COOH and E3 (VHL) provides NH2, so the linker bridges with NH2 and COOH. Each anchor atom is also capped to at most ONE bond to free atoms (via _limit_anchor_bonds) so the model can't absorb the terminus into a ring. """ terminus_type = getattr(self.args, 'fix_termini', None) if not terminus_type: return X, E cfg = self._TERMINUS_CONFIGS[terminus_type] try: C_IDX = self.atom_decoder.index("C") O_IDX = self.atom_decoder.index("O") if terminus_type == 'cooh' else None except ValueError: return X, E for i in range(X.shape[0]): n = n_nodes[i].item() if n < cfg['n_terminus_nodes']: continue if terminus_type == 'cooh': # Atom types: C, O, O, C, O, O for pos, atype in [(0, C_IDX), (1, O_IDX), (2, O_IDX), (3, C_IDX), (4, O_IDX), (5, O_IDX)]: X[i, pos] = 0.0 X[i, pos, atype] = 1.0 # Internal bonds: C=O (double=2), C-OH (single=1) for r, c, btype in [ (0, 1, 2), (1, 0, 2), (0, 2, 1), (2, 0, 1), (3, 4, 2), (4, 3, 2), (3, 5, 1), (5, 3, 1), ]: E[i, r, c] = 0.0 E[i, r, c, btype] = 1.0 # O atoms bond only to their own carbonyl C for o_node, c_node in [(1, 0), (2, 0), (4, 3), (5, 3)]: for other in range(n): if other == c_node: continue E[i, o_node, other] = 0.0; E[i, o_node, other, 0] = 1.0 E[i, other, o_node] = 0.0; E[i, other, o_node, 0] = 1.0 # Block direct C–C bond between anchor carbons E[i, 0, 3] = 0.0; E[i, 0, 3, 0] = 1.0 E[i, 3, 0] = 0.0; E[i, 3, 0, 0] = 1.0 # Cap each anchor C to ≤1 bond to free atoms (single bridge attachment). # Blocked = self + the COOH O atoms + the other anchor C. self._limit_anchor_bonds(E, i, n, 0, blocked={0, 1, 2, 3}, max_extra=1) self._limit_anchor_bonds(E, i, n, 3, blocked={0, 3, 4, 5}, max_extra=1) elif terminus_type == 'methyl': # Scaffold: CH3(0)–CH2(1)–[bridge]–CH2(2)–CH3(3) # Terminal CH3s (0, 3) bond only to their inner CH2 anchor. for pos in (0, 1, 2, 3): X[i, pos] = 0.0 X[i, pos, C_IDX] = 1.0 # Force CH3–CH2 single bonds at each end for r, c in [(0, 1), (1, 0), (2, 3), (3, 2)]: E[i, r, c] = 0.0; E[i, r, c, 1] = 1.0 # Terminal CH3 atoms (0, 3) bond ONLY to their inner CH2 for terminal, inner in [(0, 1), (3, 2)]: for other in range(n): if other == inner: continue E[i, terminal, other] = 0.0; E[i, terminal, other, 0] = 1.0 E[i, other, terminal] = 0.0; E[i, other, terminal, 0] = 1.0 # Block direct anchor–anchor bond (force bridge through free atoms) E[i, 1, 2] = 0.0; E[i, 1, 2, 0] = 1.0 E[i, 2, 1] = 0.0; E[i, 2, 1, 0] = 1.0 # Inner CH2 anchors (1, 2) bond ONCE to the bridge; everything else free. # (The terminal CH3s 0, 3 are already pinned to their inner CH2 only.) self._limit_anchor_bonds(E, i, n, 1, blocked={0, 1, 2}, max_extra=1) self._limit_anchor_bonds(E, i, n, 2, blocked={1, 2, 3}, max_extra=1) elif terminus_type == 'amine': # Two terminal N atoms at nodes 0 and 1. # Each N is the nucleophilic attachment point; warhead/E3 provide COOH. try: N_IDX = self.atom_decoder.index("N") except ValueError: continue for pos in (0, 1): X[i, pos] = 0.0 X[i, pos, N_IDX] = 1.0 # Block direct N–N bond between the two terminus atoms E[i, 0, 1] = 0.0; E[i, 0, 1, 0] = 1.0 E[i, 1, 0] = 0.0; E[i, 1, 0, 0] = 1.0 # Cap each anchor N to ≤1 bond to free atoms — keeps it as primary NH2, # not absorbed into a ring or turned into a secondary amine. self._limit_anchor_bonds(E, i, n, 0, blocked={0, 1}, max_extra=1) self._limit_anchor_bonds(E, i, n, 1, blocked={0, 1}, max_extra=1) elif terminus_type == 'amino_acid': # Node 0: N (warhead-side amine — nucleophile for warhead COOH) # Nodes 1,2,3: COOH group (VHL-side — electrophile for VHL NH2) # node 1 = carbonyl C, node 2 = C=O oxygen, node 3 = C-OH oxygen try: N_IDX = self.atom_decoder.index("N") O_IDX = self.atom_decoder.index("O") except ValueError: continue X[i, 0] = 0.0; X[i, 0, N_IDX] = 1.0 X[i, 1] = 0.0; X[i, 1, C_IDX] = 1.0 for pos in (2, 3): X[i, pos] = 0.0; X[i, pos, O_IDX] = 1.0 # COOH internal bonds: C=O (double, type 2) and C-OH (single, type 1) for r, c, btype in [(1, 2, 2), (2, 1, 2), (1, 3, 1), (3, 1, 1)]: E[i, r, c] = 0.0; E[i, r, c, btype] = 1.0 # O atoms bond only to their COOH carbon (node 1) for o_node in (2, 3): for other in range(n): if other == 1: continue E[i, o_node, other] = 0.0; E[i, o_node, other, 0] = 1.0 E[i, other, o_node] = 0.0; E[i, other, o_node, 0] = 1.0 # Block direct N(0)–C(1) bond between the two anchor atoms E[i, 0, 1] = 0.0; E[i, 0, 1, 0] = 1.0 E[i, 1, 0] = 0.0; E[i, 1, 0, 0] = 1.0 # Cap each anchor to ≤1 bond to free atoms: # - N(0) capped → primary NH2 (not absorbed into a ring) # - C(1) capped → carboxyl C with single bridge bond (not 2 substituents) self._limit_anchor_bonds(E, i, n, 0, blocked={0, 1, 2, 3}, max_extra=1) self._limit_anchor_bonds(E, i, n, 1, blocked={0, 1, 2, 3}, max_extra=1) return X, E def _check_validity(self, X: torch.Tensor, E: torch.Tensor, n_nodes: torch.Tensor) -> torch.Tensor: """Run the hard validity gate (PROTACClassifier).""" molecule_list = self._to_molecule_list(X, E, n_nodes) if self.validity_fn is None: return torch.ones(len(molecule_list), dtype=torch.bool, device=self.device) return self.validity_fn(molecule_list) > 0 def _to_molecule_list(self, X: torch.Tensor, E: torch.Tensor, n_nodes: torch.Tensor) -> list: """Convert batch tensors to DeFoG molecule_list format. X may be one-hot [B, N, d_X] or index [B, N]; same for E. _graph_to_mol expects index tensors, so collapse if needed. """ mol_list = [] for i in range(X.shape[0]): n = n_nodes[i].item() atom_types = X[i, :n] edge_types = E[i, :n, :n] if atom_types.dim() == 2: # one-hot → indices atom_types = atom_types.argmax(-1) if edge_types.dim() == 3: # one-hot → indices edge_types = edge_types.argmax(-1) mol_list.append([atom_types.cpu(), edge_types.cpu()]) return mol_list def generate_x0(self) -> Tuple[torch.Tensor, ...]: """Generate initial graphs, optionally enforcing validity.""" print("Generating initial graphs (x0 from limit distribution)...") X, E, y, node_mask, n_nodes = self._generate_single_x0() if self.args.require_valid_x0: print("Enforcing initial validity constraint...") for retry in range(self.args.max_x0_retries): validity = self._check_validity(X, E, n_nodes) num_valid = validity.sum().item() num_invalid = self.args.num_samples - num_valid if num_invalid == 0: print(f"✓ All {self.args.num_samples} samples valid!") break print(f" Retry {retry + 1}/{self.args.max_x0_retries}: " f"{num_valid}/{self.args.num_samples} valid") invalid_idx = torch.where(~validity)[0] orig = self.args.num_samples self.args.num_samples = num_invalid X_new, E_new, _, nm_new, nn_new = self._generate_single_x0() self.args.num_samples = orig X[invalid_idx] = X_new E[invalid_idx] = E_new node_mask[invalid_idx] = nm_new n_nodes[invalid_idx] = nn_new X, E = self._fix_termini(X, E, n_nodes) return X, E, y, node_mask, n_nodes # ------------------------------------------------------------------------- # Score helpers (identical to cupofmoca_guided) # ------------------------------------------------------------------------- def _get_scores(self, molecule_list: list) -> torch.Tensor: """S(z) — shape [num_objectives + 1, batch_size]. Row layout: [0] validity_fn (PROTACClassifier) — binary 0/1, hard gate only (not in the weighted sum; consumed by validity_mask in exp_tilt) [1:-1] soft scorers — fast / sa / chem (skipped if validity = 0 for all) [-1] connectivity — always runs even when validity = 0 Returning the validity row as row 0 keeps a stable interface with callers that read `raw[0]` for the validity mask. """ from protac_classifier import prebuild_mols as _prebuild mols = _prebuild(molecule_list, self.atom_decoder) batch = len(molecule_list) if self.validity_fn is not None: validity = self.validity_fn(molecule_list, mols=mols).to(self.device) else: validity = torch.ones(batch, device=self.device) if self.num_objectives == 0: return validity.unsqueeze(0) # Connectivity (last) always runs. conn_scores = self.objectives[-1](molecule_list, mols=mols).to(self.device) n_mid = self.num_objectives - 1 # objectives[:-1] if n_mid > 0 and (validity == 0).all(): zeros = torch.zeros(n_mid, batch, device=self.device) return torch.stack([validity, *[zeros[i] for i in range(n_mid)], conn_scores], dim=0) mid_scores = [obj(molecule_list, mols=mols).to(self.device) for obj in self.objectives[:-1]] return torch.stack([validity, *mid_scores, conn_scores], dim=0) def _compute_aggregated_score(self, scores: torch.Tensor, weights: torch.Tensor) -> torch.Tensor: """ scores : [1 + num_objectives, batch] (row 0 is validity, ignored here) weights : [num_objectives, 1] Weighted sum over the soft scorers only. The validity row from _get_scores is consumed separately by the validity_mask in exp_tilt. """ soft = scores[1:] if self.args.score_aggregation == "weighted_min": return torch.min(weights * soft, dim=0).values return (weights * soft).sum(dim=0) # ------------------------------------------------------------------------- # Guidance methods — one position per step, same structure as cupofmoca_guided.py # The position is one atom OR one edge pair; the method selects the type to place there. # ------------------------------------------------------------------------- def _best_of_k_guidance( self, x_idx: torch.Tensor, e_idx: torch.Tensor, ptype: str, pos, pred_X_i: torch.Tensor, pred_E_i: torch.Tensor, weights: torch.Tensor, current_score: float, n: int, ) -> Tuple[torch.Tensor, torch.Tensor, bool]: """Try every candidate type at one position; commit the highest-scoring one. All scorers are run on every candidate (with validity-first short-circuit inside _get_scores: invalid candidates skip the soft scorers). Commit the new type only if it strictly improves over the current score. """ n_atom = len(self.atom_decoder) n_bond = pred_E_i.shape[-1] if ptype == "A": if pos >= n: return x_idx, e_idx, False best_score = current_score best_type = x_idx[pos].item() for atype in range(n_atom): if self.vocab_mask[atype].item() == 0.0: continue trial_x = x_idx.clone(); trial_x[pos] = atype s = self._compute_aggregated_score( self._get_scores([[trial_x, e_idx]]), weights ).item() if s > best_score: best_score = s; best_type = atype if best_type != x_idx[pos].item(): x_idx = x_idx.clone(); x_idx[pos] = best_type return x_idx, e_idx, True elif ptype == "E": r, c = pos if r >= n or c >= n: return x_idx, e_idx, False best_score = current_score best_type = e_idx[r, c].item() for btype in range(n_bond): trial_e = e_idx.clone(); trial_e[r, c] = btype; trial_e[c, r] = btype s = self._compute_aggregated_score( self._get_scores([[x_idx, trial_e]]), weights ).item() if s > best_score: best_score = s; best_type = btype if best_type != e_idx[r, c].item(): e_idx = e_idx.clone(); e_idx[r, c] = best_type; e_idx[c, r] = best_type return x_idx, e_idx, True return x_idx, e_idx, False def _exponential_tilt_guidance( self, x_idx: torch.Tensor, e_idx: torch.Tensor, ptype: str, pos, pred_X_i: torch.Tensor, pred_E_i: torch.Tensor, beta: float, weights: torch.Tensor, current_score: float, n: int, ) -> Tuple[torch.Tensor, torch.Tensor, bool]: """ Exponential tilting: p'(c) ∝ p(c) · exp(β·S(c)) · 1_valid(c). For atoms: tries top_k types from model distribution. For edges: tries all bond types. Optional acceptance criterion: reject if selected score < current_score. """ n_atom = len(self.atom_decoder) n_bond = pred_E_i.shape[-1] if ptype == "A": if pos >= n: return x_idx, e_idx, False top_k = min(getattr(self.args, 'top_k', n_atom), n_atom) topk_out = pred_X_i[pos, :n_atom].topk(top_k) candidates = topk_out.indices.cpu() base_probs = topk_out.values.cpu() agg_list, clf_list = [], [] for atype in candidates.tolist(): tx = x_idx.clone(); tx[pos] = atype raw = self._get_scores([[tx, e_idx]]) agg_list.append(self._compute_aggregated_score(raw, weights).item()) clf_list.append(raw[0, 0].item()) agg_scores = torch.tensor(agg_list) clf_scores = torch.tensor(clf_list) current_type = x_idx[pos].item() elif ptype == "E": r, c = pos if r >= n or c >= n: return x_idx, e_idx, False candidates = torch.arange(n_bond) base_probs = pred_E_i[r, c, :n_bond].cpu() agg_list, clf_list = [], [] for btype in candidates.tolist(): te = e_idx.clone(); te[r, c] = btype; te[c, r] = btype raw = self._get_scores([[x_idx, te]]) agg_list.append(self._compute_aggregated_score(raw, weights).item()) clf_list.append(raw[0, 0].item()) agg_scores = torch.tensor(agg_list) clf_scores = torch.tensor(clf_list) current_type = e_idx[r, c].item() else: return x_idx, e_idx, False # Hard gate: zero out any candidate type whose PROTACClassifier (objectives[0]) # rejects (liability or insufficient termini). The soft scorers (connectivity, # SA, ChemScorer, FP-LogReg) provide the climbable gradient for the rest. validity_mask = (clf_scores > 0).float() log_base = torch.log(base_probs.clamp(min=1e-10)) log_tilted = log_base + beta * agg_scores + torch.log(validity_mask + 1e-10) log_max = log_tilted.max() if torch.isinf(log_max) or torch.isnan(log_max): final_probs = base_probs.clamp(min=1e-10) else: tilted = torch.exp(log_tilted - log_max) psum = tilted.sum() final_probs = tilted / psum if psum > 1e-10 else base_probs.clamp(min=1e-10) final_probs = final_probs.clamp(min=1e-10) final_probs = final_probs / final_probs.sum() selected_idx = torch.multinomial(final_probs, 1).item() selected_type = candidates[selected_idx].item() selected_score = agg_scores[selected_idx].item() if self.args.use_acceptance and selected_score < current_score: return x_idx, e_idx, False if selected_type == current_type: return x_idx, e_idx, False # All scorers already ran in the candidate loop above (no two-stage), # so the selected_score is already the full aggregated score. if ptype == "A": x_idx = x_idx.clone(); x_idx[pos] = selected_type return x_idx, e_idx, True else: r, c = pos e_idx = e_idx.clone(); e_idx[r, c] = selected_type; e_idx[c, r] = selected_type return x_idx, e_idx, True def _hybrid_guidance( self, x_idx: torch.Tensor, e_idx: torch.Tensor, ptype: str, pos, pred_X_i: torch.Tensor, pred_E_i: torch.Tensor, beta: float, weights: torch.Tensor, current_score: float, progress: float, n: int, ) -> Tuple[torch.Tensor, torch.Tensor, bool]: """BoK (exploration) for steps < 50%; exp_tilt (refinement) for steps ≥ 50%.""" if progress < 0.5: return self._best_of_k_guidance( x_idx, e_idx, ptype, pos, pred_X_i, pred_E_i, weights, current_score, n) else: return self._exponential_tilt_guidance( x_idx, e_idx, ptype, pos, pred_X_i, pred_E_i, beta, weights, current_score, n) # ------------------------------------------------------------------------- # Main generation loop — local-edit style (Algorithm 2 on graphs) # ------------------------------------------------------------------------- def generate(self) -> list: """ Run local-edit guided generation. Each step picks one position (atom or edge pair, rotating through all positions), queries the model, tries all candidate types, and commits the best-scoring one. Improvements persist across all steps. """ X, E, y, node_mask, n_nodes = self.generate_x0() batch_size = self.args.num_samples n_atom_types = len(self.atom_decoder) n_bond_types = E.shape[-1] # read from model tensor, not hardcoded # weights apply to the soft scorers only (len = num_objectives). if self.args.weights is None: weights = torch.tensor( [1.0 / self.num_objectives] * self.num_objectives, device=self.device, ).view(-1, 1) else: raw_w = list(self.args.weights) # Backward compat: if too many weights are passed (e.g. the # old 5-vector form with classifier+fast leading), strip the # extra leading entries so the trailing positions still line up # with the connectivity scorer. while len(raw_w) > self.num_objectives: raw_w = raw_w[1:] if len(raw_w) != self.num_objectives: raise ValueError( f"len(weights)={len(raw_w)} must equal num_objectives=" f"{self.num_objectives} (got {self.args.weights})" ) weights = torch.tensor(raw_w, device=self.device).view(-1, 1) # Position schedule: atoms first, then upper-triangle edge pairs. # Uses n_max so every atom and edge in the largest graph gets visited. n_max = n_nodes.max().item() atom_positions = [("A", p) for p in range(n_max)] edge_positions = [("E", (r, c)) for r in range(n_max) for c in range(r + 1, n_max)] all_positions = edge_positions + atom_positions period = len(all_positions) warmup_steps = min(getattr(self.args, 'warmup_steps', 0), self.sample_T - 1) guided_steps = self.sample_T - warmup_steps _gm_desc = { "bok": "exhaustive atom/bond search (model logits unused)", "exp_tilt": "model-prior × exp(β·score) sampling", "hybrid": "bok → exp_tilt at 50% progress", }.get(self.guidance_method.value, "") print(f"\nGuidance: {self.guidance_method.value} ({_gm_desc})") print(f"Weights: {weights.squeeze().tolist()}") print(f"β range: [{self.args.beta_min}, {self.args.beta_max}]") print(f"Schedule: {n_max} atoms + {n_max*(n_max-1)//2} edges = {period} positions, " f"{guided_steps} guided steps (positions sampled per-step by model disagreement)") if warmup_steps: print(f"Warmup: {warmup_steps} DeFoG denoising steps " f"(t=0→{warmup_steps/self.sample_T:.2f}) before guidance") with torch.no_grad(): # ------------------------------------------------------------------ # Warmup: run DeFoG's own denoising kernel to get structured starting # structures. Guidance operates on noise; warmup replaces those first # steps with proper flow-matching transitions so guided loop starts from # real chemistry rather than the limit distribution. # ------------------------------------------------------------------ if warmup_steps: for t_int in tqdm(range(warmup_steps), desc="DeFoG warmup"): t_array = t_int * torch.ones((batch_size, 1), device=self.device).float() t_norm = t_array / self.sample_T if "absorb" in self.model.cfg.model.transition and t_int == 0: t_norm = t_norm + 1e-6 s_array = t_array + 1 s_norm = s_array / self.sample_T t_norm = self.time_distorter.sample_ft(t_norm, self.model.cfg.sample.time_distortion) s_norm = self.time_distorter.sample_ft(s_norm, self.model.cfg.sample.time_distortion) sampled_s, _ = self.model.sample_p_zs_given_zt(t_norm, s_norm, X, E, y, node_mask) X, E, y = sampled_s.X, sampled_s.E, sampled_s.y X, E = self._fix_termini(X, E, n_nodes) # re-pin after each warmup step warmup_mols = self._to_molecule_list(X, E, n_nodes) current_scores = self._get_scores(warmup_mols) warmup_agg = self._compute_aggregated_score(current_scores, weights) print(f"\nPost-warmup scores: {warmup_agg.cpu().numpy()}") for t_int in tqdm(range(warmup_steps, self.sample_T), desc="DeFoG local-edit"): progress = t_int / max(self.sample_T - 1, 1) beta = self.args.beta_min + (self.args.beta_max - self.args.beta_min) * progress t_array = t_int * torch.ones((batch_size, 1), device=self.device).float() t_norm = t_array / self.sample_T if "absorb" in self.model.cfg.model.transition and t_norm.min() < 1e-6: t_norm = t_norm + 1e-6 t_norm = self.time_distorter.sample_ft(t_norm, self.model.cfg.sample.time_distortion) noisy_data = {"X_t": X, "E_t": E, "y_t": y, "t": t_norm, "node_mask": node_mask} extra_data = self.model.compute_extra_data(noisy_data) pred = self.model.forward(noisy_data, extra_data, node_mask) pred_X = F.softmax(pred.X, dim=-1) # [B, n_max, d_X] pred_E = F.softmax(pred.E, dim=-1) # [B, n_max, n_max, d_E] # Mask disallowed atom types and renormalize pred_X = pred_X * self.vocab_mask pred_X = pred_X / pred_X.sum(dim=-1, keepdim=True).clamp(min=1e-10) # Score cache: only mols changed in the previous step's commit # have stale cache entries. Use cached `current_scores` directly # (initialized post-warmup; refreshed for changed indices below). current_agg = self._compute_aggregated_score(current_scores, weights) if t_int == warmup_steps: print(f"\nInitial scores: {current_scores.cpu().numpy()}") # ── Position sampling weighted by model disagreement ──────────── # Per-position disagreement = 1 − p_θ(current_type | context). # High value ⇒ model wants to change this slot ⇒ worth visiting. # Pinned-terminus positions get weight 0 so they're never sampled. _ttype = getattr(self.args, 'fix_termini', None) _cfg = self._TERMINUS_CONFIGS[_ttype] if _ttype else None # Atom disagreement: gather p_θ(current_type) per atom slot, average over batch. _x_cur = X.argmax(-1) # [B, n_max] _atom_p_cur = pred_X.gather(-1, _x_cur.unsqueeze(-1)).squeeze(-1) # [B, n_max] _atom_dis = (1.0 - _atom_p_cur).mean(0) # [n_max] # Edge disagreement: same idea over edge slots. _e_cur = E.argmax(-1) # [B, n_max, n_max] _edge_p_cur = pred_E.gather(-1, _e_cur.unsqueeze(-1)).squeeze(-1) # [B, n_max, n_max] _edge_dis = (1.0 - _edge_p_cur).mean(0) # [n_max, n_max] _pos_weights = torch.zeros(period, device=self.device) for _idx, (_pt, _ps) in enumerate(all_positions): if _pt == "A": if _cfg and _ps in _cfg['fixed_atom_pos']: continue _pos_weights[_idx] = _atom_dis[_ps] else: _r, _c = _ps if _cfg and (_r in _cfg['fixed_o_nodes'] or _c in _cfg['fixed_o_nodes'] or _ps in _cfg['fixed_edge_pos']): continue _pos_weights[_idx] = _edge_dis[_r, _c] # Fall back to uniform if model is fully confident everywhere. if _pos_weights.sum() < 1e-10: _pos_weights = torch.ones_like(_pos_weights) if _cfg: for _idx, (_pt, _ps) in enumerate(all_positions): if _pt == "A" and _ps in _cfg['fixed_atom_pos']: _pos_weights[_idx] = 0.0 elif _pt == "E": _r, _c = _ps if (_r in _cfg['fixed_o_nodes'] or _c in _cfg['fixed_o_nodes'] or _ps in _cfg['fixed_edge_pos']): _pos_weights[_idx] = 0.0 # Position sampling mode: # "weighted" (default) — multinomial sample over all eligible positions, # weighted by disagreement (long-tail exploration) # "topk" — zero out all but top-K most-disagreed positions # before sampling (focused exploitation) _pos_mode = getattr(self.args, 'position_sampling', 'weighted') if _pos_mode == 'topk': _k = min(getattr(self.args, 'top_positions', 50), period) _topk = torch.topk(_pos_weights, _k) _mask = torch.zeros_like(_pos_weights) _mask[_topk.indices] = _pos_weights[_topk.indices] _pos_weights = _mask if _pos_weights.sum() < 1e-10: _pos_weights = torch.ones_like(_pos_weights) _sampled_idx = torch.multinomial(_pos_weights, 1).item() ptype, pos = all_positions[_sampled_idx] new_X = X.clone() new_E = E.clone() improved_indices = [] for i in range(batch_size): n = n_nodes[i].item() # Current graph as index tensors, clamped to real atom/bond types x_idx = X[i, :n].argmax(-1).cpu().clamp(0, n_atom_types - 1) e_idx = E[i, :n, :n].argmax(-1).cpu().clamp(0, n_bond_types - 1) current_score_i = current_agg[i].item() pred_X_i = pred_X[i] pred_E_i = pred_E[i] if self.guidance_method == GuidanceMethod.BEST_OF_K: x_idx, e_idx, changed = self._best_of_k_guidance( x_idx, e_idx, ptype, pos, pred_X_i, pred_E_i, weights, current_score_i, n) elif self.guidance_method == GuidanceMethod.EXPONENTIAL_TILT: x_idx, e_idx, changed = self._exponential_tilt_guidance( x_idx, e_idx, ptype, pos, pred_X_i, pred_E_i, beta, weights, current_score_i, n) elif self.guidance_method == GuidanceMethod.HYBRID: x_idx, e_idx, changed = self._hybrid_guidance( x_idx, e_idx, ptype, pos, pred_X_i, pred_E_i, beta, weights, current_score_i, progress, n) if changed: improved_indices.append(i) new_X[i, :n] = F.one_hot(x_idx.long().to(self.device), X.shape[-1]).float() new_E[i, :n, :n] = F.one_hot(e_idx.long().to(self.device), E.shape[-1]).float() X, E = new_X, new_E X, E = self._fix_termini(X, E, n_nodes) # prevent any reversion if improved_indices: # Only re-score the molecules that actually changed. Stitch the # new column scores into `current_scores` (the cross-step cache). post_mols = self._to_molecule_list(X, E, n_nodes) changed_mols = [post_mols[i] for i in improved_indices] changed_scores = self._get_scores(changed_mols) # [num_obj, len(changed)] for j, i in enumerate(improved_indices): current_scores[:, i] = changed_scores[:, j] new_agg = self._compute_aggregated_score(current_scores, weights) score_improved = [i for i in improved_indices if new_agg[i].item() > current_agg[i].item()] if score_improved: print(f"\nStep {t_int} [{ptype} pos={pos}]: improved {score_improved}") for i in score_improved: print(f" [{i}] {current_agg[i]:.4f} → {new_agg[i]:.4f}") if self.tracker: self.tracker.log_step( t_int, current_scores[0], improved_indices, guidance_info={"ptype": ptype, "pos": str(pos)} ) if t_int % 10 == 0 or t_int == self.sample_T - 1: self.tracker.print_step_summary( t_int, current_scores[0], guidance_method="local_edit" ) # Strip virtual classes, collapse one-hot → indices X, E, y = self.model.noise_dist.ignore_virtual_classes(X, E, y) final = PlaceHolder(X=X, E=E, y=y).mask(node_mask, collapse=True) return self._to_molecule_list(final.X, final.E, n_nodes) # --------------------------------------------------------------------------- # Main # --------------------------------------------------------------------------- def main(args): # PROTAC source dirs already on sys.path (set at module load above). # Re-assert here for safety in case main() is called from a wrapper. _src_root = os.path.normpath( os.path.join(os.path.dirname(os.path.abspath(__file__)), "../src") ) if _src_root not in sys.path: sys.path.insert(0, _src_root) for _sub in ("classifier", "protac"): _p = os.path.join(_src_root, _sub) if _p not in sys.path: sys.path.insert(0, _p) from rdkit import Chem from rdkit.Chem import Descriptors, rdMolDescriptors from protac_analyzer import PROTACAnalyzer from protac_classifier import PROTACClassifier, EGFRPROPKScorer, PROTACRDKitScorer, PROTACLinkerSAScorer, PROTACLinkerConnectivityScorer, _graph_to_mol _pmod_name = getattr(args, 'protac_module', 'ErlotinibVHL') import importlib as _il _pmod = _il.import_module(f"protac.{_pmod_name}") WARHEAD_MOL = _pmod.WARHEAD_MOL E3_MOL = _pmod.E3_MOL _ProtacBuilder = _pmod.Builder class PreassembledPROTACScorer: """ Fast linker scorer: no PROTAC assembly at scoring time. Pre-computes warhead (EGFR erlotinib) + E3 ligase (VHL) contributions to Scores linker quality using a logistic regression classifier trained to distinguish real PROTAC linkers (COOH-terminated, from PROTAC-DB) from generated junk (N-COOH, N-F, S-F, disconnected, etc.). Returns score in [0, 2]: 0 — disconnected or unsanitizable linker max_frag/n_atoms — soft connectivity gradient for disconnected mols 1 + clf_prob — [1, 2] for connected mols; clf_prob ∈ [0,1] is the classifier's probability of being a real linker Use as objectives[0] with sa_scorer as objectives[1]. Full assembly is still run after generation for final validity reporting. """ _ATOM_MIN, _ATOM_MAX = 3, 20 # Hard liability filters (same set as ChemScorer / PROTACClassifier). _LIABILITY_PATS = [ Chem.MolFromSmarts('[NX3][NX3]'), # hydrazide Chem.MolFromSmarts('[#7]=[#7]'), # azo Chem.MolFromSmarts('[N+][O-]'), # N-oxide Chem.MolFromSmarts('[#7]-[OX2]'), # N-O Chem.MolFromSmarts('[NX3]C(=[OX1])[OX2H1]'), # carbamate ] def __init__(self, atom_decoder, clf_path): import pickle from rdkit.Chem import AllChem as _AllChem self.atom_decoder = atom_decoder self._allchem = _AllChem with open(clf_path, 'rb') as f: saved = pickle.load(f) self._clf = saved['clf'] print(f"[PreassembledPROTACScorer] loaded linker quality classifier " f"({len(saved['pos_smiles'])} pos / {len(saved['neg_smiles'])} neg)") def _fingerprint(self, mol): import numpy as np fp = self._allchem.GetMorganFingerprintAsBitVect(mol, radius=2, nBits=1024) return np.array(list(fp), dtype=np.float32).reshape(1, -1) def __call__(self, molecule_list, mols=None): from protac_classifier import prebuild_mols as _prebuild if mols is None: mols = _prebuild(molecule_list, self.atom_decoder) scores = [] for mol in mols: if mol is None: scores.append(0.0) continue frag_mols = Chem.GetMolFrags(mol, asMols=True) if len(frag_mols) > 1: largest = max(frag_mols, key=lambda f: f.GetNumHeavyAtoms()) n_heavy_largest = largest.GetNumHeavyAtoms() if n_heavy_largest < self._ATOM_MIN: scores.append(0.0) continue if any(largest.HasSubstructMatch(p) for p in self._LIABILITY_PATS): scores.append(0.0) continue frac = n_heavy_largest / mol.GetNumHeavyAtoms() try: prob = float(self._clf.predict_proba(self._fingerprint(largest))[0, 1]) except Exception: prob = 0.0 scores.append(prob * frac) continue n_heavy = mol.GetNumHeavyAtoms() if not (self._ATOM_MIN <= n_heavy <= self._ATOM_MAX): scores.append(0.0) continue if any(mol.HasSubstructMatch(p) for p in self._LIABILITY_PATS): scores.append(0.0) continue try: prob = float(self._clf.predict_proba(self._fingerprint(mol))[0, 1]) except Exception: prob = 0.0 scores.append(prob) return torch.tensor(scores, dtype=torch.float32) class PROTACLinkerChemScorer: """ Scores linker chemistry toward assembled PROTAC target ranges (no assembly needed). MW and RotBonds windows are derived by subtracting the fixed warhead+E3 offset from the assembled PROTAC target ranges [771-1108 Da, 12-25 RotBonds]. logP scored directly on linker (training mean 0.48±1.37). Hard zero for MC liabilities: hydrazines (N–N), azo (N=N), N-oxides, any N–O single bond (hydroxylamines, alkoxyamines, oxime ethers), and carbamates (N directly bonded to a COOH carbonyl C). """ # Assembled PROTAC target ranges (from PROTAC-DB) _PROTAC_MW_LO, _PROTAC_MW_HI = 771.0, 1108.0 _PROTAC_ROTB_LO, _PROTAC_ROTB_HI = 12.0, 25.0 _ETHER_O = Chem.MolFromSmarts('[OX2;!$(O=C)]') _HYDRAZINE = Chem.MolFromSmarts('[NX3][NX3]') # N–N single (incl. hydrazine, hydrazide) _AZO = Chem.MolFromSmarts('[#7]=[#7]') # N=N (azo, diazo) _NOXIDE = Chem.MolFromSmarts('[N+][O-]') _NO_BOND = Chem.MolFromSmarts('[#7]-[OX2]') # any N–O single bond (catches hydroxylamines, alkoxyamines, oxime ethers — sp2 + sp3) _CARBAMATE = Chem.MolFromSmarts('[NX3]C(=[OX1])[OX2H1]') # N directly bonded to COOH carbonyl C — kills cooh-mode "diacid" candidates that grew an N next to a pinned carboxyl. Does NOT match α-amino acids (N–CH–COOH) where N is on the α-C, not the carbonyl. @staticmethod def _trapezoid(v, lo, lo_ramp, hi_ramp, hi): if v <= lo or v >= hi: return 0.0 if v <= lo_ramp: return (v - lo) / (lo_ramp - lo) if v >= hi_ramp: return (hi - v) / (hi - hi_ramp) return 1.0 def __init__(self, atom_decoder, warhead_mol=None, e3_mol=None): from rdkit.Chem import Descriptors as _Desc self.atom_decoder = atom_decoder self._logp = _Desc.MolLogP self._mw = _Desc.MolWt self._rotb = _Desc.NumRotatableBonds # Compute fixed warhead+E3 offset once (subtract 2×18 Da for two bond formations) if warhead_mol is not None and e3_mol is not None: fixed_mw = _Desc.MolWt(warhead_mol) + _Desc.MolWt(e3_mol) - 36.0 fixed_rotb = float(_Desc.NumRotatableBonds(warhead_mol) + _Desc.NumRotatableBonds(e3_mol)) else: fixed_mw, fixed_rotb = 0.0, 0.0 # Derive linker-level target windows; disable guidance if window collapses _MW_MIN_RANGE = 50.0 # Da — skip MW guidance if flat-top narrower than this _ROTB_MIN_RANGE = 3.0 # bonds — skip RotB guidance if flat-top narrower than this mw_lo = max(0.0, self._PROTAC_MW_LO - fixed_mw) mw_hi = max(0.0, self._PROTAC_MW_HI - fixed_mw) rotb_lo = max(0.0, self._PROTAC_ROTB_LO - fixed_rotb) rotb_hi = max(0.0, self._PROTAC_ROTB_HI - fixed_rotb) shoulder = 0.2 mw_span = mw_hi - mw_lo rotb_span = rotb_hi - rotb_lo self._mw_active = mw_span >= _MW_MIN_RANGE self._rotb_active = rotb_span >= _ROTB_MIN_RANGE if self._mw_active: self._mw_window = (mw_lo - mw_span*shoulder, mw_lo, mw_hi, mw_hi + mw_span*shoulder) if self._rotb_active: self._rotb_window = (rotb_lo - rotb_span*shoulder, rotb_lo, rotb_hi, rotb_hi + rotb_span*shoulder) print(f"[ChemScorer] linker MW target: " f"[{mw_lo:.0f}, {mw_hi:.0f}] Da (fixed {fixed_mw:.0f} Da)" f"{'' if self._mw_active else ' ← SKIPPED (window too narrow)'}") print(f"[ChemScorer] linker RotB target: " f"[{rotb_lo:.0f}, {rotb_hi:.0f}] (fixed {fixed_rotb:.0f})" f"{'' if self._rotb_active else ' ← SKIPPED (window too narrow)'}") def __call__(self, molecule_list, mols=None): from protac_classifier import prebuild_mols as _prebuild if mols is None: mols = _prebuild(molecule_list, self.atom_decoder) scores = [] for mol in mols: if mol is None: scores.append(0.0); continue if len(Chem.GetMolFrags(mol)) > 1: scores.append(0.0); continue if (mol.HasSubstructMatch(self._HYDRAZINE) or mol.HasSubstructMatch(self._AZO) or mol.HasSubstructMatch(self._NOXIDE) or mol.HasSubstructMatch(self._NO_BOND) or mol.HasSubstructMatch(self._CARBAMATE)): scores.append(0.0); continue n_ether = len(mol.GetSubstructMatches(self._ETHER_O)) ether_score = min(n_ether / 4.0, 1.0) try: logp = self._logp(mol) mw = self._mw(mol) rotb = float(self._rotb(mol)) except Exception: logp, mw, rotb = 5.0, 500.0, 20.0 logp_score = self._trapezoid(logp, -3.0, -1.0, 2.0, 5.0) mw_score = self._trapezoid(mw, *self._mw_window) if self._mw_active else 1.0 rotb_score = self._trapezoid(rotb, *self._rotb_window) if self._rotb_active else 1.0 scores.append(0.25 * ether_score + 0.25 * logp_score + 0.25 * mw_score + 0.25 * rotb_score) return torch.tensor(scores, dtype=torch.float32) device = torch.device("cuda" if torch.cuda.is_available() else "cpu") print(f"Device: {device}") # Load DeFoG model # load_from_checkpoint fails because train_metrics/sampling_metrics are not saved in hparams. # Instead: pull cfg + dataset_infos directly from the checkpoint, pass dummy metrics. print(f"Loading DeFoG checkpoint: {args.checkpoint}") from graph_discrete_flow_model import GraphDiscreteFlowModel from analysis.visualization import MolecularVisualization class _DummyMetrics: def __call__(self, *a, **kw): return {} def reset(self): pass def log_epoch_metrics(self, *a, **kw): pass raw_ckpt = torch.load(args.checkpoint, map_location='cpu', weights_only=False) hp = raw_ckpt['hyper_parameters'] model = GraphDiscreteFlowModel( cfg=hp['cfg'], dataset_infos=hp['dataset_infos'], train_metrics=_DummyMetrics(), sampling_metrics=_DummyMetrics(), visualization_tools=hp.get('visualization_tools') or MolecularVisualization( remove_h=True, dataset_infos=hp['dataset_infos'] ), extra_features=hp.get('extra_features') or _DummyMetrics(), domain_features=hp.get('domain_features') or _DummyMetrics(), test_labels=hp.get('test_labels'), ) model.load_state_dict(raw_ckpt['state_dict'], strict=False) model.to(device) model.eval() print("Model loaded.") _KNOWN_DECODERS = { "moses": ["C", "N", "S", "O", "F", "Cl", "Br", "H"], "zinc": ["C", "N", "O", "F", "P", "S", "Cl", "Br", "I"], "qm9": ["C", "N", "O", "F"], "guacamol": ["C", "N", "O", "F", "P", "S", "Cl", "Br", "I"], } if args.dataset is not None: atom_decoder = _KNOWN_DECODERS[args.dataset] print(f"Atom decoder (from --dataset {args.dataset}): {atom_decoder}") else: atom_decoder = model.dataset_info.atom_decoder print(f"Atom decoder (from checkpoint): {atom_decoder}") # Build objectives # Guidance SMARTS: linker must have at least one reactive attachment per end. # COOH = [CX3](=[OX1])[OX2H1] — direct amide bond # CHO = [CX3H1](=[OX1]) — post-assembly aldehyde, restored to COOH # CH3 = [CX4H3] — terminal methyl for C-N alkylation # When fix_termini=methyl the CH3 groups are pre-pinned so they match immediately, # making the attachment score trivially 2 and letting other objectives steer. if args.fix_termini == 'methyl': _ATTACH_SMARTS = "[$([CX3;!$([CX3][N,n])](=[OX1])[OX2H1]),$([CX3H1](=[OX1])),$([CX4H3])]" elif args.fix_termini == 'amine': # Linker presents N termini; warhead/E3 provide COOH electrophiles. _ATTACH_SMARTS = "[NX3;!H0;!$(NC=O);!$(NS=O);!$(N=O)]" elif args.fix_termini == 'amino_acid': # Linker presents one NH2 (warhead end) + one COOH (VHL end). # Match either — assembly score requires ≥2 hits (one of each type). _ATTACH_SMARTS = "[$([NX3H2]),$([CX3](=[OX1])[OX2H1])]" else: _ATTACH_SMARTS = "[$([CX3;!$([CX3][N,n])](=[OX1])[OX2H1]),$([CX3H1](=[OX1]))]" analyzer = PROTACAnalyzer(WARHEAD_MOL, E3_MOL, _ATTACH_SMARTS, _ATTACH_SMARTS, sa_score_max=5.0) builder = _ProtacBuilder() classifier = PROTACClassifier(analyzer, builder, atom_decoder) classifier.fix_termini = args.fix_termini conn_scorer = PROTACLinkerConnectivityScorer(atom_decoder) if args.fix_termini: # Anchor nodes for the connectivity scorer: halves its soft score when the # two termini end up in different fragments, so guidance is steered toward # bridging the warhead/E3 anchors specifically. conn_scorer.anchor_nodes = DeFoGGenerator._TERMINUS_CONFIGS[args.fix_termini]['anchor_nodes'] sa_scorer = PROTACLinkerSAScorer(atom_decoder, sa_threshold=4.5) if getattr(args, 'fast_scoring', False): if args.fix_termini not in ('cooh', 'amine', 'amino_acid'): raise ValueError("--fast_scoring requires --fix_termini cooh, amine, or amino_acid") # Classifier dispatch priority: # 1. Explicit --clf_path on CLI (override) # 2. protac_linker_clf_.pkl (target-specific, if present) # 3. protac_linker_clf_.pkl (termini-specific fallback) # 4. protac_linker_clf.pkl (generic default) _termini_clf = { 'amine': "protac_linker_clf_amine.pkl", 'amino_acid': "protac_linker_clf_amino_acid.pkl", }.get(args.fix_termini, "protac_linker_clf.pkl") _clf_dir = Path(__file__).resolve().parents[1] / "src" / "classifier" _module_clf = f"protac_linker_clf_{args.protac_module}.pkl" _module_path = _clf_dir / _module_clf if getattr(args, 'clf_path', None): _clf_path = args.clf_path elif _module_path.exists(): _clf_path = str(_module_path) print(f"[fast_scoring] using target-specific classifier: {_module_clf}") else: _clf_path = str(_clf_dir / _termini_clf) print(f"[fast_scoring] using termini classifier: {_termini_clf}") chem_scorer = PROTACLinkerChemScorer(atom_decoder, WARHEAD_MOL, E3_MOL) # classifier is the hard validity gate, not in the soft sum. # PreassembledPROTACScorer was dropped after the ablation showed it # hurts AsmVal/DM when weighted up. OBJECTIVE_FUNCTIONS = [sa_scorer, chem_scorer, conn_scorer] print("Scoring mode: FAST (SA + linker chem + connectivity; PROTACClassifier as hard gate)") else: if not getattr(args, 'model_dir', None): raise ValueError("--model_dir is required for full scoring mode (or use --fast_scoring)") pk_scorer = EGFRPROPKScorer(builder, args.model_dir, atom_decoder) pk_scorer.fix_termini = args.fix_termini prop_scorer = PROTACRDKitScorer(builder, atom_decoder) prop_scorer.fix_termini = args.fix_termini OBJECTIVE_FUNCTIONS = [pk_scorer, prop_scorer, sa_scorer, conn_scorer] print("Scoring mode: FULL (PROPK + RDKit + SA + connectivity; PROTACClassifier as hard gate)") tracker = GenerationTracker(output_dir=args.output_dir) generator = DeFoGGenerator(model, atom_decoder, OBJECTIVE_FUNCTIONS, args, validity_fn=classifier, tracker=tracker) all_molecules = [] for batch_idx in range(args.num_batches): print(f"\n{'=' * 60}") print(f"BATCH {batch_idx + 1}/{args.num_batches}") print(f"{'=' * 60}") molecule_list = generator.generate() final_scores = generator._get_scores(molecule_list) print(f"\n--- Batch {batch_idx + 1} Results ---") print(f"Validity scores: {final_scores[0].cpu().numpy()}") all_molecules.extend(molecule_list) linker_mols = [] for atom_types, edge_types in molecule_list: try: mol = _graph_to_mol(atom_types, edge_types, atom_decoder) Chem.SanitizeMol(mol) linker_mols.append(mol) except Exception: linker_mols.append(Chem.RWMol()) detailed = analyzer.get_detailed_scores(linker_mols) tracker.log_final(molecule_list, detailed) tracker.print_final_summary() tracker.save(filename_prefix="defog_local_edit") # Strict final validation — attempt actual assembly via builder (NH2 or reactive NH) # This is the ground truth: if builder.assemble() succeeds, the linker is genuinely # assembly-ready regardless of which N type was used. strict_pass = 0 print("\n=== Strict assembly-ready check (actual RDKit assembly attempt) ===") with open(args.output_file, "w") as f: for atom_types, edge_types in all_molecules: try: mol = _graph_to_mol(atom_types, edge_types, atom_decoder) Chem.SanitizeMol(mol) smi = Chem.MolToSmiles(mol) # Reject disconnected molecules — a valid linker must be a single fragment if "." in smi: f.write(f"DISCONNECTED {smi}\n") continue try: amide_only_strict = (args.fix_termini == 'cooh') builder.assemble(mol, return_all=False, amide_only=amide_only_strict) tag = "AssemblyReady" strict_pass += 1 except Exception: tag = "Valid" f.write(f"{tag} {smi}\n") except Exception: f.write("INVALID\n") print(f"Assembly-ready (strict): {strict_pass}/{len(all_molecules)}") print(f"\nSMILES saved to: {args.output_file}") if __name__ == "__main__": parser = argparse.ArgumentParser(description="DeFoG Guided PROTAC Linker Generation") # Model parser.add_argument("--checkpoint", type=str, required=True, help="Path to DeFoG model checkpoint (.ckpt)") parser.add_argument("--model_dir", type=str, default=None, help="Path to EGFR-PROPK models directory (required unless --fast_scoring)") parser.add_argument("--clf_path", type=str, default=None, help="Path to protac_linker_clf.pkl (default: smiles/protac_linker_clf.pkl)") parser.add_argument("--protac_module", type=str, default="ErlotinibVHL", help="Builder module name under MoCA/smiles/protac/ " "(e.g. ErlotinibVHL, ARCRBN). Must export WARHEAD_MOL, E3_MOL, Builder.") parser.add_argument("--dataset", type=str, default=None, choices=["moses", "zinc", "qm9", "guacamol"], help="Override atom decoder (default: read from checkpoint)") # Generation parser.add_argument("--num_samples", type=int, default=10) parser.add_argument("--num_batches", type=int, default=5) parser.add_argument("--num_steps", type=int, default=None, help="Override denoising steps (default: read from checkpoint, typically 1000)") parser.add_argument("--num_nodes", type=int, default=None, help="Fixed number of linker atoms (None = sample from node_dist)") parser.add_argument("--min_nodes", type=int, default=10) parser.add_argument("--max_nodes", type=int, default=25) # Warmup: run DeFoG's own denoising kernel for this many steps before guided local edits. # Replaces the first N steps of the loop with proper flow-matching transitions so # guidance starts from structured molecules rather than pure limit-distribution noise. # Good default: 40–60% of --num_steps (e.g., 400 for a 1000-step run). parser.add_argument("--warmup_steps", type=int, default=0, help="DeFoG denoising steps before guided local-edit loop (default: 0 = disabled)") # Guidance — one graph position per step, mirrors CupOfMoca's per-token approach parser.add_argument("--guidance_method", type=str, default="exp_tilt", choices=["bok", "exp_tilt", "hybrid"], help="exp_tilt (default): use DeFoG model logits as base prob, tilt by score (preserves " "learned chemistry like ring closures). bok: pure score-driven brute force " "(ignores model — empirically kills ring formation). hybrid: bok→exp_tilt.") parser.add_argument("--beta_min", type=float, default=1.0, help="Initial guidance strength β (annealed from beta_min → beta_max)") parser.add_argument("--beta_max", type=float, default=20.0, help="Final guidance strength β") parser.add_argument("--top_k", type=int, default=9, help="Atom-type candidates for exp_tilt (default: all types)") parser.add_argument("--use_acceptance", action="store_true", help="Reject exp_tilt proposals that lower the score (mirrors CupOfMoca)") parser.add_argument("--score_aggregation", type=str, default="weighted_sum", choices=["weighted_sum", "weighted_min"]) parser.add_argument("--weights", type=float, nargs="+", default=None, help="Objective weights e.g. 1.0 0.5 0.5") # Position-sampling strategy for the per-step position pick parser.add_argument("--position_sampling", type=str, default="weighted", choices=["weighted", "topk"], help="weighted (default): multinomial across all eligible positions " "weighted by 1−p_θ(current_type) — long-tail exploration. " "topk: zero out all but the top-K most-disagreed positions, then " "sample within those — focused exploitation.") parser.add_argument("--top_positions", type=int, default=50, help="K for --position_sampling topk (default 50). " "Smaller = more focused; larger = more exploration. " "Ignored when --position_sampling weighted.") # Terminus-conditioned generation # Pre-pins reactive terminus groups so the attachment score is satisfied immediately # and guidance concentrates on connecting the bridge and optimising other objectives. # cooh — two COOH groups at nodes 0-5 (amide-bond assembly path) # methyl — CH3(0)–CH2(1)…CH2(2)–CH3(3) scaffold (C-N alkylation path, 77% of PROTAC-DB) parser.add_argument("--fix_termini", default=None, choices=["cooh", "methyl", "amine", "amino_acid"], help="Pin terminus groups: 'cooh' (nodes 0-5, amide path), " "'methyl' (nodes 0-3, C-N alkylation path), " "'amine' (nodes 0-1, N termini — warhead/E3 provide COOH), " "'amino_acid' (nodes 0-3, asymmetric N+COOH — for BRD4-VHL)") # Initial generation parser.add_argument("--require_valid_x0", action="store_true") parser.add_argument("--max_x0_retries", type=int, default=10) # Fast scoring: skip full PROTAC assembly at every step. # Pre-computes warhead + E3 ligase property contributions (MW, logP, RotB) once; # scores each candidate linker by estimating full PROTAC properties via offsets. # Eliminates PROTACAnalyzer assembly calls (~10-50x faster per scoring step). # Only valid with --fix_termini cooh (COOH termini guaranteed, so assembly check is moot). # Reverts to full assembly for final validity check after generation. parser.add_argument("--fast_scoring", action="store_true", help="Use PreassembledPROTACScorer: skip assembly, estimate PROTAC " "properties via warhead+E3 offsets. Supports cooh, amine, and " "amino_acid (each has its own dedicated classifier).") # Output parser.add_argument("--output_file", type=str, default="./protac_linkers.smi") parser.add_argument("--output_dir", type=str, default="./generation_logs") parser.add_argument("--seed", type=int, default=None) args = parser.parse_args() if args.fix_termini: cfg = DeFoGGenerator._TERMINUS_CONFIGS[args.fix_termini] if args.min_nodes < cfg['min_total_nodes']: args.min_nodes = cfg['min_total_nodes'] print(f"--fix_termini {args.fix_termini} requires at least {cfg['min_total_nodes']} nodes; " f"setting --min_nodes {cfg['min_total_nodes']}") if args.seed is not None: random.seed(args.seed) np.random.seed(args.seed) torch.manual_seed(args.seed) if torch.cuda.is_available(): torch.cuda.manual_seed_all(args.seed) main(args)