File size: 8,390 Bytes
5f76b5d | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 | # -*- coding: utf-8 -*-
"""Season 1 admission gates.
Gates decide eligibility, never quality. Anything that reflects how good a molecule is
belongs in the continuous score; a gate only answers "is this a valid Season 1 entry".
Failing a gate does not put a submission at the bottom of the table - it means the entry
is returned, because a covalent warhead in a non-covalent season is not a bad candidate,
it is the wrong kind of candidate.
The one exception is ADMET, and it is deliberate: a candidate that fails a toxicity gate
is still scored in full and still shown, but sorts below every clean entry no matter how
high that score is. A molecule that binds beautifully and is mutagenic is not a lead.
Every threshold here was checked against a panel of approved drugs first. A gate that
rejects an approved drug is a broken gate, and two of the obvious candidates - hERG and
DILI - were thrown out for exactly that reason: our own ADMET model scores ensitrelvir
at hERG 0.978 and DILI 0.995, and caffeine at hERG 0.885. See data/anchors.json.
"""
from rdkit import Chem, RDLogger
from rdkit.Chem import Descriptors
from rdkit.Chem.FilterCatalog import FilterCatalog, FilterCatalogParams
RDLogger.DisableLog("rdApp.*")
_p = FilterCatalogParams()
_p.AddCatalog(FilterCatalogParams.FilterCatalogs.PAINS)
_PAINS = FilterCatalog(_p)
MW_MAX = 550.0 # ensitrelvir is 531.9; a 500 cap would disqualify the reference drug
HEAVY_MAX = 45
AMES_MAX = 0.7 # panel range was 0.024-0.302, so this leaves real headroom
LOGS_MIN = -7.0 # panel low was -5.42
# Electrophiles that form a covalent bond with the catalytic residue. Season 1 excludes
# them: they are a different medicinal-chemistry problem, and a docking-based score
# rewards raw reactivity in ways that are easy to game.
WARHEADS = [
# A nitrile on its own is not a warhead. Teriflunomide is an approved, non-covalent
# DHODH inhibitor and a bare [CX2]#[NX1] pattern rejected it - the same mistake as
# gating on hERG, caught the same way, by running an approved drug through first.
# What actually reacts with a catalytic cysteine is the peptidyl nitrile: the nitrile
# carbon sitting on an sp3 centre that carries the amide nitrogen, as in nirmatrelvir.
("peptidyl nitrile", "[NX3][CX4][CX2]#[NX1]"),
# Michael acceptors must be terminal to react. The general C=C-C(=O)N pattern also
# matches teriflunomide, whose alkene is an enol stabilised by an adjacent nitrile
# and hydroxyl - an approved drug, and not an electrophile.
("acrylamide", "[CH2X3]=[CHX3][CX3](=O)[NX3]"),
("vinyl sulfone", "[CX3]=[CX3][SX4](=O)(=O)"),
("aldehyde", "[CX3H1](=O)[#6]"),
("epoxide", "[OX2r3]1[#6r3][#6r3]1"),
("aziridine", "[NX3r3]1[#6r3][#6r3]1"),
("haloacetamide", "[NX3][CX3](=O)[CH2][F,Cl,Br,I]"),
("boronic acid", "[BX3]([OX2H1])[OX2H1]"),
("alpha-keto amide", "[CX3](=O)[CX3](=O)[NX3]"),
]
_WARHEADS = [(n, Chem.MolFromSmarts(s)) for n, s in WARHEADS]
def canonical(text):
"""Accept SMILES or InChI. A molecular formula cannot be accepted - too many isomers."""
t = (text or "").strip()
if not t:
return None, "๋น ์
๋ ฅ"
m = Chem.MolFromInchi(t) if t.upper().startswith("INCHI=") else Chem.MolFromSmiles(t)
if m is None:
return None, "๊ตฌ์กฐ๋ฅผ ํด์ํ ์ ์์ต๋๋ค (SMILES ๋๋ InChI๋ก ์ ์ถํ์ธ์; ๋ถ์์์ ์ด์ฑ์ง์ฒด๊ฐ ๋ง์ ํ๊ฐ ๋ถ๊ฐ)"
return m, None
def check(text, ames=None, logs=None, known_inchikeys=(), covalent_rule=True,
mw_max=None, heavy_max=None, pains_allow=None):
"""Return a verdict dict. `ames`/`logs` come from the ADMET engine; omit them and
those two gates are simply reported as not evaluated rather than silently passed.
`covalent_rule` is target-scoped on purpose. Excluding covalent binders makes sense
for Mpro, where warheads react with a catalytic cysteine and a docking score can be
gamed by raw reactivity. PfDHODH is not inhibited that way, so for Season 1 the rule
buys nothing and only creates false rejections - two approved drugs were wrongly
turned away here before the patterns were tightened.
`mw_max`, `heavy_max` and `pains_allow` are per-season and default to the module
constants, which are Season 1's. They are arguments rather than module state because
the Space is one process serving every season at once: reading them from the
environment would give Season 3's limits to Season 1's entries. seasons.py carries
each season's values and app.py passes them through."""
m, err = canonical(text)
if m is None:
return {"admitted": False,
"reject": [{"code": "parse", "text": err}], "relegate": []}
smi = Chem.MolToSmiles(m)
key = Chem.MolToInchiKey(m)
mw = Descriptors.MolWt(m)
heavy = m.GetNumHeavyAtoms()
reject, relegate, notes = [], [], []
# Each reason carries a code as well as a sentence. The sentence is a fallback; the
# code is what lets the page say it in the reader's language.
for name, patt in (_WARHEADS if covalent_rule else []):
if patt is not None and m.HasSubstructMatch(patt):
reject.append({"code": "covalent", "arg": name,
"text": "๊ณต์ ๊ฒฐํฉ warhead ๊ฒ์ถ (%s) โ Season 1์ ๋น๊ณต์ ํธ๋์
๋๋ค" % name})
break
mw_cap = MW_MAX if mw_max is None else float(mw_max)
heavy_cap = HEAVY_MAX if heavy_max is None else int(heavy_max)
# no module-level default here on purpose: in the Space every season's limits arrive as
# arguments, and a module constant is exactly the thing that would leak across seasons
allow = tuple(pains_allow or ())
if mw > mw_cap:
reject.append({"code": "mw", "arg": "%.1f" % mw, "limit": mw_cap,
"text": "๋ถ์๋ %.1f > %.0f" % (mw, mw_cap)})
if heavy > heavy_cap:
reject.append({"code": "heavy", "arg": heavy, "limit": heavy_cap,
"text": "๋ฌด๊ฑฐ์ด ์์ %d > %d" % (heavy, heavy_cap)})
hits = [h for h in _PAINS.GetMatches(m)
if not any(h.GetDescription().startswith(a) for a in allow)]
if hits:
d = ", ".join(h.GetDescription() for h in hits)[:80]
reject.append({"code": "pains", "arg": d, "text": "PAINS ๊ตฌ์กฐ (%s)" % d})
if key in set(known_inchikeys):
reject.append({"code": "duplicate",
"text": "๊ธฐ์กด ์ ์ถ ๋๋ ๊ธฐ์ค๋ฌผ์ง๊ณผ ๋์ผํ ๊ตฌ์กฐ์
๋๋ค"})
# ADMET does not reject. It relegates: full score, shown, but below every clean entry.
if ames is None:
notes.append("Ames ๋ฏธํ๊ฐ")
elif ames > AMES_MAX:
relegate.append({"code": "ames", "arg": "%.3f" % ames, "limit": AMES_MAX,
"text": "๋ณ์ด์์ฑ(Ames) %.3f > %.2f" % (ames, AMES_MAX)})
if logs is None:
notes.append("์ฉํด๋ ๋ฏธํ๊ฐ")
elif logs < LOGS_MIN:
relegate.append({"code": "solubility", "arg": "%.2f" % logs, "limit": LOGS_MIN,
"text": "๊ทน๋จ์ ๋ถ์ฉ์ฑ (logS %.2f < %.1f)" % (logs, LOGS_MIN)})
return {"admitted": not reject, "reject": reject, "relegate": relegate,
"notes": notes, "smiles": smi, "inchikey": key,
"mw": round(mw, 2), "heavy_atoms": heavy}
if __name__ == "__main__":
import json, os, sys
# the Windows console defaults to cp949 here and dies on the em-dash in a reject reason
try:
sys.stdout.reconfigure(encoding="utf-8")
except Exception:
pass
A = json.load(open(os.path.join("data", "anchors.json"), encoding="utf-8"))
print("%-30s %-9s %-7s %-6s %s" % ("compound", "admitted", "MW", "heavy", "reason"))
print("-" * 100)
for a in A["anchors"]:
ad = a.get("measured_admet", {})
v = check(a["smiles"], ames=ad.get("ames"), logs=ad.get("solubility-aqsoldb"))
why = "; ".join(v["reject"] + ["[๊ฐ๋ฑ] " + r for r in v["relegate"]]) or "-"
print("%-30s %-9s %-7s %-6s %s"
% (a["label"][:29], "PASS" if v["admitted"] else "REJECT",
v.get("mw"), v.get("heavy_atoms"), why[:60]))
|