Skip to content

Commit 40caeb7

Browse files
feat(model): per-head calibrated thresholds, with a precision floor (#261)
Every head decided it fired at a flat 0.5. That is a coin-flip line, not a quality bar, and it was silently withholding real matches from the thin heads: with 13 positives against 2400 negatives a forest hedges even with balanced class weights, so a genuine pine match could land at 0.42 and never be shown. Each head now carries its own cut-off, fitted on OUT-OF-FOLD probabilities at training time and stored in its manifest. The first attempt maximised F1 and was wrong in an instructive way. On a badly imbalanced head F1 peaks in a low-precision regime, because recall climbs faster than precision falls: blackberry tuned itself to 0.18, where 96% of its calls were false, and sweet fired on 1317 of 8855 molecules at 18% precision. A high AUROC gives no protection here — 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. So the threshold must now clear a 50% precision floor: a call labelled confident has to be right more often than not. Thresholds moved hard in BOTH directions — pine UP to 0.75 where it is 100% precise, rosemary down to 0.16 — and no head sits at either bound, so the data is choosing rather than the clamp. 73 of 167 aroma heads cannot reach the floor at any threshold. They are marked INDICATIVE, not deleted: they keep their score, their place in the 178-dim profile, their chips, their map colour and their molecules, because firing well above base rate is real evidence. What changes is that they are never dressed up as a confident call — the read returns `indicative` plus the head's measured precision, and the UI hatches their bar. Hiding them would have deleted reach from 73 notes to paper over a labelling problem. All five mouthfeel heads clear the floor (0.57-1.00). Applied everywhere a threshold was hard-coded, not just the live read: the precomputed profile index, the flavor map and the export card each had their own 0.5, and leaving those would have made a molecule's chips disagree with its own modal. The UI's likely/possible/weak levels are now read against each head's own threshold too, for the same reason. audit_generalization.py scores at the calibrated thresholds and grew a fourth verdict: PRECISION-LIMITED, for a head that is strict on purpose. Calling pine "under-confident" at 0.75 would be exactly backwards — loosening it trades away the accuracy it was tuned for. Signed-off-by: Austin L. <86896075+rvnminers-A-and-N@users.noreply.github.com>
1 parent 9395f54 commit 40caeb7

9 files changed

Lines changed: 268 additions & 44 deletions

File tree

docs/METHODS.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,32 @@ this, because it only ever asks about molecules *inside* the labelled set.
119119
**odour** (AUROC 0.724 over 208 positives) isn't thin — it's genuinely hard. The honest answer
120120
there is a better model (a GNN), not a longer list.
121121

122+
## When does a head "fire"? (per-head thresholds, and the precision floor)
123+
Not at a flat 0.5. A head with 13 positives against 2,400 negatives is calibrated conservatively
124+
even with balanced class weights, so a shared cut-off silently withheld real matches from exactly
125+
the thin heads — a genuine pine match could land at 0.42 and never be shown.
126+
127+
- **Per-head thresholds**`computed`. Each head's cut-off is fitted on **out-of-fold**
128+
probabilities at training time (`train_aroma._calibrate`) and stored in its manifest. Bounded to
129+
[0.15, 0.85]; both directions are allowed, and both are used — `astringent` moved **up** to 0.71,
130+
`rosemary` down to 0.16. No head sits at either bound, so the data is choosing, not the clamp.
131+
- **This changes labels, never numbers.** The raw probability is computed, returned and displayed
132+
identically. Only the *confident* flag moves. The threshold is in the API response too.
133+
- **A precision floor of 0.50, because F1 alone was not safe.** Our first pass maximised F1 and
134+
produced thresholds where the head was mostly wrong: `blackberry` tuned to 0.18, where **96% of
135+
its calls were false**. On a badly imbalanced head F1 peaks in a low-precision regime, since
136+
recall climbs faster than precision falls. **A high AUROC does not protect you** — AUROC is
137+
computed on ranking and is insensitive to class imbalance; precision is not. `coffee` has
138+
AUROC 0.960 and out-of-fold precision **0.46**; both are true of the same head.
139+
- **Heads that cannot clear the floor are `indicative`, not deleted.** 73 of 167 aroma heads never
140+
reach 50% precision at any threshold. They keep their score, their place in the 178-dim profile,
141+
their chips and their map colour — firing well above base rate is real evidence. They are simply
142+
never presented as a *confident* call: the UI marks them, and the read returns `indicative: true`
143+
plus the head's measured precision. Hiding them would delete reach to paper over a labelling
144+
problem; the honest fix is to label them.
145+
- All five **mouthfeel** heads clear the floor (precision 0.57–1.00), so the whole modality is
146+
confident-capable.
147+
122148
## The honest ceiling on "deeper flavor description"
123149
Taste tops out at the **5 basics + intensity + chemesthesis** on public data — that ceiling is
124150
real and hasn't moved. The rich descriptors people mean by "flavor" — *vanilla, fruity, green,

training/app.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -589,16 +589,19 @@ def font(path, size):
589589
# reader knows they're seeing the firing subset, not the whole model.
590590
AROMA_CAP = 18 # 3 rows of 6 — keeps the card readable
591591
pa = P.predict_aroma(smi)
592-
_all_aroma = sorted(((d["odor"], d["score"]) for d in pa.get("descriptors", [])),
593-
key=lambda kv: -kv[1])
594-
_fired = [c for c in _all_aroma if c[1] >= 0.5]
592+
# rank by score but decide "fired" from the head's OWN calibrated threshold, which
593+
# predict_aroma already applied — re-thresholding at a flat 0.5 here would disagree with the
594+
# modal for exactly the thin heads that needed calibrating
595+
_descs = sorted(pa.get("descriptors", []), key=lambda d: -d["score"])
596+
_all_aroma = [(d["odor"], d["score"]) for d in _descs]
597+
_fired = [(d["odor"], d["score"]) for d in _descs if d.get("confident")]
595598
aroma_cells = (_fired or _all_aroma[:3])[:AROMA_CAP] # nothing firing -> top 3, never a blank card
596599
aroma_total, aroma_fired = len(_all_aroma), len(_fired)
597600

598601
_mol = Chem.MolFromSmiles(smi)
599602
mouth_cells = [(d["sensation"], d["score"])
600603
for d in (P.predict_mouthfeel(_mol).get("descriptors", []) if _mol else [])
601-
if d["score"] >= 0.5]
604+
if d.get("confident")]
602605
tox_cells = [(a["assay"], a["probability"])
603606
for a in ((out.get("safety") or {}).get("tox_screen") or {}).get("assays", [])
604607
if (a.get("probability") or 0) >= 0.5]

