Skip to content

Commit 4f5f9b0

Browse files
Add public-domain documented-odor lookup; drop invented aroma scores (#76)
New build_odor_notes.py pulls PubChem's 'Odor' annotation and keeps ONLY public-domain sources (HSDB / CAMEO Chemicals), explicitly dropping proprietary flavor sources (GoodScents, Leffingwell, Flavornet, FEMA); it keeps every note (also a future training corpus) in odor_notes.parquet (inchikey, odor, source). The aroma read now shows ONLY real, cited documented odor — the hand-set descriptor 'scores' are removed (invented numbers, and their 0-1 values read misleadingly as summing past 100%). Taste meters gain a note that sweet/bitter/ umami are independent per-taste confidences, not a 100% split. Card copy now separates the documented-odor lookup from the still-deferred predictive model. Coverage over the 105 curated volatiles: 73 carry documented odor (HSDB). Signed-off-by: Austin L. <86896075+rvnminers-A-and-N@users.noreply.github.com>
1 parent 1600139 commit 4f5f9b0

3 files changed

Lines changed: 180 additions & 49 deletions

File tree

training/app.py

Lines changed: 27 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -216,55 +216,44 @@ def api_suggest(qs: str = ""):
216216
return {"items": items}
217217

218218

219-
# --- Aroma PREVIEW (illustrative, NOT a trained model) -------------------------
220-
# Well-known odor characters for a handful of famous flavor molecules, so the demo can
221-
# SHOW what an unlocked aroma read looks like. These are common-knowledge descriptors
222-
# used as an illustration — the real model trains on a customer's expert-labeled odor
223-
# data (see docs/AROMA.md). Clearly flagged "preview" in the UI; never passed off as live.
224-
_AROMA_PREVIEW_RAW = [
225-
("O=Cc1ccc(O)c(OC)c1", [("vanilla", 0.95), ("sweet", 0.6), ("creamy", 0.4), ("woody", 0.25)]),
226-
("CC1=CCC(CC1)C(C)=C", [("citrus", 0.9), ("orange", 0.7), ("fresh", 0.5), ("terpene", 0.3)]),
227-
("CC(C)C1CCC(C)CC1O", [("minty", 0.9), ("cooling", 0.7), ("fresh", 0.5), ("herbal", 0.3)]),
228-
("C=CCc1ccc(O)c(OC)c1", [("clove", 0.9), ("spicy", 0.7), ("woody", 0.4), ("sweet", 0.3)]),
229-
("O=Cc1ccccc1", [("almond", 0.95), ("cherry", 0.5), ("sweet", 0.4)]),
230-
("O=C/C=C/c1ccccc1", [("cinnamon", 0.95), ("spicy", 0.6), ("sweet", 0.4), ("warm", 0.3)]),
231-
("CC(C)=CCCC(C)(O)C=C", [("floral", 0.85), ("lavender", 0.6), ("citrus", 0.4), ("woody", 0.25)]),
232-
("CCCC(=O)OCC", [("fruity", 0.9), ("pineapple", 0.7), ("sweet", 0.5)]),
233-
("CC(=O)C(C)=O", [("buttery", 0.95), ("creamy", 0.6)]),
234-
("CC(C)=CCC/C(C)=C/CO", [("rose", 0.85), ("floral", 0.7), ("citrus", 0.3)]),
235-
("CCOC(C)=O", [("solvent", 0.6), ("fruity", 0.5), ("sweet", 0.3)]),
236-
("O=Cc1ccco1", [("almond", 0.7), ("bready", 0.6), ("caramel", 0.5), ("sweet", 0.3)]),
237-
("Cc1ccc(C(C)C)cc1O", [("thyme", 0.85), ("herbal", 0.7), ("medicinal", 0.4), ("spicy", 0.3)]),
238-
("COc1ccccc1O", [("smoky", 0.85), ("medicinal", 0.6), ("woody", 0.5), ("spicy", 0.3)]),
239-
("C/C=C/c1ccc(OC)cc1", [("anise", 0.9), ("licorice", 0.7), ("sweet", 0.5)]),
240-
("CC(=O)CCc1ccc(O)cc1", [("raspberry", 0.9), ("jammy", 0.6), ("sweet", 0.5)]),
241-
]
242-
243-
244-
def _aroma_index():
245-
out = {}
246-
for smi, descs in _AROMA_PREVIEW_RAW:
247-
m = Chem.MolFromSmiles(smi)
248-
if m is not None:
249-
out[Chem.MolToInchiKey(m).split("-")[0]] = descs
250-
return out
219+
# --- Aroma: REAL documented odor only (public-domain HSDB/CAMEO) ---------------
220+
# Hand-set illustrative descriptor "scores" were removed on purpose: made-up numbers
221+
# have no place in the read. The aroma card now shows only real, cited documented odor
222+
# (odor_notes.parquet, built by build_odor_notes.py). A trained per-molecule descriptor
223+
# model — presence/absence learned from these same descriptions, or intensity from a
224+
# customer's expert-labeled data — is the next step (see docs/AROMA.md).
225+
def _load_odor_table():
226+
"""inchikey-skeleton -> (documented odor text, source) from odor_notes.parquet — real,
227+
cited, public-domain (HSDB/CAMEO) descriptions. Empty until build_odor_notes.py has run."""
228+
try:
229+
import pandas as pd
230+
df = pd.read_parquet("odor_notes.parquet")
231+
out = {}
232+
for ik, odor, src in zip(df["inchikey"], df["odor"], df["odor_source"]):
233+
if isinstance(ik, str) and isinstance(odor, str):
234+
out[ik.split("-")[0]] = (odor, src if isinstance(src, str) else None)
235+
return out
236+
except Exception: # noqa: BLE001 — no table / no pandas
237+
return {}
251238

252239

253-
_AROMA_PREVIEW = _aroma_index()
240+
_ODOR_TABLE = _load_odor_table()
254241

255242

256243
@app.post("/api/aroma")
257244
def api_aroma(q: Query):
258-
"""ILLUSTRATIVE aroma preview for a few well-known molecules — NOT a live model."""
245+
"""Real, cited documented odor (public-domain HSDB/CAMEO) when available — NOT a trained
246+
model and NOT invented scores. A trained descriptor model comes next (see docs/AROMA.md)."""
259247
smi = _resolve(q.smiles)
260248
mol = Chem.MolFromSmiles(smi) if smi else None
261249
if mol is None:
262250
return {"available": False}
263-
descs = _AROMA_PREVIEW.get(Chem.MolToInchiKey(mol).split("-")[0])
264-
if not descs:
251+
doc = _ODOR_TABLE.get(Chem.MolToInchiKey(mol).split("-")[0])
252+
if not doc:
265253
return {"available": False}
266-
return {"available": True, "preview": True,
267-
"descriptors": [{"odor": n, "score": s} for n, s in descs]}
254+
notes = [s.strip() for s in doc[0].split("\n") if s.strip()]
255+
concise = [n for n in notes if len(n) <= 90] or notes # lead with punchy descriptors
256+
return {"available": True, "documented": {"notes": concise[:4], "source": doc[1]}}
268257

269258

270259
@app.get("/", response_class=HTMLResponse)

training/build_odor_notes.py

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
"""
2+
build_odor_notes.py — documented odor descriptions from PubChem (public-domain sources ONLY).
3+
4+
PubChem carries an "Odor" annotation for many molecules. We keep ONLY notes whose source is
5+
public domain — HSDB (NIH/NLM Hazardous Substances Data Bank) and CAMEO Chemicals (NOAA/EPA) —
6+
and EXPLICITLY DROP any proprietary flavor source (The Good Scents Company, Leffingwell,
7+
Flavornet, FEMA) so the result stays commercial-clean, consistent with the rest of the project.
8+
9+
What this IS: a cited LOOKUP of real, documented odor descriptions — honest and attributable,
10+
a real replacement for hand-written illustrative descriptors. What it is NOT: a trained aroma
11+
model, and NOT a substitute for licensed (PMP 2001) or customer panel data — the text is free-
12+
form, coverage is sparse and skewed to industrially-notable chemicals, and there's no
13+
controlled descriptor vocabulary or intensity. It's the honest public-data floor for aroma;
14+
the real model still "comes with your data."
15+
16+
Usage:
17+
python build_odor_notes.py # default: flavor_volatiles.csv
18+
python build_odor_notes.py --molecules taste_master.parquet # any SMILES/InChIKey set
19+
python build_odor_notes.py "O=Cc1ccc(O)c(OC)c1" # test mode: print odor for SMILES
20+
Output odor_notes.parquet (inchikey, odor, odor_source) is MERGED when it already exists.
21+
"""
22+
import argparse
23+
import sys
24+
from pathlib import Path
25+
26+
import pandas as pd
27+
from rdkit import Chem
28+
29+
# reuse the rate-limited fetcher + InChIKey->CID + molecule-set loader
30+
from build_properties import _BASE, _cid, _get, load_keys
31+
32+
# keep only public-domain government sources; drop proprietary flavor databases outright
33+
PUBLIC_DOMAIN = ("hazardous substances data bank", "hsdb", "cameo chemicals")
34+
BLOCK = ("good scents", "goodscents", "tgsc", "leffingwell", "flavornet", "flavordb", "fema")
35+
36+
37+
def _source_names(view):
38+
"""ReferenceNumber -> SourceName map for a PUG-View record."""
39+
refs = {}
40+
41+
def walk(o):
42+
if isinstance(o, dict):
43+
if "ReferenceNumber" in o and "SourceName" in o:
44+
refs[o["ReferenceNumber"]] = o["SourceName"]
45+
for v in o.values():
46+
walk(v)
47+
elif isinstance(o, list):
48+
for v in o:
49+
walk(v)
50+
51+
walk(view)
52+
return refs
53+
54+
55+
def fetch_odor(inchikey):
56+
"""(joined public-domain odor notes, joined source names) or (None, None)."""
57+
cid = _cid(inchikey)
58+
if not cid:
59+
return None, None
60+
view = _get(f"{_BASE}/pug_view/data/compound/{cid}/JSON?heading=Odor")
61+
if not view:
62+
return None, None
63+
refs = _source_names(view)
64+
notes, srcs = [], set()
65+
66+
def walk(o):
67+
if isinstance(o, dict):
68+
if o.get("TOCHeading") == "Odor":
69+
for info in o.get("Information", []):
70+
src = (refs.get(info.get("ReferenceNumber")) or "").strip()
71+
sl = src.lower()
72+
if not any(p in sl for p in PUBLIC_DOMAIN) or any(b in sl for b in BLOCK):
73+
continue # source not clean-public-domain -> skip this annotation
74+
for s in info.get("Value", {}).get("StringWithMarkup", []) or []:
75+
t = " ".join((s.get("String") or "").split()).strip()
76+
if t and t not in notes:
77+
notes.append(t)
78+
srcs.add(src)
79+
for v in o.values():
80+
walk(v)
81+
elif isinstance(o, list):
82+
for v in o:
83+
walk(v)
84+
85+
walk(view)
86+
if not notes:
87+
return None, None
88+
# keep EVERY note (this is also a future training corpus) — shortest first so the punchy
89+
# descriptors lead; newline-delimit so notes (which can contain ';') stay intact downstream.
90+
return "\n".join(sorted(notes, key=len)), "; ".join(sorted(srcs))
91+
92+
93+
if __name__ == "__main__":
94+
ap = argparse.ArgumentParser(description="Public-domain documented odor from PubChem.")
95+
ap.add_argument("--molecules", help="CSV/parquet with 'smiles' or 'inchikey' "
96+
"(default: flavor_volatiles.csv)")
97+
ap.add_argument("--out", default="odor_notes.parquet", help="output (merged if it exists)")
98+
ap.add_argument("smiles", nargs="*", help="SMILES to test-print (no build)")
99+
a = ap.parse_args()
100+
101+
if a.smiles: # test mode
102+
for smi in a.smiles:
103+
mol = Chem.MolFromSmiles(smi)
104+
if mol is None:
105+
print(f"{smi}: unparseable")
106+
continue
107+
odor, osrc = fetch_odor(Chem.MolToInchiKey(mol))
108+
print(f"{smi:32s} odor={odor!r} src={osrc!r}")
109+
sys.exit(0)
110+
111+
src = a.molecules or "flavor_volatiles.csv"
112+
if not Path(src).exists():
113+
print(f"{src} not found")
114+
sys.exit(1)
115+
keys = load_keys(src)
116+
cols = ["inchikey", "odor", "odor_source"]
117+
rows = []
118+
for i, ik in enumerate(keys):
119+
odor, osrc = fetch_odor(ik)
120+
if odor:
121+
rows.append({"inchikey": ik, "odor": odor, "odor_source": osrc})
122+
if (i + 1) % 50 == 0 or (i + 1) == len(keys):
123+
new = pd.DataFrame(rows, columns=cols)
124+
if Path(a.out).exists(): # checkpoint + accumulate across sets
125+
old = pd.read_parquet(a.out)
126+
new = pd.concat([old, new], ignore_index=True).drop_duplicates("inchikey", keep="last")
127+
new.to_parquet(a.out)
128+
print(f" {i + 1}/{len(keys)} processed; checkpoint -> {len(new)} with documented odor",
129+
flush=True)
130+
final = pd.read_parquet(a.out)
131+
print(f"{a.out}: {len(final)} molecules with public-domain documented odor (HSDB/CAMEO)")

training/workbench.html

Lines changed: 22 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
.cname{font-size:16px;font-weight:650;color:var(--ink);margin:0 0 4px}
3939
.iupac{font-family:var(--mono);font-size:11px;color:var(--muted);margin:0 0 14px;word-break:break-all}
4040
.meter{margin:13px 0}
41+
.meters-note{font-size:11.5px;color:var(--muted);line-height:1.5;margin:10px 2px 2px;border-top:1px solid #EEF0EC;padding-top:9px}
4142
.meter .row{display:flex;justify-content:space-between;align-items:baseline;margin-bottom:5px}
4243
.meter .name{font-weight:600;font-size:14px;text-transform:capitalize}
4344
.meter .val{font-family:var(--mono);font-size:13px;color:var(--muted)}
@@ -74,7 +75,10 @@
7475
.aroma-lead b{color:var(--aroma)}
7576
.aroma-note{font-size:13px;color:var(--muted);margin:0;line-height:1.55;max-width:760px}
7677
.aroma-note b{color:var(--ink)}
77-
.aroma-prev-tag{font-size:11px;font-weight:650;text-transform:uppercase;letter-spacing:.05em;color:var(--aroma);margin:0 0 10px}
78+
.aroma-prev-tag{font-size:11px;font-weight:650;text-transform:uppercase;letter-spacing:.05em;color:var(--aroma);margin:14px 0 10px}
79+
.aroma-doc-tag{font-size:11px;font-weight:650;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);margin:0 0 8px}
80+
.aroma-doc{display:flex;flex-wrap:wrap;gap:6px;margin-bottom:4px}
81+
.odor-note{background:#F1F4EF;border:1px solid #DDE3D8;border-radius:6px;padding:4px 9px;font-size:12.5px;line-height:1.35}
7882
.ameter{margin:8px 0}
7983
.arow{display:flex;justify-content:space-between;align-items:baseline;margin-bottom:4px}
8084
.aname{font-weight:600;font-size:13px;text-transform:capitalize}
@@ -174,11 +178,13 @@ <h2>Behavior &amp; safety</h2>
174178
<div class="card aroma-card" id="aromaCard" style="display:none">
175179
<h2>Aroma</h2>
176180
<div id="aromaPreview" style="display:none;margin-bottom:14px"></div>
177-
<div class="aroma-lead">Aroma prediction unlocks with <b>your</b> data.</div>
178-
<p class="aroma-note">The public demo predicts <b>taste</b> from open data. A useful
179-
aroma model needs expert-labeled odor data — so we train it <b>on-premise, on your
180-
licensed or in-house data</b>, never leaving your firewall. The pipeline is built and
181-
ready; it lights up the moment the data is yours to use.</p>
181+
<div class="aroma-lead">Documented odor above is a public reference; aroma <b>prediction</b> unlocks with <b>your</b> data.</div>
182+
<p class="aroma-note">The public demo predicts <b>taste</b> from open data and shows
183+
<b>documented odor</b> where a public-domain reference exists (HSDB). <b>Predicting</b>
184+
odor for new or unmeasured molecules needs a trained model: presence/absence learned
185+
from those same documented descriptions, and true intensity from expert-labeled panel
186+
data — trained <b>on-premise, on your licensed or in-house data</b>, never leaving your
187+
firewall.</p>
182188
<p class="aroma-note" style="margin-top:8px">A more robust <b>open-source academic
183189
edition</b> — trained on richer research odor data (the GS-LF set) — is coming soon.</p>
184190
</div>
@@ -262,6 +268,7 @@ <h2>Aroma</h2>
262268
for(const t of MODEL_TASTES){
263269
if(typeof p[t] === 'number') m += meter(t, p[t], known.has(t));
264270
}
271+
if(m) m += '<div class="meters-note">Each is an independent confidence that the molecule carries that taste — they don\'t sum to 100%. A molecule can be both (sweet <i>and</i> bitter) or weakly any.</div>';
265272
$('meters').innerHTML = m || '<div class="empty">No taste heads trained yet.</div>';
266273
} else {
267274
$('meters').innerHTML = '<div class="empty">Trained taste prediction not applicable to this molecule.</div>';
@@ -352,11 +359,15 @@ <h2>Aroma</h2>
352359

353360
function renderAroma(ar){
354361
const box=$('aromaPreview');
355-
if(ar && ar.available && (ar.descriptors||[]).length){
356-
box.innerHTML='<div class="aroma-prev-tag">▸ preview — illustrative of the unlocked read (not a live prediction)</div>'+
357-
ar.descriptors.map(d=>{ const pct=Math.round(d.score*100); return `<div class="ameter"><div class="arow"><span class="aname">${d.odor}</span><span class="aval">${pct}%</span></div><div class="atrack"><div class="afill" style="width:${pct}%"></div></div></div>`; }).join('');
358-
box.style.display='block';
359-
} else { box.style.display='none'; }
362+
if(!ar || !ar.available){ box.style.display='none'; return; }
363+
let html='';
364+
const doc = ar.documented;
365+
if(doc && (doc.notes||[]).length){
366+
html += `<div class="aroma-doc-tag">Documented odor · ${doc.source||'public domain'}</div>`;
367+
html += '<div class="aroma-doc">'+ doc.notes.map(n=>`<span class="odor-note">${n}</span>`).join('') +'</div>';
368+
}
369+
box.innerHTML=html;
370+
box.style.display = html ? 'block' : 'none';
360371
}
361372

362373
function chip(key, on, color, label){

0 commit comments

Comments
 (0)