|
| 1 | +"""audit_generalization.py — does a head LEARN its class, or just memorize its training set? |
| 2 | +
|
| 3 | +A high CV-AUROC is necessary but not sufficient. A head trained on twelve molecules that all share |
| 4 | +one scaffold can score 0.99 by recognizing that scaffold and nothing else: it will fire on exactly |
| 5 | +the molecules it was trained on and stay silent on every other molecule in the corpus. That head |
| 6 | +has memorized. It is not useless — it still labels its own positives correctly — but it cannot |
| 7 | +discover, so it must not be presented as if it can. |
| 8 | +
|
| 9 | +The test is deliberately simple and hard to fool: |
| 10 | +
|
| 11 | + fire the head over EVERY molecule in the corpus, then count the hits that are NOT in its |
| 12 | + training positives. |
| 13 | +
|
| 14 | +That count — `novel` below — is the head's discovery power. Zero means memorization. The AUROC |
| 15 | +never reveals this, because cross-validation only ever asks about molecules inside the labelled |
| 16 | +set; this asks what happens outside it. |
| 17 | +
|
| 18 | +The fix for a memorizing head is structural DIVERSITY among its positives, not more of them: a |
| 19 | +`tingling` head trained on nine Zanthoxylum sanshools learns "sanshool", whereas the same head |
| 20 | +trained on sanshools + Echinacea + Anacyclus + Heliopsis amides learns "long-chain unsaturated |
| 21 | +N-alkylamide" and starts finding molecules nobody labelled. See #247 (tingling) and #256 (the |
| 22 | +sixteen memorizing aroma heads) for the method applied end to end. |
| 23 | +
|
| 24 | +Some heads CANNOT be fixed with molecules. A broad, fuzzy, multi-scaffold class like `sweet` odour |
| 25 | +(AUROC 0.724 over 208 positives) is not thin — it is genuinely hard, and the honest answer is a |
| 26 | +better model (a GNN, #28), not a longer list. |
| 27 | +
|
| 28 | +Usage: |
| 29 | + python audit_generalization.py # all aroma heads |
| 30 | + python audit_generalization.py --mouthfeel # the mouthfeel heads instead |
| 31 | + python audit_generalization.py --threshold 0.6 # stricter definition of "fires" |
| 32 | +""" |
| 33 | +import argparse |
| 34 | +import json |
| 35 | +import sys |
| 36 | +from pathlib import Path |
| 37 | + |
| 38 | +import numpy as np |
| 39 | +import pandas as pd |
| 40 | + |
| 41 | +FIRE = 0.5 # a head "fires" on a molecule at or above this probability |
| 42 | +RELAXED = 0.35 # ...and this is the "would it fire if it were less shy?" probe — see below |
| 43 | + |
| 44 | +# Why two thresholds. A head with 13 positives against 2400 negatives is calibrated conservatively |
| 45 | +# even with balanced class weights: it can have genuinely learned its class and still rarely clear |
| 46 | +# 0.5 outside the molecules it was fit on. Reading novel@0.5 alone therefore conflates two very |
| 47 | +# different failures — a head that learned nothing, and a head that learned the class but is shy |
| 48 | +# about saying so. Scoring both separates them, and the distinction changes what you'd do next: |
| 49 | +# novel@0.5 > 0 -> generalizes. Nothing to fix. |
| 50 | +# novel@0.5 == 0 < novel@0.35 -> UNDER-CONFIDENT. It found real molecules nobody labelled, |
| 51 | +# just below the bar. More positives would firm it up; the |
| 52 | +# class itself is learnable and the head is not broken. |
| 53 | +# novel@0.35 == 0 -> MEMORIZING. It fires on its training set and nothing |
| 54 | +# else at any reasonable threshold. This is the real |
| 55 | +# failure, and the fix is structural diversity. |
| 56 | +# `pine` and `rosemary` looked memorizing at 0.5 and turned out to be under-confident (8 and 9 |
| 57 | +# novel at 0.35); `celery` and `turmeric` were memorizing at both. Same table, opposite verdicts. |
| 58 | + |
| 59 | + |
| 60 | +def _positives(train_path, heads): |
| 61 | + """{head -> set of InChIKey skeletons it was trained to call positive}.""" |
| 62 | + if not Path(train_path).exists(): |
| 63 | + return {} |
| 64 | + df = pd.read_parquet(train_path) |
| 65 | + cols = [h for h in heads if h in df.columns] |
| 66 | + skel = df["inchikey"].astype(str).str.split("-").str[0] |
| 67 | + return {h: set(skel[df[h].astype(int) == 1]) for h in cols} |
| 68 | + |
| 69 | + |
| 70 | +def audit(modality="aroma", threshold=FIRE, relaxed=RELAXED): |
| 71 | + """Return one row per head: name, auroc, n_pos, hits, novel, novel_lo, verdict.""" |
| 72 | + import predict as P |
| 73 | + P.MODELS_READY.wait() # heads load on a background thread — don't race it |
| 74 | + |
| 75 | + models, train_path, manifest = { |
| 76 | + "aroma": (P._AROMA_MODELS, "aroma_train.parquet", "aroma_models/manifest.json"), |
| 77 | + "mouthfeel": (P._MOUTHFEEL_MODELS, "mouthfeel_train.parquet", "mouthfeel_models/manifest.json"), |
| 78 | + }[modality] |
| 79 | + if not models: |
| 80 | + print(f"no {modality} heads loaded", file=sys.stderr) |
| 81 | + return [] |
| 82 | + |
| 83 | + meta = {} |
| 84 | + if Path(manifest).exists(): |
| 85 | + meta = json.loads(Path(manifest).read_text()).get("descriptors", {}) |
| 86 | + |
| 87 | + heads = sorted(models) |
| 88 | + pos = _positives(train_path, heads) |
| 89 | + |
| 90 | + # score every head over the whole corpus in one pass — the enrichment table already holds a |
| 91 | + # canonical molecule list, so the audit sees exactly what the app serves |
| 92 | + from rdkit import Chem |
| 93 | + df = pd.read_parquet("master_enrichment.parquet") |
| 94 | + mols = [Chem.MolFromSmiles(str(s)) for s in df["smiles"]] |
| 95 | + ok = [i for i, m in enumerate(mols) if m is not None] |
| 96 | + x = np.vstack([P._feat(mols[i])[0] for i in ok]) |
| 97 | + skel = [Chem.MolToInchiKey(mols[i]).split("-")[0] for i in ok] |
| 98 | + |
| 99 | + rows = [] |
| 100 | + for h in heads: |
| 101 | + p = models[h].predict_proba(x)[:, 1] |
| 102 | + trained = pos.get(h, set()) |
| 103 | + fired = {skel[i] for i in range(len(skel)) if p[i] >= threshold} |
| 104 | + fired_lo = {skel[i] for i in range(len(skel)) if p[i] >= relaxed} |
| 105 | + novel, novel_lo = len(fired - trained), len(fired_lo - trained) |
| 106 | + rows.append({"head": h, |
| 107 | + "auroc": meta.get(h, {}).get("auroc"), |
| 108 | + "n_pos": len(trained), |
| 109 | + "hits": len(fired), |
| 110 | + "novel": novel, |
| 111 | + "novel_lo": novel_lo, |
| 112 | + "verdict": "ok" if novel else ("shy" if novel_lo else "memorizing")}) |
| 113 | + return sorted(rows, key=lambda r: (r["novel"], r["novel_lo"], -(r["auroc"] or 0))) |
| 114 | + |
| 115 | + |
| 116 | +def main(): |
| 117 | + ap = argparse.ArgumentParser(description=__doc__.split("\n")[0]) |
| 118 | + ap.add_argument("--mouthfeel", action="store_true", help="audit mouthfeel heads instead of aroma") |
| 119 | + ap.add_argument("--threshold", type=float, default=FIRE, help=f"fire threshold (default {FIRE})") |
| 120 | + ap.add_argument("--relaxed", type=float, default=RELAXED, |
| 121 | + help=f"under-confidence probe threshold (default {RELAXED})") |
| 122 | + a = ap.parse_args() |
| 123 | + |
| 124 | + rows = audit("mouthfeel" if a.mouthfeel else "aroma", a.threshold, a.relaxed) |
| 125 | + if not rows: |
| 126 | + sys.exit(1) |
| 127 | + |
| 128 | + tag = {"ok": "", "shy": " <- under-confident", "memorizing": " <- MEMORIZING"} |
| 129 | + print(f"{'head':16s} {'AUROC':>6s} {'n_pos':>6s} {'hits':>6s} " |
| 130 | + f"{'novel':>6s} {f'@{a.relaxed:g}':>6s}") |
| 131 | + for r in rows: |
| 132 | + 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']]}") |
| 135 | + |
| 136 | + shy = [r["head"] for r in rows if r["verdict"] == "shy"] |
| 137 | + mem = [r["head"] for r in rows if r["verdict"] == "memorizing"] |
| 138 | + 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).") |
| 141 | + if shy: |
| 142 | + print(f"\n{len(shy)} UNDER-CONFIDENT — found unlabelled molecules at {a.relaxed:g} but not " |
| 143 | + f"{a.threshold:g}: {', '.join(shy)}") |
| 144 | + print(" These learned their class; they're shy because their positives are heavily " |
| 145 | + "outnumbered. More positives sharpen them. Not broken.") |
| 146 | + if mem: |
| 147 | + print(f"\n{len(mem)} MEMORIZING — fire on their own training molecules and nothing else, " |
| 148 | + f"at any threshold: {', '.join(mem)}") |
| 149 | + print(" Fix: add STRUCTURALLY DIVERSE positives to the curated supplement, then rebuild " |
| 150 | + "and re-run. More of the same scaffold will not move these off zero.") |
| 151 | + |
| 152 | + |
| 153 | +if __name__ == "__main__": |
| 154 | + main() |
0 commit comments