From 4297e09ac59d7fb1303df3c23bdd4c8fffa74ed5 Mon Sep 17 00:00:00 2001 From: "Austin L." <86896075+rvnminers-A-and-N@users.noreply.github.com> Date: Fri, 26 Jun 2026 22:55:31 +0000 Subject: [PATCH] feat(api): sour as both trained head + rule; ranked taste_profile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sour now trains as a small-data INDICATIVE head (AUROC 0.90), surfaced as sour_predicted, while its acidity rule stays the deterministic 'sour' boolean — so the chemist sees both signals (they can disagree: MSG rule=True but model=0.12). Add taste_profile: trained heads ranked by probability (the 'order of dominance' view), so MSG reads umami-first instead of an ambiguous set of flags. Salty stays rule-only. API contract updated. Closes #39. --- docs/API-CONTRACT.md | 15 ++++++++++++++- training/predict.py | 22 ++++++++++++++++++++++ training/train_taste.py | 15 ++++++++------- 3 files changed, 44 insertions(+), 8 deletions(-) diff --git a/docs/API-CONTRACT.md b/docs/API-CONTRACT.md index 70ce802..90e7012 100644 --- a/docs/API-CONTRACT.md +++ b/docs/API-CONTRACT.md @@ -51,13 +51,21 @@ Single-molecule flavor read. "bitter": 0.74, // (trained) "umami": 0.03, // (trained) "sweet_intensity": 1.8, // vs sucrose — OPTIONAL (trained) - "sour": false, // (rule) + "sour": false, // RULE call — acidic-group structural rule (rule) "sour_reason": [], // acidic groups matched (rule) + "sour_predicted": 0.12, // trained sour head — small-data INDICATIVE (trained) "salty": false, // (rule) "salty_reason": "no alkali-salt structure", // (rule) "known_tastes": ["bitter"], // OPTIONAL, verified dataset labels (lookup) "multitaste": false, // 2+ taste heads ≥ 0.5 (trained-derived) + "taste_profile": [ // tastes ranked by dominance (trained heads, desc) + { "taste": "bitter", "probability": 0.74, "basis": "trained" }, + { "taste": "sweet", "probability": 0.12, "basis": "trained" }, + { "taste": "sour", "probability": 0.12, "basis": "trained (indicative)" }, + { "taste": "umami", "probability": 0.03, "basis": "trained" } + ], + "physchem": { "computed": { "mol_weight": 152.15, "logP": 1.21, "tpsa": 46.5, "h_bond_donors": 1, "h_bond_acceptors": 3, @@ -98,6 +106,11 @@ Single-molecule flavor read. **Field notes for implementers** - Taste-head keys (`sweet`/`bitter`/`umami`) are present **per trained classifier**; a head below the data threshold is absent and the corresponding rule/flag covers it. +- **Sour carries two signals:** `sour` is the deterministic acidity-rule boolean; + `sour_predicted` is a small-data **indicative** trained probability. They can disagree + (the rule flags structure, the model reflects perception) — surface both. +- **`taste_profile`** ranks the trained heads (incl. sour-indicative) by probability, + descending — the "order of dominance" view. The `sour`/`salty` rules stay separate flags. - `sweet_intensity` and `physchem.measured` appear only when their model/table is loaded. - `salty` may be overridden to `true` with `salty_reason: "verified (dataset label)"` when a ground-truth label exists (lookup beats rule). diff --git a/training/predict.py b/training/predict.py index eaf564a..73d96c2 100644 --- a/training/predict.py +++ b/training/predict.py @@ -636,6 +636,23 @@ def predict_aroma(smiles, top_k=8): return {"available": False, "note": "aroma model load ok but prediction failed", "detail": str(e)} +def _taste_profile(out): + """Trained taste heads ranked by probability (descending) — the 'order of + dominance' view. Sour is a small-data indicative head; the deterministic + sour/salty rules remain separate flags (out['sour'], out['salty']).""" + ranked = [] + for t in ("sweet", "bitter", "umami"): + v = out.get(t) + if isinstance(v, (int, float)): + ranked.append({"taste": t, "probability": round(float(v), 3), "basis": "trained"}) + sp = out.get("sour_predicted") + if isinstance(sp, (int, float)): + ranked.append({"taste": "sour", "probability": round(float(sp), 3), + "basis": "trained (indicative)"}) + ranked.sort(key=lambda e: e["probability"], reverse=True) + return ranked + + def predict(smiles: str, include_aroma: bool = False) -> dict: mol = Chem.MolFromSmiles(smiles) if mol is None: @@ -644,6 +661,10 @@ def predict(smiles: str, include_aroma: bool = False) -> dict: out = {"smiles": Chem.MolToSmiles(mol)} for name, clf in sorted(_CLASSIFIERS.items()): out[name] = round(float(clf.predict_proba(x)[0, 1]), 3) + # 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: + out["sour_predicted"] = out.pop("sour") if _INTENSITY is not None: out["sweet_intensity"] = round(float(_INTENSITY.predict(x)[0]), 2) out.update(_sour(mol)) @@ -661,6 +682,7 @@ def predict(smiles: str, include_aroma: bool = False) -> dict: strong = [t for t in ("sweet", "bitter", "umami") if isinstance(out.get(t), float) and out[t] >= 0.5] out["multitaste"] = len(strong) >= 2 + out["taste_profile"] = _taste_profile(out) out["physchem"] = physchem(mol) out["stability"] = stability(mol) out["chemesthesis"] = chemesthesis(mol) diff --git a/training/train_taste.py b/training/train_taste.py index 36a4f80..e39bcdf 100644 --- a/training/train_taste.py +++ b/training/train_taste.py @@ -2,10 +2,10 @@ train_taste.py — taste heads (multi-taste) + sweetness-intensity regressor. Reads the merged dataset from build_taste_dataset.py and trains one binary -RandomForest per structure-driven taste (sweet/bitter/umami). Sour and salty -are validated RULES in predict.py (sourness is a pH/solution property, saltiness -a cation property), never trained — by design, not just data volume. Add more -sweet/bitter/umami data and those heads sharpen on the next run. +RandomForest per trainable taste (sweet/bitter/umami, plus sour as a small-data +*indicative* head). Salty stays a validated RULE in predict.py (a cation +property, too few labels to model); sour ALSO keeps its acidity rule there as a +deterministic cross-check. Add more data and the heads sharpen on the next run. Even with everything merged this trains in minutes on the R620 CPU. The multi-day budget is the aroma model (train_odor.py), not this. @@ -27,9 +27,10 @@ from sklearn.metrics import roc_auc_score, r2_score BASIC = ["sweet", "bitter", "umami", "sour", "salty"] -# Sour and salty are validated RULES by design (pH/solution and cation properties), -# handled in predict.py and never trained — regardless of how much data accrues. -RULE_TASTES = {"sour", "salty"} +# Salty stays a validated RULE only (cation property, too few labels to model). +# Sour trains as a small-data INDICATIVE head but ALSO keeps its acidity rule in +# predict.py as a deterministic second check (surfaced as sour_predicted + sour). +RULE_TASTES = {"salty"} FP_BITS, FP_RADIUS = 2048, 2 _MORGAN = rdFingerprintGenerator.GetMorganGenerator(radius=FP_RADIUS, fpSize=FP_BITS) # Below this, a taste is too thin for an HONEST head, so it's skipped and