From 111b8227c733f63cfe3866b2e4dad31b6957bfff Mon Sep 17 00:00:00 2001 From: "Austin L." <86896075+rvnminers-A-and-N@users.noreply.github.com> Date: Fri, 31 Jul 2026 10:02:07 +0000 Subject: [PATCH 1/2] refactor(data): resolve names from master_enrichment, the one base table (#224) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit app.py built its own name lookup from properties.parquet and then merged iupac_backfill.parquet itself. build_enrichment.py had already done that work — and better — so the two diverged in three ways, all silent: 1. The enrichment build resolves a name curated-list-first, then PubChem common name, then IUPAC name, then a molecular-formula fallback, un-inverting CAS-style ordering along the way. None of that reached app.py's table. 2. The merge read only `iupac_name` from the backfill and ignored the `common_name` column, so every PubChem Title the crawler recovered was thrown away on arrival. 3. Nothing carried the formula fallback, so the molecules that only have one resolved to nothing. Measured before the change: 754 molecules had a name in master_enrichment that app._NAME_TABLE did not know — and not obscure ones. cedrol, fenchyl alcohol, hydroxycitronellal and musk ketone all rendered by name in the grid and came back empty from the UI's own lookup. After: 0. Sourcing the resolved name straight off the base table makes the grid and the lookup agree by construction instead of by coincidence, and deletes a whole merge function rather than adding one. That is what #224 is actually asking for, applied where the duplication genuinely was. Note what is deliberately NOT folded in. profile_index.npz is an 8,850 x 183 matrix — that 183 is the FLAVOUR-PROFILE vector (6 taste + 172 aroma + 5 mouthfeel), not the head count, which is 195. The 12 Tox21 heads are excluded on purpose: safety is not a flavour-match dimension and must never steer "what tastes similar". flavor_map.parquet is a UMAP embedding. Different shapes serving different questions; forcing either into a row-oriented molecule table would be worse, not tidier. odor_notes/taste_notes are documented-text SOURCES the enrichment build consumes — upstream of the base table, not competitors to it. Signed-off-by: Austin L. <86896075+rvnminers-A-and-N@users.noreply.github.com> --- training/app.py | 53 ++++++++++++++++++++++--------------------------- 1 file changed, 24 insertions(+), 29 deletions(-) diff --git a/training/app.py b/training/app.py index 03d96f1..8299e6a 100644 --- a/training/app.py +++ b/training/app.py @@ -215,40 +215,35 @@ def _svg(smi, w=320, h=220): def _load_name_table(): - """inchikey-skeleton -> (common, IUPAC) from the precomputed enrichment table, so the - whole labeled set resolves instantly and offline. Empty until build_properties.py has - written name columns; live PubChem stays the fallback for anything not in the table.""" + """skeleton -> (common, IUPAC), sourced from master_enrichment — the ONE base table (#224). + + This used to read properties.parquet and then merge iupac_backfill.parquet itself, which + duplicated work build_enrichment.py had already done and silently diverged from it in three + ways. The enrichment build resolves a name by curated list first, then PubChem common name, + then IUPAC name, then a molecular-formula fallback — and it un-inverts CAS-style ordering + along the way. None of that reached this table, so 754 molecules the grid displayed by name + (cedrol, fenchyl alcohol, hydroxycitronellal, musk ketone...) resolved to nothing here. The + old merge also read only `iupac_name` from the backfill and ignored the `common_name` column, + so every PubChem Title the crawler recovered was thrown away. + + Reading the resolved name straight off the base table makes the UI's lookup and the grid + agree by construction rather than by coincidence. + """ try: import pandas as pd - df = pd.read_parquet(P.artifact("properties.parquet")) - if "common_name" not in df.columns: - return {} - out = {} - for ik, c, u in zip(df["inchikey"], df["common_name"], df["iupac_name"]): - if isinstance(ik, str) and (isinstance(c, str) or isinstance(u, str)): - out[ik.split("-")[0]] = (c if isinstance(c, str) else None, - u if isinstance(u, str) else None) - return out - except Exception: # noqa: BLE001 — no table / no pandas; just fall back to live lookups + df = pd.read_parquet(P.artifact("master_enrichment.parquet")) + except Exception: # noqa: BLE001 — no table / no pandas; live lookups still cover it return {} + out = {} + iupac = dict(zip(df.get("inchikey_skel", []), df.get("iupac_name", []))) # optional column + for skel, name in zip(df["inchikey_skel"], df["name"]): + if isinstance(skel, str) and isinstance(name, str) and name.strip(): + u = iupac.get(skel) + out[skel] = (name.strip(), u if isinstance(u, str) else None) + return out -def _merge_iupac_backfill(table): - """Fold in IUPAC names that build_iupac_backfill.py recovered from PubChem for molecules - the main properties crawl missed (skeleton -> keep any common name, add the IUPAC).""" - try: - import pandas as pd - bf = pd.read_parquet(P.artifact("iupac_backfill.parquet")) - except Exception: # noqa: BLE001 — backfill not built; nothing to merge - return table - for skel, u in zip(bf["inchikey_skel"], bf["iupac_name"]): - if isinstance(skel, str) and isinstance(u, str) and u: - common = table.get(skel, (None, None))[0] - table[skel] = (common, u) - return table - - -_NAME_TABLE = _merge_iupac_backfill(_load_name_table()) +_NAME_TABLE = _load_name_table() @lru_cache(maxsize=8192) From 57dc2a13893cbff876d7775159bd7650afcf6bfb Mon Sep 17 00:00:00 2001 From: "Austin L." <86896075+rvnminers-A-and-N@users.noreply.github.com> Date: Fri, 31 Jul 2026 10:05:13 +0000 Subject: [PATCH 2/2] docs: sweep every head and profile-dimension number to the live roster MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prompted by a fair question — "don't we have more heads now?" — which turned out to be pointing at a genuinely ambiguous line. Two different numbers had been drifting: 195 trained heads 6 taste + 172 aroma + 5 mouthfeel + 12 safety 183 profile dimensions 6 taste + 172 aroma + 5 mouthfeel (tox EXCLUDED, deliberately) Tox is kept out of the flavour-profile vector on purpose: safety is not a flavour-match dimension, and if it were in there two molecules could rank as "similar tasting" partly because they share a liver-toxicity signature. That is wrong in a formulation tool and dangerous in a food one. The stale numbers were spread across the schema, six docs and three source files — 175/177/178-dim profiles and 166/167-head rosters, each correct at some point in the last two days. The pgvector column was the worst of them: vector(177) would have rejected every insert of a 183-dim profile at runtime, and nothing would have caught it until the first real docker compose up. Also corrects a comment in predict.py that was not merely stale but actively wrong. It claimed the model load "is fanned out across cores (joblib.load releases the GIL)". Both halves are false — unpickling is GIL-bound, which is why threads measured ~2.5x SLOWER, and the process pool that IS faster deadlocks because loading runs during module import. Anyone optimising from that comment would have re-walked the exact path #225 documents as a dead end. Signed-off-by: Austin L. <86896075+rvnminers-A-and-N@users.noreply.github.com> --- CHANGELOG.md | 2 +- docs/ACCURACY.md | 2 +- docs/AROMA-AUDIT.md | 6 +++--- docs/AROMA.md | 2 +- docs/CAPABILITIES.md | 4 ++-- docs/DATA-PIPELINE.md | 4 ++-- docs/HOW-IT-WORKS.md | 4 ++-- docs/METHODS.md | 4 ++-- docs/MOUTHFEEL.md | 2 +- docs/TOX.md | 2 +- infra/initdb/01-schema.sql | 6 +++--- mcp-server/server.py | 2 +- training/app.py | 4 ++-- training/predict.py | 23 +++++++++++++---------- training/train_aroma.py | 2 +- training/workbench.html | 14 +++++++------- 16 files changed, 43 insertions(+), 40 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e93689..254b11c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,7 +31,7 @@ exists to install it somewhere other than the machine it was built on. - Numbers glossary in `HOW-IT-WORKS.md` and in the app's own How-it-works panel. ### Changed -- Aroma roster **164 → 166 heads**; confident-capable heads **94 → 108**. +- Aroma roster **164 → 172 heads**; confident-capable heads **94 → 108**. - Chip families (flavor / note / taste / mouthfeel) share one visual language instead of four accidental ones, and each studio section explains what its dimension *is*. - Every molecule has a display name: names fall back to molecular formula, with multi-component diff --git a/docs/ACCURACY.md b/docs/ACCURACY.md index c7449ed..3611739 100644 --- a/docs/ACCURACY.md +++ b/docs/ACCURACY.md @@ -128,7 +128,7 @@ because of it, and the reasoning is recorded in `train_aroma._calibrate` so nobo half the time when they fire. They are **not deleted, disabled, or hidden.** They keep their score, their column in the -178-dimension flavor profile, their chips in search, their colour on the map, and every molecule +183-dimension flavor profile, their chips in search, their colour on the map, and every molecule they find. What changes is one word: they are reported as **indicative** rather than *confident*, and the UI hatches their bar so you can see it at a glance. diff --git a/docs/AROMA-AUDIT.md b/docs/AROMA-AUDIT.md index 79485e4..c587215 100644 --- a/docs/AROMA-AUDIT.md +++ b/docs/AROMA-AUDIT.md @@ -2,7 +2,7 @@ > **Update — supplement applied, then made food-only.** `build_aroma_supplement.py` adds a curated > **public-domain** character-impact set (`aroma_supplement.csv`, resolved via PubChem) for the sparse -> descriptors below. Result: **42 → 167 heads** (food-safe core + non-food aroma-only tier; see the +> descriptors below. Result: **42 → 172 heads** (food-safe core + non-food aroma-only tier; see the > multi-industry note below), after open-government sourcing (see below) took the > curated supplement to **~1,994 labelled molecules**. New heads include `coconut`, `nutty`, `caramel`, > `winey`, `onion`, `honey`, `herbal`, `vanilla`, `buttery`, `balsamic`, `smoky`, `cinnamon`, `spicy`, @@ -28,10 +28,10 @@ > 2-phenylethanol) instead of the E/Z terpene twins (nerol/geraniol) that dedupe to one row under the > connectivity-skeleton key. That was the **food-safe** ceiling at ~86. > -> **Then we walked the road past it — the non-food aroma-only descriptor space — to 167 heads.** The +> **Then we walked the road past it — the non-food aroma-only descriptor space — to 172 heads.** The > corpus is now deliberately **multi-industry**, not food-only: odor is physical, so to read > structure→smell well the corpus should include the fragrance space, and a per-molecule **safety flag** -> (open-gov food registers) — not corpus membership — gates edibility per mode. The same 167 heads serve +> (open-gov food registers) — not corpus membership — gates edibility per mode. The same 172 heads serve > every "Mancer" mode: **Flavormancer** (food chemistry), **Beveragemancer** (beverage), **Aromamancer** > (fragrance), **Vapemancer** (inhalation), **Oilmancer** (essential oils). Molecule names are resolved > to structures through PubChem and folded in flagged non-food. New heads cleared across several diff --git a/docs/AROMA.md b/docs/AROMA.md index da235b6..d4f594c 100644 --- a/docs/AROMA.md +++ b/docs/AROMA.md @@ -18,7 +18,7 @@ enumerable via the annotations API. Pipeline: physicochemical descriptor block** (`chemfeatures.py`: MW, logP, TPSA, H-bond donors/ acceptors, rotatable bonds, ring counts, fraction sp3, heteroatoms — the global properties bare bits miss). After broadening the vocabulary, folding the curated `flavors.csv` molecules - in (825 → **2403** molecules, incl. a curated public-domain supplement), and adding those features, **167 heads clear CV-AUROC ≥ 0.70** + in (825 → **2403** molecules, incl. a curated public-domain supplement), and adding those features, **172 heads clear CV-AUROC ≥ 0.70** (each score is that descriptor's own held-out AUROC): medicinal 0.97, ammoniacal 0.96, petroleum 0.96, citrus 0.94, camphor 0.93, almond 0.92, fatty 0.89, minty 0.88, ethereal 0.87, fruity 0.87, fishy 0.86, sulfurous 0.85, garlic 0.84, floral 0.83, pungent 0.81, earthy 0.73. diff --git a/docs/CAPABILITIES.md b/docs/CAPABILITIES.md index 98b7b91..4393924 100644 --- a/docs/CAPABILITIES.md +++ b/docs/CAPABILITIES.md @@ -6,7 +6,7 @@ shape of the tool. The organizing discipline throughout: **every output is tagge by how it's derived, and nothing claims more certainty than its source supports.** > **Edition note.** This catalogues the **commercial** edition (Apache-2.0, -> commercial-clean data). **Aroma ships here** as 167 presence/absence odor-descriptor +> commercial-clean data). **Aroma ships here** as 172 presence/absence odor-descriptor > heads trained on public-domain HSDB text (plus 5 mouthfeel/chemesthesis heads and 12 > Tox21 safety heads) — what's still gated is scored **intensity** (*how strong* a note > is), which needs research/customer panel data and lives in the **academic edition** @@ -33,7 +33,7 @@ by how it's derived, and nothing claims more certainty than its source supports. - Multitaste — fires when 2+ taste heads are high (**trained**-derived). - Known-taste ground truth — verified labels override predictions (**lookup**). -**Aroma** — **167 odor-descriptor heads ship** (**trained**) +**Aroma** — **172 odor-descriptor heads ship** (**trained**) - Presence/absence per descriptor (citrus, floral, minty, almond, fatty, petroleum, earthy, medicinal, sulfurous, camphor, fruity, fishy, garlic, ethereal, ammoniacal, pungent) — RandomForests on fingerprint + physicochemical features, over public-domain HSDB odor text + diff --git a/docs/DATA-PIPELINE.md b/docs/DATA-PIPELINE.md index 3683cc1..c6dc02b 100644 --- a/docs/DATA-PIPELINE.md +++ b/docs/DATA-PIPELINE.md @@ -45,8 +45,8 @@ local compute. **Verify it worked** (this is the honest end-to-end check, not just "the server started"): ```bash -curl -s localhost:8000/api/status # {"ready":true,"total":190,...} (190 artifacts = 189 heads + the intensity regressor) -curl -s localhost:8000/api/heads | jq '.aroma | length' # 166 +curl -s localhost:8000/api/status # {"ready":true,"total":190,...} (190 artifacts = 195 heads + the intensity regressor) +curl -s localhost:8000/api/heads | jq '.aroma | length' # 172 # vanillin — the vanilla head should fire at 1.0, confident, with its calibrated threshold curl -s -X POST localhost:8000/api/predict \ -H 'Content-Type: application/json' \ diff --git a/docs/HOW-IT-WORKS.md b/docs/HOW-IT-WORKS.md index 7045dab..821f4a4 100644 --- a/docs/HOW-IT-WORKS.md +++ b/docs/HOW-IT-WORKS.md @@ -98,7 +98,7 @@ features (trained on SweetenersDB). --- -## 3. Aroma — 167 descriptor heads from public odor text +## 3. Aroma — 172 descriptor heads from public odor text Odor is the hard, licensed part of this field — most rich odor datasets are NonCommercial. Our clean route: @@ -111,7 +111,7 @@ clean route: `minty`), producing a presence/absence label per descriptor. We also fold in the curated character-impact facts from `flavors.csv`. 3. **`train_aroma.py`** trains one RandomForest per descriptor (on fingerprint + descriptors) and - **keeps only the heads that clear CV-AUROC ≥ 0.70** — an honest bar. **167 heads** survive + **keeps only the heads that clear CV-AUROC ≥ 0.70** — an honest bar. **172 heads** survive (citrus, floral, minty, almond, fatty, petroleum, earthy, medicinal, sulfurous, camphor, fruity, fishy, garlic, ethereal, ammoniacal, pungent, pine, rose, rancid, alcoholic, woody, green, grassy, putrid), 0.71–0.98. The lower-population heads (≈10–20 documented diff --git a/docs/METHODS.md b/docs/METHODS.md index 4f4e2c5..d6224bf 100644 --- a/docs/METHODS.md +++ b/docs/METHODS.md @@ -136,8 +136,8 @@ the thin heads — a genuine pine match could land at 0.42 and never be shown. recall climbs faster than precision falls. **A high AUROC does not protect you** — AUROC is computed on ranking and is insensitive to class imbalance; precision is not. `coffee` has AUROC 0.960 and out-of-fold precision **0.46**; both are true of the same head. -- **Heads that cannot clear the floor are `indicative`, not deleted.** 73 of 167 aroma heads never - reach 50% precision at any threshold. They keep their score, their place in the 178-dim profile, +- **Heads that cannot clear the floor are `indicative`, not deleted.** 65 of 172 aroma heads never + reach 50% precision at any threshold. They keep their score, their place in the 183-dim profile, their chips and their map colour — firing well above base rate is real evidence. They are simply never presented as a *confident* call: the UI marks them, and the read returns `indicative: true` plus the head's measured precision. Hiding them would delete reach to paper over a labelling diff --git a/docs/MOUTHFEEL.md b/docs/MOUTHFEEL.md index 40d85d2..c4c9513 100644 --- a/docs/MOUTHFEEL.md +++ b/docs/MOUTHFEEL.md @@ -10,7 +10,7 @@ without *feeling* cool, and WS-23 cools the mouth with almost no odour at all. **Five trained heads**, one RandomForest per sensation on the Morgan fingerprint + physicochemical block (the same stack as taste/aroma), in their own `mouthfeel_models/` directory, loaded and tagged -`mouthfeel` by `predict.py` and folded into the 175-dim flavor-profile vector used for substitutes. +`mouthfeel` by `predict.py` and folded into the 183-dim flavor-profile vector used for substitutes. | head | what it is | held-out CV-AUROC | |---|---|---| diff --git a/docs/TOX.md b/docs/TOX.md index 7aebd40..e571619 100644 --- a/docs/TOX.md +++ b/docs/TOX.md @@ -16,7 +16,7 @@ assay** heads that act as a *defensive screen*. |---|---|---| | label source | **documented + curated** sensory facts (a molecule is *known to be perceived* as X) | **experimental wet-lab screening** (a molecule *measurably was* active in an assay) | | what a positive means | people report this percept | this well lit up in a dish | -| in the substitute-match vector? | **yes** (175 dims) | **no** — deliberately | +| in the substitute-match vector? | **yes** (183 dims) | **no** — deliberately | | framing | prediction | **caution-only review flag** | Tox is excluded from the flavor-profile vector on purpose: you do not want reformulation diff --git a/infra/initdb/01-schema.sql b/infra/initdb/01-schema.sql index 21631cd..2d108c1 100644 --- a/infra/initdb/01-schema.sql +++ b/infra/initdb/01-schema.sql @@ -1,8 +1,8 @@ -- Flavormancer schema (#20). -- -- The substitution index is a nearest-neighbour search over the flavour-profile vector, so the --- vector lives in the database rather than being recomputed per query. 177 dimensions: --- 6 taste + 166 aroma + 5 mouthfeel. Tox is deliberately NOT in the vector — safety is not a +-- vector lives in the database rather than being recomputed per query. 183 dimensions: +-- 6 taste + 172 aroma + 5 mouthfeel. Tox is deliberately NOT in the vector — safety is not a -- flavour-match dimension, and letting it steer "what tastes similar" would be wrong. CREATE EXTENSION IF NOT EXISTS vector; @@ -19,7 +19,7 @@ CREATE TABLE IF NOT EXISTS molecule ( CREATE TABLE IF NOT EXISTS molecule_profile ( inchikey_skel text PRIMARY KEY REFERENCES molecule(inchikey_skel) ON DELETE CASCADE, - profile vector(177) NOT NULL, + profile vector(183) NOT NULL, aromas text[] -- heads clearing their own calibrated threshold ); diff --git a/mcp-server/server.py b/mcp-server/server.py index a586be1..d8deb73 100644 --- a/mcp-server/server.py +++ b/mcp-server/server.py @@ -256,7 +256,7 @@ def _flavor_map(label: str, limit: int, full: bool) -> dict: def read_flavor(molecule: str) -> dict: """Predict the flavor of a single molecule (name or SMILES). - Returns the six taste-head probabilities, confident aromas plus all 167 aroma scores, the + Returns the six taste-head probabilities, confident aromas plus all 172 aroma scores, the trained mouthfeel/chemesthesis scores (cooling/pungent/warming/astringent/tingling), GRAS status, structural alerts, caution-only Tox21 flags, and the applicability-domain flag. Prediction only — tox flags are indicative in-vitro activity, never a determination. diff --git a/training/app.py b/training/app.py index 8299e6a..a4e04a0 100644 --- a/training/app.py +++ b/training/app.py @@ -440,7 +440,7 @@ def api_substitutes(q: Query): @app.post("/api/precomputed") def api_precomputed(q: Query): """Fast check: is this molecule's profile already in the index (instant read) or does it need a - fresh 178-head compute? Lets the UI show a 'conjuring a fresh reading' note for novel molecules.""" + fresh 183-head compute? Lets the UI show a 'conjuring a fresh reading' note for novel molecules.""" smi = _resolve(q.smiles) return {"precomputed": bool(smi and P.is_precomputed(smi))} @@ -1321,7 +1321,7 @@ def _prewarm_formulation(): m = Chem.MolFromSmiles(smi) if smi else None if m is not None: canon = Chem.MolToSmiles(m) - P.predict_aroma(canon) # fills the shared 167-head aroma cache + P.predict_aroma(canon) # fills the shared 172-head aroma cache P.substitutes(canon) # taste heads + profile cosine, so the demo chips are instant diff --git a/training/predict.py b/training/predict.py index e93271f..3b587e6 100644 --- a/training/predict.py +++ b/training/predict.py @@ -214,8 +214,11 @@ def _load_rf(path): # Model heads are loaded on a BACKGROUND THREAD at import (see _load_all_models below) so that # `import predict` returns immediately and the web server can bind its port right away, showing a -# friendly "warming up" page while the ~180 forests (628 MB) load — instead of a 34 s startup 502. -# The load is fanned out across cores (joblib.load releases the GIL), which also cuts the wall time. +# friendly "warming up" page while the ~195 forests (~700 MB) load — instead of a 50 s startup 502. +# The load is SERIAL, and this comment used to claim the opposite ("fanned out across cores, +# joblib.load releases the GIL"). Both halves of that were wrong: unpickling is GIL-bound, so +# threads made it ~2.5x SLOWER, and a process pool deadlocks because this runs during module +# import. See _load_all_models for the measurements and #225 for the real fix. _CLASSIFIERS = {} # sweet/bitter/umami/... taste heads _INTENSITY = None # sweet-intensity regressor _TASTE_META = {} # taste -> {auroc, ...} from taste_models/manifest.json (held-out score) @@ -1098,12 +1101,12 @@ def analyze_balance(ingredients): @lru_cache(maxsize=8192) def _aroma_scores_canon(canon): - """Run all 167 descriptor forests for a CANONICAL SMILES and return {head: score}.""" + """Run all 172 descriptor forests for a CANONICAL SMILES and return {head: score}.""" m = Chem.MolFromSmiles(canon) if m is None or not _AROMA_MODELS: return None fp = _feat(m) - # Fan the 167 forests across cores — each predict_proba releases the GIL, so this turns the + # Fan the 172 forests across cores — each predict_proba releases the GIL, so this turns the # ~40 s serial read (the only remaining cost, for genuinely novel/out-of-corpus molecules) into # a couple of seconds. In-corpus molecules never reach here (they read the precomputed index row). def _score(it): @@ -1114,13 +1117,13 @@ def _score(it): def _aroma_scores(smiles): - """The expensive part of the aroma read: all 167 descriptor forests → {head: score}. Keyed on + """The expensive part of the aroma read: all 172 descriptor forests → {head: score}. Keyed on the CANONICAL SMILES (not threshold/top_k, not the raw string) so every caller shares one computation per molecule regardless of how they spelled it — predict_aroma, _query_profile (substitutes) and the /api/aroma endpoint all collapse to the same cache entry instead of each - re-running 167 forests (that double/mismatched inference was the ~6 s /api/substitutes). + re-running 172 forests (that double/mismatched inference was the ~6 s /api/substitutes). - In-corpus molecules skip the forests entirely: their 167 scores are read straight off the + In-corpus molecules skip the forests entirely: their 172 scores are read straight off the precomputed profile index (built at startup) — the same numbers, ~40 s cheaper on a cold hit.""" m = Chem.MolFromSmiles(smiles) if m is None or not _AROMA_MODELS: @@ -1365,7 +1368,7 @@ def _aroma(a): def _build_sub_index(): global _SUB_INDEX import numpy as np - # Fast path: load the precomputed profile index (build_profile_index.py). The 178-dim + # Fast path: load the precomputed profile index (build_profile_index.py). The 183-dim # inference over ~8.8k molecules is slow (~3 min); the cache makes startup instant. We only # rebuild the cheap Morgan fingerprints from SMILES on load. cache = artifact("profile_index.npz") @@ -1443,7 +1446,7 @@ def _ensure_sub_index(): def _index_row(mol): """Row of `mol` in the profile index (matched by connectivity skeleton), or None if the molecule isn't in the reference corpus. In-corpus molecules can reuse their PRECOMPUTED - 178-dim profile (built once at index build / startup) instead of re-running 167 forests at + 183-dim profile (built once at index build / startup) instead of re-running 172 forests at query time — that inference is ~40 s cold on a novel molecule and was the real /api/substitutes and include_aroma cost. The precomputed row is the SAME model output, just paid up front.""" _ensure_sub_index() @@ -1465,7 +1468,7 @@ def _index_row(mol): def is_precomputed(smiles): """True if this molecule's full taste+aroma profile is already in the index (an instant read), - False if it's out-of-corpus and the 178 profile heads have to run fresh (the slower path). Used by the + False if it's out-of-corpus and the 183 profile heads have to run fresh (the slower path). Used by the UI to decide whether to show the 'conjuring a fresh reading' note while a read brews.""" mol = Chem.MolFromSmiles(smiles or "") return mol is not None and _index_row(mol) is not None diff --git a/training/train_aroma.py b/training/train_aroma.py index 48380c1..8561edc 100644 --- a/training/train_aroma.py +++ b/training/train_aroma.py @@ -6,7 +6,7 @@ descriptor that has enough positives, reports HONEST 5-fold CV AUROC, and keeps only the descriptors that clear a minimum AUROC. Saves the kept heads to aroma_models/ + a manifest. -PARALLELISM: each head trains in its OWN process with n_jobs=1, so the ~167 heads train +PARALLELISM: each head trains in its OWN process with n_jobs=1, so the ~172 heads train concurrently across the box's cores instead of one-at-a-time (the same process-per-unit pattern that made build_profile_index fast). Feature matrix is memmapped so workers share it. A head either clears the bar and dumps its model, or is dropped — workers write disjoint files, no locks. diff --git a/training/workbench.html b/training/workbench.html index d97a6b2..5f69244 100644 --- a/training/workbench.html +++ b/training/workbench.html @@ -839,7 +839,7 @@
Every molecule becomes a 2,048-bit Morgan fingerprint (which substructures it contains) plus a block of physicochemical descriptors (logP, MW, TPSA, H-bonding…). That one shared vector feeds every model — the same structure-to-property representation QSAR has leaned on for decades.
Six random-forest classifiers (sweet, bitter, umami…) each return a probability, scored by an honest held-out CV-AUROC. Sour and salty are solution / ionic effects, not molecule-shape ones, so they're transparent rules — not faked models. A sweetness-intensity regressor estimates relative-to-sucrose potency.
One random forest per descriptor (citrus, floral, woody…), trained on public-domain odor text. A head ships only if it clears CV-AUROC ≥ 0.70 — 167 survive, each shown with its own score. It reads presence, not intensity (free text carries none) — an honest ceiling, stated in the UI.
One random forest per descriptor (citrus, floral, woody…), trained on public-domain odor text. A head ships only if it clears CV-AUROC ≥ 0.70 — 172 survive, each shown with its own score. It reads presence, not intensity (free text carries none) — an honest ceiling, stated in the UI.
Every value is tagged measured / predicted / estimate, so nothing reads as more precise than it is. Where a quantitative feature needs data we can't ship free-commercially (odor thresholds, panel intensities), the UI says so — and it lights up with your data.
AUROC alone will lie to you. It measures ranking and is blind to how rare a note is — a head with 11 examples among 2,403 molecules can score 0.979 and still be right just 1 time in 10 when it fires. So every head also publishes its precision: when it says yes, how often it's actually right, measured only on molecules it never trained on. Each head gets its own firing threshold (0.16 – 0.85, not a flat 0.5), fitted so a confident call is right more than half the time. 23 heads have never been wrong on unseen molecules; the 73 that can't clear 50% are shown as indicative — hatched bars, kept in full, never dressed up as confident.
Two flavor dimensions. Flip the color toggle between taste (sweet / bitter / umami / sour / salty / tasteless) and aroma (= odor — the same thing; - the 167 documented descriptor classes like citrus, floral, sulfurous). Same molecules, + the 172 documented descriptor classes like citrus, floral, sulfurous). Same molecules, same structural layout — you're overlaying a different sense. The both toggle merges them into one full-flavor view: each dot takes its taste color where a taste is known, otherwise its aroma color — so nearly every molecule is colored by its dominant known @@ -1089,7 +1089,7 @@
Predicted from structure by RandomForest heads on public-domain HSDB odor data — the same stack as taste, so aroma is predicted for any molecule. This is @@ -2179,7 +2179,7 @@
A note is what your nose reads — vanilla, smoky, green, citrus. Aroma carries most of what people call “flavor”: block your nose and a pear and a potato are hard to tell apart. This is the largest set here (167 trained heads) and the main lever for making something smell like what you intend.
A note is what your nose reads — vanilla, smoky, green, citrus. Aroma carries most of what people call “flavor”: block your nose and a pear and a potato are hard to tell apart. This is the largest set here (172 trained heads) and the main lever for making something smell like what you intend.