From 479f28a6845e645e309fe45fcec0b2a8f0d077b7 Mon Sep 17 00:00:00 2001 From: "Austin L." <86896075+rvnminers-A-and-N@users.noreply.github.com> Date: Thu, 30 Jul 2026 08:59:49 +0000 Subject: [PATCH 1/3] feat: calibrate every head, unify the chip language, fix the modal layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #265, #266, #267, #268. CALIBRATION EVERYWHERE (#265). Taste and Tox21 still ran on a flat 0.5 with only AUROC shown — the exact reporting gap #261 closed for aroma and mouthfeel. Both now use the same out-of-fold calibration with a 50% precision floor, and /api/heads publishes threshold, precision, recall and confident_capable for all four modalities. The taste heads were mildly shy, as expected with hundreds of positives each (sweet 0.33, umami 0.33, bitter 0.51), and all six clear the floor at 0.50-0.87. The Tox21 result is the one that matters: every one of the twelve calibrated UPWARD — NR-AR to 0.69, NR-AR-LBD 0.65, NR-ER 0.63 — which means a flat 0.5 had them OVER-flagging. That is the opposite direction from the thin aroma heads and the more dangerous one, because a caution flag that cries wolf teaches people to ignore the flags that matter. CHIP LANGUAGE (#266). Flavor, note, taste and mouthfeel chips had each been given a different border STYLE at a different time — dashed, dotted, thicker, plus per-taste text recolouring — so they read as four accidents rather than one system. They now share a pill, a size and a border weight, and carry identity in a leading dot in the family's own hue. Each studio section also gained a plain-language paragraph on what that dimension IS and why it matters: a flavor is the whole percept, a note is what the nose reads, taste is the basics the tongue reads, mouthfeel is touch reported by the trigeminal nerve. Menthol is cold without a temperature change; that distinction is load-bearing and nothing in the UI had ever said it. ALL MODALITIES ON CARDS (#267). Substitute and neighbor cards showed taste and aroma but not mouthfeel. Read straight off the profile matrix, whose mouthfeel columns were already there, so it costs no extra inference — and each sensation must clear its own calibrated threshold, so a card never shows something the modal would call indicative. MODAL LAYOUT (#268). Two real bugs, both measured rather than guessed: - Mobile: the swap grid used `1fr`, which is minmax(auto,1fr), and `auto` will not shrink below min-content — so the substitutes and neighbors cards rendered 468px wide inside a 358px modal. minmax(0,1fr) fixes it; .modal-top already had this and .swap-grid did not. - Desktop: #results is a flex column whose 14px gap separates the cards, but the render path set an inline display:block on it, and block boxes ignore `gap`. The Heads card had been sitting flush against the Substitutes card below it. Clearing the inline style lets the stylesheet win. Verified with a Playwright probe at 390px and 1440px: no card overflows its container at either size, and no two cards are within 8px of each other on desktop. Signed-off-by: Austin L. <86896075+rvnminers-A-and-N@users.noreply.github.com> --- training/predict.py | 50 ++++++++++++++++++++++++++------ training/train_taste.py | 19 +++++++++++-- training/train_tox.py | 19 +++++++++++-- training/workbench.html | 63 ++++++++++++++++++++++++++++++----------- 4 files changed, 121 insertions(+), 30 deletions(-) diff --git a/training/predict.py b/training/predict.py index 05b96fa..48bba8b 100644 --- a/training/predict.py +++ b/training/predict.py @@ -1218,9 +1218,16 @@ def predict_mouthfeel(mol): } -def predict_tox(mol, threshold=0.5): +def predict_tox(mol, threshold=None): """Caution-only in-vitro tox-assay activity (Tox21 models). INDICATIVE flags for - review — NEVER a toxicity/safety determination. Honest/empty if heads untrained.""" + review — NEVER a toxicity/safety determination. Honest/empty if heads untrained. + + Each assay fires at its OWN calibrated threshold, and calibration matters more here than + anywhere else in the app: assay actives are rare, so a flat 0.5 made several heads + over-flag. Every one of the twelve calibrated UPWARD (NR-AR to 0.69, NR-ER to 0.63) — the + opposite direction from the thin aroma heads. A caution flag that cries wolf is worse than + no flag, because it teaches people to ignore the ones that matter. + """ if not _TOX_MODELS: return {"available": False, "note": "tox heads not trained — run train_tox.py (Tox21, public domain)"} @@ -1228,9 +1235,12 @@ def predict_tox(mol, threshold=0.5): assays = [] for name, clf in sorted(_TOX_MODELS.items()): p = round(float(clf.predict_proba(x)[0, 1]), 3) + thr = _head_threshold(_TOX_META, name, threshold) assays.append({"assay": name, "meaning": _TOX_MEANING.get(name, name), "probability": p, + "threshold": thr, "flagged": p >= thr, + "precision": _TOX_META.get(name, {}).get("cv_precision"), "auroc": _TOX_META.get(name, {}).get("auroc")}) - flags = [a["assay"] for a in assays if a["probability"] >= threshold] + flags = [a["assay"] for a in assays if a["flagged"]] return {"available": True, "assays": assays, "flags": flags, "note": "INDICATIVE in-vitro tox-assay activity (Tox21 RandomForest heads) — " "caution-only, NOT a toxicity/safety determination; confirm with a toxicologist."} @@ -1310,13 +1320,13 @@ def _aroma(a): "desc": AROMA_DESC.get(h), **_cal(_MOUTHFEEL_META, h)} for h in mouthfeel_heads] return { - # taste heads keep a flat 0.5: hundreds of positives each, so they were never shy - "taste": [{"head": t, "auroc": _taste_auroc(t), "threshold": 0.5, - "confident_capable": True} for t in taste_heads], + "taste": [{"head": t, "auroc": _taste_auroc(t), **_cal(_TASTE_META, t)} + for t in taste_heads], "aroma": [_aroma(a) for a in aroma_heads], "mouthfeel": mouthfeel, "safety": [{"head": t, "auroc": _TOX_META.get(t, {}).get("auroc"), - "meaning": _TOX_MEANING.get(t, t)} for t in sorted(_TOX_MODELS)], + "meaning": _TOX_MEANING.get(t, t), **_cal(_TOX_META, t)} + for t in sorted(_TOX_MODELS)], } @@ -1473,6 +1483,26 @@ def _predicted_tastes_at(profiles, i): return [t for s, t in scored[:3] if s >= 0.2] +def _predicted_mouthfeel_at(profiles, i): + """The MOUTHFEEL read for reference-set row i, straight off the profile matrix — no extra + inference, the columns are already there. Each sensation must clear its OWN calibrated + threshold (and be confident-capable), so a card never shows a sensation the modal would + call indicative. Returns the firing sensations, strongest first.""" + if profiles is None: + return [] + taste_heads, aroma_heads, mouth_heads = _profile_heads() + base = len(taste_heads) + len(aroma_heads) # mouthfeel columns follow taste then aroma + row, out = profiles[i], [] + for j, name in enumerate(mouth_heads): + col = base + j + if col >= len(row): + break + score = float(row[col]) + if score >= _head_threshold(_MOUTHFEEL_META, name) and _head_capable(_MOUTHFEEL_META, name): + out.append((score, name)) + return [n for _, n in sorted(out, reverse=True)] + + def structural_neighbors(smiles: str, k: int = 8, min_similarity: float = 0.0) -> dict: """STRUCTURAL neighbors: the k labeled molecules most structurally similar to the query (Tanimoto over Morgan fingerprints), each with its known tastes. Structural look-alikes — @@ -1504,6 +1534,7 @@ def structural_neighbors(smiles: str, k: int = 8, min_similarity: float = 0.0) - neighbors.append({"smiles": smis[i], "similarity": round(float(sims[i]), 3), "known_tastes": tastes[i], "predicted_tastes": _predicted_tastes_at(profiles, i), + "mouthfeel": _predicted_mouthfeel_at(profiles, i), # confident aromas precomputed once in the index — reused so the # endpoint never re-runs the 24 aroma heads per neighbor (8x ~1.3s saved) "aromas": _aromas[i] if i < len(_aromas) else []}) @@ -1545,6 +1576,7 @@ def substitutes(smiles: str, k: int = 8, min_match: float = 0.0) -> dict: continue subs.append({"smiles": smis[i], "profile_match": round(float(sims[i]), 3), "known_tastes": tastes[i], "predicted_tastes": _predicted_tastes_at(profiles, i), + "mouthfeel": _predicted_mouthfeel_at(profiles, i), "aromas": aromas[i] if i < len(aromas) else []}) if len(subs) >= k: break @@ -1584,6 +1616,7 @@ def mixture_to_molecule(smiles_list: list, weights: list | None = None, k: int = continue out.append({"smiles": smis[i], "profile_match": round(float(sims[i]), 3), "known_tastes": tastes[i], "predicted_tastes": _predicted_tastes_at(profiles, i), + "mouthfeel": _predicted_mouthfeel_at(profiles, i), "aromas": aromas[i] if i < len(aromas) else []}) if len(out) >= k: break @@ -1733,7 +1766,8 @@ def predict(smiles: str, include_aroma: bool = False) -> dict: # If two+ taste heads both fire high, surface that as a complex-taste note — # the model-side echo of ChemTastesDB's 'multitaste' class. strong = [t for t in ("sweet", "bitter", "umami") - if isinstance(out.get(t), float) and out[t] >= 0.5] + if isinstance(out.get(t), float) + and out[t] >= _head_threshold(_TASTE_META, t)] out["multitaste"] = len(strong) >= 2 out["taste_profile"] = _taste_profile(out) out["physchem"] = physchem(mol) diff --git a/training/train_taste.py b/training/train_taste.py index e611e34..dc04b77 100644 --- a/training/train_taste.py +++ b/training/train_taste.py @@ -28,7 +28,10 @@ from rdkit.Chem import DataStructs, rdFingerprintGenerator from sklearn.ensemble import RandomForestClassifier, RandomForestRegressor from sklearn.metrics import r2_score, roc_auc_score -from sklearn.model_selection import train_test_split +from sklearn.model_selection import cross_val_predict, train_test_split +from train_aroma import ( + _calibrate, # same out-of-fold threshold + precision floor as the aroma heads +) BASIC = ["sweet", "bitter", "umami", "sour", "salty", "tasteless"] # Salty now ALSO trains as an INDICATIVE head (CV-AUROC ~0.96 once the PubChem documented- @@ -102,9 +105,19 @@ def train_classifiers(master): clf = RandomForestClassifier(n_estimators=500, n_jobs=-1, random_state=42) clf.fit(Xtr, ytr) auc = roc_auc_score(yte, clf.predict_proba(Xte)[:, 1]) - print(f" {taste:7s} AUROC={auc:.3f} (pos={pos}, neg={neg})") + # Per-head decision threshold + measured precision, same instrument as the aroma heads + # (train_aroma._calibrate). These heads have hundreds of positives each and were never + # expected to be shy — but "we measured and 0.5 was right" is a result, not a reason to + # skip measuring. See docs/ACCURACY.md. + oof = cross_val_predict(RandomForestClassifier(n_estimators=500, n_jobs=-1, random_state=42), + Xv, yv, cv=5, method="predict_proba")[:, 1] + thr, prec, rec, f1, capable = _calibrate(yv, oof) + tag = "" if capable else " INDICATIVE (never reaches 50% precision)" + print(f" {taste:7s} AUROC={auc:.3f} thr={thr:.2f} prec={prec:.2f}{tag} (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} + manifest[taste] = {"auroc": round(float(auc), 3), "n_pos": pos, "n_neg": neg, + "threshold": thr, "cv_precision": prec, "cv_recall": rec, + "cv_f1": f1, "confident_capable": capable} return manifest diff --git a/training/train_tox.py b/training/train_tox.py index 452d9ba..b4dbb2e 100644 --- a/training/train_tox.py +++ b/training/train_tox.py @@ -18,7 +18,10 @@ from rdkit import Chem from rdkit.Chem import DataStructs, rdFingerprintGenerator from sklearn.ensemble import RandomForestClassifier -from sklearn.model_selection import cross_val_score +from sklearn.model_selection import cross_val_predict, cross_val_score +from train_aroma import ( + _calibrate, # same out-of-fold threshold + precision floor as the aroma heads +) FP_BITS, FP_RADIUS = 2048, 2 _MORGAN = rdFingerprintGenerator.GetMorganGenerator(radius=FP_RADIUS, fpSize=FP_BITS) @@ -62,9 +65,19 @@ def fp(smiles): continue clf_args = {"n_estimators": 200, "n_jobs": -1, "random_state": 42, "class_weight": "balanced"} auc = cross_val_score(RandomForestClassifier(**clf_args), Xd, yd, cv=5, scoring="roc_auc").mean() + # Calibration matters MORE here than anywhere else in the app. Assay actives are rare, so a + # high AUROC can sit on top of terrible precision — and a safety flag that is wrong most of + # the time is worse than no flag, because it teaches people to ignore the ones that matter. + oof = cross_val_predict(RandomForestClassifier(**clf_args), Xd, yd, cv=5, + method="predict_proba")[:, 1] + thr, prec, rec, f1, capable = _calibrate(yd, oof) joblib.dump(RandomForestClassifier(**clf_args).fit(Xd, yd), OUT / f"{t}_rf.joblib") - manifest[t] = {"auroc": round(float(auc), 3), "n_pos": int(yd.sum()), "n": int(mask.sum())} + manifest[t] = {"auroc": round(float(auc), 3), "n_pos": int(yd.sum()), "n": int(mask.sum()), + "threshold": thr, "cv_precision": prec, "cv_recall": rec, "cv_f1": f1, + "confident_capable": capable} kept += 1 - print(f" {t:14s} n={int(mask.sum()):5d} pos={int(yd.sum()):4d} CV-AUROC={auc:.3f}") + tag = "" if capable else " INDICATIVE (never reaches 50% precision)" + print(f" {t:14s} n={int(mask.sum()):5d} pos={int(yd.sum()):4d} CV-AUROC={auc:.3f} " + f"thr={thr:.2f} prec={prec:.2f}{tag}") (OUT / "manifest.json").write_text(json.dumps({"assays": manifest}, indent=2)) print(f"\nkept {kept}/{len(TASKS)} tox heads -> tox_models/ (caution-only, Tox21 public domain)") diff --git a/training/workbench.html b/training/workbench.html index 7af6d10..c94a537 100644 --- a/training/workbench.html +++ b/training/workbench.html @@ -522,13 +522,29 @@ .fam-label{font-family:var(--mono);font-size:10.5px;letter-spacing:.07em;text-transform:uppercase; color:var(--muted);margin:0 0 5px;display:flex;align-items:baseline;gap:7px} .fam-n{font-size:10px;opacity:.6} - .schip-note{border-style:dashed} - .schip-taste{border-style:solid;border-width:1.5px} + /* ONE chip language, four members. Every family shares the same pill, size and border weight; + identity comes from a leading dot in the family's own hue plus a tinted border. Previously + each family had been given a different border STYLE (dashed / dotted / thicker) at a different + time, which read as four accidents rather than one system — the taste and mouthfeel chips in + particular stuck out against the flavor and note chips they sit beside. */ + .atag.mf{border-style:solid;position:relative;padding-left:14px} + .atag.mf::before{content:'';position:absolute;left:6px;top:50%;width:4px;height:4px;margin-top:-2px;border-radius:50%;background:currentColor} + .ss-why{font-size:11.5px;line-height:1.62;color:var(--muted);margin:2px 0 11px;max-width:74ch} + .ss-why b{color:var(--ink);font-weight:600} + .ss-why code{font-family:var(--mono);font-size:10.5px;background:var(--panel);border:1px solid var(--line);border-radius:4px;padding:0 4px} + .schip{display:inline-flex;align-items:center;gap:7px} + .schip::before{content:'';width:6px;height:6px;border-radius:50%;flex:0 0 auto; + background:var(--chip,var(--muted));box-shadow:0 0 0 2px rgb(from var(--chip,var(--muted)) r g b / .16)} + .schip-flavor{--chip:var(--brand-1)} + .schip-note{--chip:var(--aroma)} + .schip-taste{--chip:var(--brand-accent)} + .schip-mouth{--chip:var(--accent)} + .schip-flavor,.schip-note,.schip-taste,.schip-mouth{border-color:rgb(from var(--chip) r g b / .38)} + .schip.on::before{background:#08121A;box-shadow:none} .dtox{margin-top:4px;font-family:var(--mono);font-size:10.5px;color:#C0553A;cursor:help} /* mouthfeel chips: a distinct dotted edge + amber cast so the sensation modality reads apart from odour notes at a glance (cooling/pungent legitimately appear in both rows) */ - .schip-mouth{border-style:dotted;border-color:var(--accent);color:var(--accent)} - .schip-mouth:hover{border-color:var(--brand-2)} + .schip-mouth:hover,.schip-taste:hover,.schip-note:hover,.schip-flavor:hover{border-color:var(--chip)} .schip.on{background:var(--brand-grad);color:#08121A;border-color:transparent;font-weight:650;box-shadow:0 2px 12px rgba(43,196,196,.32);transform:translateY(-1px)} .schip{transition:all .13s ease} .design-grid .dcell{animation:fadeUp .3s ease both} @@ -622,10 +638,17 @@ .hg-note{font-size:11px;color:var(--muted);margin:6px 0 0;line-height:1.5} #aromaPreview::-webkit-scrollbar{width:8px}#aromaPreview::-webkit-scrollbar-thumb{background:var(--line);border-radius:4px} /* the two swap lists fill their box (bounded + internal scroll) so no wasted whitespace */ - .swap-grid{display:grid;grid-template-columns:1fr 1fr;gap:14px;align-items:stretch} - .swap-grid .card{display:flex;flex-direction:column;min-height:360px;max-height:70vh} - .swap-grid .card .nb-list{flex:1 1 auto;min-height:0;max-height:none} - @media(max-width:820px){ .modal-top{grid-template-columns:1fr} .swap-grid{grid-template-columns:1fr} .swap-grid .card{max-height:none} } + /* minmax(0,1fr), NOT 1fr: a bare `1fr` is minmax(auto,1fr), and `auto` refuses to shrink below + the content's min-content width — which is how these two cards ended up 468px wide inside a + 358px modal on a phone. .modal-top already had this; .swap-grid did not. */ + .swap-grid{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:14px;align-items:stretch} + .swap-grid .card{display:flex;flex-direction:column;min-width:0;min-height:360px;max-height:70vh} + .swap-grid .card .nb-list{flex:1 1 auto;min-width:0;min-height:0;max-height:none} + @media(max-width:820px){ + .modal-top{grid-template-columns:minmax(0,1fr)} + .swap-grid{grid-template-columns:minmax(0,1fr)} /* stack into one column */ + .swap-grid .card{max-height:none;min-height:0} + } /* mobile */ @media(max-width:640px){ header{padding:22px 14px 18px} @@ -1633,6 +1656,7 @@
A flavor is the whole percept — banana, cola, root beer. It is what a person names, and it is almost never one molecule: it is a taste, several aroma notes and often a mouthfeel arriving together. Start here when you know the target and need the parts.
A note is what your nose reads — vanilla, smoky, green, citrus. Aroma carries most of what people call “flavor”: block your nose and a pear and a potato are hard to tell apart. This is the largest set here (167 trained heads) and the main lever for making something smell like what you intend.
Taste is only the five basics the tongue detects — sweet, bitter, umami, sour, salty (plus tasteless). It is a genuinely different channel from smell, which is why taste:sweet and the aroma note sweet are separate heads: vanillin smells sweet without being a sweetener. Use taste to set the backbone, notes to give it character.
Mouthfeel is touch, not taste or smell — the trigeminal nerve reporting cooling, warming, pungency, tingle and astringency. Menthol is cold with no temperature change; capsaicin burns with no heat. It is the dimension most often left out of a formulation and the one people notice immediately when it is wrong.
One random forest per descriptor (citrus, floral, woody…), trained on public-domain odor text. A head ships only if it clears CV-AUROC ≥ 0.70 — 167 survive, each shown with its own score. It reads presence, not intensity (free text carries none) — an honest ceiling, stated in the UI.
Every value is tagged measured / predicted / estimate, so nothing reads as more precise than it is. Where a quantitative feature needs data we can't ship free-commercially (odor thresholds, panel intensities), the UI says so — and it lights up with your data.
AUROC alone will lie to you. It measures ranking and is blind to how rare a note is — a head with 11 examples among 2,403 molecules can score 0.979 and still be right just 1 time in 10 when it fires. So every head also publishes its precision: when it says yes, how often it's actually right, measured only on molecules it never trained on. Each head gets its own firing threshold (0.16 – 0.85, not a flat 0.5), fitted so a confident call is right more than half the time. 23 heads have never been wrong on unseen molecules; the 73 that can't clear 50% are shown as indicative — hatched bars, kept in full, never dressed up as confident.
The Formulation Studio reads a whole recipe before you pour — weighting each ingredient by odor impact, aggregating the blend's note-profile, flagging the overpowering component, and closing the gap to your target. That's single-molecule ML turned into a bench tool.
Nothing leaves the box — a read makes no cloud calls. The shipped models train only on public-domain or permissively-licensed data, so the commercial edition stays clean (provenance tracked in the repo).
The Formulation Studio reads a whole recipe before you pour — weighting each ingredient by odor impact, aggregating the blend's note-profile, flagging the overpowering component, and closing the gap to your target. That's single-molecule ML turned into a bench tool.
Nothing leaves the box — a read makes no cloud calls. The shipped models train only on public-domain or permissively-licensed data, so the commercial edition stays clean (provenance tracked in the repo).
Deeper dives in the repo: docs/HOW-IT-WORKS.md (the full method) · docs/DATA-SOURCES.md (every source + license) · docs/AROMA.md (the odor heads).
Taste is only the five basics the tongue detects — sweet, bitter, umami, sour, salty (plus tasteless). It is a genuinely different channel from smell, which is why taste:sweet and the aroma note sweet are separate heads: vanillin smells sweet without being a sweetener. Use taste to set the backbone, notes to give it character.
Taste is the five basics the tongue detects — sweet, bitter, umami, sour, salty. We train six heads: those five plus tasteless, which predicts the documented absence of taste and is how you find a clean carrier. It is a genuinely different channel from smell, which is why taste:sweet and the aroma note sweet are separate heads: vanillin smells sweet without being a sweetener. Use taste to set the backbone, notes to give it character.