Skip to content

Commit 556ba4a

Browse files
feat: Tox21 caution-only tox-assay heads (public domain) + value rationale (#60)
The one "could-add" item that's commercial-clean: Tox21 (NIH/NCATS · EPA · FDA · NTP) toxicity-assay data is public domain. Adds: - train_tox.py: 12 RandomForest heads (genotoxic-stress SR-p53/SR-ATAD5, AhR, mitochondrial, endocrine) on Morgan fingerprints — CV-AUROC 0.72–0.90. - predict.py predict_tox(): INDICATIVE, caution-only flags wired into safety.tox_screen ("active in an in-vitro assay → review", NEVER a determination). Honest available:False until trained; tested (ethanol benign, estragole/safrole show modest receptor activity). - workbench: tox-assay caution chips in the Behavior & safety card. - tests + CAPABILITIES + SOURCES updated. README "Why it's built this way": neutral product rationale (prediction-not-lookup; on-prem privacy; one integrated read; sharpens on your own data) — no prospect names, not a directed pitch. BitterDB (CC-BY-NC) and Kovats RI (no clean open set / paid NIST) stay out of the commercial edition, as documented. Signed-off-by: Austin L. <86896075+rvnminers-A-and-N@users.noreply.github.com>
1 parent 4f77afd commit 556ba4a

7 files changed

Lines changed: 139 additions & 2 deletions

File tree

README.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,23 @@ rule), **estimate** (published QSPR with known error), **lookup** (from a loaded
6767
reference table), and **qualitative** (a class/flag, not a number). Full map in
6868
[docs/CAPABILITIES.md](docs/CAPABILITIES.md).
6969

70+
## Why it's built this way
71+
72+
The value isn't any single number — those you can look up. It's four design choices:
73+
74+
- **Prediction, not lookup.** The trained models read a molecule from its *structure*,
75+
so they answer for a **novel or unmeasured** compound that's in no database — not just
76+
for known ones.
77+
- **On-premise, behind your firewall.** Proprietary candidate structures **never leave
78+
your network**. You can screen confidential molecules without exposing your direction
79+
to any external service — something no public web tool can offer.
80+
- **One integrated read.** Taste, behaviour, safety, and substitution in a single
81+
confidence-tagged screen, instead of stitching together half a dozen databases and
82+
manual checks per molecule.
83+
- **It sharpens on your own data.** The open-data models are the floor. Trained on a
84+
user's *own* formulation and sensory data — on the same on-prem box — they become
85+
specific to that user's products, which no public dataset can be.
86+
7087
## Two editions
7188

7289
Flavormancer ships as two editions of one method:

docs/CAPABILITIES.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,10 @@ by how it's derived, and nothing claims more certainty than its source supports.
5454
**Safety (all defensive, caution-only — never a clearance)**
5555
- Disclaimer + scope on every result.
5656
- Structural tox-alert screen — nitro/N-nitroso/azo/epoxide (**rule**).
57-
- GRAS / approved-ingredient cross-reference (**lookup**, data-gated).
57+
- GRAS / approved-ingredient cross-reference — FDA SAF, public domain, `build_gras_reference.py` (**lookup**).
58+
- **In-vitro tox-assay flags** — 12 Tox21 assays (genotoxic-stress SR-p53/SR-ATAD5, AhR,
59+
mitochondrial, endocrine), `predict_tox()` (**trained**, Tox21 public domain). INDICATIVE
60+
activity for review, **never a determination**.
5861
- Preliminary TTC concern tier (**qualitative** heuristic; Toxtree for the real call).
5962
- EU declarable fragrance-allergen labeling flag — `labeling()` (**lookup**, curated subset).
6063

docs/SOURCES.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,9 @@ record of provenance, not legal advice. Get an IP/OSS-license review before ship
6464
~$2,850 single-seat) — *not used* (paywall); RI deferred to customer/licensed data. (RI
6565
is **not** in PubChem — verified by probe.)
6666
- **Tox21** (NIH/NCATS · EPA · FDA · NTP) — public toxicity-assay data (public domain, on
67-
PubChem / data.gov). A **clean candidate** for caution-only tox flags; **not yet built**.
67+
PubChem / data.gov; MoleculeNet mirror of the Tox21 Challenge set). **In use** — 12
68+
caution-only RandomForest tox-assay heads (`train_tox.py``predict_tox()`),
69+
CV-AUROC 0.72–0.90. INDICATIVE in-vitro flags, never a determination.
6870

