Skip to content

Commit 3bdf4e7

Browse files
feat(api): sour as both trained head + rule; ranked taste_profile
Sour trains (indicative sour_predicted) + keeps its rule; taste_profile ranks by dominance (MSG umami-first). Closes #39.
1 parent 29e083d commit 3bdf4e7

3 files changed

Lines changed: 44 additions & 8 deletions

File tree

docs/API-CONTRACT.md

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,13 +51,21 @@ Single-molecule flavor read.
5151
"bitter": 0.74, // (trained)
5252
"umami": 0.03, // (trained)
5353
"sweet_intensity": 1.8, // vs sucrose — OPTIONAL (trained)
54-
"sour": false, // (rule)
54+
"sour": false, // RULE call — acidic-group structural rule (rule)
5555
"sour_reason": [], // acidic groups matched (rule)
56+
"sour_predicted": 0.12, // trained sour head — small-data INDICATIVE (trained)
5657
"salty": false, // (rule)
5758
"salty_reason": "no alkali-salt structure", // (rule)
5859
"known_tastes": ["bitter"], // OPTIONAL, verified dataset labels (lookup)
5960
"multitaste": false, // 2+ taste heads ≥ 0.5 (trained-derived)
6061

62+
"taste_profile": [ // tastes ranked by dominance (trained heads, desc)
63+
{ "taste": "bitter", "probability": 0.74, "basis": "trained" },
64+
{ "taste": "sweet", "probability": 0.12, "basis": "trained" },
65+
{ "taste": "sour", "probability": 0.12, "basis": "trained (indicative)" },
66+
{ "taste": "umami", "probability": 0.03, "basis": "trained" }
67+
],
68+
6169
"physchem": {
6270
"computed": { "mol_weight": 152.15, "logP": 1.21, "tpsa": 46.5,
6371
"h_bond_donors": 1, "h_bond_acceptors": 3,
@@ -98,6 +106,11 @@ Single-molecule flavor read.
98106
**Field notes for implementers**
99107
- Taste-head keys (`sweet`/`bitter`/`umami`) are present **per trained classifier**; a
100108
head below the data threshold is absent and the corresponding rule/flag covers it.
109+
- **Sour carries two signals:** `sour` is the deterministic acidity-rule boolean;
110+
`sour_predicted` is a small-data **indicative** trained probability. They can disagree
111+
(the rule flags structure, the model reflects perception) — surface both.
112+
- **`taste_profile`** ranks the trained heads (incl. sour-indicative) by probability,
113+
descending — the "order of dominance" view. The `sour`/`salty` rules stay separate flags.
101114
- `sweet_intensity` and `physchem.measured` appear only when their model/table is loaded.
102115
- `salty` may be overridden to `true` with `salty_reason: "verified (dataset label)"` when
103116
a ground-truth label exists (lookup beats rule).

training/predict.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -636,6 +636,23 @@ def predict_aroma(smiles, top_k=8):
636636
return {"available": False, "note": "aroma model load ok but prediction failed", "detail": str(e)}
637637

638638

639+
def _taste_profile(out):
640+
"""Trained taste heads ranked by probability (descending) — the 'order of
641+
dominance' view. Sour is a small-data indicative head; the deterministic
642+
sour/salty rules remain separate flags (out['sour'], out['salty'])."""
643+
ranked = []
644+
for t in ("sweet", "bitter", "umami"):
645+
v = out.get(t)
646+
if isinstance(v, (int, float)):
647+
ranked.append({"taste": t, "probability": round(float(v), 3), "basis": "trained"})
648+
sp = out.get("sour_predicted")
649+
if isinstance(sp, (int, float)):
650+
ranked.append({"taste": "sour", "probability": round(float(sp), 3),
651+
"basis": "trained (indicative)"})
652+
ranked.sort(key=lambda e: e["probability"], reverse=True)
653+
return ranked
654+
655+
639656
def predict(smiles: str, include_aroma: bool = False) -> dict:
640657
mol = Chem.MolFromSmiles(smiles)
641658
if mol is None:
@@ -644,6 +661,10 @@ def predict(smiles: str, include_aroma: bool = False) -> dict:
644661
out = {"smiles": Chem.MolToSmiles(mol)}
645662
for name, clf in sorted(_CLASSIFIERS.items()):
646663
out[name] = round(float(clf.predict_proba(x)[0, 1]), 3)
664+
# Sour trains as a small-data INDICATIVE head, but its boolean stays the rule's
665+
# call below — keep the model probability separately as sour_predicted.
666+
if "sour" in out:
667+
out["sour_predicted"] = out.pop("sour")
647668
if _INTENSITY is not None:
648669
out["sweet_intensity"] = round(float(_INTENSITY.predict(x)[0]), 2)
649670
out.update(_sour(mol))
@@ -661,6 +682,7 @@ def predict(smiles: str, include_aroma: bool = False) -> dict:
661682
strong = [t for t in ("sweet", "bitter", "umami")
662683
if isinstance(out.get(t), float) and out[t] >= 0.5]
663684
out["multitaste"] = len(strong) >= 2
685+
out["taste_profile"] = _taste_profile(out)
664686
out["physchem"] = physchem(mol)
665687
out["stability"] = stability(mol)
666688
out["chemesthesis"] = chemesthesis(mol)

training/train_taste.py

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,10 @@
22
train_taste.py — taste heads (multi-taste) + sweetness-intensity regressor.
33
44
Reads the merged dataset from build_taste_dataset.py and trains one binary
5-
RandomForest per structure-driven taste (sweet/bitter/umami). Sour and salty
6-
are validated RULES in predict.py (sourness is a pH/solution property, saltiness
7-
a cation property), never trained — by design, not just data volume. Add more
8-
sweet/bitter/umami data and those heads sharpen on the next run.
5+
RandomForest per trainable taste (sweet/bitter/umami, plus sour as a small-data
6+
*indicative* head). Salty stays a validated RULE in predict.py (a cation
7+
property, too few labels to model); sour ALSO keeps its acidity rule there as a
8+
deterministic cross-check. Add more data and the heads sharpen on the next run.
99
1010
Even with everything merged this trains in minutes on the R620 CPU. The
1111
multi-day budget is the aroma model (train_odor.py), not this.
@@ -27,9 +27,10 @@
2727
from sklearn.metrics import roc_auc_score, r2_score
2828

2929
BASIC = ["sweet", "bitter", "umami", "sour", "salty"]
30-
# Sour and salty are validated RULES by design (pH/solution and cation properties),
31-
# handled in predict.py and never trained — regardless of how much data accrues.
32-
RULE_TASTES = {"sour", "salty"}
30+
# Salty stays a validated RULE only (cation property, too few labels to model).
31+
# Sour trains as a small-data INDICATIVE head but ALSO keeps its acidity rule in
32+
# predict.py as a deterministic second check (surfaced as sour_predicted + sour).
33+
RULE_TASTES = {"salty"}
3334
FP_BITS, FP_RADIUS = 2048, 2
3435
_MORGAN = rdFingerprintGenerator.GetMorganGenerator(radius=FP_RADIUS, fpSize=FP_BITS)
3536
# Below this, a taste is too thin for an HONEST head, so it's skipped and

0 commit comments

Comments
 (0)