Skip to content

Commit c8b0345

Browse files
docs(aroma): record the aroma evaluation + defer decision
Honest negative result (keller_2016 CV-R² ≤ 0 across all 20 descriptors); defer aroma, keep OpenPOM scaffold for licensed/customer data, lead with taste. + keller_2016 attribution.
1 parent 2b24f6f commit c8b0345

5 files changed

Lines changed: 194 additions & 0 deletions

File tree

docs/AROMA.md

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
# Aroma — evaluated and deferred
2+
3+
Flavor = taste **+** aroma. The taste side ships and is clean (sweet/bitter/umami
4+
~0.95 AUROC + intensity + sour/salty). The aroma side is **deliberately deferred**
5+
not for lack of effort, but because the public, commercially-clean data is
6+
insufficient. This documents the evaluation, so the decision is legible.
7+
8+
## What an aroma model needs
9+
10+
A model mapping a molecule's **structure → odor descriptors** (sweet, floral,
11+
woody…). The state of the art is the **Principal Odor Map** (Lee et al., *Science*
12+
2023; Google/Osmo), reimplemented open-source as **OpenPOM** (`BioMachineLearning/
13+
openpom`, MIT) — a message-passing GNN trained on ~5,000 **expert-labeled** molecules
14+
(the GS-LF dataset).
15+
16+
## The licensing wall (audited)
17+
18+
OpenPOM's *code* is MIT, but its *training data* is restricted. A full audit of the
19+
Pyrfume catalogue (see [`DATA-SOURCES.md`](DATA-SOURCES.md)) found that **every rich
20+
odor-descriptor dataset is proprietary or NonCommercial** — Leffingwell, GoodScents,
21+
Arctander, Flavornet (©Datu), FlavorDB / FooDB (NC), OlfactionBase ("all rights
22+
reserved"), AromaDB (CSIR), Dravnieks (ASTM ©), sharma_2021 (ACS ©), snitz_2019
23+
(CC-BY-NC). The **only** commercially-clean odor-descriptor set is **`keller_2016`**
24+
(Keller & Vosshall 2016, *BMC Neuroscience*, CC-BY-4.0; ~480 molecules, 20 descriptors).
25+
26+
## The empirical result
27+
28+
We aggregated `keller_2016` ([`training/build_aroma_dataset.py`](../training/build_aroma_dataset.py))
29+
and trained one RandomForest regressor per descriptor on 2048-bit Morgan fingerprints
30+
([`training/train_aroma.py`](../training/train_aroma.py)), scored by **honest 5-fold
31+
cross-validation** (400 trees):
32+
33+
| descriptor | CV-R² | descriptor | CV-R² | descriptor | CV-R² | descriptor | CV-R² |
34+
|---|---|---|---|---|---|---|---|
35+
| acid | −0.24 | cold | −0.19 | fruit | −0.05 | sour | −0.05 |
36+
| ammonia | −0.15 | decayed | −0.20 | garlic | −0.12 | spices | −0.12 |
37+
| bakery | −0.22 | edible | −0.19 | grass | −0.45 | sweaty | +0.03 |
38+
| burnt | −0.13 | fish | −0.04 | musky | −0.23 | sweet | −0.05 |
39+
| chemical | −0.10 | flower | −0.12 | warm | −0.28 | wood | −0.08 |
40+
41+
**All 20 descriptors scored CV-R² ≤ 0** (range −0.45 to +0.03) — every model is
42+
*worse than predicting the mean*. **0/20 usable heads.**
43+
44+
## Root cause
45+
46+
`keller_2016` is **naive-subject** data: random volunteers rating *unfamiliar*
47+
molecules on a 0–100 scale. People can't reliably name what a molecule smells like,
48+
so the labels are noise (the per-descriptor means compress to ~22–30 for nearly every
49+
molecule). The *learnable* odor data uses **expert** labels — which is exactly the
50+
data that's restricted. **That is the structural reason the entire field trains on
51+
GS-LF**, and why a clean public aroma model isn't currently possible.
52+
53+
## Decision
54+
55+
We do **not** ship a negative-R² model — it would output confident, wrong smells, and
56+
a flavor chemist would catch it instantly (worse than nothing). `predict_aroma()`
57+
returns an honest "not available" marker, and **we lead with the taste engine**.
58+
59+
The **OpenPOM scaffold (`training/train_odor.py`) is kept** as the aroma engine for
60+
when clean fuel exists:
61+
1. **License PMP 2001** (~$2,775, Leffingwell & Associates) → re-curate → train.
62+
2. **A customer's own odor data** (the paid pilot) → train on-prem.
63+
3. A future open *expert-labeled* dataset, if one emerges.
64+
65+
Until then: **aroma comes with your data** — which is on-thesis (public data proves
66+
the method; the customer's data unlocks the rest).

docs/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,5 +8,6 @@ Architecture, capability catalogue, and design decisions.
88
- `SOURCES.md` — data sources, libraries, research, and license attribution
99
- `API-CONTRACT.md` — the fixed JSON contract the .NET API exposes and the React UI consumes
1010
- `DATA-SOURCES.md` — data acquisition tracker: what each source unlocks and how to get it
11+
- `AROMA.md` — why the aroma model is deferred (clean-data audit + the empirical evaluation)
1112

1213
These document *what we're building and why*, so decisions don't get re-argued.

docs/SOURCES.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ record of provenance, not legal advice. Get an IP/OSS-license review before ship
4242

4343
**Aroma**
4444
- **Pyrfume** + **Leffingwell / GoodScents (GS-LF)** odor datasets — the usual training data behind OpenPOM, but **RESTRICTED and NOT USED**: Leffingwell's manifest cites use restrictions (*John Leffingwell & Google*); GoodScents/Arctander/Flavornet (© Datu Inc.) are likewise proprietary. We **exclude all of them** (the demo may go to a customer / commercial use). The aroma model will use only commercial-clean **open** odor data (CC-BY sets like `keller_2016`; smaller — see `DATA-SOURCES.md`). The OpenPOM *code* is MIT.
45+
- **keller_2016** — Keller & Vosshall (2016), *BMC Neuroscience*, **CC-BY-4.0**; ~480 molecules with naive-subject odor-descriptor ratings. The only commercially-clean odor-descriptor set — evaluated for the aroma model and found too noisy to learn from (CV-R² ≤ 0 across all 20 descriptors; see `docs/AROMA.md`).
4546

4647
**Safety / regulatory (lookups — data-gated)**
4748
- **FEMA GRAS list** — usual/maximum use levels for the dosing analyzer. (FEMA.)

training/build_aroma_dataset.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
"""
2+
build_aroma_dataset.py — assemble the clean aroma (odor-descriptor) training table.
3+
4+
Source: keller_2016 (Keller & Vosshall 2016, BMC Neuroscience, CC-BY-4.0) — the only
5+
commercially-clean odor-descriptor dataset available (~480 molecules, 20 descriptors).
6+
Each molecule gets a mean 0-100 panel rating per descriptor.
7+
8+
Output: aroma_master.parquet (smiles + 20 descriptor columns, 0-100)
9+
"""
10+
from pathlib import Path
11+
12+
import pandas as pd
13+
from rdkit import Chem
14+
15+
KELLER = Path("aroma/keller_2016")
16+
DESCRIPTORS = ["ACID", "AMMONIA/URINOUS", "BAKERY", "BURNT", "CHEMICAL", "COLD",
17+
"DECAYED", "EDIBLE", "FISH", "FLOWER", "FRUIT", "GARLIC", "GRASS",
18+
"MUSKY", "SOUR", "SPICES", "SWEATY", "SWEET", "WARM", "WOOD"]
19+
20+
21+
def canon(smiles):
22+
if not isinstance(smiles, str):
23+
return None
24+
m = Chem.MolFromSmiles(smiles)
25+
return Chem.MolToSmiles(m) if m else None
26+
27+
28+
def build():
29+
mol = pd.read_csv(KELLER / "molecules.csv")
30+
sti = pd.read_csv(KELLER / "stimuli.csv")
31+
beh = pd.read_csv(KELLER / "behavior.csv")
32+
33+
beh = beh[beh["MeasurementValue"].isin(DESCRIPTORS)].copy()
34+
beh["Value"] = pd.to_numeric(beh["Value"], errors="coerce")
35+
beh = beh.dropna(subset=["Value"])
36+
37+
# mean rating per (Stimulus, descriptor) across the panel
38+
sm = beh.groupby(["Stimulus", "MeasurementValue"])["Value"].mean().unstack()
39+
40+
# Stimulus -> CID (single-molecule) -> canonical SMILES
41+
sti = sti[["Stimulus", "CIDs"]].copy()
42+
sti["CID"] = pd.to_numeric(sti["CIDs"], errors="coerce")
43+
sm = sm.join(sti.set_index("Stimulus")[["CID"]]).dropna(subset=["CID"])
44+
sm["CID"] = sm["CID"].astype(int)
45+
mol_map = mol.dropna(subset=["CID"]).drop_duplicates("CID").set_index("CID")["CanonicalSMILES"]
46+
sm["smiles"] = sm["CID"].map(mol_map).map(canon)
47+
sm = sm.dropna(subset=["smiles"])
48+
49+
# molecule level: mean across the molecule's stimuli (concentrations)
50+
agg = sm.groupby("smiles")[DESCRIPTORS].mean().reset_index()
51+
agg.to_parquet("aroma_master.parquet")
52+
53+
print(f"aroma_master.parquet: {len(agg)} molecules x {len(DESCRIPTORS)} descriptors")
54+
for d in DESCRIPTORS:
55+
print(f" {d:16s} mean={agg[d].mean():5.1f} max={agg[d].max():5.1f} "
56+
f">=15: {int((agg[d] >= 15).sum())}")
57+
58+
59+
if __name__ == "__main__":
60+
build()

training/train_aroma.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
"""
2+
train_aroma.py — odor-descriptor regressors (one per descriptor) on Morgan fingerprints.
3+
4+
Reads aroma_master.parquet (keller_2016, CC-BY); trains a RandomForestRegressor per
5+
descriptor predicting its 0-100 panel rating; reports HONEST 5-fold CV R2 (small,
6+
noisy panel-mean data — expect modest, descriptor-dependent scores). Saves the ones
7+
that clear a minimum CV-R2 to aroma_models/.
8+
"""
9+
from pathlib import Path
10+
11+
import joblib
12+
import numpy as np
13+
import pandas as pd
14+
from rdkit import Chem
15+
from rdkit.Chem import DataStructs, rdFingerprintGenerator
16+
from sklearn.ensemble import RandomForestRegressor
17+
from sklearn.model_selection import cross_val_score
18+
19+
FP_BITS, FP_RADIUS = 2048, 2
20+
_MORGAN = rdFingerprintGenerator.GetMorganGenerator(radius=FP_RADIUS, fpSize=FP_BITS)
21+
MIN_R2 = 0.10 # below this a descriptor is too noisy to ship an honest head
22+
OUT = Path("aroma_models")
23+
OUT.mkdir(exist_ok=True)
24+
for s in OUT.glob("*_reg.joblib"):
25+
s.unlink()
26+
27+
DESCRIPTORS = ["ACID", "AMMONIA/URINOUS", "BAKERY", "BURNT", "CHEMICAL", "COLD",
28+
"DECAYED", "EDIBLE", "FISH", "FLOWER", "FRUIT", "GARLIC", "GRASS",
29+
"MUSKY", "SOUR", "SPICES", "SWEATY", "SWEET", "WARM", "WOOD"]
30+
31+
32+
def fp(smiles):
33+
m = Chem.MolFromSmiles(smiles)
34+
if m is None:
35+
return None
36+
bv = _MORGAN.GetFingerprint(m)
37+
arr = np.zeros((FP_BITS,), dtype=np.int8)
38+
DataStructs.ConvertToNumpyArray(bv, arr)
39+
return arr
40+
41+
42+
df = pd.read_parquet("aroma_master.parquet")
43+
feats, keep = [], []
44+
for i, s in enumerate(df["smiles"]):
45+
f = fp(s)
46+
if f is not None:
47+
feats.append(f)
48+
keep.append(i)
49+
X = np.array(feats)
50+
df = df.iloc[keep].reset_index(drop=True)
51+
52+
print(f"training odor-descriptor regressors on {len(df)} molecules:")
53+
kept = 0
54+
for d in DESCRIPTORS:
55+
y = df[d].values
56+
mask = ~np.isnan(y)
57+
Xd, yd = X[mask], y[mask]
58+
r2 = cross_val_score(RandomForestRegressor(n_estimators=400, n_jobs=-1, random_state=42),
59+
Xd, yd, cv=5, scoring="r2").mean()
60+
flag = "kept" if r2 >= MIN_R2 else "drop (too noisy)"
61+
if r2 >= MIN_R2:
62+
reg = RandomForestRegressor(n_estimators=400, n_jobs=-1, random_state=42).fit(Xd, yd)
63+
joblib.dump(reg, OUT / f"{d.replace('/', '_')}_reg.joblib")
64+
kept += 1
65+
print(f" {d:16s} n={int(mask.sum()):3d} CV-R2={r2:+.2f} -> {flag}")
66+
print(f"\nkept {kept}/{len(DESCRIPTORS)} descriptor heads (CV-R2 >= {MIN_R2}) -> aroma_models/")

0 commit comments

Comments
 (0)