File size: 11,877 Bytes
e6555ec
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7ea9f42
e6555ec
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7ea9f42
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e6555ec
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
224d843
e6555ec
 
 
 
 
224d843
e6555ec
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7ea9f42
e6555ec
 
 
 
 
 
7ea9f42
e6555ec
7ea9f42
e6555ec
 
 
 
 
7ea9f42
e6555ec
 
 
7ea9f42
e6555ec
 
 
 
 
7ea9f42
 
 
 
 
e6555ec
7ea9f42
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e6555ec
 
 
 
 
63f6c7c
e6555ec
 
 
 
 
224d843
 
 
 
7ea9f42
e6555ec
 
 
 
224d843
e6555ec
 
 
7ea9f42
e6555ec
224d843
 
7ea9f42
224d843
 
e6555ec
 
 
 
 
 
 
 
 
224d843
 
 
 
e6555ec
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
#!/usr/bin/env python3
"""Evaluate a trained PIMT checkpoint ONCE on the frozen benchmarks.

Benchmarks (all label_access=evaluation_only, never used in training/selection):
  1. Discordant substitution triplets: the model should rank the perceptually
     preferred substitute CLOSER to the target than the structurally-closer
     distractor. Scored in the model's learned odor space.
  2. Prospective formulas: predicted family profile vs sequestered intended
     profile (cosine similarity per formula).

Reports every outcome including negative/inconclusive. This is the single,
preregistered readout for the two-arm representation A/B.
"""
from __future__ import annotations

import argparse
import csv
import hashlib
import json
from pathlib import Path
from typing import Any

import numpy as np
import torch
from scipy.spatial.distance import cosine

from pino.embeddings import OlfactoryEmbeddingEngine
from pino.heads import PIMTHeads
from pino.pimt_model import PhysicsInformedMixtureTransformer

ROOT = Path(__file__).resolve().parents[1]


def _canon_smiles(smiles: str) -> str | None:
    try:
        from rdkit import Chem
        m = Chem.MolFromSmiles(smiles or "")
        return Chem.MolToSmiles(m, isomericSmiles=False) if m else None
    except Exception:  # noqa: BLE001
        return None


def _load_openpom_proxy() -> set[str]:
    """Public GoodScents+Leffingwell corpus OpenPOM was trained on (proxy bound)."""
    f = ROOT / "data/openpom_curated_4983.csv"
    keys: set[str] = set()
    if not f.exists():
        return keys
    with open(f, newline="") as fh:
        for row in csv.DictReader(fh):
            k = _canon_smiles(row.get("nonStereoSMILES", ""))
            if k:
                keys.add(k)
    return keys


def sha256_file(path: Path) -> str:
    return hashlib.sha256(path.read_bytes()).hexdigest()


def infer_model_dims(state_dict: dict[str, torch.Tensor]) -> dict[str, int]:
    hidden_dim, embedding_dim = state_dict["input_proj.weight"].shape
    state_dim = state_dict["gating.physics_scaler"].shape[0]
    num_layers = max(
        int(k.split(".")[2]) for k in state_dict
        if k.startswith("encoder.layers.") and k.endswith(".self_attn.in_proj_weight")
    ) + 1
    num_heads = 8 if hidden_dim % 8 == 0 and hidden_dim >= 512 else 4
    return {"embedding_dim": int(embedding_dim), "state_dim": int(state_dim),
            "hidden_dim": int(hidden_dim), "num_heads": num_heads, "num_layers": int(num_layers)}


def load_model(ckpt_path: Path, objective_dim: int = 138):
    ckpt = torch.load(ckpt_path, map_location="cpu", weights_only=False)
    dims = infer_model_dims(ckpt["model_state_dict"])
    model = PhysicsInformedMixtureTransformer(**dims)
    model.load_state_dict(ckpt["model_state_dict"])
    model.eval()
    heads = PIMTHeads(hidden_dim=dims["hidden_dim"], objective_dim=objective_dim)
    heads.load_state_dict(ckpt["heads_state_dict"], strict=False)
    heads.eval()
    return model, heads, dims


