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
15 changes: 14 additions & 1 deletion docs/API-CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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).
Expand Down
22 changes: 22 additions & 0 deletions training/predict.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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))
Expand All @@ -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)
Expand Down
15 changes: 8 additions & 7 deletions training/train_taste.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down
Loading