training/audit_generalization.py

Lines changed: 39 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,11 @@ def audit(modality="aroma", threshold=FIRE, relaxed=RELAXED):
100100
for h in heads:
101101
p = models[h].predict_proba(x)[:, 1]
102102
trained = pos.get(h, set())
103-
fired = {skel[i] for i in range(len(skel)) if p[i] >= threshold}
103+
# score at the head's OWN calibrated threshold when it has one, so the audit measures the
104+
# product's actual behaviour rather than a hypothetical flat cut-off it no longer uses
105+
thr = meta.get(h, {}).get("threshold")
106+
thr = float(thr) if isinstance(thr, (int, float)) else threshold
107+
fired = {skel[i] for i in range(len(skel)) if p[i] >= thr}
104108
fired_lo = {skel[i] for i in range(len(skel)) if p[i] >= relaxed}
105109
novel, novel_lo = len(fired - trained), len(fired_lo - trained)
106110
rows.append({"head": h,
@@ -109,7 +113,19 @@ def audit(modality="aroma", threshold=FIRE, relaxed=RELAXED):
109113
"hits": len(fired),
110114
"novel": novel,
111115
"novel_lo": novel_lo,
112-
"verdict": "ok" if novel else ("shy" if novel_lo else "memorizing")})
116+
"threshold": round(thr, 2),
117+
"precision": meta.get(h, {}).get("cv_precision"),
118+
"capable": meta.get(h, {}).get("confident_capable", True),
119+
# A head whose calibrated threshold sits ABOVE the relaxed probe is not shy —
120+
# it was deliberately made strict to reach the precision floor, and loosening
121+
# it would trade away the accuracy it was tuned for. That is precision-limited,
122+
# a different diagnosis with a different fix (more positives, not a lower bar).
123+
# Order matters: a head that finds nothing at EITHER threshold is memorizing,
124+
# whatever its cut-off. Only once we know relaxing would actually find
125+
# something does a high threshold mean "strict on purpose" rather than "broken".
126+
"verdict": ("ok" if novel else
127+
"memorizing" if not novel_lo else
128+
"precision-limited" if thr > relaxed else "shy")})
113129
return sorted(rows, key=lambda r: (r["novel"], r["novel_lo"], -(r["auroc"] or 0)))
114130

115131

@@ -125,24 +141,39 @@ def main():
125141
if not rows:
126142
sys.exit(1)
127143

