pino-source-code / scripts /discrimination_probe.py
Matthew Ford
chore: add model audit run artifacts
4f26cce
Raw
History Blame Contribute Delete
11.7 kB
#!/usr/bin/env python3
"""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)
# ─── 1. Descriptor keep/drop mapping ─────────────────────────────────────────
# Perfumery-relevant descriptors β€” terms used in actual fragrance pyramids.
# Includes: floral families, citrus, woods, resins, spices, gourmand, musk/amber,
# green/herbal, aldehydic, and common qualitative notes (clean, powdery, sweet).
KEEP = {
# Floral
"floral", "rose", "jasmin", "jasmine", "muguet", "hyacinth", "lily",
"violet", "geranium", "orris",
# Citrus
"citrus", "lemon", "orange", "grapefruit", "bergamot",
# Woody / resin
"woody", "cedar", "sandalwood", "pine", "cypress",
# Amber / balsamic / resinous
"amber", "balsamic", "vanilla", "coumarinic", "tonka",
# Musk / animalic (perfumery-relevant)
"musk", "leathery", "animal",
# Spicy
"spicy", "cinnamon", "clove", "pepper",
# Green / herbal
"green", "herbal", "grassy", "leafy", "lavender", "tea", "tobacco",
"hay", "aromatic",
# Aldehydic / clean
"aldehydic", "clean", "fresh", "soapy",
# Fruity (perfumery subset)
"fruity", "apple", "peach", "pear", "berry", "plum",
# Gourmand / sweet
"sweet", "honey", "caramellic", "chocolate", "cocoa", "coconut",
"creamy", "milky",
# Powder / soft
"powdery", "warm",
# Earthy / mossy (perfumery-relevant)
"earthy", "mossy", "oakmoss",
# Marine / ozonic
"ozone",
# Miscellaneous perfumery
"waxy", "dry",
}
# Off-odor / raw-material GC-O descriptors that have no place in perfume pyramids
DROP_EXPLICIT = {
# Food/spoilage off-odors
"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",
# Chemical / industrial
"acidic", "alliaceous", "camphoreous", "ketonic", "lactonic", "estery",
"ethereal", "solvent", "gasoline", "metallic", "medicinal", "phenolic",
"musty", "moldy",
# Redundant / too generic to be discriminative
"bland", "mild", "odorless", "cooked", "roasted", "ripe", "juicy",
"sour", "sharp", "terpenic", "weedy", "celery",
# Food-specific (non-perfumery)
"banana", "cherry", "grape", "melon", "pineapple", "raspberry",
"strawberry", "tropical", "cucumber", "cortex", "almond", "hazelnut",
"nutty", "malty", "bready", "brown", "buttery", "dairy",
# Duplicate / variant
"minty", "mint", "mentholic", # keep "cooling" or "fresh" instead
"caramellic", # keep "sweet" instead
"chamomile", # rarely used in perfume pyramids
"tropical",
}
# Any descriptor not in KEEP or DROP_EXPLICIT gets flagged for manual review
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}: # case-insensitive catch
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:
# Load data
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]
# Build descriptor mapping
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()
# Auto-resolve review items: default to DROP for anything not explicitly kept
# (conservative β€” prefer signal over noise)
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()
# Persist mapping artifact
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()
# ─── 2. Recompute cross-genre Jaccard on filtered subset ──────────────────
# Build target matrix
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) # (N, 3, 138)
genres = np.array(genres)
keep_arr = np.array(sorted(final_keep))
# ─── ORIGINAL (unfiltered) Jaccard ────────────────────────────────────────
# Jaccard is computed per-sample (binary vectors), then averaged within
# each genre pair. Genre means are fractional (0-1 across samples), so
# we must binarize at a lower threshold that reflects "this descriptor
# appears in a meaningful fraction of formulas of this genre."
BINARIZE_THRESHOLD = 0.05 # descriptor active in β‰₯5% of genre formulas
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()
# ─── Filtered Jaccard β€” TOP ───────────────────────────────────────────────
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()
# ─── Filtered Jaccard β€” MIDDLE ────────────────────────────────────────────
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()
# ─── 3. Decision ──────────────────────────────────────────────────────────
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()