Skip to content

Commit 5318831

Browse files
feat: mouthfeel map colours, taste namespacing, cross-listed terms, full head coverage (#248)
Full head coverage: the design index only tagged sweet/bitter/umami, so sour, salty and tasteless were trained heads the UI pretended didn't exist. All six taste heads are now offered; audit reads taste 6/6, aroma 164/164, mouthfeel 5/5. The 12 Tox21 heads surface as a "hide tox-flagged" filter plus per-result flags rather than as design targets — all 187 heads now have a role. Taste tags namespaced (taste:sweet) like mouthfeel, since `sweet` is both a taste and an aroma head. Map gains a mouthfeel colour mode (8,834 points, 100 labelled). Cross-listing (#241) links terms that are the same thing spelled differently — 5 auto-derived plus 10 curated synonyms, verified symmetric. Tingling broadened with amides from other genera: 0 -> 2 novel hits, documented honestly in MOUTHFEEL.md's new generalisation section. Closes #241.
1 parent 792efae commit 5318831

5 files changed

Lines changed: 205 additions & 30 deletions

File tree

docs/MOUTHFEEL.md

Lines changed: 33 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,16 @@ block (the same stack as taste/aroma), in their own `mouthfeel_models/` director
1515
| head | what it is | held-out CV-AUROC |
1616
|---|---|---|
1717
| **cooling** | TRPM8 coolants — menthol family + food-authorised WS-agents | 0.94 |
18-
| **pungent** | TRPV1 / mustard-oil heat & bite — capsaicinoids, isothiocyanates, allium sulfur | 0.98 |
19-
| **warming** | capsaicinoid & warm-spice heat — chili, pepper, ginger, cinnamon | 0.99 |
20-
| **astringent** | tannins & polyphenols — the puckering, mouth-drying sensation | 1.00 |
21-
| **tingling** | paresthesia alkylamides — Sichuan-pepper sanshools, jambu spilanthol, ZP-amides | 1.00 |
18+
| **pungent** | TRPV1 / mustard-oil heat & bite — capsaicinoids, isothiocyanates, allium sulfur | 0.97 |
19+
| **warming** | capsaicinoid & warm-spice heat — chili, pepper, ginger, cinnamon | 0.98 |
20+
| **astringent** | tannins & polyphenols — the puckering, mouth-drying sensation | 0.95 |
21+
| **tingling** | paresthesia alkylamides — sanshools, spilanthol, ZP-amides, Echinacea/Anacyclus amides | 1.00 |
2222

2323
The high AUROCs reflect **narrow, structurally-distinct classes** (a capsaicinoid does not look like
2424
a tannin) trained against a broad random background — honest for what they are, but not a claim of
2525
fine-grained intensity resolution. Like aroma, this is **presence/absence**, not scored intensity.
26+
A high AUROC alone does not prove a head is useful; see *Does a head generalize, or is it
27+
memorizing?* below for the test that does.
2628

2729
## `cooling` / `pungent` exist in **both** aroma and mouthfeel
2830

@@ -53,8 +55,31 @@ tannic acid 21 CFR) — but that a molecule is a good *warming* example does not
5355
flavouring. The per-molecule food-safe flag and the standing IP/food review gate still govern any
5456
commercial use, exactly as for the aroma corpus.
5557

56-
## Near-misses (documented, not shipped)
58+
## Does a head generalize, or is it memorizing?
5759