128-
tag = {"ok": "", "shy": " <- under-confident", "memorizing": " <- MEMORIZING"}
129-
print(f"{'head':16s} {'AUROC':>6s} {'n_pos':>6s} {'hits':>6s} "
144+
tag = {"ok": "", "shy": " <- under-confident",
145+
"precision-limited": " <- precision-limited (strict on purpose)",
146+
"memorizing": " <- MEMORIZING"}
147+
print(f"{'head':16s} {'AUROC':>6s} {'thr':>5s} {'prec':>5s} {'n_pos':>6s} {'hits':>6s} "
130148
f"{'novel':>6s} {f'@{a.relaxed:g}':>6s}")
131149
for r in rows:
132150
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']]}")
151+
pc = f"{r['precision']:.2f}" if r["precision"] is not None else " - "
152+
ind = "" if r["capable"] else " (indicative)"
153+
print(f"{r['head']:16s} {au:>6s} {r['threshold']:5.2f} {pc:>5s} {r['n_pos']:6d} "
154+
f"{r['hits']:6d} {r['novel']:6d} {r['novel_lo']:6d}{tag[r['verdict']]}{ind}")
135155

156+
indicative = [r["head"] for r in rows if not r["capable"]]
136157
shy = [r["head"] for r in rows if r["verdict"] == "shy"]
158+
plim = [r["head"] for r in rows if r["verdict"] == "precision-limited"]
137159
mem = [r["head"] for r in rows if r["verdict"] == "memorizing"]
138160
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).")
161+
print(f"\n{len(rows) - len(shy) - len(plim) - len(mem)}/{len(rows)} heads generalize at their "
162+
f"calibrated threshold (median {novel[len(novel) // 2]} novel discoveries).")
163+
if plim:
164+
print(f"\n{len(plim)} PRECISION-LIMITED — strict by design, to hold the 50% precision "
165+
f"floor: {', '.join(plim)}")
166+
print(" Not a defect and not a shy head: lowering the bar would trade away the accuracy "
167+
"it was tuned for. More positives is the only real fix.")
141168
if shy:
142169
print(f"\n{len(shy)} UNDER-CONFIDENT — found unlabelled molecules at {a.relaxed:g} but not "
143170
f"{a.threshold:g}: {', '.join(shy)}")
144171
print(" These learned their class; they're shy because their positives are heavily "
145172
"outnumbered. More positives sharpen them. Not broken.")
173+
if indicative:
174+
print(f"\n{len(indicative)}/{len(rows)} are INDICATIVE — they never reach 50% out-of-fold "
175+
f"precision at any threshold, so they fire as evidence, never as a confident call. "
176+
f"They are still far better than the base rate; they are not yet trustworthy alone.")
146177
if mem:
147178
print(f"\n{len(mem)} MEMORIZING — fire on their own training molecules and nothing else, "
148179
f"at any threshold: {', '.join(mem)}")

training/build_flavor_map.py

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -172,21 +172,27 @@ def _all_structures():
172172
dp = doc.get(ik)
173173
if dp: # rarest documented aroma (tie-break by model)
174174
best[i] = min(dp, key=lambda h: (docfreq[h], -prob[h][i]))
175-
else: # else the strongest predicted head >= 0.5
176-
bp = 0.5
175+
else: # else the strongest head clearing its OWN calibrated threshold
176+
# a flat 0.5 here left thin heads uncoloured on the map even where they were
177+
# the best available call — see docs/METHODS.md on per-head thresholds
178+
bp = 0.0
179+
# indicative heads still colour points — the map is exploratory, and greying
180+
# out 73 heads' molecules would hide more than it clarifies
177181
for name in heads:
178-
if prob[name][i] >= bp:
179-
bp, best[i] = prob[name][i], name
182+
p = prob[name][i]
183+
if p >= P._head_threshold(P._AROMA_META, name) and p > bp:
184+
bp, best[i] = p, name
180185
out["aroma_label"] = best
181-
# dominant MOUTHFEEL sensation (for the map's "color by mouthfeel" mode). Straight
182-
# strongest-head-over-0.5: unlike aroma there's no documented corpus to prefer, and
183-
# with only five heads there's no rarity problem to correct for.
186+
# dominant MOUTHFEEL sensation (for the map's "color by mouthfeel" mode): the strongest
187+
# head that clears its own calibrated threshold. Unlike aroma there's no documented
188+
# corpus to prefer, and with only five heads there's no rarity problem to correct for.
184189
if P._MOUTHFEEL_MODELS:
185-
mbest, mbp = ["other"] * len(out), [0.5] * len(out)
190+
mbest, mbp = ["other"] * len(out), [0.0] * len(out)
186191
for name, clf in P._MOUTHFEEL_MODELS.items():
187192
col = clf.predict_proba(Xf)[:, 1]
193+
thr = P._head_threshold(P._MOUTHFEEL_META, name)
188194
for i in range(len(out)):
189-
if col[i] >= mbp[i]:
195+
if col[i] >= thr and col[i] > mbp[i]:
190196
mbp[i], mbest[i] = col[i], name
191197
out["mouthfeel_label"] = mbest
192198
out.to_parquet("flavor_map.parquet")

