Skip to content

Commit cbc7893

Browse files
fix(data): recover 524 silently-unnamed molecules; prefer flavorist names (#255)
Three naming defects found by auditing rendered output rather than reading code. NaN is truthy, so `common_name or iupac_name` returned NaN whenever the common name was missing and never fell through — ~500 molecules stayed unnamed with a good name sitting in the table, rendering as raw SMILES. Replaced with an explicit type-checking _pick_name(). Curated flavorist names now take precedence over PubChem's "common_name", which is frequently systematic (spilanthol's is N-(2-Methylpropyl)-2,6,8-decatrienamide). spilanthol now reads "spilanthol". _uninvert_cas() rewrites CAS index ordering forwards, handling stereo prefixes, derivative suffixes and substituent prefixes separately, and returning anything unrecognised untouched. 124 -> 16. Net 7,559 -> 8,083 named of 8,853; the remaining 770 need the PubChem crawl (#209). Part of #217.
1 parent 43bf224 commit cbc7893

1 file changed

Lines changed: 72 additions & 3 deletions

File tree

training/build_enrichment.py

Lines changed: 72 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
"""
1616
import contextlib
1717
import glob
18+
import re
1819

1920
import numpy as np
2021
import pandas as pd
@@ -120,6 +121,69 @@ def _documented_isomer_rows(name_by_skel):
120121
return rows
121122

122123

124+
def _pick_name(pr):
125+
"""Best display name from a properties row: common name if there is one, else the IUPAC name.
126+
127+
Written the long way ON PURPOSE. The obvious `pr.get("common_name") or pr.get("iupac_name")`
128+
is a real bug here: a missing pandas value is NaN, NaN is TRUTHY, so the `or` returns NaN and
129+
never falls through to the IUPAC name. That silently left ~500 molecules unnamed even though a
130+
perfectly good name sat in the table — they rendered as raw SMILES in the grid and on cards."""
131+
for key in ("common_name", "iupac_name"):
132+
v = pr.get(key)
133+
if isinstance(v, str) and v.strip():
134+
return v.strip()
135+
return None
136+
137+
138+
_CAS_INVERTED = re.compile(r"^([A-Za-z0-9\-\[\]\(\)']+), ([0-9A-Za-z\-\(\),+\u00b1\s]+?)-?$")
139+
140+
141+
def _uninvert_cas(name):
142+
"""Un-invert CAS-style index names so they read forwards.
143+
144+
PubChem/CAS list many compounds parent-first — "Carvone, (+-)-", "Limonene, (-)-",
145+
"Cyclohexanol, 5-methyl-2-(1-methylethenyl)-". That ordering exists for alphabetised print
146+
indexes and reads backwards to everyone else. Three cases, because they resolve differently:
147+
stereo descriptor "Carvone, (+-)-" -> "(+-)-Carvone" (parent keeps its case)
148+
derivative suffix "Linalool, oxide" -> "Linalool oxide"
149+
"2-Hexen-1-ol, 1-acetate" -> "2-Hexen-1-ol 1-acetate"
150+
substituent prefix "Cyclohexanol, 5-methyl-" -> "5-methyl-cyclohexanol"
151+
Anything that doesn't match is returned untouched — a wrong "fix" is worse than none."""
152+
if not isinstance(name, str) or ", " not in name:
153+
return name
154+
m = _CAS_INVERTED.match(name.strip())
155+
if not m:
156+
return name
157+
parent, mod = m.group(1), m.group(2).strip().rstrip("-").strip()
158+
if not mod:
159+
return parent
160+
# 1. pure stereo / optical descriptor -> prefix, keep the parent's capitalisation
161+
if re.fullmatch(r"[\(\[][^)\]]*[\)\]]", mod) or mod.lower() in ("cis", "trans", "d", "l", "dl"):
162+
return f"{mod}-{parent}"
163+
# 2. a derivative/functional word -> it's a suffix, not a substituent
164+
if re.search(r"(acetate|oxide|ester|ether|hydrate|hydrochloride|anhydride|lactone|"
165+
r"[a-z]+oate|[a-z]+ate|alcohol|aldehyde|ketone|acid)$", mod, re.IGNORECASE):
166+
return f"{parent} {mod}"
167+
# 3. otherwise a substituent prefix -> lowercase the parent, which is now mid-name
168+
return f"{mod}-{parent[0].lower()}{parent[1:]}"
169+
170+
171+
def _curated_names():
172+
"""{skeleton -> human name} from the curated CSVs (flavors + aroma/mouthfeel supplements).
173+
These are the names a flavorist actually uses — "gamma-nonalactone", "hydroxy-alpha-sanshool" —
174+
and for the molecules we hand-added they are usually the ONLY good name available."""
175+
import csv
176+
out = {}
177+
for path in ("flavors.csv", "aroma_supplement.csv", "mouthfeel_supplement.csv"):
178+
with contextlib.suppress(Exception), open(path, encoding="utf-8") as fh:
179+
for r in csv.DictReader(fh):
180+
nm = (r.get("molecule") or "").strip()
181+
m = Chem.MolFromSmiles((r.get("smiles") or "").strip())
182+
if nm and m is not None:
183+
out.setdefault(Chem.MolToInchiKey(m).split("-")[0], nm)
184+
return out
185+
186+
123187
def _taste_by_skel():
124188
out = {}
125189
try:
@@ -150,16 +214,21 @@ def _taste_by_skel():
150214
props = _by_skel("properties.parquet",
151215
["common_name", "iupac_name", "melting_point_c", "boiling_point_c"])
152216
taste_doc = _taste_by_skel()
217+
curated = _curated_names() # human names for the molecules we hand-curated
153218
print(f"{len(structs)} molecules; {len(props)} with crawl properties; "
154-
f"{len(taste_doc)} with documented taste", flush=True)
219+
f"{len(taste_doc)} with documented taste; {len(curated)} curated names", flush=True)
155220

156221
skels, smis, rows, feats = [], [], [], []
157222
for skel, smi in structs.items():
158223
m = Chem.MolFromSmiles(smi)
159224
if m is None:
160225
continue
161226
pr = props.get(skel, {})
162-
name = pr.get("common_name") or pr.get("iupac_name")
227+
# Curated FIRST: those names were hand-picked as what a flavorist calls the molecule,
228+
# and PubChem's "common_name" is often systematic anyway (spilanthol's is
229+
# "N-(2-Methylpropyl)-2,6,8-decatrienamide"). Only fall through to PubChem when we
230+
# haven't named it ourselves.
231+
name = _uninvert_cas(curated.get(skel) or _pick_name(pr))
163232
rows.append({
164233
"inchikey_skel": skel, "smiles": smi,
165234
"name": name if isinstance(name, str) else None,
@@ -221,7 +290,7 @@ def _taste_by_skel():
221290
r["tox_flags"] = ",".join(n for n, col in tox_cols.items() if col[i] >= 0.5) # assays firing >=0.5
222291

223292
# first-class rows for stereoisomers that differ in documented odor/taste
224-
name_by_skel = {sk: (props.get(sk, {}).get("common_name") or props.get(sk, {}).get("iupac_name"))
293+
name_by_skel = {sk: _uninvert_cas(curated.get(sk) or _pick_name(props.get(sk, {})))
225294
for sk in structs}
226295
iso_rows = _documented_isomer_rows(name_by_skel)
227296
if iso_rows:

0 commit comments

Comments
 (0)