58-
`tingling` sits right at the bar — the pure sanshool/spilanthol/ZP-amide class is genuinely small
59-
(~11 public structures). It ships, but strictly on real paresthesia agents; the Piper long-pepper
60-
amides that *read pungent* were routed to the aroma `pungent` head instead, not used to pad tingling.
60+
A high CV-AUROC on a narrow, structurally distinctive class can mean "this head learned the class"
61+
or "this head memorized its training molecules" — and the two look identical in the AUROC column.
62+
The honest test is different: run the head over the whole universe and count how many molecules it
63+
fires on that were **not** in its training set.
64+
65+
Measured over 8.8k molecules:
66+
67+
| head | fires | trained on | **novel** |
68+
|---|---|---|---|
69+
| cooling | 32 | 14 | **16** |
70+
| pungent | 24 | 12 | **12** |
71+
| warming | 21 | 12 | **9** |
72+
| astringent | 19 | 11 | **8** |
73+
| tingling | 16 | 18 | **2** |
74+
75+
`cooling` is the clearest case of the model actually working: it independently surfaced **AR-15512**,
76+
**menthyl lactate** and **menthone glycerol ketal** — real commercial coolants it was never shown.
77+
78+
**`tingling` remains the weak one, and this is a known limitation.** Trained only on Zanthoxylum
79+
sanshools it generalized to *nothing* — it fired on exactly its own training molecules, i.e. it was
80+
a lookup table wearing a 1.00 AUROC. Broadening the positives with paresthesia amides from other
81+
genera (Echinacea tetraenoic isobutylamides, Anacyclus, Heliopsis, Piper — deliberately different
82+
chain lengths and unsaturation patterns) moved it to **2 novel hits**, one of which is piperine.
83+
That is real generalization but a thin margin: the class of public tingle structures is genuinely
84+
small, and the head still leans on the scaffold it was taught. Treat a tingling prediction on an
85+
unfamiliar scaffold with less confidence than the other four; #247 tracks broadening it further.

training/app.py

