josefchen's picture
Add UMAP visualisation, fridge parser, basket cosine heatmap (8 tabs total)
93e4469 verified
Raw
History Blame
29.2 kB
"""Epicure Explorer: chef-facing operators over the three sibling embeddings.
Eight tabs:
- Basket pairings (with pairwise cosine heatmap of the basket itself)
- Supervised SLERP
- Emergent SLERP
- Arithmetic (Mikolov-style)
- Mode atlas (filter + search the GMM mode atlas)
- Compare siblings (same query, three columns)
- UMAP visualisation (Plotly scatter coloured by food group, basket highlighted)
- Parse my fridge (paste free-text ingredient list, fuzzy-match to canonical vocab)
Paper: https://arxiv.org/abs/2605.22391
"""
from __future__ import annotations
import os
import re
import sys
import json
import numpy as np
import gradio as gr
import plotly.graph_objects as go
try:
from epicure import Epicure
except ImportError:
from huggingface_hub import hf_hub_download
epicure_py = hf_hub_download("Kaikaku/epicure-cooc", "epicure.py")
sys.path.insert(0, os.path.dirname(epicure_py))
from epicure import Epicure
from rapidfuzz import process as fuzz_process, fuzz as fuzz_scorers
MODELS = {
"cooc": Epicure.from_pretrained("Kaikaku/epicure-cooc"),
"core": Epicure.from_pretrained("Kaikaku/epicure-core"),
"chem": Epicure.from_pretrained("Kaikaku/epicure-chem"),
}
ALL_INGREDIENTS = sorted(MODELS["cooc"].vocab.keys())
# Load precomputed UMAP coords + food-group labels
_HERE = os.path.dirname(os.path.abspath(__file__))
UMAP = np.load(os.path.join(_HERE, "umap_2d.npz")) # keys: cooc, core, chem ; (1790, 2)
_lab = json.load(open(os.path.join(_HERE, "ingredient_labels.json")))
NAMES_BY_IDX = _lab["names"]
FOOD_GROUPS = _lab["food_groups"]
FG_COLORS = {
"Vegetable": "#2ca02c",
"Fruit": "#e377c2",
"Grain": "#bcbd22",
"Dairy": "#17becf",
"Spice": "#d62728",
"Pantry": "#ff7f0e",
"Beverage": "#9467bd",
"Other": "#888888",
}
# ===== math helpers =====
def _unit(v: np.ndarray, eps: float = 1e-9) -> np.ndarray:
n = np.linalg.norm(v); return v / max(n, eps)
def _basket_centroid(m, names):
valid = [n for n in (names or []) if n in m.vocab]
if not valid: return None
return _unit(m.E[[m.vocab[n] for n in valid]].mean(axis=0))
def _stack_directions(m, keys, use_factor_pole=False):
poles = []
for k in keys or []:
if use_factor_pole:
for mode in m.modes:
if mode.mode_id == k:
poles.append(_unit(mode.pole)); break
else:
if k in m.supervised_poles:
poles.append(_unit(m.supervised_poles[k]))
if not poles: return None
return _unit(np.stack(poles, axis=0).sum(axis=0))
def _topk(m, q, k, exclude):
sims = m.E @ q
for n in exclude or []:
if n in m.vocab: sims[m.vocab[n]] = -np.inf
order = np.argsort(-sims)
return [(m.itos[int(i)], float(sims[i])) for i in order[:k]]
def _supervised_choices(sibling):
return sorted(MODELS[sibling].supervised_poles.keys())
def _factor_mode_choices(sibling):
return [(f"{m.label} ({m.mode_id})", m.mode_id) for m in MODELS[sibling].modes if m.kind == "factor"]
def _slerp(m, v, d, theta_deg):
d_perp = d - (d @ v) * v
n = np.linalg.norm(d_perp)
if n < 1e-9: return v
d_perp = d_perp / n
th = np.deg2rad(float(theta_deg))
return _unit(np.cos(th)*v + np.sin(th)*d_perp)
# ===== tab handlers =====
def basket_pairings(sibling, basket, k):
m = MODELS[sibling]
centroid = _basket_centroid(m, basket)
if centroid is None:
return [], [], None
nb = _topk(m, centroid, k, exclude=basket or [])
scored = [(mode.mode_id, mode.label, mode.kind, float(_unit(mode.pole) @ centroid)) for mode in m.modes]
scored.sort(key=lambda x: -x[3])
heatmap = _basket_heatmap(m, basket)
return (
[[name, f"{sim:.4f}"] for name, sim in nb],
[[mid, label, kind, f"{sim:.4f}"] for mid, label, kind, sim in scored[:k]],
heatmap,
)
def _basket_heatmap(m, basket):
valid = [n for n in (basket or []) if n in m.vocab]
if len(valid) < 2:
return None
idxs = [m.vocab[n] for n in valid]
sub = m.E[idxs] # already L2-normalised
sim = sub @ sub.T
fig = go.Figure(go.Heatmap(
z=sim, x=valid, y=valid,
colorscale="Viridis", zmin=-0.2, zmax=1.0,
colorbar=dict(title="cos"),
hovertemplate="%{y} <> %{x}<br>cos = %{z:.3f}<extra></extra>",
))
fig.update_layout(
title="Pairwise cosine between basket members",
height=420, width=520,
margin=dict(l=80, r=20, t=50, b=80),
)
return fig
def supervised_slerp_multi(sibling, basket, directions, theta, k):
m = MODELS[sibling]
v = _basket_centroid(m, basket)
if v is None: return []
d = _stack_directions(m, directions, use_factor_pole=False)
if d is None:
return [[n, f"{s:.4f}"] for n, s in _topk(m, v, k, basket)]
q = _slerp(m, v, d, theta)
return [[n, f"{s:.4f}"] for n, s in _topk(m, q, k, basket)]
def emergent_slerp_multi(sibling, basket, mode_labels, theta, k):
m = MODELS[sibling]
label_to_id = {f"{mode.label} ({mode.mode_id})": mode.mode_id for mode in m.modes if mode.kind == "factor"}
mode_ids = [label_to_id[lab] for lab in (mode_labels or []) if lab in label_to_id]
v = _basket_centroid(m, basket)
if v is None: return []
d = _stack_directions(m, mode_ids, use_factor_pole=True)
if d is None:
return [[n, f"{s:.4f}"] for n, s in _topk(m, v, k, basket)]
q = _slerp(m, v, d, theta)
return [[n, f"{s:.4f}"] for n, s in _topk(m, q, k, basket)]
def arithmetic(sibling, positives, negatives, k):
m = MODELS[sibling]
pos = _basket_centroid(m, positives)
if pos is None: return []
neg = _basket_centroid(m, negatives) if negatives else None
q = _unit(pos - neg) if neg is not None else pos
return [[n, f"{s:.4f}"] for n, s in _topk(m, q, k, (positives or []) + (negatives or []))]
def browse_modes(sibling, kind_filter, query):
m = MODELS[sibling]
rows, q = [], (query or "").strip().lower()
for mode in m.modes:
if kind_filter != "all" and mode.kind != kind_filter:
continue
if q and q not in mode.label.lower() and q not in mode.property.lower():
continue
rows.append([mode.mode_id, mode.kind, mode.property, mode.label, mode.n_members, ", ".join(mode.members[:12])])
rows.sort(key=lambda r: (r[1], -r[4]))
return rows
def compare_siblings(basket, directions, theta, k):
out = []
for sib in ["cooc","core","chem"]:
m = MODELS[sib]
v = _basket_centroid(m, basket)
if v is None:
out.append([]); continue
valid_dirs = [d for d in (directions or []) if d in m.supervised_poles]
if valid_dirs:
d_vec = _stack_directions(m, valid_dirs)
q = _slerp(m, v, d_vec, theta) if d_vec is not None else v
else:
q = v
hits = _topk(m, q, k=k, exclude=basket)
out.append([[n, f"{s:.4f}"] for n, s in hits])
return out[0], out[1], out[2]
def umap_view(sibling, basket, show_neighbours, k):
coords = UMAP[sibling] # (1790, 2)
m = MODELS[sibling]
name_to_idx = m.vocab
fig = go.Figure()
# Background scatter coloured by food group
by_group = {}
for i, fg in enumerate(FOOD_GROUPS):
by_group.setdefault(fg, []).append(i)
# Plot Other first so it sits behind the colourful groups
order = ["Other"] + [g for g in FG_COLORS if g != "Other"]
for fg in order:
if fg not in by_group: continue
idxs = by_group[fg]
fig.add_trace(go.Scatter(
x=coords[idxs, 0], y=coords[idxs, 1],
mode="markers",
name=fg,
marker=dict(
size=5, color=FG_COLORS.get(fg, "#888888"),
opacity=0.35 if fg == "Other" else 0.55,
line=dict(width=0),
),
text=[NAMES_BY_IDX[i] for i in idxs],
hovertemplate="%{text}<br>food group: " + fg + "<extra></extra>",
))
# Highlight the basket members (red, larger, with text labels)
if basket:
bi = [name_to_idx[b] for b in basket if b in name_to_idx]
if bi:
fig.add_trace(go.Scatter(
x=coords[bi, 0], y=coords[bi, 1],
mode="markers+text",
name="Basket",
marker=dict(size=14, color="#e30613", symbol="star", line=dict(color="white", width=1.5)),
text=[NAMES_BY_IDX[i] for i in bi],
textposition="top center",
textfont=dict(size=12, color="#000000"),
hovertemplate="<b>%{text}</b><extra></extra>",
))
# Optionally show top-K neighbours of the basket centroid
if show_neighbours:
centroid = _basket_centroid(m, basket)
if centroid is not None:
nb_pairs = _topk(m, centroid, k=int(k), exclude=basket)
nb_idxs = [name_to_idx[n] for n, _ in nb_pairs if n in name_to_idx]
if nb_idxs:
fig.add_trace(go.Scatter(
x=coords[nb_idxs, 0], y=coords[nb_idxs, 1],
mode="markers+text",
name=f"Top-{k} neighbours",
marker=dict(size=9, color="#ff8800", symbol="circle", line=dict(color="white", width=1)),
text=[NAMES_BY_IDX[i] for i in nb_idxs],
textposition="top center",
textfont=dict(size=10, color="#444444"),
hovertemplate="<b>%{text}</b> (neighbour)<extra></extra>",
))
fig.update_layout(
title=f"UMAP of Epicure-{sibling.capitalize()} (cosine, n_neighbors=30, min_dist=0.03)",
xaxis_title="UMAP 1", yaxis_title="UMAP 2",
height=650, width=900,
legend=dict(orientation="v", x=1.02, y=1, font=dict(size=11)),
margin=dict(l=60, r=160, t=70, b=60),
plot_bgcolor="#ffffff",
)
fig.update_xaxes(showgrid=True, gridcolor="#eee", zeroline=False)
fig.update_yaxes(showgrid=True, gridcolor="#eee", zeroline=False)
return fig
_LINE_SPLIT = re.compile(r"[\n;]")
_BRACKET = re.compile(r"\([^)]*\)")
# Number or word number
_QTY = (
r"(?:\d+(?:[\.,/]\d+)?|"
r"a|an|one|two|three|four|five|six|seven|eight|nine|ten|half|quarter)"
)
# Units (word-boundary protected so 'g' does NOT eat the 'g' in 'ginger')
_UNIT = (
r"(?:cups?|tbsp\.?|tablespoons?|tsp\.?|teaspoons?|"
r"oz\.?|ounces?|lbs?\.?|pounds?|grams?|kgs?|kilos?|"
r"ml|liters?|litres?|cloves?|bunches?|sprigs?|pinch(?:es)?|"
r"slices?|pieces?|cans?|packets?|sticks?|leaves?|stalks?|heads?|inch(?:es)?|"
r"splash(?:es)?|dash(?:es)?|drops?|handfuls?|large|small|medium)"
)
_LEADING_QTY = re.compile(rf"^\s*{_QTY}\s+(?:{_UNIT}\b\s*)?(?:of\s+)?", re.IGNORECASE)
_LEADING_UNIT_ONLY = re.compile(rf"^\s*{_UNIT}\b\s*(?:of\s+)?", re.IGNORECASE)
_JUICE_OF = re.compile(rf"^\s*(?:juice|zest)\s+(?:of\s+)?(?:{_QTY}\s+)?", re.IGNORECASE)
_LEADING_PREP = re.compile(
r"^\s*(?:fresh|dried|cooked|frozen|raw|ripe|firm|boneless|skinless|smoked|low[- ]fat)\s+",
re.IGNORECASE,
)
# Trailing prep: only after a comma (so 'boneless chicken thighs' is not nuked)
_TRAILING_PREP = re.compile(
r"\s*,\s*(?:chopped|minced|diced|sliced|grated|crushed|whole|ground|peeled|"
r"to taste|optional|finely|coarsely|cubed|shredded|julienned|halved|quartered|warmed|"
r"toasted|roasted|bruised|melted|softened|cooked|drained|rinsed|patted dry|trimmed|"
r"deveined|seeded|stemmed|crumbled).*$",
re.IGNORECASE,
)
# Some plural -> singular forms we hand-massage before fuzzy lookup
_KNOWN_PLURALS = {
"tortillas": "tortilla",
"thighs": "thigh",
"leaves": "leaf",
"onions": "onion",
"potatoes": "potato",
"tomatoes": "tomato",
"cloves": "clove",
}
def _clean_line(line: str) -> str:
s = line.strip().lower()
s = _BRACKET.sub(" ", s)
if "juice" in s or "zest" in s:
s = _JUICE_OF.sub("", s)
s = _TRAILING_PREP.sub("", s)
s = _LEADING_QTY.sub("", s)
s = _LEADING_UNIT_ONLY.sub("", s)
s = _LEADING_PREP.sub("", s)
# Run the leading-prep / unit cleanup once more to catch chains like "fresh whole bean"
s = _LEADING_PREP.sub("", s)
# Hand-massage common plurals so 'tortillas' fuzzy-matches 'tortilla' / 'corn_tortilla' better
tokens = s.split()
tokens = [_KNOWN_PLURALS.get(t, t) for t in tokens]
s = " ".join(tokens)
s = re.sub(r"\s+", " ", s).strip()
return s
def _fuzzy_lookup(cleaned: str, vocab: list[str], vocab_sp: list[str], min_score: int):
"""Pick the best canonical match across three scorers, breaking ties by canonical-name length."""
if not cleaned:
return None, 0.0
candidates = []
for scorer in (fuzz_scorers.token_set_ratio, fuzz_scorers.WRatio, fuzz_scorers.partial_ratio):
hits = fuzz_process.extract(cleaned, vocab_sp, scorer=scorer, score_cutoff=min_score, limit=10)
for _name_sp, score, idx in hits:
candidates.append((vocab[idx], float(score)))
if not candidates:
return None, 0.0
# Tie-break: higher score first, then longer canonical name (prefer 'fish_sauce' over 'fish').
# We also prefer canonical names whose token-set is a subset of the input (avoid 'black_garlic' for 'garlic').
def tokens(name): return set(name.replace("_"," ").split())
cleaned_tokens = set(cleaned.split())
def rank_key(c):
name, score = c
nt = tokens(name)
# 0 if all canonical tokens appear in input, 1 if not (penalty)
extra_penalty = 0 if nt.issubset(cleaned_tokens) else 1
return (-score, extra_penalty, -len(name))
candidates.sort(key=rank_key)
return candidates[0]
def parse_fridge(raw_text: str, sibling: str, min_score: int = 70):
if not raw_text or not raw_text.strip():
return [], []
vocab = list(MODELS[sibling].vocab.keys())
vocab_sp = [v.replace("_", " ") for v in vocab]
rows, matched_set = [], []
for line in _LINE_SPLIT.split(raw_text):
if not line.strip(): continue
cleaned = _clean_line(line)
if not cleaned:
rows.append([line.strip(), "(empty after cleaning)", 0.0, ""])
continue
match, score = _fuzzy_lookup(cleaned, vocab, vocab_sp, int(min_score))
if match is None:
# last-ditch: drop the last token (handles 'tortillas warmed' -> 'tortillas')
tokens = cleaned.split()
if len(tokens) > 1:
match, score = _fuzzy_lookup(" ".join(tokens[:-1]), vocab, vocab_sp, int(min_score))
if match is None:
rows.append([line.strip(), "(no match)", 0.0, cleaned])
continue
rows.append([line.strip(), match, round(score, 1), cleaned])
matched_set.append(match)
seen, dedup = set(), []
for n in matched_set:
if n not in seen:
seen.add(n); dedup.append(n)
return rows, dedup
# ===== UI =====
with gr.Blocks(title="Epicure Explorer") as demo:
gr.Markdown(
"""# Epicure Explorer
Chef-facing operators over the three Epicure sibling embeddings (Cooc, Core, Chem),
from [arXiv:2605.22391](https://arxiv.org/abs/2605.22391).
- **Cooc** walks recipe co-occurrence only. Neighbours are recipe companions.
- **Core** blends typed FlavorDB compound walks with injected I-I walks. Concentrated geometry, tightest modes.
- **Chem** walks typed FlavorDB compound metapaths only. Strongest supervised-direction recovery; neighbours are flavour-profile peers.
Pick a sibling, then explore. Each operator tab has worked examples below the form (click any row to populate inputs).
"""
)
sibling = gr.Radio(choices=["cooc","core","chem"], value="chem", label="Sibling embedding")
# ---------- Tab 1: Basket pairings + heatmap ----------
with gr.Tab("Basket pairings"):
gr.Markdown(
"Pick one or more ingredients. Tool averages their unit vectors and returns nearest neighbours "
"plus closest modes of that centroid. The heatmap on the right shows how related the basket "
"members already are to each other -- a coherent basket has bright off-diagonals, a scattered "
"basket has dark ones."
)
basket = gr.Dropdown(
choices=ALL_INGREDIENTS, value=["chicken","lemon","garlic"],
label="Ingredient basket (pick 1+)", multiselect=True, max_choices=10,
)
k_pair = gr.Slider(1, 15, value=8, step=1, label="K")
pair_btn = gr.Button("Find pairings", variant="primary")
with gr.Row():
nb_table = gr.Dataframe(headers=["Neighbour","Cosine"], label="Top-K nearest neighbours", interactive=False)
mode_table = gr.Dataframe(headers=["Mode id","Label","Kind","Cosine"], label="Closest modes", interactive=False)
heatmap_plot = gr.Plot(label="Pairwise cosine within the basket")
pair_btn.click(
basket_pairings,
inputs=[sibling, basket, k_pair],
outputs=[nb_table, mode_table, heatmap_plot],
)
gr.Examples(
examples=[
["chem", ["chicken","lemon","garlic"], 8],
["core", ["miso","ginger","sesame_oil"], 8],
["chem", ["tomato","basil","mozzarella_cheese"], 8],
["cooc", ["chocolate","strawberry","cream"], 8],
["chem", ["cumin","coriander","turmeric"], 8],
["core", ["soy_sauce","ginger","scallion"], 8],
["chem", ["red_wine","beef","rosemary"], 8],
["core", ["coconut_milk","lemongrass","fish_sauce"], 8],
],
inputs=[sibling, basket, k_pair],
label="Try one of these baskets",
)
# ---------- Tab 2: Supervised SLERP ----------
with gr.Tab("Supervised SLERP"):
gr.Markdown(
"Rotate the seed basket toward one or more supervised direction poles. Multiple directions "
"are summed and L2-normalised before rotation, matching the paper's multi-constraint queries."
)
sup_basket = gr.Dropdown(
choices=ALL_INGREDIENTS, value=["rice"],
label="Seed basket (pick 1+)", multiselect=True, max_choices=10,
)
sup_dirs = gr.Dropdown(
choices=_supervised_choices("chem"), value=["cuisine:South_Asian"],
label="Supervised directions (pick 1+; summed before rotation)",
multiselect=True, max_choices=5,
)
sup_theta = gr.Slider(0, 90, value=30, step=5, label="Rotation angle (deg)")
sup_k = gr.Slider(1, 15, value=8, step=1, label="K")
sup_btn = gr.Button("Rotate", variant="primary")
sup_table = gr.Dataframe(headers=["Ingredient","Cosine"], label="Top-K rotated-query neighbours")
sup_btn.click(supervised_slerp_multi, inputs=[sibling, sup_basket, sup_dirs, sup_theta, sup_k], outputs=sup_table)
sibling.change(lambda s: gr.Dropdown(choices=_supervised_choices(s), value=[]), inputs=sibling, outputs=sup_dirs)
gr.Examples(
examples=[
["chem", ["rice"], ["cuisine:South_Asian"], 30, 8],
["chem", ["corn"], ["cuisine:Latin_American"], 30, 8],
["core", ["chicken"], ["cuisine:Mediterranean"], 45, 8],
["core", ["tomato","basil"], ["cuisine:Southeast_Asian"], 45, 8],
["chem", ["beef"], ["cuisine:East_Asian"], 60, 8],
["cooc", ["chocolate"], ["cuisine:Latin_American"], 30, 8],
],
inputs=[sibling, sup_basket, sup_dirs, sup_theta, sup_k],
label="Try one of these rotations",
)
# ---------- Tab 3: Emergent SLERP ----------
with gr.Tab("Emergent SLERP"):
gr.Markdown(
"Rotate the seed basket toward one or more emergent factor-mode poles discovered "
"by multi-seed-stable FastICA + GMM. Stack mode targets to combine culinary axes."
)
em_basket = gr.Dropdown(
choices=ALL_INGREDIENTS, value=["chocolate"],
label="Seed basket (pick 1+)", multiselect=True, max_choices=10,
)
factor_opts = _factor_mode_choices("chem")
em_modes = gr.Dropdown(
choices=[label for label, _ in factor_opts],
value=[factor_opts[0][0]] if factor_opts else [],
label="Factor modes (pick 1+; summed before rotation)",
multiselect=True, max_choices=5,
)
em_theta = gr.Slider(0, 90, value=30, step=5, label="Rotation angle (deg)")
em_k = gr.Slider(1, 15, value=8, step=1, label="K")
em_btn = gr.Button("Rotate", variant="primary")
em_table = gr.Dataframe(headers=["Ingredient","Cosine"], label="Top-K rotated-query neighbours")
em_btn.click(emergent_slerp_multi, inputs=[sibling, em_basket, em_modes, em_theta, em_k], outputs=em_table)
sibling.change(lambda s: gr.Dropdown(choices=[label for label, _ in _factor_mode_choices(s)], value=[]), inputs=sibling, outputs=em_modes)
# ---------- Tab 4: Arithmetic ----------
with gr.Tab("Arithmetic"):
gr.Markdown(
"Classic Mikolov-style vector arithmetic: `centroid(positives) - centroid(negatives)`, "
"then top-K nearest neighbours. The killer demo is `miso - salt` on Core: returns the "
"Japanese fermented-umami pantry minus the salty component (mirin, kombu, wakame, sake, dashi)."
)
pos_box = gr.Dropdown(choices=ALL_INGREDIENTS, value=["miso"], label="Positives", multiselect=True, max_choices=10)
neg_box = gr.Dropdown(choices=ALL_INGREDIENTS, value=["salt"], label="Negatives", multiselect=True, max_choices=10)
ar_k = gr.Slider(1, 15, value=8, step=1, label="K")
ar_btn = gr.Button("Compute", variant="primary")
ar_table = gr.Dataframe(headers=["Ingredient","Cosine"], label="Top-K nearest to result vector")
ar_btn.click(arithmetic, inputs=[sibling, pos_box, neg_box, ar_k], outputs=ar_table)
gr.Examples(
examples=[
["core", ["miso"], ["salt"], 8],
["core", ["chicken","tofu"], ["beef"], 8],
["cooc", ["basil","cumin"], ["parsley"], 8],
["chem", ["chocolate"], ["sugar"], 8],
["chem", ["wine"], ["beer"], 8],
["core", ["bread"], ["flour"], 8],
["core", ["coffee"], ["milk"], 8],
["chem", ["mozzarella_cheese"], ["milk"], 8],
],
inputs=[sibling, pos_box, neg_box, ar_k],
label="Try one of these arithmetic queries",
)
# ---------- Tab 5: Mode atlas ----------
with gr.Tab("Mode atlas"):
gr.Markdown(
"Browse the GMM mode atlas of the selected sibling (Cooc 150 / Core 193 / Chem 200 modes). "
"`factor` = emergent FastICA modes; `continuous` = quartile partitions of NOVA/sensory/USDA; "
"`binary` = food-group buckets. Search by label or property substring."
)
atlas_kind = gr.Radio(choices=["all","factor","continuous","binary"], value="all", label="Mode kind")
atlas_search = gr.Textbox(label="Search labels / properties", placeholder="e.g. South Asian, baking, fiber", value="")
atlas_btn = gr.Button("Browse modes", variant="primary")
atlas_table = gr.Dataframe(
headers=["mode_id","kind","property","label","n_members","top members"],
label="Modes (sorted by kind, then size descending)",
wrap=True, interactive=False,
)
atlas_btn.click(browse_modes, inputs=[sibling, atlas_kind, atlas_search], outputs=atlas_table)
# ---------- Tab 6: Compare siblings ----------
with gr.Tab("Compare siblings"):
gr.Markdown(
"Same query, three siblings, side-by-side. The chemistry-vs-recipe-context spectrum visible in one screen."
)
cmp_basket = gr.Dropdown(choices=ALL_INGREDIENTS, value=["chicken"], label="Seed basket", multiselect=True, max_choices=10)
cmp_dirs = gr.Dropdown(
choices=_supervised_choices("chem"), value=[],
label="Optional directions (leave empty for pure pairings)",
multiselect=True, max_choices=5,
)
cmp_theta = gr.Slider(0, 90, value=30, step=5, label="Rotation angle (deg; ignored if no directions)")
cmp_k = gr.Slider(1, 15, value=8, step=1, label="K")
cmp_btn = gr.Button("Compare across siblings", variant="primary")
with gr.Row():
cmp_cooc = gr.Dataframe(headers=["Cooc neighbour","Cosine"], label="Cooc (recipe-context)")
cmp_core = gr.Dataframe(headers=["Core neighbour","Cosine"], label="Core (blended)")
cmp_chem = gr.Dataframe(headers=["Chem neighbour","Cosine"], label="Chem (chemistry)")
cmp_btn.click(compare_siblings, inputs=[cmp_basket, cmp_dirs, cmp_theta, cmp_k], outputs=[cmp_cooc, cmp_core, cmp_chem])
gr.Examples(
examples=[
[["chicken"], [], 0, 8],
[["basil"], [], 0, 8],
[["miso"], [], 0, 8],
[["rice"], ["cuisine:South_Asian"], 30, 8],
[["corn"], ["cuisine:Latin_American"], 30, 8],
[["chicken","onion"], ["cuisine:Mediterranean"], 45, 8],
],
inputs=[cmp_basket, cmp_dirs, cmp_theta, cmp_k],
label="Try one of these side-by-side comparisons",
)
# ---------- Tab 7: UMAP visualisation ----------
with gr.Tab("UMAP visualisation"):
gr.Markdown(
"2-D UMAP projection of the 1,790-ingredient embedding (cosine metric, "
"n_neighbors=30, min_dist=0.03 -- the paper's Figure 1 hyperparameters). "
"Points coloured by food group when known. Add ingredients to the basket to highlight "
"them as red stars, and optionally show their nearest neighbours as orange circles."
)
umap_basket = gr.Dropdown(
choices=ALL_INGREDIENTS, value=["chicken","lemon","garlic"],
label="Highlight these ingredients", multiselect=True, max_choices=10,
)
with gr.Row():
umap_show_nb = gr.Checkbox(value=True, label="Also show top-K neighbours of the basket centroid")
umap_k = gr.Slider(1, 20, value=10, step=1, label="K neighbours to draw")
umap_btn = gr.Button("Plot UMAP", variant="primary")
umap_plot = gr.Plot(label="UMAP")
umap_btn.click(umap_view, inputs=[sibling, umap_basket, umap_show_nb, umap_k], outputs=umap_plot)
# ---------- Tab 8: Parse my fridge ----------
with gr.Tab("Parse my fridge"):
gr.Markdown(
"Paste a free-text ingredient list (recipe lines, shopping list, fridge contents). "
"Tool strips quantities/units/prep notes and fuzzy-matches each line against the 1,790 canonical "
"vocab via rapidfuzz. Threshold defaults to 70 (out of 100); lower = more lenient. "
"Useful because chefs do not think in `corn_tortilla` -- they write `2 corn tortillas, warmed`."
)
fridge_text = gr.Textbox(
label="Free-text ingredients (one per line or semicolon-separated)",
lines=8,
placeholder=(
"2 boneless chicken thighs\n"
"1 cup coconut milk\n"
"1 tbsp fish sauce (or soy sauce)\n"
"fresh lemongrass, bruised\n"
"3 cloves garlic, minced\n"
"1 inch fresh ginger\n"
"juice of one lime\n"
"salt to taste"
),
)
fridge_min = gr.Slider(40, 100, value=70, step=5, label="Min match score (rapidfuzz WRatio)")
fridge_btn = gr.Button("Parse and match", variant="primary")
fridge_table = gr.Dataframe(
headers=["Input line", "Canonical match", "Score", "Cleaned"],
label="Parsed matches", interactive=False,
)
fridge_matched = gr.Textbox(label="Matched ingredients (paste into a Basket dropdown)", interactive=False)
def _parse(txt, sib, mn):
rows, matches = parse_fridge(txt, sib, int(mn))
return rows, ", ".join(matches)
fridge_btn.click(_parse, inputs=[fridge_text, sibling, fridge_min], outputs=[fridge_table, fridge_matched])
gr.Markdown(
"""---
**Cite:** Radzikowski and Chen, 2026, *Epicure: Navigating the Emergent Geometry of Food Ingredient Embeddings*, [arXiv:2605.22391](https://arxiv.org/abs/2605.22391).
Models: [epicure-cooc](https://huggingface.co/Kaikaku/epicure-cooc) | [epicure-core](https://huggingface.co/Kaikaku/epicure-core) | [epicure-chem](https://huggingface.co/Kaikaku/epicure-chem). Dataset: [epicure-corpus-resources](https://huggingface.co/datasets/Kaikaku/epicure-corpus-resources).
"""
)
if __name__ == "__main__":
demo.launch()