Skip to content

Commit ce7a480

Browse files
feat: chip legend, measured-property crawler, and the data-pipeline doc (#229, #26, #25) (#273)
CHIP LEGEND (#229). Four dimensions with four different meanings sit side by side in the studios, and after the chip-system work in #269 the dot colour was the only thing distinguishing them — which works only if you already know the code. A key now states it once, up front: what each family means, and what solid / outline / hatched marking indicates (documented, predicted, indicative). The hatched swatch matters most: it is the one that says "right less than half the time — a hint, not an answer". MEASURED PROPERTIES (#209, in progress). 73% of the enrichment table had no measured boiling point, and the assumption was that the data did not exist. It does. build_properties.py reads PubChem's property table, which only ever carries COMPUTED values; measured boiling and melting points live in PUG-View under Experimental Properties, an endpoint this codebase had never called. A sample of molecules with no BP found 8 of 8 had a PubChem record. build_measured_properties.py crawls that endpoint. Parsing is the real work — values are free text written by whoever took the measurement ("246 °C", "115-116 °C at 12 mm Hg", "410 °F") — so it converts Fahrenheit, takes the midpoint of a range, and DISCARDS anything measured at reduced pressure. A boiling point at 12 mmHg is not comparable to one at atmospheric, and silently mixing them would corrupt the volatility ordering the formulation studio depends on. properties.parquet already carries boiling_point_pressure_mmhg for exactly this reason. Smoke test resolved 11 of 12 with values that check out (pyrrolidine 86.6 °C). The full crawl is ~7,200 molecules at roughly 57% hit rate and takes hours; it checkpoints every 100 so it can be folded in whenever it finishes. _by_skel() would have silently discarded all of it. It required an `inchikey` column, but both backfill tables are keyed by `inchikey_skel` — so it returned an empty dict, with no error, and every backfilled name and boiling point would simply never have appeared. It now accepts either. DATA-PIPELINE.md (#26, #25). What every build script reads, the REAL column names in each table read off the built artifacts rather than copied from the loader's hopes, and a clean-machine first run in dependency order with timings and an end-to-end verification that checks a prediction rather than just that the server started. Two claims in the first draft were wrong and are corrected: the verification example printed `.aroma.top[0]`, which is the highest-scoring CONFIDENT head and not the one you searched for; and I had written that `n_sources` breaks label conflicts by weight of evidence. It does not — nothing reads it, and conflicts resolve POSITIVE-WINS: one source calling a molecule sweet makes it sweet. That is the right default for sparse partially-annotated sources, but it means a single mislabelled source can assert a taste the others deny, which is worth knowing and is now written down. Also resolves the [OBTAIN]/[VERIFY] markers: flavordb_taste.csv is permanently excluded (NonCommercial), umami_list.csv is an unused nice-to-have, sweeteners_db.csv is present and drives the intensity regressor. Signed-off-by: Austin L. <86896075+rvnminers-A-and-N@users.noreply.github.com>
1 parent d8949bb commit ce7a480

5 files changed

Lines changed: 357 additions & 3 deletions

File tree

docs/DATA-PIPELINE.md

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
# Data pipeline — sources, exact columns, and a clean-machine first run
2+
3+
What every build script reads, the **real** column names in each table (not the ones the code
4+
hopes for), and the order to run things in from nothing. Companion to
5+
[`training/SETUP.md`](../training/SETUP.md) (install steps) and
6+
[`SOURCES.md`](SOURCES.md) (licensing and attribution).
7+
8+
Written because the loaders carried `[VERIFY]` markers where nobody had opened the file and
9+
confirmed what was actually in it.
10+
11+
---
12+
13+
## 1. First run from a clean machine
14+
15+
Assumes Python 3.12 and ~8 GB free. The whole chain is CPU-only; no GPU anywhere.
16+
17+
```bash
18+
git clone https://github.com/echelonts/flavormancer.git && cd flavormancer
19+
python -m venv .venv && . .venv/bin/activate
20+
pip install -r requirements.txt
21+
pip install fastapi "uvicorn[standard]" pydantic umap-learn openpyxl # serving + map + xlsx
22+
```
23+
24+
Then, **in this order** — each step consumes the previous step's output:
25+
26+
| # | command | produces | ~time |
27+
|---|---|---|---|
28+
| 1 | `python build_taste_dataset.py` | `taste_master.parquet` | 1 min |
29+
| 2 | `python train_taste.py` | `taste_models/` + manifest | 3 min |
30+
| 3 | `python build_odor_notes.py --pubchem-all` | `odor_notes.parquet` | **hours** (crawl) |
31+
| 4 | `python build_aroma_supplement.py` | `aroma_supplement.csv` | 10 min (crawl) |
32+
| 5 | `python build_aroma_dataset.py` | `aroma_train.parquet` | 2 min |
33+
| 6 | `python train_aroma.py` | `aroma_models/` + manifest | 10 min (32 cores) |
34+
| 7 | `python build_mouthfeel_supplement.py && python build_mouthfeel_dataset.py && python train_mouthfeel.py` | `mouthfeel_models/` | 5 min |
35+
| 8 | `python train_tox.py` | `tox_models/` | 5 min |
36+
| 9 | `python build_enrichment.py` | `master_enrichment.parquet` | 8 min |
37+
| 10 | `python build_profile_index.py` | `profile_index.npz` | 4 min |
38+
| 11 | `python build_flavor_map.py` | `flavor_map.parquet` | 6 min |
39+
| 12 | `uvicorn app:app --host 0.0.0.0 --port 8000` | the workbench | 50 s to warm |
40+
41+
**Steps 3 and 4 are network crawls against PubChem.** They cache, so a re-run is cheap, but a
42+
cold step 3 is genuinely hours — start it and go and do something else. Everything downstream is
43+
local compute.
44+
45+
**Verify it worked** (this is the honest end-to-end check, not just "the server started"):
46+
47+
```bash
48+
curl -s localhost:8000/api/status # {"ready":true,"total":190,...}
49+
curl -s localhost:8000/api/heads | jq '.aroma | length' # 166
50+
# vanillin — the vanilla head should fire at 1.0, confident, with its calibrated threshold
51+
curl -s -X POST localhost:8000/api/predict \
52+
-H 'Content-Type: application/json' \
53+
-d '{"smiles":"COc1cc(C=O)ccc1O"}' \
54+
| jq '.aroma.descriptors[] | select(.odor=="vanilla")'
55+
# {"odor":"vanilla","score":1.0,"threshold":0.42,"confident":true,"precision":1.0,"auroc":0.93,...}
56+
```
57+
58+
`.aroma.top` is the *confident* subset ordered by score, so `top[0]` is whichever head scored
59+
highest — not necessarily the one you are looking for. Query `descriptors[]` by name, as above.
60+
61+
If `/api/status` reports fewer than 190 heads, a training step was skipped — the app degrades
62+
gracefully rather than failing, so it will start regardless.
63+
64+
---
65+
66+
## 2. What each table actually contains
67+
68+
Column names below were read off the built artifacts, not copied from the loader's expectations.
69+
Where a loader accepts several spellings it is noted, because the upstream files change.
70+
71+
### Taste
72+
73+
| table | rows | key columns |
74+
|---|---|---|
75+
| `taste_master.parquet` | 3,845 | `inchikey`, `smiles`, `multitaste`, `sweet`, `bitter`, `umami`, `sour`, `salty`, `n_sources` |
76+
| `taste_notes.parquet` | 676 | `inchikey`, `smiles`, `name`, `taste`, `taste_source` |
77+
78+
`taste_master` is one row per molecule with a 0/1 column per basic taste; `multitaste` marks
79+
molecules ChemTastesDB records under more than one class. **There is no `tasteless` column**
80+
that head trains from documented-tasteless text in `taste_notes`, which is why
81+
`build_flavor_map.py` overlays it separately rather than reading a column. `n_sources` counts the distinct upstream datasets a molecule appeared in. It is **provenance
82+
metadata only** — nothing downstream reads it today.
83+
84+
Worth knowing how conflicts actually resolve, because it is not a vote: `build_taste_dataset`
85+
aggregates with **positive-wins** — if any source labels a molecule sweet, it is sweet; only if
86+
every source says 0 does it become 0. That maximises recall on sparse, partially-annotated
87+
sources, which is the right default here, but it does mean one mislabelled source can assert a
88+
taste the other four deny. `n_sources` is the column that would let a future weighted rule
89+
override that, which is why it is recorded even though it is unused.
90+
91+
92+
### Aroma
93+
94+
| table | rows | key columns |
95+
|---|---|---|
96+
| `odor_notes.parquet` | 2,258 | `inchikey`, `smiles`, `name`, `odor`, `odor_source`, `odor_threshold_ppm`, `odor_threshold_note` |
97+
| `aroma_supplement.csv` | 2,159 | `flavor`, `molecule`, `smiles`, `category` |
98+
| `aroma_train.parquet` | 2,417 | `inchikey`, `smiles`, + one 0/1 column per descriptor |
99+
100+
`odor` is **free text** as recorded ("sweet, floral, slightly minty"), normalised into the
101+
controlled vocabulary by `build_aroma_dataset.tag()`. `odor_threshold_ppm` is populated only where
102+
PubChem cites one — treat it as a lookup, never a prediction.
103+
104+
### Properties
105+
106+
`properties.parquet` — 9,154 rows: `inchikey`, `common_name`, `iupac_name`, `boiling_point_c`,
107+
`boiling_point_pressure_mmhg`, `vapor_pressure_pa`, `melting_point_c`.
108+
109+
**`boiling_point_pressure_mmhg` is the column that makes the BP usable.** A boiling point measured
110+
at reduced pressure is not comparable to one at atmospheric, and averaging them together would
111+
corrupt any volatility ordering. `build_measured_properties.py` therefore *discards* reduced-pressure
112+
values outright rather than storing them without their pressure.
113+
114+
Backfill tables, both keyed by **`inchikey_skel`** rather than `inchikey`:
115+
116+
- `iupac_backfill.parquet``common_name`, `iupac_name` from PubChem **Title + IUPACName**
117+
- `measured_properties.parquet``boiling_point_c`, `melting_point_c` from PUG-View
118+
119+
> That key difference is a real trap. `build_enrichment._by_skel()` accepts either column for
120+
> exactly this reason — an `inchikey`-only reader returns an empty dict for these two, silently,
121+
> and every backfilled value simply never appears.
122+
123+
### Food-use registers
124+
125+
| table | rows | key columns |
126+
|---|---|---|
127+
| `saf_universe.parquet` | 2,782 | `smiles`, `inchikey`, `name`, `cas` (FDA Substances Added to Food) |
128+
| `gb_union_list.parquet` | 2,164 | `smiles`, `inchikey`, `name`, `fl` (EU/GB flavourings, FL number) |
129+
| `gras_reference.parquet` | 2,781 | `inchikey` |
130+
131+
These carry **listing status only**. A row here means a regulator lists the substance — it is not
132+
a safety clearance, and the UI must never render it as one. See
133+
[`food-safety provenance`](SOURCES.md).
134+
135+
### Master table
136+
137+
`master_enrichment.parquet` — 8,861 rows, the single table the app reads for everything that is
138+
not a live prediction: `inchikey_skel`, `smiles`, `name`, `mw`, `logp`, `tpsa`, `hbd`, `hba`,
139+
`rot_bonds`, `rings`, `melting_point_c`, `boiling_point_c`, `gras`, `taste_documented`, plus a
140+
`tox_*` column per Tox21 assay.
141+
142+
`name` is **never null**: it falls back to the molecular formula, with multi-component structures
143+
labelled as mixtures (`CaI2O6 (mixture: Ca+2 + IO3- + IO3-)`). A raw SMILES in the grid looks
144+
broken; a formula is honest.
145+
146+
---
147+
148+
## 3. Unresolved source markers
149+
150+
`build_taste_dataset.py` carries `[OBTAIN]` markers for three sources. Their status, so nobody
151+
re-investigates:
152+
153+
| source | status |
154+
|---|---|
155+
| `flavordb_taste.csv` | **Excluded, permanently.** FlavorDB is NonCommercial — incompatible with this project's commercial-clean requirement. The loader tolerates its absence. |
156+
| `umami_list.csv` | Optional. UMP442/BIOPEP-UWM umami SMILES. Not currently used; the umami head trains from ChemTastesDB alone and reaches AUROC 0.990, so this is a nice-to-have. |
157+
| `sweeteners_db.csv` | **Present and used.** Cheron SweetenersDB (MIT) drives the sweetness-intensity regressor (R² 0.836, n=316 — small, treat as indicative). |
158+
159+
The `[VERIFY]` marker at `build_taste_dataset.py:72` is a defensive `KeyError` for when an upstream
160+
file renames a column. It is working as intended: the loader tries several known spellings and
161+
fails loudly with the actual column list rather than silently producing an empty dataset.

docs/README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ Architecture, capability catalogue, and design decisions.
88
- `ACCURACY.md`**how accurate is it, really?** AUROC vs precision, per-head thresholds,
99
and what every reported number does and doesn't mean. Written to be read without an ML background
1010
- `METHODS.md` — every rule, threshold, heuristic, and trick the system uses, with rationale and ceiling
11+
- `DATA-PIPELINE.md` — what every build script reads, the **real** column names in each table,
12+
and a clean-machine first run in order
1113
- `SOURCES.md` — data sources, libraries, research, and license attribution
1214
- `API-CONTRACT.md` — the fixed JSON contract the .NET API exposes and the React UI consumes
1315
- `DATA-SOURCES.md` — data acquisition tracker: what each source unlocks and how to get it

training/build_enrichment.py

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,17 +47,24 @@ def _all_structures():
4747

4848

4949
def _by_skel(path, cols):
50-
"""{skeleton -> {col: val}} from a parquet keyed by full inchikey."""
50+
"""{skeleton -> {col: val}} from a parquet keyed by either `inchikey` or `inchikey_skel`.
51+
52+
Accepting BOTH keys matters: properties.parquet is keyed by the full InChIKey, while the
53+
backfill tables written by build_iupac_backfill.py and build_measured_properties.py are keyed
54+
by the skeleton. An inchikey-only reader returns an empty dict for those two — silently, with
55+
no error — so the backfilled names and measured boiling points would simply never appear.
56+
"""
5157
out = {}
5258
try:
5359
d = pd.read_parquet(path)
5460
except Exception: # noqa: BLE001 — table absent; return what we have
5561
return out
56-
if "inchikey" not in d.columns:
62+
key = next((k for k in ("inchikey", "inchikey_skel") if k in d.columns), None)
63+
if key is None:
5764
return out
5865
have = [c for c in cols if c in d.columns]
5966
for _, r in d.iterrows():
60-
ik = r["inchikey"]
67+
ik = r[key]
6168
if isinstance(ik, str):
6269
out[ik.split("-")[0]] = {c: r[c] for c in have}
6370
return out
@@ -237,6 +244,21 @@ def _taste_by_skel():
237244
structs.setdefault(Chem.MolToInchiKey(_m).split("-")[0], Chem.MolToSmiles(_m))
238245
props = _by_skel("properties.parquet",
239246
["common_name", "iupac_name", "melting_point_c", "boiling_point_c"])
247+
# Names PubChem's property table missed (build_iupac_backfill.py: Title AND IUPACName).
248+
for _skel, _row in _by_skel("iupac_backfill.parquet", ["common_name", "iupac_name"]).items():
249+
tgt = props.setdefault(_skel, {})
250+
for _k in ("common_name", "iupac_name"):
251+
if not isinstance(tgt.get(_k), str) and isinstance(_row.get(_k), str):
252+
tgt[_k] = _row[_k]
253+
# MEASURED boiling/melting points from PUG-View (build_measured_properties.py). The property
254+
# table only ever carried COMPUTED values, which is why 73% of rows had no measured BP despite
255+
# every one of those molecules having a PubChem record — we had never asked the right endpoint.
256+
for _skel, _row in _by_skel("measured_properties.parquet",
257+
["melting_point_c", "boiling_point_c"]).items():
258+
tgt = props.setdefault(_skel, {})
259+
for _k in ("melting_point_c", "boiling_point_c"):
260+
if tgt.get(_k) is None and _row.get(_k) is not None:
261+
tgt[_k] = _row[_k]
240262
taste_doc = _taste_by_skel()
241263
curated = _curated_names() # human names for the molecules we hand-curated
242264
print(f"{len(structs)} molecules; {len(props)} with crawl properties; "
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
"""build_measured_properties.py — crawl EXPERIMENTAL boiling/melting points from PubChem PUG-View.
2+
3+
`build_properties.py` uses PubChem's property table, which only carries COMPUTED values (MW, logP,
4+
TPSA). Measured boiling and melting points live somewhere else entirely: in PUG-View, under the
5+
Experimental Properties section, sourced from HSDB and other public-domain records. That is why
6+
73% of the enrichment table had no measured BP despite every one of those molecules having a
7+
PubChem record — we had never asked the endpoint that holds them.
8+
9+
Values arrive as free text written by whoever recorded the measurement, so parsing is the real
10+
work here: "246 °C", "115-116 °C at 12 mm Hg", "-11.5 °C", "410 °F". We take the first plausible
11+
Celsius figure, convert Fahrenheit, take the midpoint of a range, and DROP anything reported at
12+
reduced pressure — a boiling point at 12 mmHg is not comparable to one at atmospheric and
13+
silently mixing them would corrupt the volatility ordering the formulation studio relies on.
14+
15+
Public-domain PubChem/HSDB data, cached locally.
16+
17+
python build_measured_properties.py # -> measured_properties.parquet
18+
python build_measured_properties.py --limit 50 # smoke test
19+
"""
20+
import argparse
21+
import contextlib
22+
import json
23+
import re
24+
import time
25+
import urllib.parse
26+
import urllib.request
27+
28+
import pandas as pd
29+
from rdkit import Chem
30+
31+
ENRICH = "master_enrichment.parquet"
32+
OUT = "measured_properties.parquet"
33+
PAUSE = 0.25 # PUG-View is heavier than the property table; stay well under the guidance
34+
TIMEOUT = 10
35+
36+
# "115-116 °C at 12 mm Hg" — reduced-pressure boiling points are not comparable to atmospheric
37+
# ones and must not be mixed into the same column.
38+
_REDUCED = re.compile(r"\bat\s+[\d.]+\s*(mm\s*hg|torr|kpa|mbar|hpa)\b", re.IGNORECASE)
39+
_TEMP = re.compile(r"(-?\d+(?:\.\d+)?)\s*(?:-\s*(-?\d+(?:\.\d+)?)\s*)?°?\s*([CF])\b")
40+
41+
42+
def _parse_temp_c(text):
43+
"""First plausible temperature in the string, as Celsius. None if unusable."""
44+
if not text or _REDUCED.search(text):
45+
return None
46+
m = _TEMP.search(text)
47+
if not m:
48+
return None
49+
lo = float(m.group(1))
50+
val = (lo + float(m.group(2))) / 2 if m.group(2) else lo # midpoint of a reported range
51+
if m.group(3).upper() == "F":
52+
val = (val - 32) * 5 / 9
53+
return round(val, 1) if -200 <= val <= 800 else None # outside this, it isn't a BP or MP
54+
55+
56+
def _get(url):
57+
try:
58+
with urllib.request.urlopen(url, timeout=TIMEOUT) as r:
59+
return json.load(r)
60+
except Exception: # noqa: BLE001 — not found / throttled / timeout
61+
return None
62+
63+
64+
def _cid(smiles):
65+
d = _get("https://pubchem.ncbi.nlm.nih.gov/rest/pug/compound/smiles/"
66+
f"{urllib.parse.quote(smiles)}/cids/JSON")
67+
with contextlib.suppress(Exception):
68+
return d["IdentifierList"]["CID"][0]
69+
return None
70+
71+
72+
def _experimental(cid, heading):
73+
"""All free-text values PubChem lists under one Experimental Properties heading."""
74+
d = _get(f"https://pubchem.ncbi.nlm.nih.gov/rest/pug_view/data/compound/{cid}/JSON"
75+
f"?heading={urllib.parse.quote(heading)}")
76+
if not d:
77+
return []
78+
out, stack = [], [d]
79+
while stack: # the response nests sections arbitrarily deep
80+
node = stack.pop()
81+
if isinstance(node, dict):
82+
for info in node.get("Information", []) or []:
83+
for sw in (info.get("Value", {}) or {}).get("StringWithMarkup", []) or []:
84+
if sw.get("String"):
85+
out.append(sw["String"])
86+
stack.extend(v for v in node.values() if isinstance(v, (dict, list)))
87+
elif isinstance(node, list):
88+
stack.extend(node)
89+
return out
90+
91+
92+
def main():
93+
ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
94+
ap.add_argument("--limit", type=int, default=0, help="only crawl N molecules (smoke test)")
95+
a = ap.parse_args()
96+
97+
enrich = pd.read_parquet(ENRICH)
98+
done = set()
99+
with contextlib.suppress(Exception):
100+
done = set(pd.read_parquet(OUT)["inchikey_skel"])
101+
102+
todo = {}
103+
for _, r in enrich.iterrows():
104+
skel, smi = r.get("inchikey_skel"), r.get("smiles")
105+
if not isinstance(skel, str) or not isinstance(smi, str) or skel in done:
106+
continue
107+
# only chase molecules that are actually missing a measured value
108+
if pd.notna(r.get("boiling_point_c")) and pd.notna(r.get("melting_point_c")):
109+
continue
110+
todo.setdefault(skel, smi)
111+
if a.limit:
112+
todo = dict(list(todo.items())[:a.limit])
113+
114+
print(f"{len(done)} already crawled; {len(todo)} to go.", flush=True)
115+
rows, got = [], 0
116+
for i, (skel, smi) in enumerate(todo.items(), 1):
117+
mol = Chem.MolFromSmiles(smi)
118+
cid = _cid(Chem.MolToSmiles(mol)) if mol else None
119+
if cid:
120+
bp = next((v for v in (_parse_temp_c(t) for t in _experimental(cid, "Boiling Point"))
121+
if v is not None), None)
122+
mp = next((v for v in (_parse_temp_c(t) for t in _experimental(cid, "Melting Point"))
123+
if v is not None), None)
124+
if bp is not None or mp is not None:
125+
rows.append({"inchikey_skel": skel, "boiling_point_c": bp, "melting_point_c": mp})
126+
got += 1
127+
if i % 100 == 0:
128+
print(f" {i}/{len(todo)} crawled, {got} with a measured value", flush=True)
129+
_save(rows)
130+
time.sleep(PAUSE)
131+
_save(rows)
132+
print(f"Done. {got}/{len(todo)} gained a measured property -> {OUT}")
133+
134+
135+
def _save(rows):
136+
if not rows:
137+
return
138+
new = pd.DataFrame(rows).drop_duplicates("inchikey_skel")
139+
with contextlib.suppress(Exception):
140+
new = pd.concat([pd.read_parquet(OUT), new]).drop_duplicates("inchikey_skel", keep="last")
141+
new.to_parquet(OUT)
142+
143+
144+
if __name__ == "__main__":
145+
main()

0 commit comments

Comments
 (0)