Lines changed: 97 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1423,13 +1423,15 @@ def norm(col):
14231423
cols = {c: norm(c).tolist() for c in ("x", "y", "x3", "y3", "z3") if c in df.columns}
14241424
smis, labs = df["smiles"].tolist(), df["label"].tolist()
14251425
aromas = df["aroma_label"].tolist() if "aroma_label" in df.columns else [None] * len(smis)
1426+
mouth = (df["mouthfeel_label"].tolist() if "mouthfeel_label" in df.columns
1427+
else [None] * len(smis)) # dominant trigeminal sensation, for color-by-mouthfeel
14261428
# raw physicochemical values (for the interpretable MW×logP×TPSA axes view — real units)
14271429
mw = df["mw"].tolist() if "mw" in df.columns else [None] * len(smis)
14281430
logp = df["logp"].tolist() if "logp" in df.columns else [None] * len(smis)
14291431
tpsa = df["tpsa"].tolist() if "tpsa" in df.columns else [None] * len(smis)
14301432
pts = []
14311433
for i in range(len(smis)):
1432-
p = {"label": labs[i], "aroma": aromas[i], "smiles": smis[i],
1434+
p = {"label": labs[i], "aroma": aromas[i], "mouth": mouth[i], "smiles": smis[i],
14331435
"name": _table_name(smis[i]) or "",
14341436
"mw": None if mw[i] != mw[i] else mw[i], # NaN -> None
14351437
"logp": None if logp[i] != logp[i] else logp[i],
@@ -1458,6 +1460,57 @@ def api_map():
14581460
_DESIGN = [] # [{smiles, name, tags:set, gras:bool}]
14591461
_DESIGN_DESCS = [] # descriptors with enough molecules to offer as options
14601462
_DESIGN_MOUTHFEEL = [] # trained mouthfeel/chemesthesis sensations, offered as their own pick-list
1463+
_DESIGN_TASTE = [] # trained taste heads, offered as their own pick-list (namespaced taste:*)
1464+
_TERM_ALIASES = {} # term -> (equivalent terms in the other modality), both directions
1465+
1466+
# Curated flavor<->note synonyms the string normalizer can't reach: genuinely the same material
1467+
# under two names, not a loose association. Deliberately conservative — "lemon"/"citrus" is NOT
1468+
# here, because citrus is broader than lemon and equating them would silently widen a search.
1469+
_TERM_SYNONYMS = {
1470+
"blackcurrant": "cassis", # cassis IS blackcurrant
1471+
"tangerine": "mandarin", # tangerine IS a mandarin
1472+
"orange blossom": "neroli", # neroli IS orange-blossom
1473+
"cilantro": "coriander", # cilantro IS coriander leaf
1474+
"chocolate": "cocoa",
1475+
"licorice": "anise",
1476+
"cheese": "cheesy",
1477+
"smoke": "smoky",
1478+
"peppermint": "minty",
1479+
"spearmint": "minty",
1480+
}
1481+
1482+
1483+
def _build_term_aliases():
1484+
"""flavor <-> note aliases, both directions. Auto-derives spelling/adjective pairs (bread ->
1485+
bready, black pepper -> blackpepper) by normalizing away spacing and common suffixes, then
1486+
folds in the curated synonym table. Auto-derivation means new vocabulary keeps linking up
1487+
without anyone maintaining a list."""
1488+
import re
1489+
1490+
def norm(t):
1491+
t = re.sub(r"[^a-z]", "", t.lower())
1492+
for suf in ("y", "ic", "ish"):
1493+
if t.endswith(suf) and len(t) > len(suf) + 3:
1494+
t = t[: -len(suf)]
1495+
return t
1496+
1497+
by_norm = {}
1498+
for n in _DESIGN_DESCS:
1499+
by_norm.setdefault(norm(n), set()).add(n)
1500+
pairs = set()
1501+
for f in _FLAVORS:
1502+
for n in by_norm.get(norm(f), ()):
1503+
if n != f:
1504+
pairs.add((f, n))
1505+
for f, n in _TERM_SYNONYMS.items():
1506+
if f in _FLAVORS and n in _DESIGN_DESCS:
1507+
pairs.add((f, n))
1508+
out = {}
1509+
for f, n in pairs: # link both ways
1510+
out.setdefault(f, set()).add(n)
1511+
out.setdefault(n, set()).add(f)
1512+
_TERM_ALIASES.clear()
1513+
_TERM_ALIASES.update({k: tuple(sorted(v)) for k, v in out.items()})
14611514

14621515

14631516
def _precompute_design():
@@ -1487,11 +1540,16 @@ def _precompute_design():
14871540
for name, clf in P._AROMA_MODELS.items(): # model-confident aroma
14881541
for i in np.where(clf.predict_proba(X)[:, 1] >= 0.5)[0]:
14891542
tagsets[i].add(name)
1490-
for t in ("sweet", "bitter", "umami"): # taste
1543+
# Taste tags are NAMESPACED ("taste:sweet") for the same reason mouthfeel is: `sweet` is
1544+
# both a taste head and an aroma head, and a bare tag conflated "tastes sweet" with
1545+
# "smells sweet". `bitter` had additionally leaked into the aroma-notes picker.
1546+
# EVERY trained taste head, not just sweet/bitter/umami — sour, salty and tasteless are
1547+
# equally real targets (a neutral, tasteless carrier is a genuine formulation ask).
1548+
for t in sorted(P._CLASSIFIERS): # taste
14911549
clf = P._CLASSIFIERS.get(t)
14921550
if clf is not None:
14931551
for i in np.where(clf.predict_proba(X)[:, 1] >= 0.5)[0]:
1494-
tagsets[i].add(t)
1552+
tagsets[i].add(f"taste:{t}")
14951553
# Mouthfeel tags are NAMESPACED ("mouthfeel:pungent"), matching how the profile index keys
14961554
# its dims. Without this, picking `pungent` under Mouthfeel returned sharp-SMELLING
14971555
# molecules (acetic acid, ammonia, CO2) from the far larger aroma:pungent set instead of
@@ -1524,11 +1582,16 @@ def _precompute_design():
15241582
# food-safe carriers), plus any design note with >=5 carriers. Namespaced mouthfeel tags
15251583
# are excluded — they're a separate modality with their own pick-list below.
15261584
_DESIGN_DESCS[:] = sorted(
1527-
{d for d, n in cnt.items() if n >= 5 and not d.startswith("mouthfeel:")}
1528-
| set(P._AROMA_MODELS))
1529-
# Mouthfeel terms stay namespaced ("mouthfeel:cooling") so picking `cooling` here matches
1530-
# the SENSATION, not the like-named odour note. The UI shows the bare label.
1585+
{d for d, n in cnt.items() if n >= 5 and ":" not in d} | set(P._AROMA_MODELS))
1586+
# Mouthfeel and taste terms stay namespaced ("mouthfeel:cooling", "taste:sweet") so picking
1587+
# `cooling`/`sweet` there matches the SENSATION / the TASTE, not the like-named odour note.
1588+
# The UI shows the bare label. Only tastes with carriers in the pool are offered.
15311589
_DESIGN_MOUTHFEEL[:] = [f"mouthfeel:{m}" for m in sorted(P._MOUTHFEEL_MODELS)]
1590+
# EVERY trained taste head is offered, exactly like the aroma heads above — a head with few
1591+
# confident carriers in this pool (sour, salty) must still be selectable, or the picker
1592+
# silently hides a dimension the model can actually read.
1593+
_DESIGN_TASTE[:] = [f"taste:{t}" for t in sorted(P._CLASSIFIERS)]
1594+
_build_term_aliases() # needs _FLAVORS + the finished _DESIGN_DESCS
15321595

15331596

15341597
def _fpvec(mol):
@@ -1705,9 +1768,10 @@ def _gras_subs(smi, k=3):
17051768

17061769
@app.get("/api/studio_terms")
17071770
def api_studio_terms():
1708-
"""The unified pick-list: curated flavors (grouped by category), matchable aroma-note
1709-
descriptors, and mouthfeel sensations — three modalities the studios can target."""
1710-
return {"flavors": _FLAVOR_CATS, "notes": _DESIGN_DESCS, "mouthfeel": _DESIGN_MOUTHFEEL}
1771+
"""The unified pick-list, one entry per modality the studios can target: curated flavors
1772+
(grouped by category), aroma-note descriptors, mouthfeel sensations, and basic tastes."""
1773+
return {"flavors": _FLAVOR_CATS, "notes": _DESIGN_DESCS,
1774+
"mouthfeel": _DESIGN_MOUTHFEEL, "taste": _DESIGN_TASTE}
17111775

17121776

17131777
@app.get("/api/nl")
@@ -1843,8 +1907,20 @@ def _svg_cell(smi):
18431907
return _svg(smi, 104, 62)
18441908

18451909

1910+
@lru_cache(maxsize=8192)
1911+
def _tox_flags(smiles):
1912+
"""Tox21 assays this molecule is predicted active in (>=0.5), as a tuple. Caution-only: assay
1913+
activity is INDICATIVE and warrants review, it is never a toxicity determination (see TOX.md).
1914+
Cached because the studio calls it once per result row."""
1915+
mol = Chem.MolFromSmiles(smiles or "")
1916+
if mol is None:
1917+
return ()
1918+
scr = P.predict_tox(mol)
1919+
return tuple(a["assay"] for a in scr.get("assays", []) if (a.get("probability") or 0) >= 0.5)
1920+
1921+
18461922
@app.get("/api/studio")
1847-
def api_studio(terms: str = "", gras: int = 0, offset: int = 0, limit: int = 20):
1923+
def api_studio(terms: str = "", gras: int = 0, no_tox: int = 0, offset: int = 0, limit: int = 20):
18481924
"""Unified search: given any mix of flavors and notes, rank the molecules that carry them.
18491925
Each molecule scores by how many distinct picked terms it matches (a flavor's character
18501926
molecule matches that flavor; a molecule with a note matches that note)."""
@@ -1853,6 +1929,10 @@ def api_studio(terms: str = "", gras: int = 0, offset: int = 0, limit: int = 20)
18531929
return {"items": [], "requested": want, "total_matches": 0, "offset": offset, "limit": limit}
18541930
flavor_terms = [t for t in want if t in _FLAVORS]
18551931
note_terms = [t for t in want if t not in _FLAVORS]
1932+
# cross-list dual-purpose terms: a flavor and its like-named note are the same concept spelled
1933+
# differently ("black pepper"/"blackpepper", "butter"/"buttery", "cilantro"/"coriander"), so
1934+
# picking either should surface the other's molecules too.
1935+
note_terms += [a for t in want for a in _TERM_ALIASES.get(t, ()) if a not in note_terms]
18561936
cand = {} # skeleton -> {smiles, name, gras, matched:set}
18571937

18581938
def add(smi, name, is_gras, term):
@@ -1878,6 +1958,11 @@ def add(smi, name, is_gras, term):
18781958
add(m["smiles"], m["name"], m["gras"], nt)
18791959
cand[Chem.MolToInchiKey(Chem.MolFromSmiles(m["smiles"])).split("-")[0]]["tags"] = m["tags"]
18801960
scored = [r for r in cand.values() if not (gras and not r["gras"])]
1961+
# The 12 Tox21 heads aren't design TARGETS — nobody formulates *for* assay activity — but they
1962+
# shouldn't be invisible either, so they surface per result and can filter the list. Still
1963+
# caution-only: an assay flag means "review this", never "this is toxic".
1964+
if no_tox:
1965+
scored = [r for r in scored if not _tox_flags(r["smiles"])]
18811966
scored.sort(key=lambda r: (-len(r["matched"]), not r["gras"], r["name"] == ""))
18821967
items = []
18831968
for r in scored[offset:offset + limit]:
@@ -1886,6 +1971,7 @@ def add(smi, name, is_gras, term):
18861971
"matched": matched, "n_matched": len(matched),
18871972
"svg": _svg(r["smiles"], 108, 78),
18881973
"other": sorted(t for t in r["tags"] if t not in r["matched"])[:5],
1974+
"tox_flags": _tox_flags(r["smiles"]),
18891975
"subs": _gras_subs(r["smiles"])})
18901976
return {"items": items, "requested": want, "flavor_terms": flavor_terms, "note_terms": note_terms,
18911977
"total_matches": len(scored), "offset": offset, "limit": limit}