training/build_profile_index.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
Output profile_index.npz:
1212
smiles (N,) canonical SMILES, deduped by connectivity skeleton
1313
taste_documented (N,) comma-separated documented tastes ("" if none)
14-
aromas (N,) comma-separated confident aroma heads (score >= 0.5)
14+
aromas (N,) comma-separated confident aroma heads (score >= that head's calibrated threshold)
1515
profiles (N,D) float32 head-score matrix: taste heads then aroma heads (sorted names)
1616
dims (D,) column labels ("taste:sweet", "aroma:citrus", ...)
1717
@@ -106,11 +106,22 @@ def main():
106106
dims = ([f"taste:{t}" for t in taste] + [f"aroma:{a}" for a in aroma]
107107
+ [f"mouthfeel:{h}" for h in mouth])
108108
profiles = np.column_stack([scores[k] for k in dims]).astype("float32") # scores keyed by dim
109+
# Per-head calibrated thresholds, read straight from the manifest: this module runs under
110+
# NO_MODELS so predict's own _AROMA_META is empty, and defaulting to a flat 0.5 here would make
111+
# the precomputed chip list disagree with a live read for exactly the thin heads (#261).
112+
import json
113+
_mf = AROMA_DIR / "manifest.json"
114+
aroma_meta = json.loads(_mf.read_text()).get("descriptors", {}) if _mf.exists() else {}
109115
aromas = [[] for _ in smis]
116+
# Indicative heads (those that never reach 50% out-of-fold precision) are INCLUDED here on
117+
# purpose. Dropping them would silently delete 73 of 167 notes from chip search and palette
118+
# match — a real loss of reach to avoid a labelling problem. The honest fix is to mark them,
119+
# which the read does (`indicative` per descriptor), not to hide the molecules they find.
110120
for a in aroma:
111121
col = scores[f"aroma:{a}"]
122+
thr = P._head_threshold(aroma_meta, a)
112123
for i in range(len(smis)):
113-
if col[i] >= 0.5:
124+
if col[i] >= thr:
114125
aromas[i].append(a)
115126
tmp_out = "profile_index.building.npz" # write then atomically replace so the live index is never half-written
116127
np.savez_compressed(

training/predict.py

Lines changed: 54 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1101,8 +1101,38 @@ def _aroma_scores(smiles):
11011101
return _aroma_scores_canon(Chem.MolToSmiles(m))
11021102

11031103

1104+
def _head_threshold(meta, name, override=None):
1105+
"""The probability at or above which a head counts as FIRING.
1106+
1107+
Not a flat 0.5. Each head carries its own threshold, fitted on out-of-fold predictions at
1108+
training time (train_aroma._calibrate) and stored in its manifest. The thin heads need this:
1109+
with 13 positives against 2400 negatives a forest hedges, so a genuine pine match can land at
1110+
0.42 and a flat cut-off would silently withhold it — while a head with 800 positives has no
1111+
such problem and keeps a threshold near 0.5.
1112+
1113+
This decides only whether a descriptor is marked *confident*. The raw probability is returned
1114+
and displayed either way, so nothing is hidden and nothing is inflated.
1115+
"""
1116+
if override is not None:
1117+
return override
1118+
t = meta.get(name, {}).get("threshold")
1119+
return float(t) if isinstance(t, (int, float)) else 0.5
1120+
1121+
1122+
def _head_capable(meta, name):
1123+
"""Whether this head may be presented as making a CONFIDENT call.
1124+
1125+
False for heads that never reach 50% out-of-fold precision at any threshold — they are right
1126+
less than half the time when they fire, so calling them confident would be a lie no matter
1127+
where the cut-off sits. Such a head keeps its score and its place in the profile (it is still
1128+
real evidence, and often far better than the base rate); it is reported as INDICATIVE instead.
1129+
Heads trained before calibration shipped have no flag, and are trusted as before.
1130+
"""
1131+
return meta.get(name, {}).get("confident_capable", True)
1132+
1133+
11041134
@lru_cache(maxsize=8192)
1105-
def predict_aroma(smiles, top_k=8, threshold=0.5):
1135+
def predict_aroma(smiles, top_k=8, threshold=None):
11061136
"""Predicted odor descriptors from RandomForest heads trained on the PUBLIC-DOMAIN HSDB
11071137
odor corpus (see docs/AROMA.md). Returns the descriptors the model scores above threshold,
11081138
each with its probability and the head's CV-AUROC. This is PRESENCE/ABSENCE (the free-text
@@ -1115,17 +1145,27 @@ def predict_aroma(smiles, top_k=8, threshold=0.5):
11151145
if scores is None:
11161146
return {"available": False,
11171147
"note": "aroma model not trained here — build with train_aroma.py"}
1118-
preds = [{"odor": name, "score": p, "confident": p >= threshold,
1119-
"auroc": _AROMA_META.get(name, {}).get("auroc"),
1120-
"desc": AROMA_DESC.get(name)}
1121-
for name, p in scores.items()]
1148+
preds = []
1149+
for name, p in scores.items():
1150+
thr = _head_threshold(_AROMA_META, name, threshold)
1151+
fires, capable = p >= thr, _head_capable(_AROMA_META, name)
1152+
preds.append({"odor": name, "score": p, "threshold": thr,
1153+
# a head that never reaches 50% out-of-fold precision fires as INDICATIVE,
1154+
# never as confident — see _head_capable
1155+
"confident": fires and capable,
1156+
"indicative": fires and not capable,
1157+
"precision": _AROMA_META.get(name, {}).get("cv_precision"),
1158+
"auroc": _AROMA_META.get(name, {}).get("auroc"),
1159+
"desc": AROMA_DESC.get(name)})
11221160
preds.sort(key=lambda d: -d["score"])
11231161
# Return EVERY head (like the taste meters list every taste), ranked, each flagged confident
11241162
# or not — so the read shows the full aroma profile across all trained descriptor models, not
11251163
# just the ones that fired. `top` is the confident shortlist for compact tag uses elsewhere.
11261164
confident = [d for d in preds if d["confident"]]
1165+
indicative = [d for d in preds if d["indicative"]]
11271166
return {"available": True, "predicted": True, "descriptors": preds,
11281167
"top": (confident or preds[:3]), "any_confident": bool(confident),
1168+
"indicative": indicative,
11291169
"note": "presence/absence model on public-domain HSDB odor text; not intensity"}
11301170

11311171

@@ -1150,7 +1190,11 @@ def predict_mouthfeel(mol):
11501190
preds = []
11511191
for name, clf in sorted(_MOUTHFEEL_MODELS.items()):
11521192
p = round(float(clf.predict_proba(x)[0, 1]), 3)
1153-
preds.append({"sensation": name, "score": p, "confident": p >= 0.5,
1193+
thr = _head_threshold(_MOUTHFEEL_META, name)
1194+
fires, capable = p >= thr, _head_capable(_MOUTHFEEL_META, name)
1195+
preds.append({"sensation": name, "score": p, "threshold": thr,
1196+
"confident": fires and capable, "indicative": fires and not capable,
1197+
"precision": _MOUTHFEEL_META.get(name, {}).get("cv_precision"),
11541198
"auroc": _MOUTHFEEL_META.get(name, {}).get("auroc"),
11551199
"desc": _MOUTHFEEL_DESC.get(name)})
11561200
preds.sort(key=lambda d: -d["score"])
@@ -1314,8 +1358,11 @@ def _build_sub_index():
13141358
for name in aroma_heads:
13151359
col = _AROMA_MODELS[name].predict_proba(X)[:, 1]
13161360
cols.append(col)
1361+
# per-head threshold, same as a live read — otherwise the precomputed chip list
1362+
# and the on-the-fly one disagree for exactly the thin heads this fixes
1363+
thr = _head_threshold(_AROMA_META, name)
13171364
for i in range(len(smis)):
1318-
if col[i] >= 0.5:
1365+
if col[i] >= thr:
13191366
aromas[i].append(name)
13201367
cols += [_MOUTHFEEL_MODELS[h].predict_proba(X)[:, 1] for h in mouthfeel_heads]
13211368
profile_dims = ([f"taste:{t}" for t in taste_heads] + [f"aroma:{a}" for a in aroma_heads]

0 commit comments

Comments
 (0)