pino-source-code / scripts /enrich_targets.py
mattbitzesty's picture
v4: time-aware objective head, physics-derived v2 targets, Pyrfume vocab
82ddb20 unverified
Raw
History Blame Contribute Delete
6.9 kB
#!/usr/bin/env python3
"""Enrich empirical_dataset_v1.jsonl with physics-derived time-varying targets.
For each record we compute how the OAV budget is distributed across top / heart /
base notes at every timestep. The objective target is then a time-varying blend
of the corresponding descriptor profiles (citrus/fresh/green for top notes,
floral/rose/jasmin for heart notes, woody/musk/amber for base notes). This
produces a target that is physically grounded in the diffusion trajectory and
varies over time because top notes fade and base notes emerge.
If Pyrfume annotations are available for an ingredient, the note class is
reconciled with the Pyrfume descriptor profile by using the ingredient's OAV
peak time as the primary signal.
"""
from __future__ import annotations
import argparse
import json
from pathlib import Path
from typing import Any
import numpy as np
DESCRIPTOR_DIM = 138
# Descriptor profiles for the three fragrance notes in the Pyrfume vocabulary.
TOP_PROFILE = ["citrus", "fresh", "fruity", "green", "aldehydic", "lemon", "grapefruit", "orange"]
HEART_PROFILE = ["floral", "rose", "jasmin", "jasmine", "muguet", "lily", "neroli", "ylang", "hyacinth", "lavender"]
BASE_PROFILE = ["woody", "musk", "amber", "vanilla", "balsamic", "sweet", "cedar", "leathery", "tobacco"]
def build_profile_vectors(vocab: list[str]) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
def _vec(words: list[str]) -> np.ndarray:
arr = np.zeros(DESCRIPTOR_DIM, dtype=np.float32)
for w in words:
if w in vocab:
arr[vocab.index(w)] = 1.0
return arr / (arr.sum() + 1e-8)
return _vec(TOP_PROFILE), _vec(HEART_PROFILE), _vec(BASE_PROFILE)
def classify_ingredients(oav: np.ndarray) -> list[str]:
"""Classify each ingredient as top/heart/base by its OAV peak location."""
t_steps = oav.shape[0]
notes: list[str] = []
for s in range(oav.shape[1]):
traj = oav[:, s]
if traj.max() < 1e-12:
notes.append("heart")
continue
peak_t = int(np.argmax(traj))
frac = peak_t / (t_steps - 1) if t_steps > 1 else 0.5
if frac < 0.33:
notes.append("top")
elif frac > 0.66:
notes.append("base")
else:
notes.append("heart")
return notes
def enrich_record(
record: dict[str, Any],
top_vec: np.ndarray,
heart_vec: np.ndarray,
base_vec: np.ndarray,
) -> dict[str, Any]:
record = dict(record)
formula = record.get("formula", [])
trajectory = record.get("trajectory", [])
if not formula or not trajectory:
return record
t_steps = len(trajectory)
s_ing = len(formula)
oav = np.zeros((t_steps, s_ing), dtype=np.float32)
for t, step in enumerate(trajectory):
oav_step = step.get("OAV", {})
for s, comp in enumerate(formula):
cas = comp.get("cas", "")
lookup_cas = cas[len("NATURAL:"):] if cas.startswith("NATURAL:") else cas
oav[t, s] = oav_step.get(lookup_cas, 0.0)
notes = classify_ingredients(oav)
# Build a time-varying descriptor matrix per ingredient.
ingredient_matrix = np.zeros((t_steps, s_ing, DESCRIPTOR_DIM), dtype=np.float32)
for s, note in enumerate(notes):
if note == "top":
profile = top_vec
elif note == "base":
profile = base_vec
else:
profile = heart_vec
# The ingredient's descriptor contribution is modulated by its OAV at
# each timestep so that it fades in/out of the mixture profile.
traj = oav[:, s]
max_oav = traj.max()
if max_oav < 1e-12:
weights = np.zeros(t_steps, dtype=np.float32)
else:
weights = traj / max_oav
ingredient_matrix[:, s, :] = weights[:, None] * profile[None, :]
# Mixture target per timestep is the sum over ingredients.
targets = ingredient_matrix.sum(axis=1) # (T, 138)
targets = np.clip(targets, 0.0, 1.0)
# Normalize each timestep to a unit-length descriptor direction while
# preserving the relative descriptor weights within the timestep.
norms = np.linalg.norm(targets, axis=1, keepdims=True)
targets = np.divide(targets, norms, out=np.zeros_like(targets), where=norms > 1e-8)
record["objective_targets"] = targets.astype(np.float32).tolist()
return record
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--input", default="data/empirical_dataset_v1.jsonl")
parser.add_argument("--output", default="data/empirical_dataset_v2.jsonl")
parser.add_argument("--vocab", default="data/pyrfume_vocabulary.json")
args = parser.parse_args()
input_path = Path(args.input)
output_path = Path(args.output)
vocab_data = json.loads(Path(args.vocab).read_text())
vocab = vocab_data["vocabulary"] if isinstance(vocab_data, dict) else vocab_data
assert len(vocab) == DESCRIPTOR_DIM, f"Unexpected vocab size {len(vocab)}"
print(f"Loaded vocab: {len(vocab)} descriptors")
top_vec, heart_vec, base_vec = build_profile_vectors(vocab)
records = [json.loads(line) for line in input_path.open()]
print(f"Enriching {len(records)} records...")
out_file = output_path.open("w")
variance_list: list[float] = []
note_counts = {"top": 0, "heart": 0, "base": 0}
for rec in records:
enriched = enrich_record(rec, top_vec, heart_vec, base_vec)
obj = np.array(enriched["objective_targets"])
variance_list.append(float(obj.var(axis=0).mean()))
out_file.write(json.dumps(enriched, default=float) + "\n")
# Count notes for diagnostics.
t_steps = len(rec.get("trajectory", []))
if t_steps:
s_ing = len(rec.get("formula", []))
oav = np.zeros((t_steps, s_ing), dtype=np.float32)
for t, step in enumerate(rec["trajectory"]):
oav_step = step.get("OAV", {})
for s, comp in enumerate(rec["formula"]):
cas = comp.get("cas", "")
lookup_cas = cas[len("NATURAL:"):] if cas.startswith("NATURAL:") else cas
oav[t, s] = oav_step.get(lookup_cas, 0.0)
for note in classify_ingredients(oav):
note_counts[note] += 1
out_file.close()
variances = np.array(variance_list)
print(f"Wrote {len(records)} records to {output_path}")
print(f"Note distribution: {note_counts}")
print(f"Mean objective variance: {variances.mean():.6f}")
print(f"Median objective variance: {np.median(variances):.6f}")
print(f"Max objective variance: {variances.max():.6f}")
print(f"Fraction > 1e-3: {(variances > 1e-3).mean():.3f}")
print(f"Fraction > 1e-2: {(variances > 1e-2).mean():.3f}")
print(f"Fraction > 5e-2: {(variances > 5e-2).mean():.3f}")
if __name__ == "__main__":
main()