training/build_flavor_map.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -178,7 +178,22 @@ def _all_structures():
178178
if prob[name][i] >= bp:
179179
bp, best[i] = prob[name][i], name
180180
out["aroma_label"] = best
181+
# dominant MOUTHFEEL sensation (for the map's "color by mouthfeel" mode). Straight
182+
# strongest-head-over-0.5: unlike aroma there's no documented corpus to prefer, and
183+
# with only five heads there's no rarity problem to correct for.
184+
if P._MOUTHFEEL_MODELS:
185+
mbest, mbp = ["other"] * len(out), [0.5] * len(out)
186+
for name, clf in P._MOUTHFEEL_MODELS.items():
187+
col = clf.predict_proba(Xf)[:, 1]
188+
for i in range(len(out)):
189+
if col[i] >= mbp[i]:
190+
mbp[i], mbest[i] = col[i], name
191+
out["mouthfeel_label"] = mbest
181192
out.to_parquet("flavor_map.parquet")
193+
mf = ""
194+
if "mouthfeel_label" in out.columns:
195+
hits = int((out.mouthfeel_label != "other").sum())
196+
mf = f", mouthfeel-labelled={hits}"
182197
print(f"flavor_map.parquet: {len(out)} points "
183198
f"({', '.join(f'{t}={int((out.label == t).sum())}' for t in TASTES)}, "
184-
f"other={int((out.label == 'other').sum())})")
199+
f"other={int((out.label == 'other').sum())}{mf})")

