Skip to content

Commit 928a9f0

Browse files
Broaden the memorizing aroma heads, and measure whether it worked (#258)
* feat(training): audit whether a head learns its class or memorizes it A cross-validated AUROC only ever asks about molecules inside the labelled set, so it cannot see the failure that matters most for the thin heads: a head fit on twelve molecules sharing one scaffold scores 0.99 by recognising that scaffold, fires on exactly its own training molecules, and discovers nothing. audit_generalization.py fires every head over the whole corpus and counts the hits that were NOT trained on. Scoring at a single threshold turned out to conflate two opposite problems. A head with 13 positives against 2400 negatives is calibrated conservatively even with balanced class weights, so "0 novel at 0.5" covers both "learned nothing" and "learned the class but is shy about saying so" — and those want opposite fixes. The audit therefore also probes at 0.35 and returns a three-way verdict: generalizes / under-confident / memorizing. That distinction is not hypothetical. Of the aroma roster, 153 generalize, 12 are merely under-confident (pine finds 8 unlabelled molecules at 0.35, rosemary 9, freesia 9), and only turmeric and celery memorize at any threshold — both genuinely narrow single-scaffold classes where more molecules will not help. Read at 0.5 alone, all 14 looked like the same problem. Also corrects METHODS.md, which still described the aroma model as deferred to licensed data several hundred heads after it shipped. The gated piece is depth, not vocabulary. Signed-off-by: Austin L. <86896075+rvnminers-A-and-N@users.noreply.github.com> * data(aroma): broaden the twelve memorizing heads with diverse positives (#256) Each of these heads was drawing its positives solely from the weak-labelled HSDB odor corpus, with no entry in CURATED at all — which is why they were thin and narrow enough to memorize. Adds well-established character-impact chemistry for each, chosen for structural spread rather than volume, the same way #247 moved tingling off zero: pinenes/camphene/carene/bornyl esters for pine, cineole/terpinen-4-ol/aromadendrene for eucalyptus, phthalides for celery, indole/cresol/benzyl esters/cinnamyl alcohol for narcissus. Honest result: five moved off zero at the 0.5 threshold — narcissus 0 to 4 novel discoveries, frankincense and costus to 2, fennel and allspice to 1. Another five (pine, eucalyptus, rosemary, freesia, elemi) turned out to be under-confident rather than memorizing once probed at 0.35, so the extra positives sharpened heads that had already learned their class. Turmeric and celery did not move at any threshold and are tracked separately; a single-scaffold class that thin is a model problem, not a data problem. AUROC fell for several heads (pine 0.965 to 0.905, frankincense 0.916 to 0.829). That is the right direction — a broader class is genuinely harder to fit, and a score dropping while discovery rises is what learning instead of memorizing looks like. Corpus now trains 167 aroma heads, up from 164. Signed-off-by: Austin L. <86896075+rvnminers-A-and-N@users.noreply.github.com> --------- Signed-off-by: Austin L. <86896075+rvnminers-A-and-N@users.noreply.github.com>
1 parent cbc7893 commit 928a9f0

3 files changed

Lines changed: 226 additions & 5 deletions

File tree

docs/METHODS.md

Lines changed: 43 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -86,9 +86,47 @@ and a rule is used only where it's *more honest* than a model. Companion to
8686

8787
---
8888

89+
## Does a head LEARN, or just MEMORIZE?
90+
A high cross-validated AUROC is necessary but **not sufficient**, and this is the single easiest
91+
place to fool yourself. A head trained on twelve molecules that all share one scaffold can score
92+
0.99 by recognizing that scaffold and nothing else — it fires on exactly its own training
93+
molecules and stays silent on every other molecule in the corpus. Cross-validation cannot see
94+
this, because it only ever asks about molecules *inside* the labelled set.
95+
96+
- **Generalization test**`computed`. Fire the head over the **whole corpus**, then count the
97+
hits that are **not** in its training positives. That count is the head's discovery power.
98+
Run it with [`training/audit_generalization.py`](../training/audit_generalization.py).
99+
- **Two thresholds, because zero is ambiguous.** A head with 13 positives against 2400 negatives
100+
is calibrated conservatively even with balanced class weights — it can have learned its class
101+
and still rarely clear 0.5 outside the molecules it was fit on. So the audit also scores at
102+
**0.35**, which splits one number into two very different diagnoses:
103+
- `novel@0.5 > 0`**generalizes**.
104+
- `novel@0.5 = 0 < novel@0.35`**under-confident**. It found real unlabelled molecules just
105+
below the bar. The class is learnable and the head isn't broken; more positives sharpen it.
106+
- `novel@0.35 = 0`**memorizing**. Fires on its training set and nothing else, at any
107+
threshold. This is the real failure.
108+
- Worth stating plainly because it bit us: `pine` and `rosemary` read as memorizing at 0.5 and
109+
turned out to be under-confident (8 and 9 novel hits at 0.35). `celery` and `turmeric` were
110+
memorizing at both. Same table, opposite verdicts, opposite fixes.
111+
- **Reading the result.** A memorizing head is not worthless — it still labels its own positives
112+
correctly — but it must not be presented as if it can *discover*, and it is not evidence the
113+
model learned the class.
114+
- **The fix is diversity, not volume.** `tingling` trained on nine *Zanthoxylum* sanshools learns
115+
"sanshool"; the same head trained on sanshools **plus** *Echinacea*, *Anacyclus* and *Heliopsis*
116+
amides learns "long-chain unsaturated N-alkylamide" and starts finding molecules nobody
117+
labelled. More of the same scaffold never moves the count off zero.
118+
- **Some heads can't be fixed with molecules.** A broad, fuzzy, multi-scaffold class like `sweet`
119+
**odour** (AUROC 0.724 over 208 positives) isn't thin — it's genuinely hard. The honest answer
120+
there is a better model (a GNN), not a longer list.
121+
89122
## The honest ceiling on "deeper flavor description"
90-
Taste tops out at the **5 basics + intensity + chemesthesis** on public data. The rich
91-
descriptors people mean by "flavor" — *vanilla, fruity, green, woody, minty, caramel…* — are
92-
**aroma**, not taste: a separate odor model that needs expert-labeled odor data (licensed or
93-
customer; deferred — see [`AROMA.md`](AROMA.md)). That model is the real route to deeper flavor
94-
language, and it's exactly the "comes with your data" piece.
123+
Taste tops out at the **5 basics + intensity + chemesthesis** on public data — that ceiling is
124+
real and hasn't moved. The rich descriptors people mean by "flavor" — *vanilla, fruity, green,
125+
woody, minty, caramel…* — are **aroma**, a separate modality with its own heads (see
126+
[`AROMA.md`](AROMA.md)), now trained from open sources rather than deferred to licensed data.
127+
128+
What remains gated is **depth**, not vocabulary: the aroma heads are weakly labelled from public
129+
odor text plus a hand-curated character-impact supplement, so they are strongest on the classic,
130+
widely-documented associations and thinnest on the rare naturals — which is precisely what the
131+
generalization test above measures and reports honestly. Expert-labelled odor panel data (licensed
132+
or customer-supplied) is still the route to depth, and it remains the "comes with your data" piece.

training/audit_generalization.py

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
"""audit_generalization.py — does a head LEARN its class, or just memorize its training set?
2+
3+
A high CV-AUROC is necessary but not sufficient. A head trained on twelve molecules that all share
4+
one scaffold can score 0.99 by recognizing that scaffold and nothing else: it will fire on exactly
5+
the molecules it was trained on and stay silent on every other molecule in the corpus. That head
6+
has memorized. It is not useless — it still labels its own positives correctly — but it cannot
7+
discover, so it must not be presented as if it can.
8+
9+
The test is deliberately simple and hard to fool:
10+
11+
fire the head over EVERY molecule in the corpus, then count the hits that are NOT in its
12+
training positives.
13+
14+
That count — `novel` below — is the head's discovery power. Zero means memorization. The AUROC
15+
never reveals this, because cross-validation only ever asks about molecules inside the labelled
16+
set; this asks what happens outside it.
17+
18+
The fix for a memorizing head is structural DIVERSITY among its positives, not more of them: a
19+
`tingling` head trained on nine Zanthoxylum sanshools learns "sanshool", whereas the same head
20+
trained on sanshools + Echinacea + Anacyclus + Heliopsis amides learns "long-chain unsaturated
21+
N-alkylamide" and starts finding molecules nobody labelled. See #247 (tingling) and #256 (the
22+
sixteen memorizing aroma heads) for the method applied end to end.
23+
24+
Some heads CANNOT be fixed with molecules. A broad, fuzzy, multi-scaffold class like `sweet` odour
25+
(AUROC 0.724 over 208 positives) is not thin — it is genuinely hard, and the honest answer is a
26+
better model (a GNN, #28), not a longer list.
27+
28+
Usage:
29+
python audit_generalization.py # all aroma heads
30+
python audit_generalization.py --mouthfeel # the mouthfeel heads instead
31+
python audit_generalization.py --threshold 0.6 # stricter definition of "fires"
32+
"""
33+
import argparse
34+
import json
35+
import sys
36+
from pathlib import Path
37+
38+
import numpy as np
39+
import pandas as pd
40+
41+
FIRE = 0.5 # a head "fires" on a molecule at or above this probability
42+
RELAXED = 0.35 # ...and this is the "would it fire if it were less shy?" probe — see below
43+
44+
# Why two thresholds. A head with 13 positives against 2400 negatives is calibrated conservatively
45+
# even with balanced class weights: it can have genuinely learned its class and still rarely clear
46+
# 0.5 outside the molecules it was fit on. Reading novel@0.5 alone therefore conflates two very
47+
# different failures — a head that learned nothing, and a head that learned the class but is shy
48+
# about saying so. Scoring both separates them, and the distinction changes what you'd do next:
49+
# novel@0.5 > 0 -> generalizes. Nothing to fix.
50+
# novel@0.5 == 0 < novel@0.35 -> UNDER-CONFIDENT. It found real molecules nobody labelled,
51+
# just below the bar. More positives would firm it up; the
52+
# class itself is learnable and the head is not broken.
53+
# novel@0.35 == 0 -> MEMORIZING. It fires on its training set and nothing
54+
# else at any reasonable threshold. This is the real
55+
# failure, and the fix is structural diversity.
56+
# `pine` and `rosemary` looked memorizing at 0.5 and turned out to be under-confident (8 and 9
57+
# novel at 0.35); `celery` and `turmeric` were memorizing at both. Same table, opposite verdicts.
58+
59+
60+
def _positives(train_path, heads):
61+
"""{head -> set of InChIKey skeletons it was trained to call positive}."""
62+
if not Path(train_path).exists():
63+
return {}
64+
df = pd.read_parquet(train_path)
65+
cols = [h for h in heads if h in df.columns]
66+
skel = df["inchikey"].astype(str).str.split("-").str[0]
67+
return {h: set(skel[df[h].astype(int) == 1]) for h in cols}
68+
69+
70+
def audit(modality="aroma", threshold=FIRE, relaxed=RELAXED):
71+
"""Return one row per head: name, auroc, n_pos, hits, novel, novel_lo, verdict."""
72+
import predict as P
73+
P.MODELS_READY.wait() # heads load on a background thread — don't race it
74+
75+
models, train_path, manifest = {
76+
"aroma": (P._AROMA_MODELS, "aroma_train.parquet", "aroma_models/manifest.json"),
77+
"mouthfeel": (P._MOUTHFEEL_MODELS, "mouthfeel_train.parquet", "mouthfeel_models/manifest.json"),
78+
}[modality]
79+
if not models:
80+
print(f"no {modality} heads loaded", file=sys.stderr)
81+
return []
82+
83+
meta = {}
84+
if Path(manifest).exists():
85+
meta = json.loads(Path(manifest).read_text()).get("descriptors", {})
86+
87+
heads = sorted(models)
88+
pos = _positives(train_path, heads)
89+
90+
# score every head over the whole corpus in one pass — the enrichment table already holds a
91+
# canonical molecule list, so the audit sees exactly what the app serves
92+
from rdkit import Chem
93+
df = pd.read_parquet("master_enrichment.parquet")
94+
mols = [Chem.MolFromSmiles(str(s)) for s in df["smiles"]]
95+
ok = [i for i, m in enumerate(mols) if m is not None]
96+
x = np.vstack([P._feat(mols[i])[0] for i in ok])
97+
skel = [Chem.MolToInchiKey(mols[i]).split("-")[0] for i in ok]
98+
99+
rows = []
100+
for h in heads:
101+
p = models[h].predict_proba(x)[:, 1]
102+
trained = pos.get(h, set())
103+
fired = {skel[i] for i in range(len(skel)) if p[i] >= threshold}
104+
fired_lo = {skel[i] for i in range(len(skel)) if p[i] >= relaxed}
105+
novel, novel_lo = len(fired - trained), len(fired_lo - trained)
106+
rows.append({"head": h,
107+
"auroc": meta.get(h, {}).get("auroc"),
108+
"n_pos": len(trained),
109+
"hits": len(fired),
110+
"novel": novel,
111+
"novel_lo": novel_lo,
112+
"verdict": "ok" if novel else ("shy" if novel_lo else "memorizing")})
113+
return sorted(rows, key=lambda r: (r["novel"], r["novel_lo"], -(r["auroc"] or 0)))
114+
115+
116+
def main():
117+
ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
118+
ap.add_argument("--mouthfeel", action="store_true", help="audit mouthfeel heads instead of aroma")
119+
ap.add_argument("--threshold", type=float, default=FIRE, help=f"fire threshold (default {FIRE})")
120+
ap.add_argument("--relaxed", type=float, default=RELAXED,
121+
help=f"under-confidence probe threshold (default {RELAXED})")
122+
a = ap.parse_args()
123+
124+
rows = audit("mouthfeel" if a.mouthfeel else "aroma", a.threshold, a.relaxed)
125+
if not rows:
126+
sys.exit(1)
127+
128+
tag = {"ok": "", "shy": " <- under-confident", "memorizing": " <- MEMORIZING"}
129+
print(f"{'head':16s} {'AUROC':>6s} {'n_pos':>6s} {'hits':>6s} "
130+
f"{'novel':>6s} {f'@{a.relaxed:g}':>6s}")
131+
for r in rows:
132+
au = f"{r['auroc']:.3f}" if r["auroc"] is not None else " - "
133+
print(f"{r['head']:16s} {au:>6s} {r['n_pos']:6d} {r['hits']:6d} "
134+
f"{r['novel']:6d} {r['novel_lo']:6d}{tag[r['verdict']]}")
135+
136+
shy = [r["head"] for r in rows if r["verdict"] == "shy"]
137+
mem = [r["head"] for r in rows if r["verdict"] == "memorizing"]
138+
novel = sorted(r["novel"] for r in rows)
139+
print(f"\n{len(rows) - len(shy) - len(mem)}/{len(rows)} heads generalize at {a.threshold:g} "
140+
f"(median {novel[len(novel) // 2]} novel discoveries).")
141+
if shy:
142+
print(f"\n{len(shy)} UNDER-CONFIDENT — found unlabelled molecules at {a.relaxed:g} but not "
143+
f"{a.threshold:g}: {', '.join(shy)}")
144+
print(" These learned their class; they're shy because their positives are heavily "
145+
"outnumbered. More positives sharpen them. Not broken.")
146+
if mem:
147+
print(f"\n{len(mem)} MEMORIZING — fire on their own training molecules and nothing else, "
148+
f"at any threshold: {', '.join(mem)}")
149+
print(" Fix: add STRUCTURALLY DIVERSE positives to the curated supplement, then rebuild "
150+
"and re-run. More of the same scaffold will not move these off zero.")
151+
152+
153+
if __name__ == "__main__":
154+
main()

training/build_aroma_supplement.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,35 @@
8282
"vegetable":["2-isobutyl-3-methoxypyrazine", "2-isopropyl-3-methoxypyrazine", "dimethyl sulfide", "2-acetylpyrrole"],
8383
"grassy": ["cis-3-hexenal", "cis-3-hexen-1-ol", "trans-2-hexenal", "hexanal",
8484
"trans-2-hexen-1-ol", "cis-3-hexenyl acetate"],
85+
# --- Heads that were MEMORIZING (#256): each fired on exactly its own training molecules and
86+
# nothing else. The fix is structural DIVERSITY within the class, not more of one scaffold —
87+
# the same approach that moved `tingling` off zero in #247. All well-established
88+
# character-impact chemistry for these materials, public-domain flavour/essential-oil facts.
89+
"pine": ["alpha-pinene", "beta-pinene", "camphene", "delta-3-carene", "terpinolene",
90+
"myrcene", "bornyl acetate", "isobornyl acetate", "longifolene", "borneol",
91+
"verbenone", "alpha-terpineol"],
92+
"eucalyptus": ["1,8-cineole", "alpha-terpineol", "terpinen-4-ol", "p-cymene", "aromadendrene",
93+
"globulol", "alpha-phellandrene", "gamma-terpinene", "trans-pinocarveol"],
94+
"fennel": ["anethole", "fenchone", "estragole", "alpha-phellandrene", "anisaldehyde",
95+
"fenchyl alcohol", "limonene", "beta-phellandrene", "camphene"],
96+
"celery": ["3-n-butylphthalide", "sedanolide", "sedanenolide", "neocnidilide",
97+
"beta-selinene", "3-n-butyl-4,5-dihydrophthalide", "senkyunolide A", "ligustilide"],
98+
"rosemary": ["1,8-cineole", "camphor", "borneol", "verbenone", "alpha-pinene", "bornyl acetate",
99+
"camphene", "isoborneol", "rosmarinic acid"],
100+
"turmeric": ["ar-turmerone", "alpha-turmerone", "beta-turmerone", "ar-curcumene", "zingiberene",
101+
"beta-sesquiphellandrene", "curlone", "curcumene"],
102+
"allspice": ["eugenol", "methyl eugenol", "beta-caryophyllene", "1,8-cineole", "chavicol",
103+
"eugenyl acetate", "alpha-phellandrene", "terpinen-4-ol"],
104+
"frankincense": ["incensole", "incensole acetate", "alpha-pinene", "octyl acetate", "verbenone",
105+
"serratol", "cembrene", "alpha-thujene", "octanol"],
106+
"narcissus": ["indole", "p-cresol", "benzyl acetate", "cinnamyl alcohol", "benzyl benzoate",
107+
"methyl benzoate", "alpha-terpineol", "eugenol", "benzyl alcohol"],
108+
"freesia": ["linalool", "alpha-ionone", "beta-ionone", "geraniol", "nerol", "benzaldehyde",
109+
"dihydro-beta-ionone", "linalyl acetate", "citronellol"],
110+
"elemi": ["elemol", "elemicin", "limonene", "alpha-phellandrene", "beta-elemene",
111+
"dill apiole", "elemene", "sabinene"],
112+
"costus": ["costunolide", "dehydrocostus lactone", "costol", "costic acid",
113+
"dihydrocostunolide", "alpha-costene", "aplotaxene"],
85114
"green": ["cis-3-hexenal", "trans-2-hexenal", "cis-3-hexen-1-ol", "hexanal"],
86115
# pungent (an odor/chemesthesis head, also tagged mouthfeel): sharp biting Piper long/black-pepper
87116
# amides on top of the corpus's documented pungent molecules (piperine, isothiocyanates, etc.)

0 commit comments

Comments
 (0)