6971
---
7072

tests/test_predict.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,3 +22,10 @@ def test_predict_aroma_is_honest_placeholder():
2222
out = predict.predict_aroma("CCO")
2323
assert out["available"] is False
2424
assert "AROMA.md" in out["note"]
25+
26+
27+
def test_predict_includes_tox_screen():
28+
# tox_screen is always present and well-formed (honest available:False with no models, as in CI)
29+
out = predict.predict("CCO")
30+
assert "tox_screen" in out["safety"]
31+
assert isinstance(out["safety"]["tox_screen"]["available"], bool)

training/predict.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,14 @@ def _ik1(*smiles):
185185
else:
186186
_CLASSIFIERS[name] = joblib.load(p)
187187

188+
# Caution-only toxicity-assay heads (Tox21, public domain). Loaded if trained.
189+
# INDICATIVE in-vitro signals — never a toxicity determination.
190+
_TOX = {}
191+
_TOX_DIR = Path("tox_models")
192+
if _TOX_DIR.exists():
193+
for p in _TOX_DIR.glob("*_rf.joblib"):
194+
_TOX[p.stem.replace("_rf", "")] = joblib.load(p)
195+
188196
# Known-label lookup: ground truth for molecules we actually have data on. This
189197
# is how the salty/sour data works as a FLAG without a model — if a queried
190198
# molecule is in our labeled set, we report the verified fact instead of a guess.
@@ -598,6 +606,37 @@ def predict_aroma(smiles, top_k=8):
598606
"note": "aroma deferred — needs licensed/customer odor data; see docs/AROMA.md"}
599607

600608

