Download CupOFMoCA-PROTAC/scripts/train_target_clf.py from ChatterjeeLab/CupOFMoCA: direct link, hf CLI and curl.
- Browser
- Download file 8.76 kB
-
https://huggingface.co/ChatterjeeLab/CupOFMoCA/resolve/main/CupOFMoCA-PROTAC/scripts/train_target_clf.py
- Command line
-
hf download hf://ChatterjeeLab/CupOFMoCA/CupOFMoCA-PROTAC/scripts/train_target_clf.py
-
curl -L -o train_target_clf.py https://huggingface.co/ChatterjeeLab/CupOFMoCA/resolve/main/CupOFMoCA-PROTAC/scripts/train_target_clf.py
8.76 kB
| #!/usr/bin/env python3 | |
| """Train a target-specific PROTAC linker classifier. | |
| For a given protac_module (e.g. ErlotinibVHL, BRD9VHL, CDK6CRBN): | |
| 1. Load PROTAC-DB entries matching the target + E3 pair | |
| 2. Extract linker SMILES from each (strip warhead + E3 substructures) | |
| 3. Train LogisticRegression on Morgan FP (radius=2, 1024 bits) of | |
| target-specific linkers (positives) vs the existing generic negatives | |
| 4. Save as protac_linker_clf_<module>.pkl | |
| DeFoG generation can then load this classifier (when --protac_module matches) | |
| and steer linker generation toward the chemistry distribution that's actually | |
| represented in the target's PROTAC-DB active subset. | |
| Usage: | |
| python train_target_specific_clf.py --protac-module BRD9VHL | |
| python train_target_specific_clf.py --protac-module CDK6CRBN | |
| python train_target_specific_clf.py --protac-module ErlotinibVHL | |
| """ | |
| import argparse | |
| import importlib | |
| import json | |
| import pickle | |
| import sys | |
| from pathlib import Path | |
| import numpy as np | |
| from rdkit import Chem, RDLogger | |
| from rdkit.Chem import AllChem, DataStructs | |
| from sklearn.linear_model import LogisticRegression | |
| RDLogger.DisableLog("rdApp.*") | |
| # Resolve paths relative to the repo layout (scripts/ -> ../src/...). | |
| _REPO_ROOT = Path(__file__).resolve().parent.parent | |
| CLF_DIR = _REPO_ROOT / "src" / "classifier" | |
| PROTACDB_JSON = _REPO_ROOT / "src" / "degrademaster" / "data" / "PROTAC" / "name.json" | |
| _TARGET_FILTER = { | |
| "ErlotinibVHL": ("EGFR", "VHL"), | |
| "BRD9VHL": ("BRD9", "VHL"), | |
| "BRD4VHL": ("BRD4", "VHL"), | |
| "KRASVHL": ("KRAS", "VHL"), | |
| "ARCRBN": ("AR-V7", "CRBN"), | |
| "ARCRBNv2": ("AR-V7", "CRBN"), | |
| "BRD4CRBN": ("BRD4", "CRBN"), | |
| "CDK6CRBN": ("CDK6", "CRBN"), | |
| } | |
| _TERMINI_FOR_NEGATIVES = { | |
| # Use the matching termini's existing negatives so the classifier learns | |
| # to distinguish target-specific linker chemistry from generic junk. | |
| "ErlotinibVHL": "protac_linker_clf.pkl", # cooh | |
| "BRD9VHL": "protac_linker_clf.pkl", # cooh | |
| "BRD4VHL": "protac_linker_clf_amino_acid.pkl", | |
| "KRASVHL": "protac_linker_clf.pkl", | |
| "ARCRBN": "protac_linker_clf_amine.pkl", | |
| "ARCRBNv2": "protac_linker_clf.pkl", # methyl uses generic | |
| "BRD4CRBN": "protac_linker_clf_amine.pkl", | |
| "CDK6CRBN": "protac_linker_clf.pkl", | |
| } | |
| def _morgan_fp(smi): | |
| mol = Chem.MolFromSmiles(smi) | |
| if mol is None: | |
| return None | |
| fp = AllChem.GetMorganFingerprintAsBitVect(mol, radius=2, nBits=1024) | |
| arr = np.zeros(1024, dtype=np.float32) | |
| DataStructs.ConvertToNumpyArray(fp, arr) | |
| return arr | |
| def _load_protac_module(module_name): | |
| sys.path.insert(0, str(CLF_DIR / "protac")) | |
| mod = importlib.import_module(module_name) | |
| # Prefer the loose SMARTS (WARHEAD_PAT / E3_PAT) which match the core scaffold | |
| # — full WARHEAD_MOL only matches one specific variant, missing PROTAC-DB | |
| # diversity (e.g., gefitinib vs erlotinib both = "EGFR" but fail strict match). | |
| wh = getattr(mod, "WARHEAD_PAT", None) or mod.WARHEAD_MOL | |
| e3 = getattr(mod, "E3_PAT", None) or mod.E3_MOL | |
| return wh, e3 | |
| def _extract_linker_smi(protac_mol, warhead_pat, e3_pat): | |
| """Get all atoms in PROTAC NOT belonging to either warhead-pattern or E3-pattern matches. | |
| Take the largest connected fragment after stripping. Returns None on ambiguity.""" | |
| wh_atoms = set() | |
| for match in protac_mol.GetSubstructMatches(warhead_pat): | |
| wh_atoms.update(match) | |
| e3_atoms = set() | |
| for match in protac_mol.GetSubstructMatches(e3_pat): | |
| e3_atoms.update(match) | |
| if not wh_atoms or not e3_atoms: | |
| return None | |
| lk_atoms = set(range(protac_mol.GetNumAtoms())) - wh_atoms - e3_atoms | |
| if not lk_atoms: | |
| return None | |
| # Bonds at the linker boundary (exactly one endpoint in lk_atoms) | |
| bond_ids = [ | |
| b.GetIdx() for b in protac_mol.GetBonds() | |
| if (b.GetBeginAtomIdx() in lk_atoms) != (b.GetEndAtomIdx() in lk_atoms) | |
| ] | |
| if not bond_ids: | |
| return None | |
| try: | |
| broken = Chem.FragmentOnBonds(protac_mol, bond_ids, addDummies=False) | |
| # Find the fragment containing the linker atoms | |
| frag_atom_lists = Chem.GetMolFrags(broken, asMols=False) | |
| for atom_list in frag_atom_lists: | |
| if set(atom_list).issubset(lk_atoms): | |
| frag_atoms = list(atom_list) | |
| if len(frag_atoms) >= 3: # min linker size | |
| sub_mol = Chem.PathToSubmol(broken, [b.GetIdx() for b in broken.GetBonds() | |
| if b.GetBeginAtomIdx() in frag_atoms | |
| and b.GetEndAtomIdx() in frag_atoms]) | |
| try: | |
| Chem.SanitizeMol(sub_mol) | |
| smi = Chem.MolToSmiles(sub_mol) | |
| if smi and '.' not in smi: | |
| return smi | |
| except Exception: | |
| pass | |
| # Fallback: take all fragments, find the one with exact linker atom count | |
| frags = Chem.GetMolFrags(broken, asMols=True) | |
| for frag in frags: | |
| if frag.GetNumHeavyAtoms() == len(lk_atoms): | |
| Chem.SanitizeMol(frag) | |
| return Chem.MolToSmiles(frag) | |
| except Exception: | |
| return None | |
| return None | |
| def main(): | |
| ap = argparse.ArgumentParser() | |
| ap.add_argument("--protac-module", required=True, choices=list(_TARGET_FILTER.keys())) | |
| ap.add_argument("--out", default=None, | |
| help="Output .pkl path (default: protac_linker_clf_<module>.pkl)") | |
| args = ap.parse_args() | |
| module = args.protac_module | |
| target, e3 = _TARGET_FILTER[module] | |
| out_path = Path(args.out) if args.out else CLF_DIR / f"protac_linker_clf_{module}.pkl" | |
| print(f"=== Training target-specific classifier for {module} ({target}+{e3}) ===") | |
| warhead_mol, e3_mol = _load_protac_module(module) | |
| # 1. Load PROTAC-DB target+E3 entries (positives) | |
| with open(PROTACDB_JSON) as f: | |
| name = json.load(f) | |
| target_entries = [n for n in name.values() | |
| if target in n.get("tar_path", "") | |
| and e3 in n.get("e3_ligase_path", "") | |
| and "smiles" in n] | |
| print(f" PROTAC-DB {target}+{e3} entries: {len(target_entries)}") | |
| # 2. Extract linkers | |
| linker_smis = set() | |
| for n in target_entries: | |
| m = Chem.MolFromSmiles(n["smiles"]) | |
| if m is None: | |
| continue | |
| lk_smi = _extract_linker_smi(m, warhead_mol, e3_mol) | |
| if lk_smi: | |
| linker_smis.add(lk_smi) | |
| pos_smiles = sorted(linker_smis) | |
| print(f" Extracted unique target-specific linkers (positives): {len(pos_smiles)}") | |
| if len(pos_smiles) < 10: | |
| print(f" ⚠ Only {len(pos_smiles)} positives — classifier may overfit. Consider " | |
| f"augmenting with similar linkers or using termini-default classifier.") | |
| # 3. Load negatives from existing termini-matched classifier | |
| neg_pkl = CLF_DIR / _TERMINI_FOR_NEGATIVES[module] | |
| with open(neg_pkl, "rb") as f: | |
| existing = pickle.load(f) | |
| neg_smiles = list(existing["neg_smiles"]) | |
| print(f" Negatives (from {neg_pkl.name}): {len(neg_smiles)}") | |
| # 4. Featurize + train | |
| X_pos = [_morgan_fp(s) for s in pos_smiles] | |
| X_pos = [x for x in X_pos if x is not None] | |
| X_neg = [_morgan_fp(s) for s in neg_smiles] | |
| X_neg = [x for x in X_neg if x is not None] | |
| X = np.vstack(X_pos + X_neg) | |
| y = np.concatenate([np.ones(len(X_pos), dtype=int), | |
| np.zeros(len(X_neg), dtype=int)]) | |
| print(f" Training set: {len(X_pos)} pos / {len(X_neg)} neg, {X.shape[1]}-dim FP") | |
| # Class-balanced LogReg (since pos is much smaller than neg) | |
| clf = LogisticRegression(class_weight="balanced", max_iter=2000) | |
| clf.fit(X, y) | |
| train_acc = clf.score(X, y) | |
| print(f" Training accuracy: {train_acc*100:.1f}%") | |
| # Sanity: predict prob on a few positives + negatives | |
| sample_pos = clf.predict_proba(X_pos[:5])[:, 1] | |
| sample_neg = clf.predict_proba(X_neg[:5])[:, 1] | |
| print(f" Sample pos probs: {[f'{p:.3f}' for p in sample_pos]}") | |
| print(f" Sample neg probs: {[f'{p:.3f}' for p in sample_neg]}") | |
| # 5. Save | |
| with open(out_path, "wb") as f: | |
| pickle.dump({ | |
| "clf": clf, | |
| "pos_smiles": pos_smiles, | |
| "neg_smiles": neg_smiles, | |
| "module": module, | |
| "target": target, | |
| "e3": e3, | |
| "n_pos": len(X_pos), | |
| "n_neg": len(X_neg), | |
| }, f) | |
| print(f" Saved → {out_path}") | |
| if __name__ == "__main__": | |
| main() | |