| |
| """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 |
|
|
| |
| 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) |
|
|
| |
| 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 |
| |
| |
| 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, :] |
|
|
| |
| targets = ingredient_matrix.sum(axis=1) |
| targets = np.clip(targets, 0.0, 1.0) |
|
|
| |
| |
| 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") |
|
|
| |
| 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() |
|
|