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())
|