Spaces:
Sleeping
Sleeping
| """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() | |