| |
| """Task 0.5.1 β Discrimination probe. |
| |
| Loads current tier labels, filters the descriptor vocabulary to a |
| perfumery-relevant subset, and recomputes cross-genre Jaccard to determine |
| whether the vocabulary carries recoverable signal (Option A) or must be |
| replaced entirely (Option B). |
| |
| Decision rule: |
| - Filtered Jaccard β€ 0.70 β Path A (remapping is viable) |
| - Filtered Jaccard > 0.85 β Path B (vocabulary is non-discriminative) |
| - In between β Partial; escalate |
| """ |
| from __future__ import annotations |
|
|
| import json |
| import numpy as np |
| from pathlib import Path |
| from collections import Counter |
|
|
| DATA = Path("data") |
| ARTIFACTS = Path("artifacts") |
| ARTIFACTS.mkdir(exist_ok=True) |
|
|
| |
|
|
| |
| |
| |
| KEEP = { |
| |
| "floral", "rose", "jasmin", "jasmine", "muguet", "hyacinth", "lily", |
| "violet", "geranium", "orris", |
| |
| "citrus", "lemon", "orange", "grapefruit", "bergamot", |
| |
| "woody", "cedar", "sandalwood", "pine", "cypress", |
| |
| "amber", "balsamic", "vanilla", "coumarinic", "tonka", |
| |
| "musk", "leathery", "animal", |
| |
| "spicy", "cinnamon", "clove", "pepper", |
| |
| "green", "herbal", "grassy", "leafy", "lavender", "tea", "tobacco", |
| "hay", "aromatic", |
| |
| "aldehydic", "clean", "fresh", "soapy", |
| |
| "fruity", "apple", "peach", "pear", "berry", "plum", |
| |
| "sweet", "honey", "caramellic", "chocolate", "cocoa", "coconut", |
| "creamy", "milky", |
| |
| "powdery", "warm", |
| |
| "earthy", "mossy", "oakmoss", |
| |
| "ozone", |
| |
| "waxy", "dry", |
| } |
|
|
| |
| DROP_EXPLICIT = { |
| |
| "sulfurous", "fishy", "cheesy", "burnt", "popcorn", "fatty", "garlic", |
| "onion", "cabbage", "radish", "horseradish", "potato", "tomato", |
| "vegetable", "brothy", "beefy", "meaty", "chicken", "mushroom", |
| "fermented", "rummy", "brandy", "cognac", "winey", "rum", |
| |
| "acidic", "alliaceous", "camphoreous", "ketonic", "lactonic", "estery", |
| "ethereal", "solvent", "gasoline", "metallic", "medicinal", "phenolic", |
| "musty", "moldy", |
| |
| "bland", "mild", "odorless", "cooked", "roasted", "ripe", "juicy", |
| "sour", "sharp", "terpenic", "weedy", "celery", |
| |
| "banana", "cherry", "grape", "melon", "pineapple", "raspberry", |
| "strawberry", "tropical", "cucumber", "cortex", "almond", "hazelnut", |
| "nutty", "malty", "bready", "brown", "buttery", "dairy", |
| |
| "minty", "mint", "mentholic", |
| "caramellic", |
| "chamomile", |
| "tropical", |
| } |
|
|
| |
| NEEDS_REVIEW: set[str] = set() |
|
|
|
|
| def build_descriptor_map(vocab: list[str]) -> dict[str, dict]: |
| """Classify each vocabulary descriptor into keep/drop/review.""" |
| mapping = {} |
| for idx, desc in enumerate(vocab): |
| desc_lower = desc.lower() |
| if desc_lower in KEEP: |
| status = "keep" |
| elif desc_lower in DROP_EXPLICIT: |
| status = "drop" |
| elif desc_lower in {d.lower() for d in KEEP}: |
| status = "keep" |
| elif desc_lower in {d.lower() for d in DROP_EXPLICIT}: |
| status = "drop" |
| else: |
| status = "review" |
| NEEDS_REVIEW.add(desc) |
| mapping[desc] = {"index": idx, "status": status} |
| return mapping |
|
|
|
|
| def main() -> None: |
| |
| vocab = json.loads((DATA / "pyrfume_vocabulary.json").read_text())["vocabulary"] |
| assert len(vocab) == 138 |
|
|
| with open(DATA / "empirical_dataset_v8.jsonl") as f: |
| records = [json.loads(l) for l in f] |
|
|
| |
| mapping = build_descriptor_map(vocab) |
| keep_indices = [v["index"] for v in mapping.values() if v["status"] == "keep"] |
| drop_indices = [v["index"] for v in mapping.values() if v["status"] == "drop"] |
| review_indices = [v["index"] for v in mapping.values() if v["status"] == "review"] |
|
|
| print(f"=== Descriptor Mapping ({len(vocab)} total) ===") |
| print(f" KEEP: {len(keep_indices):3d} descriptors") |
| print(f" DROP: {len(drop_indices):3d} descriptors") |
| print(f" REVIEW: {len(review_indices):3d} descriptors") |
| if review_indices: |
| print(f" Review items: {[vocab[i] for i in review_indices]}") |
| print() |
|
|
| |
| |
| for desc in NEEDS_REVIEW: |
| mapping[desc]["status"] = "drop_auto" |
| final_keep = [v["index"] for v in mapping.values() if v["status"] in ("keep",)] |
| final_drop = [v["index"] for v in mapping.values() if v["status"] in ("drop", "drop_auto")] |
|
|
| print(f"After auto-resolution (review β drop):") |
| print(f" Final KEEP: {len(final_keep)} descriptors") |
| print(f" Final DROP: {len(final_drop)} descriptors") |
| print(f" Kept: {[vocab[i] for i in sorted(final_keep)]}") |
| print() |
|
|
| |
| artifact = { |
| "version": "v1", |
| "total_descriptors": len(vocab), |
| "keep_count": len(final_keep), |
| "drop_count": len(final_drop), |
| "keep_indices": sorted(final_keep), |
| "drop_indices": sorted(final_drop), |
| "mapping": mapping, |
| } |
| (ARTIFACTS / "descriptor_map_v1.json").write_text(json.dumps(artifact, indent=2)) |
| print(f"Mapping persisted to artifacts/descriptor_map_v1.json") |
| print() |
|
|
| |
|
|
| |
| targets = [] |
| genres = [] |
| for r in records: |
| if r.get("is_control"): |
| continue |
| targets.append(np.array(r["pyramid_targets"])) |
| genres.append(r.get("genre", "unknown")) |
|
|
| targets = np.stack(targets) |
| genres = np.array(genres) |
|
|
| keep_arr = np.array(sorted(final_keep)) |
|
|
| |
| |
| |
| |
| |
| BINARIZE_THRESHOLD = 0.05 |
|
|
| print(f"=== ORIGINAL (unfiltered) Cross-Genre Jaccard β TOP tier ===") |
| print(f" (binarizing genre-mean vectors at {BINARIZE_THRESHOLD} frequency threshold)") |
| genre_means = {} |
| for genre in sorted(set(genres)): |
| mask = genres == genre |
| if mask.sum() < 10: |
| continue |
| genre_means[genre] = targets[mask, 0, :].mean(axis=0) |
|
|
| genres_list = list(genre_means.keys()) |
| orig_jaccards_top = [] |
| for gi in range(len(genres_list)): |
| for gj in range(gi + 1, len(genres_list)): |
| v1, v2 = genre_means[genres_list[gi]], genre_means[genres_list[gj]] |
| b1 = (v1 >= BINARIZE_THRESHOLD).astype(float) |
| b2 = (v2 >= BINARIZE_THRESHOLD).astype(float) |
| intersection = np.sum((b1 > 0) & (b2 > 0)) |
| union = np.sum((b1 > 0) | (b2 > 0)) |
| jac = intersection / max(1, union) |
| orig_jaccards_top.append(jac) |
| print(f" {genres_list[gi]:16s} vs {genres_list[gj]:16s}: Jaccard={jac:.3f} (|Aβ©B|={int(intersection)}, |AβͺB|={int(union)})") |
| print(f" MEAN original Jaccard (top): {np.mean(orig_jaccards_top):.3f}") |
| print() |
|
|
| |
| print("=== FILTERED Cross-Genre Jaccard β TOP tier ===") |
| filt_jaccards_top = [] |
| for gi in range(len(genres_list)): |
| for gj in range(gi + 1, len(genres_list)): |
| v1 = genre_means[genres_list[gi]][keep_arr] |
| v2 = genre_means[genres_list[gj]][keep_arr] |
| b1 = (v1 >= BINARIZE_THRESHOLD).astype(float) |
| b2 = (v2 >= BINARIZE_THRESHOLD).astype(float) |
| intersection = np.sum((b1 > 0) & (b2 > 0)) |
| union = np.sum((b1 > 0) | (b2 > 0)) |
| jac = intersection / max(1, union) |
| filt_jaccards_top.append(jac) |
| print(f" {genres_list[gi]:16s} vs {genres_list[gj]:16s}: Jaccard={jac:.3f} (|Aβ©B|={int(intersection)}, |AβͺB|={int(union)})") |
| mean_filt_top = np.mean(filt_jaccards_top) |
| print(f" MEAN filtered Jaccard (top): {mean_filt_top:.3f}") |
| print() |
|
|
| |
| genre_means_mid = {} |
| for genre in sorted(set(genres)): |
| mask = genres == genre |
| if mask.sum() < 10: |
| continue |
| genre_means_mid[genre] = targets[mask, 1, :].mean(axis=0) |
|
|
| print("=== FILTERED Cross-Genre Jaccard β MIDDLE tier ===") |
| filt_jaccards_mid = [] |
| for gi in range(len(genres_list)): |
| for gj in range(gi + 1, len(genres_list)): |
| v1 = genre_means_mid[genres_list[gi]][keep_arr] |
| v2 = genre_means_mid[genres_list[gj]][keep_arr] |
| b1 = (v1 >= BINARIZE_THRESHOLD).astype(float) |
| b2 = (v2 >= BINARIZE_THRESHOLD).astype(float) |
| intersection = np.sum((b1 > 0) & (b2 > 0)) |
| union = np.sum((b1 > 0) | (b2 > 0)) |
| jac = intersection / max(1, union) |
| filt_jaccards_mid.append(jac) |
| print(f" {genres_list[gi]:16s} vs {genres_list[gj]:16s}: Jaccard={jac:.3f} (|Aβ©B|={int(intersection)}, |AβͺB|={int(union)})") |
| mean_filt_mid = np.mean(filt_jaccards_mid) |
| print(f" MEAN filtered Jaccard (mid): {mean_filt_mid:.3f}") |
| print() |
|
|
| |
| print("=" * 60) |
| print("=== DECISION ===") |
| print(f" Filtered top Jaccard: {mean_filt_top:.3f}") |
| print(f" Filtered mid Jaccard: {mean_filt_mid:.3f}") |
| print() |
|
|
| worst = max(mean_filt_top, mean_filt_mid) |
| if worst <= 0.70: |
| decision = "PATH A β Vocabulary carries recoverable signal. Remapping is viable." |
| elif worst > 0.85: |
| decision = "PATH B β Vocabulary is fundamentally non-discriminative. Label SOURCE must change." |
| else: |
| decision = "PARTIAL β Vocabulary has some signal but insufficient. Escalate to Matt." |
|
|
| print(f" Decision: {decision}") |
| print("=" * 60) |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|