Skip to content

Commit 2748c25

Browse files
feat(data): resolve multitaste via the Taste column; keep sour/salty as rules
Parse the granular Taste column (positive-wins) → resolves 172/176 multitaste rows; sour/salty stay rules via RULE_TASTES; clean stale artifacts. Verified end to end.
1 parent f79b06d commit 2748c25

3 files changed

Lines changed: 66 additions & 4 deletions

File tree

training/build_taste_dataset.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,19 +103,67 @@ def _row(ik, cs, source, labels=None, multitaste=0.0):
103103
return rec
104104

105105

106+
# Intensity modifiers stripped when parsing the granular 'Taste' column.
107+
_MODIFIERS = {"slightly", "very", "extremely", "mildly", "highly", "faintly",
108+
"weakly", "strongly"}
109+
110+
111+
def parse_taste_column(val):
112+
"""Granular ChemTastesDB 'Taste' value -> per-taste 1/0 for basic tastes.
113+
114+
Handles compounds (Sweet/Bitter, Non-sweet; Bitter, Salty/Umami), intensity
115+
modifiers (Slightly bitter -> bitter), and negations (Non-sweet -> sweet=0),
116+
positive-wins within the row. Non-basic descriptors (cooling, pungent,
117+
astringent, ...) are ignored — they're chemesthesis, not basic taste.
118+
"""
119+
s = str(val).strip().lower()
120+
if s in ("", "nan"):
121+
return {}
122+
if s == "tasteless":
123+
return {t: 0.0 for t in BASIC}
124+
for d in (";", ","):
125+
s = s.replace(d, "/")
126+
pos, neg = set(), set()
127+
for tok in s.split("/"):
128+
tok = tok.strip()
129+
negated = tok.startswith("non-") or tok.startswith("non ")
130+
if negated:
131+
tok = tok[3:].lstrip("- ").strip()
132+
tok = " ".join(w for w in tok.split() if w not in _MODIFIERS).strip()
133+
if tok in BASIC:
134+
(neg if negated else pos).add(tok)
135+
out = {t: 1.0 for t in pos}
136+
for t in neg:
137+
if t not in pos:
138+
out[t] = 0.0
139+
return out
140+
141+
106142
def load_chemtastes(path):
107143
if not path.exists():
108144
print(f" [skip] {path}")
109145
return pd.DataFrame()
110146
df = pd.read_excel(path)
111147
sc = _find(df, ["canonical smiles", "smiles"])
112148
cc = _find(df, ["class taste", "taste class", "class", "taste"])
149+
cols = {c.lower().strip(): c for c in df.columns}
150+
tc = cols.get("taste") # granular multi-label column, if present
113151
rows = []
114152
for _, r in df.iterrows():
115153
ik, cs = canon(r[sc])
116154
if ik is None:
117155
continue
118156
labels, mt = chemtastes_labels(r[cc])
157+
if tc is not None and tc != cc:
158+
# Merge the granular 'Taste' multi-labels (positive-wins) so multitaste
159+
# rows (Sweet/Bitter, Salty/Umami, ...) resolve into concrete labels.
160+
fine = parse_taste_column(r[tc])
161+
for t in BASIC:
162+
vals = [labels.get(t), fine.get(t)]
163+
if 1.0 in vals:
164+
labels[t] = 1.0
165+
elif 0.0 in vals:
166+
labels[t] = 0.0
119167
rows.append(_row(ik, cs, "chemtastes", labels, mt))
120168
print(f" chemtastes: {len(rows)}")
121169
return pd.DataFrame(rows)

training/export_onnx.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,10 @@
2424
SRC = Path("taste_models") # produced by train_taste.py
2525
OUT = Path("onnx_models")
2626
OUT.mkdir(exist_ok=True)
27+
# Clear stale exports so a removed/renamed head doesn't leave an orphan .onnx
28+
# alongside a manifest that no longer references it.
29+
for _stale in OUT.glob("*.onnx"):
30+
_stale.unlink()
2731
FP_BITS, FP_RADIUS, INPUT_NAME = 2048, 2, "fp"
2832

2933
if not SRC.exists() or not list(SRC.glob("*_rf.joblib")):

training/train_taste.py

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,10 @@
22
train_taste.py — taste heads (multi-taste) + sweetness-intensity regressor.
33
44
Reads the merged dataset from build_taste_dataset.py and trains one binary
5-
RandomForest per taste that clears the data thresholds. Sweet/bitter/umami
6-
will train; sour/salty are auto-skipped (too few labels) and handled by the
7-
acidity rule in predict.py instead. If you add more sour/umami data later,
8-
they upgrade themselves on the next run — no code change.
5+
RandomForest per structure-driven taste (sweet/bitter/umami). Sour and salty
6+
are validated RULES in predict.py (sourness is a pH/solution property, saltiness
7+
a cation property), never trained — by design, not just data volume. Add more
8+
sweet/bitter/umami data and those heads sharpen on the next run.
99
1010
Even with everything merged this trains in minutes on the R620 CPU. The
1111
multi-day budget is the aroma model (train_odor.py), not this.
@@ -27,6 +27,9 @@
2727
from sklearn.metrics import roc_auc_score, r2_score
2828

2929
BASIC = ["sweet", "bitter", "umami", "sour", "salty"]
30+
# Sour and salty are validated RULES by design (pH/solution and cation properties),
31+
# handled in predict.py and never trained — regardless of how much data accrues.
32+
RULE_TASTES = {"sour", "salty"}
3033
FP_BITS, FP_RADIUS = 2048, 2
3134
# Below this, a taste is too thin for an HONEST head, so it's skipped and
3235
# handled by rule/flag instead. It's not a hard exclusion: add more data (more
@@ -37,6 +40,10 @@
3740
MIN_POS, MIN_NEG = 80, 80
3841
OUT = Path("taste_models")
3942
OUT.mkdir(exist_ok=True)
43+
# Clear stale heads first, so a taste that no longer trains (e.g. now rule-handled)
44+
# can't leave an orphan .joblib that export_onnx would pick up.
45+
for _stale in OUT.glob("*_rf.joblib"):
46+
_stale.unlink()
4047

4148
# Acidic-group SMARTS — used both for the sour rule and to VALIDATE it against
4249
# whatever labeled sour compounds exist (so that data isn't wasted either).
@@ -73,6 +80,9 @@ def train_classifiers(master):
7380
yv = y[mask].astype(int).values
7481
Xv = X_all[mask]
7582
pos, neg = int((yv == 1).sum()), int((yv == 0).sum())
83+
if taste in RULE_TASTES:
84+
print(f" {taste:7s} RULE by design (pos={pos}, neg={neg}) -> handled in predict.py")
85+
continue
7686
if pos < MIN_POS or neg < MIN_NEG:
7787
print(f" {taste:7s} SKIP (pos={pos}, neg={neg}) -> handled by rule/omitted")
7888
continue

0 commit comments

Comments
 (0)