pino-source-code / scripts /fetch_goodscents_substantivity.py
Matthew Ford
feat: fetch GoodScents substantivity data and calibrate thresholds
e52f355 unverified
Raw
History Blame Contribute Delete
7.05 kB
#!/usr/bin/env python3
"""Download and process GoodScents/Pyrfume substantivity data.
Substantivity = how long a material's odor lasts on a substrate (hours).
This is the physical basis for top/middle/base classification.
"""
import pandas as pd
import json
import numpy as np
from collections import defaultdict
BASE = "https://raw.githubusercontent.com/pyrfume/pyrfume-data/main/goodscents"
# Download all relevant files
print("Downloading GoodScents data from Pyrfume...")
opl = pd.read_csv(f"{BASE}/opl.csv")
data_rw_opl = pd.read_csv(f"{BASE}/data_rw_opl.csv")
data_rw_odor = pd.read_csv(f"{BASE}/data_rw_odor.csv")
stimuli = pd.read_csv(f"{BASE}/stimuli.csv")
molecules = pd.read_csv(f"{BASE}/molecules.csv")
print(f" opl: {opl.shape}")
print(f" data_rw_opl: {data_rw_opl.shape}")
print(f" data_rw_odor: {data_rw_odor.shape}")
print(f" stimuli: {stimuli.shape}")
print(f" molecules: {molecules.shape}")
# --- Step 1: Build TGSC ID -> CAS mapping ---
# data_rw_opl links TGSC ID -> CAS Number via TGSC OPL ID
# Some entries have multiple OPL IDs per TGSC ID
tgsc_to_cas = {}
for _, row in data_rw_opl.iterrows():
tgsc_id = row["TGSC ID"]
cas = str(row.get("CAS Number", "")).strip()
if cas and cas != "nan" and cas not in tgsc_to_cas:
tgsc_to_cas[tgsc_id] = cas
print(f"\nTGSC ID -> CAS mapping: {len(tgsc_to_cas)} entries")
# --- Step 2: Build TGSC ID -> Substantivity mapping ---
# data_rw_odor has the substantivity columns
tgsc_to_subst = {}
for _, row in data_rw_odor.iterrows():
tgsc_id = row["TGSC ID"]
subst = row.get("Substantivity (Hours)")
subst_min = row.get("Substantivity Min (Hours)")
subst_max = row.get("Substantivity Max (Hours)")
if pd.notna(subst) and subst > 0:
tgsc_to_subst[tgsc_id] = float(subst)
elif pd.notna(subst_min) and pd.notna(subst_max):
tgsc_to_subst[tgsc_id] = (float(subst_min) + float(subst_max)) / 2
print(f"TGSC ID -> Substantivity: {len(tgsc_to_subst)} entries")
# Substantivity distribution
if tgsc_to_subst:
vals = np.array(list(tgsc_to_subst.values()))
print(f" Range: {vals.min():.1f} - {vals.max():.1f} hours")
print(f" Median: {np.median(vals):.1f}h, Mean: {vals.mean():.1f}h")
print(f" Quartiles: {np.percentile(vals, 25):.1f} / {np.percentile(vals, 50):.1f} / {np.percentile(vals, 75):.1f}h")
# --- Step 3: Build CAS -> Substantivity mapping ---
cas_to_subst = {}
for tgsc_id, subst in tgsc_to_subst.items():
cas = tgsc_to_cas.get(tgsc_id)
if cas:
# If multiple entries, keep the one with higher substantivity (pure material)
if cas not in cas_to_subst or subst > cas_to_subst[cas]:
cas_to_subst[cas] = subst
print(f"\nCAS -> Substantivity: {len(cas_to_subst)} entries")
# --- Step 4: Derive tier from substantivity ---
# Poucher thresholds in hours (approximate):
# Top notes: < 2 hours (evaporate quickly)
# Middle notes: 2-8 hours
# Base notes: > 8 hours (long-lasting)
# These are based on perfumery practice: materials are smelled on blotter at intervals
cas_to_tier_gs = {}
for cas, subst in cas_to_subst.items():
if subst < 2:
cas_to_tier_gs[cas] = "top"
elif subst < 8:
cas_to_tier_gs[cas] = "mid"
else:
cas_to_tier_gs[cas] = "base"
tier_counts = defaultdict(int)
for tier in cas_to_tier_gs.values():
tier_counts[tier] += 1
print(f"CAS -> Tier (from substantivity):")
for t in ["top", "mid", "base"]:
print(f" {t}: {tier_counts[t]}")
# --- Step 5: Compare with our dataset ---
with open("data/empirical_dataset_v8.jsonl") as f:
records = [json.loads(l) for l in f]
dataset_cas = set()
for r in records:
if r.get("is_control"):
continue
for comp in r.get("formula", []):
cas = comp.get("cas", "")
if cas:
dataset_cas.add(cas)
matched = dataset_cas.intersection(set(cas_to_subst.keys()))
print(f"\n=== Dataset Coverage ===")
print(f"Dataset CAS: {len(dataset_cas)}")
print(f"Matched with GoodScents substantivity: {len(matched)} ({100*len(matched)/len(dataset_cas):.1f}%)")
# Also check with Poucher tiers
with open("data/poucher_tier_lookup_expanded.json") as f:
poucher_data = json.load(f)
poucher_tiers = poucher_data["cas_to_tier"]
poucher_matched = dataset_cas.intersection(set(poucher_tiers.keys()))
combined_matched = dataset_cas.intersection(set(poucher_tiers.keys()) | set(cas_to_tier_gs.keys()))
print(f"Poucher tier matched: {len(poucher_matched)} ({100*len(poucher_matched)/len(dataset_cas):.1f}%)")
print(f"Combined (Poucher + GoodScents): {len(combined_matched)} ({100*len(combined_matched)/len(dataset_cas):.1f}%)")
# --- Step 6: Validate against Poucher ---
# Where we have both Poucher tier and GoodScents substantivity, check agreement
overlap = set(poucher_tiers.keys()).intersection(set(cas_to_tier_gs.keys()))
print(f"\n=== Validation: Poucher tier vs GoodScents tier (n={len(overlap)}) ===")
if overlap:
from sklearn.metrics import cohen_kappa_score
poucher_labels = [poucher_tiers[c] for c in overlap]
gs_labels = [cas_to_tier_gs[c] for c in overlap]
kappa = cohen_kappa_score(poucher_labels, gs_labels)
print(f"Cohen's kappa: {kappa:.3f}")
# Confusion matrix
tier_to_idx = {"top": 0, "mid": 1, "base": 2}
matrix = np.zeros((3, 3), dtype=int)
for c in overlap:
matrix[tier_to_idx[poucher_tiers[c]], tier_to_idx[gs_labels[list(overlap).index(c)]]] += 1
print(f"\nConfusion (rows=Poucher, cols=GoodScents):")
print(f" {'':12s} {'GS-top':>10s} {'GS-mid':>10s} {'GS-base':>10s} {'Total':>10s}")
for i, tier in enumerate(["top", "mid", "base"]):
row = matrix[i]
print(f" Poucher-{tier:5s} {row[0]:>10d} {row[1]:>10d} {row[2]:>10d} {sum(row):>10d}")
# --- Step 7: Save everything ---
output = {
"cas_to_substantivity_hours": {k: round(v, 2) for k, v in sorted(cas_to_subst.items())},
"cas_to_tier": {k: v for k, v in sorted(cas_to_tier_gs.items())},
"tier_thresholds": {"top": "<2h", "mid": "2-8h", "base": ">8h"},
"total_cas": len(cas_to_subst),
}
with open("data/goodscents_substantivity.json", "w") as f:
json.dump(output, f, indent=2)
print(f"\nSaved to data/goodscents_substantivity.json")
# Merge with Poucher tiers (Poucher takes priority)
merged_tiers = dict(cas_to_tier_gs)
for cas, tier in poucher_tiers.items():
if cas not in merged_tiers:
merged_tiers[cas] = tier
# Poucher is ground truth - override
else:
merged_tiers[cas] = tier
with open("data/perfumer_tier_lookup.json", "w") as f:
json.dump({k: v for k, v in sorted(merged_tiers.items())}, f, indent=2)
print(f"Merged tier lookup: {len(merged_tiers)} CAS")
merged_dist = defaultdict(int)
for t in merged_tiers.values():
merged_dist[t] += 1
for t in ["top", "mid", "base"]:
print(f" {t}: {merged_dist[t]}")
# Coverage with merged
merged_matched = dataset_cas.intersection(set(merged_tiers.keys()))
print(f"Dataset coverage with merged: {len(merged_matched)}/{len(dataset_cas)} ({100*len(merged_matched)/len(dataset_cas):.1f}%)")