def odor_pyramid(model, heads, engine, smiles: str, cas: str) -> np.ndarray:
    """Encode one molecule to the model's 138-dim odor pyramid (mean of tiers).

    This is the learned perceptual space used for triplet ranking."""
    z = torch.from_numpy(engine.get_embedding(smiles, cas=cas)).float()
    tokens = z.unsqueeze(0).unsqueeze(0)            # (1, T=1, S=1, E)
    physics = torch.zeros(1, 1, 1, 2)               # (1, T=1, S=1, 2)
    with torch.no_grad():
        latent = model(tokens, physics)             # (1, T, S, H)
        out = heads(latent, physics)
        pyramid = out["objective"].squeeze(0).numpy()  # (3, 138)
    return pyramid.mean(axis=0)                      # (138,)


def eval_triplets(model, heads, engine) -> dict[str, Any]:
    f = ROOT / "data/benchmarks/substitution_triplets/triplets.jsonl"
    rows = [json.loads(l) for l in f.read_text().splitlines() if l.strip()]
    correct, details = 0, []
    for r in rows:
        # Triplets store CAS/SMILES: keys; the engine resolves structure via CAS.
        t_emb = odor_pyramid(model, heads, engine, "", r["target_cas"])
        p_emb = odor_pyramid(model, heads, engine, "", r["preferred_cas"])
        d_emb = odor_pyramid(model, heads, engine, "", r["structural_distractor_cas"])
        d_pref = cosine(t_emb, p_emb)
        d_dist = cosine(t_emb, d_emb)
        ok = bool(d_pref < d_dist)  # preferred should be perceptually closer
        correct += int(ok)
        details.append({"triplet_id": r["triplet_id"], "target": r["target"],
                        "cos_dist_preferred": round(float(d_pref), 4),
                        "cos_dist_distractor": round(float(d_dist), 4), "correct": ok})
    n = len(rows)
    return {"n_triplets": n, "n_correct": correct,
            "accuracy": round(correct / n, 4) if n else None,
            "chance": 0.5, "details": details}


# Family -> Pyrfume 138 tags that belong to that olfactory family. Used to project
# the model's predicted pyramid onto a family profile.
FAMILY_TAGS = {
    "citrus_fresh":   ["citrus", "lemon", "orange", "grapefruit", "fresh", "ozone", "terpenic", "bergamot"],
    "aromatic_herbal": ["herbal", "lavender", "chamomile", "camphoreous", "mentholic", "mint", "minty", "aromatic"],
    "floral_sweet":   ["floral", "rose", "jasmin", "jasmine", "muguet", "violet", "hyacinth", "lily", "geranium", "sweet"],
    "woody_amber":    ["woody", "cedar", "pine", "amber", "sandalwood", "vetiver", "patchouli", "mossy"],
    "gourmand":       ["vanilla", "chocolate", "caramellic", "honey", "cocoa", "coffee", "nutty", "coumarinic", "lactonic", "creamy", "balsamic"],
    "musk_clean":     ["musk", "clean", "powdery", "soapy", "aldehydic", "animal"],
    "green":          ["green", "grassy", "leafy", "cucumber", "vegetable", "hay", "weedy"],
}
FAMS = list(FAMILY_TAGS)


def _family_projection(vocab: list[str], pyramid: np.ndarray) -> np.ndarray:
    """Project a predicted 138-dim pyramid onto family mass via tag membership."""
    idx = {t: i for i, t in enumerate(vocab)}
    prof = np.zeros(len(FAMS))
    for fi, fam in enumerate(FAMS):
        for tag in FAMILY_TAGS[fam]:
            if tag in idx:
                prof[fi] += pyramid[idx[tag]]
    return prof


