Download CupOFMoCA-PROTAC/src/classifier/protac_analyzer.py from ChatterjeeLab/CupOFMoCA: direct link, hf CLI and curl.
- Browser
- Download file 16.3 kB
-
https://huggingface.co/ChatterjeeLab/CupOFMoCA/resolve/main/CupOFMoCA-PROTAC/src/classifier/protac_analyzer.py
- Command line
-
hf download hf://ChatterjeeLab/CupOFMoCA/CupOFMoCA-PROTAC/src/classifier/protac_analyzer.py
-
curl -L -o protac_analyzer.py https://huggingface.co/ChatterjeeLab/CupOFMoCA/resolve/main/CupOFMoCA-PROTAC/src/classifier/protac_analyzer.py
16.3 kB
| """ | |
| PROTAC Analyzer | |
| Analyzes PROTAC linkers for structural validity | |
| The linker is the generated component; warhead and E3 ligand are fixed inputs. | |
| Hard constraints (validity = 1 if all pass, 0 if any fails): | |
| 1. Attachment site availability: linker has reactive termini for both warhead and E3 ends | |
| 2. Linker feasibility: ring-closure constraints, long carbon chains | |
| 3. Linker atom count: 3-20 heavy atoms in the linker | |
| 4. Assembled PROTAC MW: full PROTAC within 700-1200 Da (requires warhead + E3) | |
| 5. Representational validity: RDKit sanitization, valence, round-trip | |
| 6. Synthetic accessibility: SA score β€ threshold (default 6.0) | |
| All methods accept Chem.Mol objects β the molecular graph produced by DeFoG. | |
| SMILES conversion happens once at the DeFoG graph β RDKit Mol boundary upstream; | |
| this class never calls MolFromSmiles on method inputs. | |
| Note: Bond-forming chemistry (which atom connects to which) is in protac_builder.py. | |
| """ | |
| from __future__ import annotations | |
| import os | |
| import sys | |
| import torch | |
| from rdkit import Chem, RDConfig | |
| from rdkit.Chem import Descriptors, AllChem, rdMolDescriptors | |
| from typing import Optional | |
| sys.path.insert(0, os.path.join(RDConfig.RDContribDir, "SA_Score")) | |
| import sascorer | |
| LINKER_ATOM_MIN = 3 | |
| LINKER_ATOM_MAX = 25 | |
| PROTAC_MW_MIN = 700.0 | |
| PROTAC_MW_MAX = 1250.0 | |
| SA_SCORE_MAX = 6.0 # 1 (easy) β 10 (hard); β€6 keeps synthetically accessible linkers | |
| _LONG_CHAIN_SMARTS = "[CH2]" + "[CH2]" * 15 # 16 consecutive CH2 | |
| # Atoms allowed in PROTAC linkers β excludes halogens (I, Br optionally) and P | |
| # which appear as nonsensical bridge atoms when generated from GuacaMol atom decoder. | |
| class PROTACAnalyzer: | |
| """ | |
| Validates PROTAC linkers against hard chemical and structural constraints. | |
| All check_* methods and the batch interface accept Chem.Mol objects only. | |
| The DeFoG graph β Chem.Mol conversion happens once upstream; this class | |
| never parses SMILES strings. | |
| Parameters | |
| ---------- | |
| warhead : Chem.Mol | |
| Fixed warhead molecule. Required for MW check. | |
| e3 : Chem.Mol | |
| Fixed E3-ligand molecule. Required for MW check. | |
| warhead_attachment_smarts : str | |
| SMARTS for the reactive group the linker must have on the warhead-facing end. | |
| e3_attachment_smarts : str | |
| SMARTS for the reactive group the linker must have on the E3-facing end. | |
| linker_atom_min / linker_atom_max : int | |
| Heavy-atom count bounds for the linker (default 3β20). | |
| protac_mw_min / protac_mw_max : float | |
| Assembled PROTAC MW window in Da (default 700β1200). | |
| check_mw : bool | |
| Enforce the MW window. Requires warhead and e3. | |
| sa_score_max : float | |
| Maximum SA score for the linker (default 6.0). Set to None to disable. | |
| calculate_properties : bool | |
| Compute optional descriptors in get_detailed_scores. | |
| """ | |
| def __init__( | |
| self, | |
| warhead: Chem.Mol, | |
| e3: Chem.Mol, | |
| warhead_attachment_smarts: str, | |
| e3_attachment_smarts: str, | |
| linker_atom_min: int = LINKER_ATOM_MIN, | |
| linker_atom_max: int = LINKER_ATOM_MAX, | |
| protac_mw_min: float = PROTAC_MW_MIN, | |
| protac_mw_max: float = PROTAC_MW_MAX, | |
| check_mw: bool = True, | |
| sa_score_max: Optional[float] = SA_SCORE_MAX, | |
| calculate_properties: bool = True, | |
| ): | |
| if warhead is None: | |
| raise ValueError("warhead must be a Chem.Mol object") | |
| if e3 is None: | |
| raise ValueError("e3 must be a Chem.Mol object") | |
| self._warhead_mol = warhead | |
| self._e3_mol = e3 | |
| self.linker_atom_min = linker_atom_min | |
| self.linker_atom_max = linker_atom_max | |
| self.protac_mw_min = protac_mw_min | |
| self.protac_mw_max = protac_mw_max | |
| self.check_mw = check_mw | |
| self.sa_score_max = sa_score_max | |
| self.calculate_properties = calculate_properties | |
| self._warhead_pat = Chem.MolFromSmarts(warhead_attachment_smarts) | |
| self._e3_pat = Chem.MolFromSmarts(e3_attachment_smarts) | |
| if self._warhead_pat is None: | |
| raise ValueError(f"Invalid warhead_attachment_smarts: {warhead_attachment_smarts!r}") | |
| if self._e3_pat is None: | |
| raise ValueError(f"Invalid e3_attachment_smarts: {e3_attachment_smarts!r}") | |
| self._long_chain_pat = Chem.MolFromSmarts(_LONG_CHAIN_SMARTS) | |
| self.property_flags = { | |
| "mf": True, "mw": True, "polararea": True, "xlogp": True, | |
| "heavycnt": True, "hbonddonor": True, "hbondacc": True, "rotbonds": True, | |
| } | |
| # ------------------------------------------------------------------ | |
| # Property flag helpers | |
| # ------------------------------------------------------------------ | |
| def set_property_flags(self, **kwargs): | |
| for k, v in kwargs.items(): | |
| if k in self.property_flags: | |
| self.property_flags[k] = bool(v) | |
| else: | |
| raise ValueError(f"Unknown property: {k}. Valid: {list(self.property_flags)}") | |
| def enable_all_properties(self): | |
| for k in self.property_flags: | |
| self.property_flags[k] = True | |
| def disable_all_properties(self): | |
| for k in self.property_flags: | |
| self.property_flags[k] = False | |
| # ------------------------------------------------------------------ | |
| # Hard constraint checks β all accept Chem.Mol | |
| # ------------------------------------------------------------------ | |
| def check_attachment_sites(self, mol: Chem.Mol) -> bool: | |
| """Constraint 1: linker must have two *distinct* attachment atoms β | |
| one for the warhead end and one for the E3 end. Requiring the same | |
| atom to satisfy both patterns (e.g. a single CHO) is rejected.""" | |
| warhead_atoms = {m[0] for m in mol.GetSubstructMatches(self._warhead_pat)} | |
| e3_atoms = {m[0] for m in mol.GetSubstructMatches(self._e3_pat)} | |
| return any(w != e for w in warhead_atoms for e in e3_atoms) | |
| def check_linker_atom_count(self, mol: Chem.Mol) -> bool: | |
| """Constraint 3: heavy-atom count within [min, max].""" | |
| n = mol.GetNumHeavyAtoms() | |
| return self.linker_atom_min <= n <= self.linker_atom_max | |
| def check_protac_mw(self, linker_mol: Chem.Mol) -> bool: | |
| """ | |
| Constraint 4: MW(warhead) + MW(linker) + MW(E3) - 36 Da | |
| (subtracts two amide-bond condensation water losses). | |
| """ | |
| if not self.check_mw: | |
| return True | |
| mw = (Descriptors.MolWt(self._warhead_mol) | |
| + Descriptors.MolWt(linker_mol) | |
| + Descriptors.MolWt(self._e3_mol) | |
| - 36.0) | |
| return self.protac_mw_min <= mw <= self.protac_mw_max | |
| def _find_linker_rings(self, mol: Chem.Mol): | |
| linker_rings = [] | |
| for ring in mol.GetRingInfo().AtomRings(): | |
| ring_atoms = [mol.GetAtomWithIdx(i) for i in ring] | |
| if all(a.GetIsAromatic() for a in ring_atoms): | |
| continue | |
| size = len(ring) | |
| symbols = [a.GetSymbol() for a in ring_atoms] | |
| if size == 5 and "N" in symbols: | |
| c_count = sum( | |
| 1 for idx in ring | |
| for nb in mol.GetAtomWithIdx(idx).GetNeighbors() | |
| if nb.GetSymbol() == "O" | |
| and nb.GetIdx() not in ring | |
| and mol.GetBondBetweenAtoms(idx, nb.GetIdx()).GetBondType() | |
| == Chem.BondType.DOUBLE | |
| ) | |
| if c_count >= 2: | |
| linker_rings.append(ring) | |
| continue | |
| linker_rings.append(ring) | |
| return linker_rings | |
| def check_ring_closure_constraints(self, mol: Chem.Mol) -> bool: | |
| """ | |
| Constraint 2a: | |
| - 3-ring with C=C β reject (severe strain) | |
| - 4-ring with >2 sp2 centers β reject | |
| - ring > 12 atoms β reject | |
| """ | |
| for ring in self._find_linker_rings(mol): | |
| size = len(ring) | |
| if size < 3: | |
| return False | |
| if size == 3: | |
| for idx in ring: | |
| atom = mol.GetAtomWithIdx(idx) | |
| if (atom.GetSymbol() == "C" | |
| and atom.GetHybridization() == Chem.HybridizationType.SP2): | |
| for nb in atom.GetNeighbors(): | |
| bond = mol.GetBondBetweenAtoms(idx, nb.GetIdx()) | |
| if bond.GetBondType() == Chem.BondType.DOUBLE and nb.GetSymbol() == "C": | |
| return False | |
| if size == 4: | |
| sp2 = sum(1 for idx in ring | |
| if mol.GetAtomWithIdx(idx).GetHybridization() | |
| == Chem.HybridizationType.SP2) | |
| if sp2 > 2: | |
| return False | |
| if size > 12: | |
| return False | |
| return True | |
| def check_long_chain(self, mol: Chem.Mol) -> bool: | |
| """Constraint 2b: reject β₯16 consecutive CH2 groups.""" | |
| if self._long_chain_pat is None: | |
| return True | |
| return not mol.HasSubstructMatch(self._long_chain_pat) | |
| def check_linker_feasibility(self, mol: Chem.Mol) -> bool: | |
| return self.check_ring_closure_constraints(mol) and self.check_long_chain(mol) | |
| def check_representational_validity(self, mol: Chem.Mol) -> bool: | |
| """Constraint 5: sanitization, round-trip SMILES, valence, 2D coords.""" | |
| try: | |
| Chem.SanitizeMol(mol) | |
| except Exception: | |
| return False | |
| try: | |
| if Chem.MolFromSmiles(Chem.MolToSmiles(mol)) is None: | |
| return False | |
| except Exception: | |
| return False | |
| try: | |
| for prob in Chem.DetectChemistryProblems(mol): | |
| if prob.GetType() in ("AtomValenceException", "KekulizeException"): | |
| return False | |
| except Exception: | |
| return False | |
| try: | |
| AllChem.Compute2DCoords(mol) | |
| except Exception: | |
| return False | |
| return True | |
| # ------------------------------------------------------------------ | |
| # Validation entry point | |
| # ------------------------------------------------------------------ | |
| def check_sa_score(self, mol: Chem.Mol) -> bool: | |
| """Constraint 6: SA score β€ sa_score_max (skipped if sa_score_max is None).""" | |
| if self.sa_score_max is None: | |
| return True | |
| score = sascorer.calculateScore(mol) | |
| if score is None: | |
| return False | |
| return score <= self.sa_score_max | |
| def _run_checks(self, mol: Chem.Mol) -> dict: | |
| return { | |
| "connected": len(Chem.GetMolFrags(mol)) == 1, | |
| "attachment_sites": self.check_attachment_sites(mol), | |
| "ring_closure": self.check_ring_closure_constraints(mol), | |
| "long_chain": self.check_long_chain(mol), | |
| "linker_atom_count": self.check_linker_atom_count(mol), | |
| "protac_mw": self.check_protac_mw(mol), | |
| "representational": self.check_representational_validity(mol), | |
| "sa_score": self.check_sa_score(mol), | |
| } | |
| def validate(self, linker_mol: Chem.Mol) -> bool: | |
| """Run all hard constraints. Returns True only if every check passes.""" | |
| return all(self._run_checks(linker_mol).values()) | |
| # ------------------------------------------------------------------ | |
| # Optional property computation | |
| # ------------------------------------------------------------------ | |
| def calculate_linker_properties(self, mol: Chem.Mol) -> dict: | |
| if not self.calculate_properties: | |
| return {} | |
| props = {} | |
| if self.property_flags.get("mf"): | |
| props["mf"] = rdMolDescriptors.CalcMolFormula(mol) | |
| if self.property_flags.get("mw"): | |
| props["mw"] = round(Descriptors.MolWt(mol), 2) | |
| if self.property_flags.get("polararea"): | |
| props["polararea"] = round(Descriptors.TPSA(mol), 2) | |
| if self.property_flags.get("xlogp"): | |
| props["xlogp"] = round(Descriptors.MolLogP(mol), 2) | |
| if self.property_flags.get("heavycnt"): | |
| props["heavycnt"] = mol.GetNumHeavyAtoms() | |
| if self.property_flags.get("hbonddonor"): | |
| props["hbonddonor"] = Descriptors.NumHDonors(mol) | |
| if self.property_flags.get("hbondacc"): | |
| props["hbondacc"] = Descriptors.NumHAcceptors(mol) | |
| if self.property_flags.get("rotbonds"): | |
| props["rotbonds"] = Descriptors.NumRotatableBonds(mol) | |
| return props | |
| def estimate_protac_mw(self, linker_mol: Chem.Mol) -> Optional[float]: | |
| if not (self._warhead_mol and self._e3_mol): | |
| return None | |
| return (Descriptors.MolWt(self._warhead_mol) | |
| + Descriptors.MolWt(linker_mol) | |
| + Descriptors.MolWt(self._e3_mol) | |
| - 36.0) | |
| # ------------------------------------------------------------------ | |
| # Batch interface β list[Chem.Mol] | |
| # ------------------------------------------------------------------ | |
| def get_scores(self, linker_mols: list[Chem.Mol]) -> list[int]: | |
| """ | |
| Validate a batch of linker Mol objects. | |
| Returns list of 0/1 scores (1 = all constraints pass). | |
| """ | |
| return [ | |
| 1 if all(self._run_checks(mol).values()) else 0 | |
| for mol in linker_mols | |
| ] | |
| def get_detailed_scores(self, linker_mols: list[Chem.Mol]) -> list[dict]: | |
| """ | |
| Per-constraint breakdown for each linker Mol. | |
| Keys: valid, smiles (canonical), checks, properties, estimated_protac_mw. | |
| """ | |
| detailed = [] | |
| for mol in linker_mols: | |
| checks = self._run_checks(mol) | |
| entry = { | |
| "valid": 1 if all(checks.values()) else 0, | |
| "smiles": Chem.MolToSmiles(mol), | |
| "checks": checks, | |
| "properties": self.calculate_linker_properties(mol), | |
| } | |
| est_mw = self.estimate_protac_mw(mol) | |
| if est_mw is not None: | |
| entry["estimated_protac_mw"] = round(est_mw, 2) | |
| detailed.append(entry) | |
| return detailed | |
| # ------------------------------------------------------------------ | |
| # Tensor interface for guidance loop | |
| # ------------------------------------------------------------------ | |
| def __call__(self, linker_mols: list[Chem.Mol]) -> torch.Tensor: | |
| """Return float tensor of validity scores (0 or 1) for the guidance loop.""" | |
| return torch.tensor(self.get_scores(linker_mols), dtype=torch.float32) | |
| # --------------------------------------------------------------------------- | |
| # Self-test | |
| # --------------------------------------------------------------------------- | |
| if __name__ == "__main__": | |
| import sys, os | |
| sys.path.insert(0, os.path.dirname(__file__)) | |
| from protac.ErlotinibVHL import EGFR_WARHEAD_SMILES, VHL_E3_SMILES | |
| analyzer = PROTACAnalyzer( | |
| warhead=Chem.MolFromSmiles(EGFR_WARHEAD_SMILES), | |
| e3=Chem.MolFromSmiles(VHL_E3_SMILES), | |
| warhead_attachment_smarts="[NX3;H2]", | |
| e3_attachment_smarts="[NX3;H2]", | |
| ) | |
| test_cases = [ | |
| ("NCCOCCOCCN", "Valid PEG-diamine"), | |
| ("NCCCCN", "Valid butane-1,4-diamine"), | |
| ("NCCCCCCCCCCCCCCCCCN", "Invalid: too long + long chain"), | |
| ("NCC1=CC1N", "Invalid: strained 3-ring"), | |
| ("N", "Invalid: too short"), | |
| ] | |
| print("=" * 70) | |
| print("PROTAC Linker Analyzer β Self-Test") | |
| print("=" * 70) | |
| for smiles, desc in test_cases: | |
| mol = Chem.MolFromSmiles(smiles) # SMILES used only here in the test | |
| if mol is None: | |
| print(f"\n[INVALID] {desc} β unparseable SMILES") | |
| continue | |
| result = analyzer.get_detailed_scores([mol])[0] | |
| tag = "VALID" if result["valid"] == 1 else "INVALID" | |
| print(f"\n[{tag}] {desc}") | |
| for k, v in result["checks"].items(): | |
| print(f" {'β' if v else 'β'} {k}") | |
| if "estimated_protac_mw" in result: | |
| print(f" estimated PROTAC MW: {result['estimated_protac_mw']} Da") |