609+
# Plain-language meaning of each Tox21 assay, for caution context.
610+
_TOX_MEANING = {
611+
"NR-AhR": "aryl-hydrocarbon receptor (xenobiotic / dioxin-like activity)",
612+
"NR-AR": "androgen receptor", "NR-AR-LBD": "androgen receptor (LBD)",
613+
"NR-Aromatase": "aromatase (estrogen synthesis)",
614+
"NR-ER": "estrogen receptor", "NR-ER-LBD": "estrogen receptor (LBD)",
615+
"NR-PPAR-gamma": "PPAR-γ (metabolic)",
616+
"SR-ARE": "oxidative-stress response (ARE)",
617+
"SR-ATAD5": "ATAD5 — genotoxicity / DNA damage",
618+
"SR-HSE": "heat-shock response", "SR-MMP": "mitochondrial toxicity",
619+
"SR-p53": "p53 — DNA-damage response (genotoxic stress)",
620+
}
621+
622+
623+
def predict_tox(mol, threshold=0.5):
624+
"""Caution-only in-vitro tox-assay activity (Tox21 models). INDICATIVE flags for
625+
review — NEVER a toxicity/safety determination. Honest/empty if heads untrained."""
626+
if not _TOX:
627+
return {"available": False,
628+
"note": "tox heads not trained — run train_tox.py (Tox21, public domain)"}
629+
x = _fp(mol)
630+
assays = []
631+
for name, clf in sorted(_TOX.items()):
632+
p = round(float(clf.predict_proba(x)[0, 1]), 3)
633+
assays.append({"assay": name, "meaning": _TOX_MEANING.get(name, name), "probability": p})
634+
flags = [a["assay"] for a in assays if a["probability"] >= threshold]
635+
return {"available": True, "assays": assays, "flags": flags,
636+
"note": "INDICATIVE in-vitro tox-assay activity (Tox21 RandomForest heads) — "
637+
"caution-only, NOT a toxicity/safety determination; confirm with a toxicologist."}
638+
639+
601640
def _taste_profile(out):
602641
"""Trained taste heads ranked by probability (descending) — the 'order of
603642
dominance' view. Sour is a small-data indicative head; the deterministic
@@ -707,6 +746,7 @@ def predict(smiles: str, include_aroma: bool = False) -> dict:
707746
out["labeling"] = labeling(mol)
708747
out["safety"] = _safety(mol)
709748
out["safety"]["ttc_hint"] = ttc_hint(mol)
749+
out["safety"]["tox_screen"] = predict_tox(mol)
710750
if include_aroma:
711751
out["aroma"] = predict_aroma(smiles)
712752
return out

training/train_tox.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
"""
2+
train_tox.py — caution-only toxicity-assay heads from Tox21 (public domain).
3+
4+
Tox21 (NIH/NCATS · EPA · FDA · NTP) screened ~8k compounds against 12 in-vitro assays
5+
(nuclear-receptor + stress-response, incl. genotoxic-stress SR-p53 / SR-ATAD5 and AhR).
6+
This trains one RandomForest per assay on Morgan fingerprints — **INDICATIVE, caution-only**
7+
flags ("active in an in-vitro tox assay → review"), **NEVER a toxicity determination**.
8+
Reports honest 5-fold CV AUROC; saves to tox_models/.
9+
10+
Data: tox21.csv — the MoleculeNet mirror of the NCATS public-domain Tox21 Challenge set.
11+
"""
12+
from pathlib import Path
13+
14+
import joblib
15+
import numpy as np
16+
import pandas as pd
17+
from rdkit import Chem
18+
from rdkit.Chem import DataStructs, rdFingerprintGenerator
19+
from sklearn.ensemble import RandomForestClassifier
20+
from sklearn.model_selection import cross_val_score
21+
22+
FP_BITS, FP_RADIUS = 2048, 2
23+
_MORGAN = rdFingerprintGenerator.GetMorganGenerator(radius=FP_RADIUS, fpSize=FP_BITS)
24+
TASKS = ["NR-AR", "NR-AR-LBD", "NR-AhR", "NR-Aromatase", "NR-ER", "NR-ER-LBD",
25+
"NR-PPAR-gamma", "SR-ARE", "SR-ATAD5", "SR-HSE", "SR-MMP", "SR-p53"]
26+
OUT = Path("tox_models")
27+
OUT.mkdir(exist_ok=True)
28+
for s in OUT.glob("*_rf.joblib"):
29+
s.unlink()
30+
31+
32+
def fp(smiles):
33+
m = Chem.MolFromSmiles(smiles)
34+
if m is None:
35+
return None
36+
bv = _MORGAN.GetFingerprint(m)
37+
arr = np.zeros((FP_BITS,), dtype=np.int8)
38+
DataStructs.ConvertToNumpyArray(bv, arr)
39+
return arr
40+
41+
42+
df = pd.read_csv("tox21.csv")
43+
feats, idx = [], []
44+
for i, s in enumerate(df["smiles"]):
45+
f = fp(s)
46+
if f is not None:
47+
feats.append(f)
48+
idx.append(i)
49+
X = np.array(feats)
50+
df = df.iloc[idx].reset_index(drop=True)
51+
52+
print(f"training caution-only tox heads on {len(df)} molecules:")
53+
kept = 0
54+
for t in TASKS:
55+
y = df[t].values
56+
mask = ~np.isnan(y)
57+
Xd, yd = X[mask], y[mask].astype(int)
58+
if yd.sum() < 30:
59+
print(f" {t:14s} too few positives — skip")
60+
continue
61+
clf_args = dict(n_estimators=200, n_jobs=-1, random_state=42, class_weight="balanced")
62+
auc = cross_val_score(RandomForestClassifier(**clf_args), Xd, yd, cv=5, scoring="roc_auc").mean()
63+
joblib.dump(RandomForestClassifier(**clf_args).fit(Xd, yd), OUT / f"{t}_rf.joblib")
64+
kept += 1
65+
print(f" {t:14s} n={int(mask.sum()):5d} pos={int(yd.sum()):4d} CV-AUROC={auc:.3f}")
66+
print(f"\nkept {kept}/{len(TASKS)} tox heads -> tox_models/ (caution-only, Tox21 public domain)")

training/workbench.html

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,8 @@ <h2>Aroma</h2>
221221
let html = '<div class="kv">'+rows.map(r=>`<div class="k">${r[0]}</div><div class="v">${r[1]}</div>`).join('')+'</div>';
222222
const alerts = sf.structural_alerts||[];
223223
if(alerts.length) html += '<div class="chips">'+alerts.map(a=>`<span class="chip" style="background:#FBEDE9;color:#8A3A22;border-color:#E7C4B8">⚠ ${a}</span>`).join('')+'</div>';
224+
const tox = sf.tox_screen||{};
225+
if(tox.available && (tox.flags||[]).length) html += '<div class="chips">'+tox.flags.map(f=>`<span class="chip" style="background:#FBEDE9;color:#8A3A22;border-color:#E7C4B8">⚠ tox-assay: ${f}</span>`).join('')+'</div>';
224226
html += '<div class="empty" style="font-size:12px">Defensive screen — flags for review, never a clearance.</div>';
225227
$('behavior').innerHTML = html;
226228
}

0 commit comments

Comments
 (0)