training/build_mouthfeel_supplement.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,20 @@
5757
# more genuine tingle amides — tested against PubChem for an authoritative structure
5858
"bungeanool", "isobungeanool", "tetrahydrobungeanool", "neoherculin",
5959
"ZP-amide A", "ZP-amide C", "hydroxy-epsilon-sanshool", "sanshoamide",
60-
"dihydro-alpha-sanshool"],
60+
"dihydro-alpha-sanshool",
61+
# STRUCTURAL DIVERSITY, deliberately beyond Zanthoxylum: a head trained only on
62+
# sanshools memorizes that one scaffold instead of learning "long-chain unsaturated
63+
# N-alkylamide". These are paresthesia amides from other genera — Echinacea,
64+
# Anacyclus (pellitory), Heliopsis, Piper — with different chain lengths and
65+
# unsaturation patterns, so the class itself becomes learnable.
66+
"dodeca-2E,4E,8Z,10E-tetraenoic acid isobutylamide",
67+
"dodeca-2E,4E,8Z,10Z-tetraenoic acid isobutylamide",
68+
"undeca-2E,4Z-diene-8,10-diynoic acid isobutylamide",
69+
"dodeca-2E,4E-dienoic acid isobutylamide",
70+
"deca-2E,4E-dienoic acid isobutylamide",
71+
"anacyclin", "scabrin", "achilleamide", "retrofractamide A",
72+
"guineensine", "piperlonguminine", "trichostachine",
73+
"N-isobutyl-2E,4E-decadienamide", "echinacein", "affinin"],
6174
}
6275
# NOTE: cooling & pungent ALSO exist as AROMA odour-descriptor heads (menthol smells cool; mustard
6376
# smells pungent). Those stay — this file trains the SENSATION versions. A molecule can be both.

0 commit comments

Comments
 (0)