Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions training/predict.py
Original file line number Diff line number Diff line change
Expand Up @@ -177,13 +177,18 @@ def _ik1(*smiles):
# load whatever classifier heads exist (sweet/bitter/umami...) + intensity
_CLASSIFIERS = {}
_INTENSITY = None
_TASTE_META = {} # taste -> {auroc, ...} from taste_models/manifest.json (held-out score)
if TASTE.exists():
for p in TASTE.glob("*_rf.joblib"):
name = p.stem.replace("_rf", "")
if name == "sweet_intensity":
_INTENSITY = joblib.load(p)
else:
_CLASSIFIERS[name] = joblib.load(p)
_tm = TASTE / "manifest.json"
if _tm.exists():
import json as _json
_TASTE_META = _json.loads(_tm.read_text())

# Caution-only toxicity-assay heads (Tox21, public domain). Loaded if trained.
# INDICATIVE in-vitro signals — never a toxicity determination.
Expand Down Expand Up @@ -777,6 +782,10 @@ def predict(smiles: str, include_aroma: bool = False) -> dict:
}
for name, clf in sorted(_CLASSIFIERS.items()):
out[name] = round(float(clf.predict_proba(x)[0, 1]), 3)
# per-head held-out CV-AUROC (from taste_models/manifest.json) so the UI can show how
# trustworthy each trained taste head is, the same way the aroma descriptors carry theirs
out["taste_meta"] = {t: {"auroc": _TASTE_META[t]["auroc"]}
for t in _CLASSIFIERS if t in _TASTE_META and "auroc" in _TASTE_META[t]}
# Sour trains as a small-data INDICATIVE head, but its boolean stays the rule's
# call below — keep the model probability separately as sour_predicted.
if "sour" in out:
Expand Down
14 changes: 11 additions & 3 deletions training/train_taste.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
python train_taste.py
"""

import json
from pathlib import Path

import joblib
Expand Down Expand Up @@ -79,6 +80,7 @@ def featurize(smiles_series):
def train_classifiers(master):
X_all, keep = featurize(master["smiles"])
df = master.iloc[keep].reset_index(drop=True)
manifest = {}
for taste in BASIC:
y = df[taste]
mask = y.notna().values
Expand All @@ -98,6 +100,8 @@ def train_classifiers(master):
auc = roc_auc_score(yte, clf.predict_proba(Xte)[:, 1])
print(f" {taste:7s} AUROC={auc:.3f} (pos={pos}, neg={neg})")
joblib.dump(clf, OUT / f"{taste}_rf.joblib")
manifest[taste] = {"auroc": round(float(auc), 3), "n_pos": pos, "n_neg": neg}
return manifest


def train_intensity(path=Path("sweet_intensity.parquet")):
Expand All @@ -113,6 +117,7 @@ def train_intensity(path=Path("sweet_intensity.parquet")):
r2 = r2_score(yte, reg.predict(Xte))
print(f" sweet_intensity R2={r2:.3f} (n={len(y)}) [small data; treat as indicative]")
joblib.dump(reg, OUT / "sweet_intensity_rf.joblib")
return round(float(r2), 3)


def validate_sour_rule(master):
Expand Down Expand Up @@ -140,9 +145,12 @@ def validate_sour_rule(master):
if __name__ == "__main__":
master = pd.read_parquet("taste_master.parquet")
print("training taste classifiers:")
train_classifiers(master)
manifest = train_classifiers(master)
print("validating the sour rule against labeled data:")
validate_sour_rule(master)
print("training sweetness-intensity regressor:")
train_intensity()
print(f"\nsaved models -> {OUT}/")
r2 = train_intensity()
if r2 is not None:
manifest["sweet_intensity"] = {"r2": r2}
(OUT / "manifest.json").write_text(json.dumps(manifest, indent=2))
print(f"\nsaved models + manifest.json -> {OUT}/")
9 changes: 6 additions & 3 deletions training/workbench.html
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
.meter .row{display:flex;justify-content:space-between;align-items:baseline;margin-bottom:5px}
.meter .name{font-weight:600;font-size:14px;text-transform:capitalize}
.meter .val{font-family:var(--mono);font-size:13px;color:var(--muted)}
.meter .au{font-size:11px;color:var(--muted);opacity:.85}
.meter .tag{font-family:var(--mono);font-size:10px;padding:2px 7px;border-radius:20px;
margin-left:8px;vertical-align:middle;letter-spacing:.04em}
.tag.measured{background:var(--accent-soft);color:var(--accent)}
Expand Down Expand Up @@ -232,12 +233,14 @@ <h2>Aroma</h2>
return r.json();
}

function meter(name, value, measured){
function meter(name, value, measured, auroc){
const pct = Math.round(value*100);
const tag = measured ? '<span class="tag measured">measured</span>'
: '<span class="tag predicted">predicted</span>';
// show the head's held-out AUROC on model predictions (not on measured ground truth)
const au = (auroc && !measured) ? ` <span class="au">· AUROC ${auroc}</span>` : '';
return `<div class="meter">
<div class="row"><span class="name">${name}${tag}</span><span class="val">${pct}%</span></div>
<div class="row"><span class="name">${name}${tag}</span><span class="val">${pct}%${au}</span></div>
<div class="track"><div class="fill" style="background:${TASTE_COLORS[name]}"
data-w="${pct}"></div></div></div>`;
}
Expand Down Expand Up @@ -289,7 +292,7 @@ <h2>Aroma</h2>
if(inDomain){
let m = '';
for(const t of MODEL_TASTES){
if(typeof p[t] === 'number') m += meter(t, p[t], known.has(t));
if(typeof p[t] === 'number') m += meter(t, p[t], known.has(t), (p.taste_meta&&p.taste_meta[t]||{}).auroc);
}
if(m) m += '<div class="meters-note">Each is an independent confidence that the molecule carries that taste — they don\'t sum to 100%. A molecule can be both (sweet <i>and</i> bitter) or weakly any.</div>';
$('meters').innerHTML = m || '<div class="empty">No taste heads trained yet.</div>';
Expand Down
Loading