CoolFace
Apppublic

mondalsou/lead_optimization_agent

sourceHugging Faceupdated 6mo agoView on Hugging Face
0likes
agent_utils.py511 linesDownload Raw Back to root
1"""2Lead Optimization Agent — Utility Functions3============================================4Thin wrappers around RDKit, exposing ADMET analysis as tool-callable5functions for the Claude agent. Fully local — no external API required.6"""7import math8from typing import Optional9 10try:11    from rdkit import Chem12    from rdkit.Chem import Descriptors, rdMolDescriptors, QED, Crippen13    from rdkit.Chem.FilterCatalog import FilterCatalog, FilterCatalogParams14    RDKIT_AVAILABLE = True15except ImportError:16    RDKIT_AVAILABLE = False17    print("Warning: RDKit not available. Install with: conda install -c conda-forge rdkit")18 19 20# ─────────────────────────────────────────────────────────────────────────────21# Internal: Local ADMET Computation (pure RDKit, no network)22# ─────────────────────────────────────────────────────────────────────────────23 24def _des(val: float, lo: float, hi: float, lo_d: float = 1.0, hi_d: float = 0.0) -> float:25    """Linear desirability: clamps to [lo_d, hi_d] outside [lo, hi] range."""26    if lo_d >= hi_d:  # decreasing: higher val → lower desirability27        if val <= lo:28            return lo_d29        if val >= hi:30            return hi_d31    else:              # increasing: higher val → higher desirability32        if val <= lo:33            return lo_d34        if val >= hi:35            return hi_d36    t = (val - lo) / (hi - lo)37    return lo_d + t * (hi_d - lo_d)38 39 40def _has_smarts(mol, smarts: str) -> bool:41    try:42        pat = Chem.MolFromSmarts(smarts)43        return pat is not None and mol.HasSubstructMatch(pat)44    except Exception:45        return False46 47 48 49def _cyp_flags(mol) -> dict:50    """Heuristic CYP450 substrate/inhibitor flags using SMARTS."""51    # CYP3A4 substrate: large + aromatic + heteroatom (most drugs)52    mw = Descriptors.ExactMolWt(mol)53    has_ar = _has_smarts(mol, "a")54    has_n_or_o = _has_smarts(mol, "[N,O]")55    cyp3a4 = mw > 300 and has_ar and has_n_or_o56 57    # CYP2D6 substrate: basic N within 5 bonds of aromatic ring58    cyp2d6 = _has_smarts(mol, "[NX3;H0,H1,H2;!$(NC=O)]~*~*~a") or \59              _has_smarts(mol, "[NX3;H0,H1,H2;!$(NC=O)]~*~a")60 61    # Inhibitor risk: imidazole, pyridine N, or extended pi system62    cyp_inhib = (63        _has_smarts(mol, "n1ccnc1")   # imidazole-like64        or _has_smarts(mol, "n1ccccc1")  # pyridine65        or _has_smarts(mol, "c1ccc2ccccc2c1")  # naphthalene / fused rings66    )67 68    return {69        "cyp3a4_substrate": cyp3a4,70        "cyp2d6_substrate": cyp2d6,71        "cyp2c9_substrate": has_ar and _has_smarts(mol, "C(=O)[OH]"),  # carboxylic acid + ring72        "cyp_inhibitor_risk": cyp_inhib,73    }74 75 76def _esol_log_s(mol, mw: float, clogp: float, rb: int) -> float:77    """78    ESOL (Delaney 2004) solubility estimate:79    logS = 0.16 − 0.63·cLogP − 0.0062·MW + 0.066·RB − 0.74·AP80    AP = fraction aromatic carbons (proxy for aromaticity penalty)81    """82    ap = sum(1 for a in mol.GetAtoms()83             if a.GetIsAromatic() and a.GetAtomicNum() == 6) / max(mol.GetNumHeavyAtoms(), 1)84    return 0.16 - 0.63 * clogp - 0.0062 * mw + 0.066 * rb - 0.74 * ap85 86 87def _solubility_class(log_s: float) -> str:88    if log_s >= -1:   return "Highly Soluble"89    if log_s >= -2:   return "Soluble"90    if log_s >= -4:   return "Moderately Soluble"91    if log_s >= -6:   return "Poorly Soluble"92    return "Insoluble"93 94 95def _gi_absorption(tpsa: float, rb: int, mw: float, clogp: float, hbd: int) -> dict:96    """97    GI absorption: High if Veber rules pass AND Lipinski-like.98    Veber (2002): TPSA ≤ 140 AND rotatable bonds ≤ 1099    Also penalise if multiple Lipinski violations.100    """101    veber = tpsa <= 140 and rb <= 10102    lipinski_ok = mw <= 500 and clogp <= 5 and hbd <= 5103    if veber and lipinski_ok:104        absorption = "High"105        score = 0.85 + 0.15 * max(0, (140 - tpsa) / 140)106    elif veber or lipinski_ok:107        absorption = "Moderate"108        score = 0.55109    else:110        absorption = "Low"111        score = 0.20112    return {"absorption": absorption, "bioavailability_score": round(score, 2)}113 114 115def _bbb_probability(clogp: float, mw: float, tpsa: float, hbd: int) -> dict:116    """117    BBB penetration probability from rule-based weighted desirability.118    Reference ranges: Pajouhesh & Lenz (2005), Wager CNS MPO.119    """120    # Each parameter contributes 0-1 desirability121    d_logp = _des(clogp, 0.0, 5.0, 0.1, 1.0)   # cLogP 0-5 (peak ~2-3)122    if clogp > 3:123        d_logp = _des(clogp, 3.0, 5.0, 1.0, 0.3)124 125    d_mw   = _des(mw,   200.0, 450.0, 0.7, 0.0)  # MW < 400 strongly preferred126    d_tpsa = _des(tpsa, 40.0,  90.0,  1.0, 0.0)  # TPSA < 60 ideal127    d_hbd  = _des(float(hbd), 0.0, 3.0, 1.0, 0.0)128 129    # Weighted average (TPSA and HBD are most important for BBB)130    prob = 0.20 * d_logp + 0.20 * d_mw + 0.35 * d_tpsa + 0.25 * d_hbd131    prob = round(max(0.05, min(0.98, prob)), 3)132    penetrates = prob >= 0.5133 134    confidence = "High" if prob > 0.75 or prob < 0.25 else "Moderate"135    return {"penetrates": penetrates, "probability": prob, "confidence": confidence}136 137 138def _cns_mpo(clogp: float, mw: float, tpsa: float, hbd: int) -> dict:139    """140    CNS MPO score — Wager et al. 2010 (Pfizer), 5-parameter version.141    Each parameter contributes 0-1 desirability, summed to max 5.142    (pKa excluded — cannot be reliably computed from SMILES alone.)143    """144    d1 = _des(clogp,       3.0, 5.0, 1.0, 0.0)   # cLogP ≤ 3 → 1, ≥ 5 → 0145    d2 = _des(clogp,       1.0, 3.0, 0.0, 1.0)   # cLogD ≈ cLogP (neutral approx)146    if clogp > 3.0:147        d2 = _des(clogp,   3.0, 5.0, 1.0, 0.0)148    d3 = _des(mw,        360.0, 500.0, 1.0, 0.0)  # MW ≤ 360 → 1, ≥ 500 → 0149    d4 = _des(tpsa,       40.0,  90.0, 1.0, 0.0)  # TPSA ≤ 40 → 1, ≥ 90 → 0150    d5 = _des(float(hbd),  0.5,   3.5, 1.0, 0.0)  # HBD ≤ 0.5 → 1, ≥ 3.5 → 0151 152    score = round(d1 + d2 + d3 + d4 + d5, 2)153 154    if score >= 4.0:155        cns_class = "CNS+"156    elif score >= 3.0:157        cns_class = "CNS Borderline"158    else:159        cns_class = "CNS-"160 161    return {"score": score, "cns_class": cns_class}162 163 164def _lipinski_check(mw: float, clogp: float, hbd: int, hba: int) -> tuple:165    """Returns (rules list, summary string)."""166    rules = [167        {"name": "MW ≤ 500",        "value": mw,    "pass": mw    <= 500},168        {"name": "cLogP ≤ 5",       "value": clogp, "pass": clogp <= 5},169        {"name": "HBD ≤ 5",         "value": hbd,   "pass": hbd   <= 5},170        {"name": "HBA ≤ 10",        "value": hba,   "pass": hba   <= 10},171    ]172    n_fail = sum(1 for r in rules if not r["pass"])173    summary = "Pass" if n_fail == 0 else ("Borderline" if n_fail == 1 else "Fail")174    return rules, summary175 176 177def _pains_alerts(mol) -> list:178    """Return PAINS alert names using RDKit FilterCatalog."""179    try:180        params = FilterCatalogParams()181        params.AddCatalog(FilterCatalogParams.FilterCatalogs.PAINS)182        catalog = FilterCatalog(params)183        entries = catalog.GetMatches(mol)184        return [{"name": e.GetDescription(), "description": "PAINS structural alert"} for e in entries]185    except Exception:186        return []187 188 189def _sa_score(mol, mw: float) -> tuple:190    """191    Synthetic accessibility heuristic.192    Rough inverse of complexity: penalise rings, stereocenters, MW.193    Returns (score 1-10, class string). Lower = easier to synthesise.194    """195    ring_count = rdMolDescriptors.CalcNumRings(mol)196    spiro = rdMolDescriptors.CalcNumSpiroAtoms(mol)197    stereo = len(Chem.FindMolChiralCenters(mol, includeUnassigned=True))198    heavy = mol.GetNumHeavyAtoms()199 200    complexity = (0.02 * mw + 0.5 * ring_count + 1.0 * spiro + 0.7 * stereo)201    score = min(10.0, max(1.0, complexity))202    score = round(score, 1)203 204    if score <= 3:205        sa_class = "Easy"206    elif score <= 6:207        sa_class = "Moderate"208    else:209        sa_class = "Difficult"210 211    return score, sa_class212 213 214def _fail_fast_score(lipinski_summary: str, num_alerts: int,215                     mw: float, tpsa: float, clogp: float) -> float:216    """217    Composite risk score 0-10 (higher = more concerning).218    """219    score = 0.0220    score += {"Pass": 0, "Borderline": 2, "Fail": 5}.get(lipinski_summary, 0)221    score += min(num_alerts * 2, 4)222    if mw > 600:   score += 2223    if tpsa > 140: score += 1224    if clogp > 5:  score += 1225    if clogp < 0:  score += 1226    return round(min(score, 10.0), 1)227 228 229def _decision(fail_fast: float, bbb_prob: float, qed_score: float,230              cns_mpo: float, num_alerts: int) -> tuple:231    """232    Returns (decision: str, rationale: str).233    Decision: 'Progress' | 'Optimize' | 'Kill'234    """235    if fail_fast >= 7 or num_alerts >= 2:236        return (237            "Kill",238            f"High risk score ({fail_fast}/10) or PAINS alerts ({num_alerts}) "239            "indicate major liabilities. Not worth further optimization."240        )241    if qed_score >= 0.6 and cns_mpo >= 4.0 and bbb_prob >= 0.5:242        return (243            "Progress",244            f"Solid drug-likeness (QED {qed_score:.2f}), "245            f"CNS MPO {cns_mpo:.1f}, BBB probability {bbb_prob:.2f}. "246            "Molecule shows promise — continue optimization."247        )248    return (249        "Optimize",250        f"Some properties need improvement: QED {qed_score:.2f}, "251        f"CNS MPO {cns_mpo:.1f}, BBB prob {bbb_prob:.2f}. "252        "Targeted structural changes can address the gaps."253    )254 255 256def _optimization_suggestions(clogp: float, mw: float, tpsa: float,257                               hbd: int, bbb_prob: float, cns_mpo: float,258                               qed_score: float, rb: int) -> list:259    """Generate context-specific optimization suggestions."""260    tips = []261    if bbb_prob < 0.5:262        if tpsa > 90:263            tips.append({"text": "Reduce polar surface area (TPSA > 90 Ų) — consider replacing amide/OH with ester or nitrile bioisostere"})264        if hbd > 1:265            tips.append({"text": f"Reduce H-bond donors ({hbd}) — replace NH/OH groups with methylated analogues or bioisosteres"})266        if mw > 400:267            tips.append({"text": f"Reduce molecular weight ({mw:.0f} Da) — remove non-essential substituents"})268    if clogp < 1.0:269        tips.append({"text": "Increase lipophilicity (cLogP too low for good CNS penetration) — add small alkyl or fluoro groups"})270    elif clogp > 4.5:271        tips.append({"text": "Reduce lipophilicity (cLogP too high risks off-target effects) — add polar groups or replace alkyl with heteroatom"})272    if qed_score < 0.6:273        tips.append({"text": f"Drug-likeness (QED {qed_score:.2f}) is suboptimal — simplify structure and reduce MW toward 300-400 Da range"})274    if cns_mpo < 4.0:275        tips.append({"text": f"CNS MPO score ({cns_mpo:.1f}) below threshold — target TPSA < 60, HBD ≤ 1, cLogP 1-3, MW < 400"})276    if rb > 8:277        tips.append({"text": f"Too many rotatable bonds ({rb}) — constrain flexible chains by ring formation or rigidification"})278    if not tips:279        tips.append({"text": "Properties are well-balanced — fine-tune specific targets while preserving overall profile"})280    return tips[:4]  # cap at 4 suggestions281 282 283def analyze_local(smiles: str) -> dict:284    """285    Compute a full ADMET profile for a SMILES string using local RDKit.286    Returns a dict in the same shape as the Drug Discovery Triage API response,287    so extract_key_scores() works without any changes.288    """289    if not RDKIT_AVAILABLE:290        return {"error": "RDKit not installed. Run: conda install -c conda-forge rdkit"}291 292    mol = Chem.MolFromSmiles(smiles.strip())293    if mol is None:294        return {"error": f"Invalid SMILES: RDKit could not parse '{smiles[:60]}'"}295 296    # ── Physicochemical ───────────────────────────────────────────────────────297    canonical = Chem.MolToSmiles(mol)298    mw        = round(Descriptors.ExactMolWt(mol), 2)299    clogp     = round(Crippen.MolLogP(mol), 2)300    tpsa      = round(rdMolDescriptors.CalcTPSA(mol), 1)301    hbd       = rdMolDescriptors.CalcNumHBD(mol)302    hba       = rdMolDescriptors.CalcNumHBA(mol)303    rb        = rdMolDescriptors.CalcNumRotatableBonds(mol)304    fsp3      = round(rdMolDescriptors.CalcFractionCSP3(mol), 3)305    ring_ct   = rdMolDescriptors.CalcNumRings(mol)306    ar_ring   = rdMolDescriptors.CalcNumAromaticRings(mol)307 308    # ── QED ───────────────────────────────────────────────────────────────────309    qed_val   = round(QED.qed(mol), 3)310    if qed_val >= 0.67:311        qed_class = "Drug-like"312    elif qed_val >= 0.34:313        qed_class = "Moderate"314    else:315        qed_class = "Poor"316 317    # ── Lipinski ──────────────────────────────────────────────────────────────318    lip_rules, lip_summary = _lipinski_check(mw, clogp, hbd, hba)319 320    # ── PAINS alerts ──────────────────────────────────────────────────────────321    alerts = _pains_alerts(mol)322 323    # ── ADMET ─────────────────────────────────────────────────────────────────324    log_s        = round(_esol_log_s(mol, mw, clogp, rb), 2)325    sol_class    = _solubility_class(log_s)326    gi           = _gi_absorption(tpsa, rb, mw, clogp, hbd)327    bbb          = _bbb_probability(clogp, mw, tpsa, hbd)328    cns          = _cns_mpo(clogp, mw, tpsa, hbd)329    cyp          = _cyp_flags(mol)330 331    # ── Synthetic accessibility & scoring ─────────────────────────────────────332    sa_score, sa_class = _sa_score(mol, mw)333    fail_fast    = _fail_fast_score(lip_summary, len(alerts), mw, tpsa, clogp)334    decision, rationale = _decision(fail_fast, bbb["probability"], qed_val,335                                    cns["score"], len(alerts))336    suggestions  = _optimization_suggestions(clogp, mw, tpsa, hbd,337                                             bbb["probability"], cns["score"],338                                             qed_val, rb)339 340    return {341        # ── Identity ──────────────────────────────────────────────────────────342        "canonical_smiles": canonical,343        # ── Physicochemical ───────────────────────────────────────────────────344        "molecular_weight": mw,345        "clogp":            clogp,346        "tpsa":             tpsa,347        "hbd":              hbd,348        "hba":              hba,349        "rotatable_bonds":  rb,350        "fraction_sp3":     fsp3,351        "ring_count":       ring_ct,352        "aromatic_ring_count": ar_ring,353        # ── Drug-likeness ─────────────────────────────────────────────────────354        "qed": {"qed_score": qed_val, "qed_class": qed_class},355        "lipinski_rules":   lip_rules,356        "lipinski_summary": lip_summary,357        "alerts":           alerts,358        "synthetic_accessibility_score": sa_score,359        "synthetic_accessibility_class": sa_class,360        "fail_fast_score":  fail_fast,361        "decision":         decision,362        "decision_rationale": rationale,363        "optimization_suggestions": suggestions,364        # ── ADMET ─────────────────────────────────────────────────────────────365        "admet": {366            "solubility": {367                "log_s":            log_s,368                "solubility_class": sol_class,369            },370            "gi_absorption": gi,371            "bbb_penetration": bbb,372            "cns_mpo": cns,373            "cyp_metabolism": cyp,374            "toxicity": {"endpoints": {}},375        },376    }377 378 379# ─────────────────────────────────────────────────────────────────────────────380# Tool 1: Analyze Molecule  (local RDKit — no network required)381# ─────────────────────────────────────────────────────────────────────────────382def call_admet_api(smiles: str, timeout: int = 90) -> dict:383    """384    Analyze a SMILES string and return full ADMET profile.385    Computed locally via RDKit — instant, no API key or network needed.386    (timeout arg kept for API compatibility but unused)387    """388    return analyze_local(smiles.strip())389 390 391# ─────────────────────────────────────────────────────────────────────────────392# Tool 2: Validate SMILES  (local RDKit, no API cost)393# ─────────────────────────────────────────────────────────────────────────────394def is_valid_smiles(smiles: str) -> dict:395    """396    Validate a SMILES string using RDKit (fast, offline).397    The agent should always call this before analyze_molecule.398    """399    if not RDKIT_AVAILABLE:400        return {"valid": True, "note": "RDKit unavailable — skipping local validation"}401    try:402        mol = Chem.MolFromSmiles(smiles.strip())403        if mol is None:404            return {"valid": False, "error": "RDKit could not parse this SMILES string"}405        if mol.GetNumHeavyAtoms() == 0:406            return {"valid": False, "error": "Empty SMILES — no atoms found"}407        return {408            "valid": True,409            "canonical_smiles": Chem.MolToSmiles(mol),410            "num_heavy_atoms": mol.GetNumHeavyAtoms(),411        }412    except Exception as e:413        return {"valid": False, "error": str(e)}414 415 416# ─────────────────────────────────────────────────────────────────────────────417# Score Extraction  (flatten full API response → concise dict)418# ─────────────────────────────────────────────────────────────────────────────419def extract_key_scores(response: dict) -> dict:420    """421    Flatten the full API response into the metrics the agent needs to reason about.422    Includes physicochemical properties, ADMET, toxicity, and the API's own decision.423    """424    if "error" in response:425        return response426 427    admet = response.get("admet", {})428    cyp   = admet.get("cyp_metabolism", {})429    alerts = response.get("alerts", [])430    alert_names = [a.get("name", "") for a in alerts] if isinstance(alerts, list) else []431    suggestions = [s.get("text", "") for s in response.get("optimization_suggestions", [])]432 433    qed_block = response.get("qed", {})434    qed_score = (qed_block.get("qed_score") or 0) if isinstance(qed_block, dict) else 0435 436    return {437        # ── Identity ──────────────────────────────────────────────────────────438        "canonical_smiles":   response.get("canonical_smiles", ""),439        # ── Physicochemical ───────────────────────────────────────────────────440        "molecular_weight":   round(response.get("molecular_weight", 0), 1),441        "clogp":              round(response.get("clogp", 0), 2),442        "tpsa":               round(response.get("tpsa", 0), 1),443        "hbd":                response.get("hbd", 0),444        "hba":                response.get("hba", 0),445        "rotatable_bonds":    response.get("rotatable_bonds", 0),446        # ── Drug-likeness ─────────────────────────────────────────────────────447        "qed_score":          round(float(qed_score), 3),448        "qed_class":          qed_block.get("qed_class", "") if isinstance(qed_block, dict) else "",449        "lipinski_summary":   response.get("lipinski_summary", "Unknown"),450        "fail_fast_score":    round(response.get("fail_fast_score", 0), 1),451        "decision":           response.get("decision", "Unknown"),452        "decision_rationale": response.get("decision_rationale", ""),453        # ── ADMET ─────────────────────────────────────────────────────────────454        "solubility_class":   admet.get("solubility", {}).get("solubility_class", "Unknown"),455        "log_s":              admet.get("solubility", {}).get("log_s", None),456        "gi_absorption":      admet.get("gi_absorption", {}).get("absorption", "Unknown"),457        "bbb_penetrates":     admet.get("bbb_penetration", {}).get("penetrates", False),458        "bbb_probability":    round(admet.get("bbb_penetration", {}).get("probability", 0), 3),459        "cns_mpo_score":      round(admet.get("cns_mpo", {}).get("score", 0), 2),460        "cns_class":          admet.get("cns_mpo", {}).get("cns_class", "Unknown"),461        # ── Toxicity ──────────────────────────────────────────────────────────462        "alerts":             alert_names,463        "num_alerts":         len(alert_names),464        "cyp3a4_substrate":   cyp.get("cyp3a4_substrate", False),465        "cyp_inhibitor_risk": cyp.get("cyp_inhibitor_risk", False),466        # ── API's own suggestions (bonus context for the agent) ───────────────467        "api_suggestions":    suggestions,468    }469 470 471# ─────────────────────────────────────────────────────────────────────────────472# Tool 3: Compare Candidates473# ─────────────────────────────────────────────────────────────────────────────474def compare_candidates(smiles_list: list, labels: list = None) -> dict:475    """476    Analyze multiple SMILES and return side-by-side key scores.477    Used by the agent to rank candidates across optimization rounds.478    """479    if not labels:480        labels = [f"Candidate {i}" for i in range(len(smiles_list))]481 482    results = []483    for smiles, label in zip(smiles_list, labels):484        resp = call_admet_api(smiles)485        entry = extract_key_scores(resp) if "error" not in resp else resp486        entry["label"] = label487        results.append(entry)488 489    return {"candidates": results, "count": len(results)}490 491 492# ─────────────────────────────────────────────────────────────────────────────493# Tool Dispatcher  (called by the agent loop)494# ─────────────────────────────────────────────────────────────────────────────495def tool_executor(tool_name: str, tool_input: dict) -> dict:496    """Route a Claude tool_use block to the correct function."""497    if tool_name == "analyze_molecule":498        resp = call_admet_api(tool_input["smiles"])499        return extract_key_scores(resp) if "error" not in resp else resp500 501    elif tool_name == "validate_smiles":502        return is_valid_smiles(tool_input["smiles"])503 504    elif tool_name == "compare_candidates":505        return compare_candidates(506            tool_input["smiles_list"],507            tool_input.get("labels"),508        )509 510    return {"error": f"Unknown tool: '{tool_name}'"}511