def eval_prospective(model, heads, engine, openpom_proxy: set[str] | None = None) -> dict[str, Any]:
    vocab = json.loads((ROOT / "data/pyrfume_vocabulary.json").read_text())["vocabulary"]
    ff = ROOT / "data/benchmarks/prospective_formulas/formulas.jsonl"
    lf = ROOT / "data/benchmarks/prospective_formulas/labels.sequestered.json"
    formulas = [json.loads(l) for l in ff.read_text().splitlines() if l.strip()]
    labels = {l["formula_id"]: l["target_family_profile"]
              for l in json.loads(lf.read_text())["labels"]}
    proxy = openpom_proxy if openpom_proxy is not None else set()

    sims, weights, details = [], [], []
    for form in formulas:
        # Model prediction: weight-average each ingredient's predicted pyramid by
        # the model's concentration-normalised contribution (weight_fraction here
        # as the physical dose), then project onto family tag-space.
        blend = np.zeros(138)
        ing_keys = []
        for ing in form["ingredients"]:
            emb = odor_pyramid(model, heads, engine, ing.get("smiles", ""), ing.get("cas") or "")
            blend += ing["weight_fraction"] * emb
            ing_keys.append(_canon_smiles(ing.get("smiles", "")))
        pred = _family_projection(vocab, blend)
        pred = pred / pred.sum() if pred.sum() else pred
        tgt = np.array([labels[form["formula_id"]].get(f, 0.0) for f in FAMS])
        tgt = tgt / tgt.sum() if tgt.sum() else tgt
        sim = 1.0 - cosine(pred, tgt) if (pred.any() and tgt.any()) else 0.0
        # Novelty weight: fraction of ingredients absent from OpenPOM's public training
        # corpus. On this benchmark overlap is near-total, so this bounds the honest,
        # overlap-free agreement. 1.0 when no proxy is available (unweighted).
        valid = [k for k in ing_keys if k]
        novelty = (sum(1 for k in valid if k not in proxy) / len(valid)) if valid and proxy else 1.0
        sims.append(sim)
        weights.append(novelty)
        details.append({**{"formula_id": form["formula_id"], "genre": form["genre"],
                        "family_profile_cosine": round(float(sim), 4),
                        "novelty_weight": round(float(novelty), 4)}})
    w = np.array(weights)
    s = np.array(sims)
    wmean = float((w * s).sum() / w.sum()) if w.sum() > 0 else None
    result = {"n_formulas": len(formulas),
              "mean_family_profile_cosine": round(float(np.mean(sims)), 4) if sims else None,
              "note": "model-predicted pyramid projected onto family tag-space vs sequestered intended profile",
              "details": details}
    if proxy:
        result["openpom_overlap"] = {
            "novelty_weighted_mean_cosine": round(wmean, 4) if wmean is not None else None,
            "n_formulas_with_any_novelty": int((w > 0).sum()),
            "mean_novelty_weight": round(float(w.mean()), 4),
            "note": ("novelty_weight = fraction of ingredients NOT in OpenPOM's public training corpus; "
                     "the novelty-weighted cosine is the honest overlap-bounded readout (unweighted mean is "
                     "an upper bound inflated by pretraining memorization)."),
        }
    return result


def main() -> int:
    ap = argparse.ArgumentParser()
    ap.add_argument("--checkpoint", required=True)
    ap.add_argument("--structural-source", choices=["morgan", "morgan_2048_rp", "morgan_2048_direct", "openpom_256", "pom_alltags", "disjoint_256"], required=True)
    ap.add_argument("--arm-label", default=None)
    ap.add_argument("--output", required=True)
    args = ap.parse_args()

    ckpt = Path(args.checkpoint)
    objective_dim = 575 if args.structural_source == "pom_alltags" else 138
    engine_source = "openpom_256" if args.structural_source == "pom_alltags" else args.structural_source
    model, heads, dims = load_model(ckpt, objective_dim=objective_dim)
    engine = OlfactoryEmbeddingEngine(structural_source=engine_source)
    openpom_proxy = _load_openpom_proxy()

    result = {
        "arm": args.arm_label or args.structural_source,
        "structural_source": args.structural_source,
        "objective_dim": objective_dim,
        "checkpoint": {"path": str(ckpt), "sha256": sha256_file(ckpt)},
        "model_dims": dims,
        "input_embedding_dim": engine.embedding_dim,
        "openpom_overlap_proxy_corpus_size": len(openpom_proxy),
        "substitution_triplets": eval_triplets(model, heads, engine),
        # Prospective family-profile projection is defined over the 138-dim
        # Pyrfume vocabulary; not comparable for the 575-dim all-tags arm.
        "prospective_formulas": (eval_prospective(model, heads, engine, openpom_proxy)
                                  if objective_dim == 138
                                  else {"not_applicable": "575-dim tag target; family projection is 138-vocab specific"}),
        "label_access": "evaluation_only; results reported for all outcomes incl. negative",
    }
    out = Path(args.output)
    out.parent.mkdir(parents=True, exist_ok=True)
    out.write_text(json.dumps(result, indent=2))
    print(json.dumps({k: result[k] for k in
                      ["arm", "input_embedding_dim"]}, indent=2))
    print("triplets:", result["substitution_triplets"]["accuracy"],
          f"({result['substitution_triplets']['n_correct']}/{result['substitution_triplets']['n_triplets']})")
    if objective_dim == 138:
        print("prospective mean cosine:", result["prospective_formulas"]["mean_family_profile_cosine"])
    else:
        print("prospective: not applicable (575-dim tag target)")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())