Spaces:
Paused
Paused
Download app.py from Pixelkram/LLM_Embeddings: direct link, hf CLI and curl.
- Browser
- Download file 58.3 kB
-
https://huggingface.co/spaces/Pixelkram/LLM_Embeddings/resolve/main/app.py
- Command line
-
hf download hf://spaces/Pixelkram/LLM_Embeddings/app.py
-
curl -L -o app.py https://huggingface.co/spaces/Pixelkram/LLM_Embeddings/resolve/main/app.py
58.3 kB
| """ | |
| Inside GPT-2: Embeddings | |
| ======================== | |
| An interactive teaching demo (Gradio, runs on a free CPU Hugging Face Space). | |
| Models | |
| - GPT-2 small (OpenAI, 2019): tokens, token/position embeddings, hidden states. | |
| - paraphrase-multilingual-MiniLM-L12-v2: a small multilingual sentence-embedding model, | |
| used for the shared prompt map and as a comparison in "Compare your own lists". | |
| Companion Space: "Inside GPT-2: Attention". | |
| """ | |
| import html | |
| import os | |
| import re | |
| import threading | |
| import time | |
| import uuid | |
| import gradio as gr | |
| import numpy as np | |
| import plotly.graph_objects as go | |
| import torch | |
| import umap | |
| from transformers import AutoModel, AutoTokenizer | |
| from reference_words import REFERENCE_WORDS | |
| torch.set_grad_enabled(False) # note: thread-local, so model calls below also use inference_mode | |
| # --------------------------------------------------------------------------- | |
| # Models | |
| # --------------------------------------------------------------------------- | |
| GPT2_ID = "openai-community/gpt2" | |
| ST_ID = "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2" | |
| # Optional: link to the companion attention Space, e.g. "https://huggingface.co/spaces/<user>/inside-gpt2-attention" | |
| COMPANION_URL = os.environ.get("COMPANION_URL", "") | |
| tok = AutoTokenizer.from_pretrained(GPT2_ID) | |
| gpt2 = AutoModel.from_pretrained(GPT2_ID).eval() | |
| st_tok = AutoTokenizer.from_pretrained(ST_ID) | |
| st_model = AutoModel.from_pretrained(ST_ID).eval() | |
| N_LAYERS = gpt2.config.n_layer # 12 | |
| D_MODEL = gpt2.config.n_embd # 768 | |
| BOS = tok.bos_token_id # 50256, "<|endoftext|>" | |
| N_PARAMS = sum(p.numel() for p in gpt2.parameters()) | |
| MAX_TEXT_TOKENS = 128 # tokens tab | |
| MAX_MAP_ITEMS = 60 | |
| WTE = gpt2.wte.weight.detach().float().numpy() # (50257, 768) token embeddings | |
| WPE = gpt2.wpe.weight.detach().float().numpy() # (1024, 768) position embeddings | |
| WTE_UNIT = WTE / np.linalg.norm(WTE, axis=1, keepdims=True) | |
| VOCAB = [tok.decode([i]) for i in range(WTE.shape[0])] | |
| WORD_RE = re.compile(r" ?[^\W\d_]{2,}") # whole-word tokens (letters only) | |
| WORD_MASK = np.array([bool(WORD_RE.fullmatch(v)) for v in VOCAB]) | |
| NORM_KEY = [v.strip().lower() for v in VOCAB] | |
| def hidden_states(ids): | |
| """All 13 residual-stream states for a list of token ids: (13, T, 768). | |
| Index 0 = token + position embedding, 1..12 = after each block | |
| (HF applies the final LayerNorm to index 12).""" | |
| out = gpt2(torch.tensor([ids]), output_hidden_states=True) | |
| return torch.stack(out.hidden_states)[:, 0].float().numpy() | |
| # Reference statistics used to "normalize dimensions" of hidden states. | |
| # GPT-2's residual stream has a large shared offset and a few huge outlier | |
| # dimensions, which make every cosine similarity look like 0.9+. We z-score | |
| # each dimension with mean/std measured on this neutral reference text. | |
| REF_TEXT = ( | |
| "The history of the town goes back many centuries. Today it is known for its " | |
| "museums, an old stone bridge and a lively music scene. Many students come here " | |
| "to study engineering, design and medicine, and the market sells fresh bread, " | |
| "cheese and vegetables every morning. In winter the river sometimes freezes, and " | |
| "children skate on the ice while their parents drink tea in small cafes. A new " | |
| "train line connects the station with the airport, which has made the region " | |
| "popular with tourists. Scientists at the local institute work on batteries, " | |
| "weather models and software for hospitals. My neighbour repairs old bicycles in " | |
| "her garage and sells them online. Last year she sold more than two hundred of " | |
| "them. When I asked her why she enjoys it, she said that every bike has a story, " | |
| "and that fixing something is better than throwing it away. Football is played " | |
| "on Saturdays, and the stadium can hold about twenty thousand people. Prices for " | |
| "apartments have risen quickly, so many young families now live in the villages " | |
| "nearby. The mayor promised more parks, cheaper buses and faster internet, but " | |
| "critics say the plans are too expensive. Still, most people agree that the " | |
| "town is a good place to live, especially in spring when the trees are green." | |
| ) | |
| _REF = hidden_states([BOS] + tok.encode(REF_TEXT))[:, 1:] # drop the BOS position | |
| REF_MU = _REF.mean(axis=1) # (13, 768) | |
| REF_SD = _REF.std(axis=1) + 1e-5 | |
| # --------------------------------------------------------------------------- | |
| # Visual style (light theme, validated categorical palette) | |
| # --------------------------------------------------------------------------- | |
| SURFACE = "#fcfcfb" | |
| INK = "#0b0b0b" | |
| INK2 = "#52514e" | |
| MUTED = "#898781" | |
| GRID = "#e1e0d9" | |
| AXIS = "#c3c2b7" | |
| MASKED = "#efeee9" | |
| SERIES = ["#2a78d6", "#eb6834", "#1baf7a", "#eda100", "#e87ba4", "#008300", "#6250d6", "#e34948"] | |
| CHIP_TINTS = ["#d8e7fa", "#fbdccf", "#cdeee1", "#fbe7b9", "#e3dff8"] | |
| FONT = "system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif" | |
| BLUE_STEPS = ["#fcfcfb", "#cde2fb", "#9ec5f4", "#6da7ec", "#3987e5", "#256abf", "#184f95", "#0d366b"] | |
| SEQ_SCALE = [[i / (len(BLUE_STEPS) - 1), c] for i, c in enumerate(BLUE_STEPS)] | |
| DIV_SCALE = [ | |
| [0.0, "#8f2424"], [0.17, "#e34948"], [0.34, "#f2b8b5"], [0.5, "#f0efec"], | |
| [0.66, "#9ec5f4"], [0.83, "#3987e5"], [1.0, "#104281"], | |
| ] | |
| def style(fig, title=None, height=420, legend=False): | |
| fig.update_layout( | |
| template="plotly_white", | |
| paper_bgcolor=SURFACE, | |
| plot_bgcolor=SURFACE, | |
| font=dict(family=FONT, color=INK2, size=13), | |
| title=dict(text=title, font=dict(color=INK, size=15), x=0.01, xanchor="left") if title else None, | |
| margin=dict(l=10, r=10, t=56 if title else 16, b=10), | |
| height=height, | |
| showlegend=legend, | |
| legend=dict(orientation="h", yanchor="bottom", y=1.0, x=1, xanchor="right", font=dict(color=INK2)), | |
| hoverlabel=dict(bgcolor="white", font=dict(family=FONT, color=INK), bordercolor=AXIS), | |
| ) | |
| fig.update_xaxes(gridcolor=GRID, linecolor=AXIS, zeroline=False, tickfont=dict(color=MUTED)) | |
| fig.update_yaxes(gridcolor=GRID, linecolor=AXIS, zeroline=False, tickfont=dict(color=MUTED)) | |
| return fig | |
| def empty_fig(message, height=300): | |
| fig = go.Figure() | |
| fig.add_annotation(text=message, showarrow=False, font=dict(size=14, color=INK2), x=0.5, y=0.5, | |
| xref="paper", yref="paper") | |
| fig.update_xaxes(visible=False) | |
| fig.update_yaxes(visible=False) | |
| return style(fig, height=height) | |
| def _bytes_to_unicode(): | |
| """GPT-2's byte <-> printable-character table (from the original GPT-2 code).""" | |
| bs = list(range(ord("!"), ord("~") + 1)) + list(range(ord("¡"), ord("¬") + 1)) + list(range(ord("®"), ord("ÿ") + 1)) | |
| cs = bs[:] | |
| n = 0 | |
| for b in range(256): | |
| if b not in bs: | |
| bs.append(b) | |
| cs.append(256 + n) | |
| n += 1 | |
| return dict(zip(bs, map(chr, cs))) | |
| _BYTE_DECODER = {v: k for k, v in _bytes_to_unicode().items()} | |
| def token_text(i): | |
| """Token string. Byte fragments (parts of emoji or rare characters) become e.g. '[F0 9F]'.""" | |
| t = VOCAB[i] | |
| if "\ufffd" not in t: | |
| return t | |
| raw = bytes(_BYTE_DECODER[c] for c in tok.convert_ids_to_tokens(int(i))) | |
| lead = " " if raw.startswith(b" ") else "" | |
| return lead + "[" + "\u2009".join(f"{b:02X}" for b in raw.lstrip(b" ")) + "]" # thin space, not a token space | |
| def show_tok(i, spaces=True, maxlen=None): | |
| """Readable label for token id i: leading space as '␣', BOS as [BOS], raw bytes as [F0 9F].""" | |
| if i == BOS: | |
| return "[BOS]" | |
| t = token_text(i).replace("\n", "↵").replace("\t", "⇥") | |
| t = t.replace(" ", "␣") if spaces else t.strip() or "␣" | |
| if maxlen and len(t) > maxlen: | |
| t = t[: maxlen - 1] + "…" | |
| return t | |
| def short(s, n=34): | |
| s = s.strip() | |
| return s if len(s) <= n else s[: n - 1] + "…" | |
| def parse_groups(text): | |
| """Lines -> [(group_name, [items])]. A line starting with '#' names a new | |
| group, a blank line also starts a new group.""" | |
| groups, name, items = [], None, [] | |
| def flush(): | |
| nonlocal items, name | |
| if items: | |
| groups.append((name or f"group {len(groups) + 1}", items)) | |
| items, name = [], None | |
| for raw in (text or "").splitlines(): | |
| line = raw.strip() | |
| if not line: | |
| flush() | |
| elif line.startswith("#"): | |
| flush() | |
| name = line.lstrip("#").strip() or None | |
| else: | |
| items.append(line) | |
| flush() | |
| return groups | |
| def cosine_matrix(X): | |
| Xu = X / (np.linalg.norm(X, axis=-1, keepdims=True) + 1e-9) | |
| return Xu @ np.swapaxes(Xu, -1, -2) | |
| def pca(X, k=2): | |
| """Project L2-normalised rows of X onto their top-k principal axes.""" | |
| Xu = X / (np.linalg.norm(X, axis=1, keepdims=True) + 1e-9) | |
| Xc = Xu - Xu.mean(axis=0) | |
| U, S, _ = np.linalg.svd(Xc, full_matrices=False) | |
| var = S**2 / max((S**2).sum(), 1e-12) | |
| P = U[:, :k] * S[:k] | |
| if P.shape[1] < k: | |
| P = np.pad(P, ((0, 0), (0, k - P.shape[1]))) | |
| var = np.pad(var, (0, k - len(var))) | |
| return P, var[:k] | |
| def similarity_heatmap(C, labels, hover_labels, title, height=None): | |
| n = len(labels) | |
| numbered = max(len(l) for l in labels) > 12 | |
| ylabels = [f"{i + 1}. {l}" if numbered and not l[:1].isdigit() else l for i, l in enumerate(labels)] | |
| xlabels = [str(i + 1) for i in range(n)] if numbered else labels | |
| hover = [[f"{html.escape(hover_labels[i])}<br>vs. {html.escape(hover_labels[j])}<br><b>cosine {C[i, j]:.3f}</b>" | |
| for j in range(n)] for i in range(n)] | |
| fig = go.Figure(go.Heatmap( | |
| z=C, x=list(range(n)), y=list(range(n)), zmin=-1, zmax=1, zmid=0, colorscale=DIV_SCALE, | |
| text=hover, hoverinfo="text", xgap=1, ygap=1, | |
| colorbar=dict(title=dict(text="cosine", font=dict(color=INK2)), thickness=12, tickfont=dict(color=MUTED)), | |
| )) | |
| style(fig, title, height or max(420, 150 + 30 * n)) | |
| fig.update_xaxes(tickvals=list(range(n)), ticktext=xlabels, tickangle=0 if numbered else -45, showgrid=False, | |
| constrain="domain") | |
| fig.update_yaxes(tickvals=list(range(n)), ticktext=ylabels, autorange="reversed", showgrid=False, | |
| scaleanchor="x", scaleratio=1, constrain="domain") | |
| return fig | |
| def scatter_groups(P, names, group_idx, group_names, title, var, point_labels=None): | |
| """2D or 3D (if P has 3 columns) scatter, one trace per group, every point labelled.""" | |
| three_d = P.shape[1] == 3 | |
| fig = go.Figure() | |
| for g, gname in enumerate(group_names): | |
| sel = [i for i, gi in enumerate(group_idx) if gi == g] | |
| if not sel: | |
| continue | |
| common = dict( | |
| mode="markers+text", name=gname, | |
| text=[point_labels[i] if point_labels else short(names[i], 28) for i in sel], | |
| textposition="top center", textfont=dict(color=INK, size=12), | |
| hovertext=[names[i] for i in sel], hoverinfo="text", | |
| ) | |
| color = SERIES[g % len(SERIES)] | |
| if three_d: | |
| fig.add_trace(go.Scatter3d(x=P[sel, 0], y=P[sel, 1], z=P[sel, 2], | |
| marker=dict(size=6, color=color, line=dict(color=SURFACE, width=1)), | |
| **common)) | |
| else: | |
| fig.add_trace(go.Scatter(x=P[sel, 0], y=P[sel, 1], | |
| marker=dict(size=11, color=color, line=dict(color=SURFACE, width=2)), | |
| **common)) | |
| style(fig, title, 560 if three_d else 520, legend=len(group_names) > 1) | |
| labs = [f"PCA axis {i + 1} ({100 * v:.0f}%)" for i, v in enumerate(var)] | |
| if three_d: | |
| ax = dict(showticklabels=False, backgroundcolor=SURFACE, gridcolor=GRID, zeroline=False, | |
| title=dict(font=dict(color=MUTED, size=11))) | |
| fig.update_layout(scene=dict( | |
| xaxis=dict(ax, title=dict(text=labs[0], font=dict(color=MUTED, size=11))), | |
| yaxis=dict(ax, title=dict(text=labs[1], font=dict(color=MUTED, size=11))), | |
| zaxis=dict(ax, title=dict(text=labs[2], font=dict(color=MUTED, size=11))), | |
| aspectmode="data")) | |
| else: | |
| span = np.ptp(P, axis=0) + 1e-9 | |
| lo, hi = P.min(axis=0), P.max(axis=0) | |
| fig.update_xaxes(title=dict(text=labs[0], font=dict(color=MUTED)), showticklabels=False, zeroline=False, | |
| range=[lo[0] - 0.22 * span[0], hi[0] + 0.22 * span[0]]) | |
| fig.update_yaxes(title=dict(text=labs[1], font=dict(color=MUTED)), showticklabels=False, zeroline=False, | |
| range=[lo[1] - 0.1 * span[1], hi[1] + 0.15 * span[1]]) | |
| return fig | |
| # --------------------------------------------------------------------------- | |
| # Prompt map: a shared, live map of reference words + everybody's prompts | |
| # --------------------------------------------------------------------------- | |
| def st_embed(texts): | |
| """Sentence embeddings (mean pooling over tokens, unit length), as sentence-transformers does it.""" | |
| batch = st_tok(list(texts), padding=True, truncation=True, max_length=128, return_tensors="pt") | |
| out = st_model(**batch).last_hidden_state | |
| mask = batch["attention_mask"].unsqueeze(-1).float() | |
| emb = (out * mask).sum(1) / mask.sum(1) | |
| return torch.nn.functional.normalize(emb, dim=1).float().numpy() | |
| MAP_GROUPS = list(REFERENCE_WORDS) | |
| MAP_WORDS, MAP_GROUP_IDX = [], [] | |
| for _g, _words in enumerate(REFERENCE_WORDS.values()): | |
| MAP_WORDS += _words | |
| MAP_GROUP_IDX += [_g] * len(_words) | |
| MAP_GROUP_IDX = np.array(MAP_GROUP_IDX) | |
| MAP_X = st_embed(MAP_WORDS) # (320, 384) | |
| # Layout 1: PCA, a plain linear projection (new points are projected exactly) | |
| _PCA_MU = MAP_X.mean(axis=0) | |
| _, _S, _VT = np.linalg.svd(MAP_X - _PCA_MU, full_matrices=False) | |
| _PCA_AXES = _VT[:2] | |
| PCA_VAR = (_S**2 / (_S**2).sum())[:2] | |
| # Layout 2: UMAP, non-linear, keeps neighbourhoods (chosen over t-SNE because it can place new | |
| # points with .transform(); see README for the comparison) | |
| _UMAP = umap.UMAP(n_neighbors=15, min_dist=0.15, metric="cosine", random_state=0, n_jobs=1).fit(MAP_X) | |
| _UMAP.transform(MAP_X[:2]) # warm-up: compiles numba code at startup instead of on the first prompt | |
| LAYOUTS = ["UMAP (clusters)", "PCA (linear)"] | |
| MAP_XY = {LAYOUTS[0]: _UMAP.embedding_.astype(float), LAYOUTS[1]: (MAP_X - _PCA_MU) @ _PCA_AXES.T} | |
| def _group_label_positions(XY, k=8): | |
| """For each group, the member whose k nearest 2D neighbours contain the most members of its own group.""" | |
| d = ((XY[:, None, :] - XY[None, :, :]) ** 2).sum(-1) | |
| np.fill_diagonal(d, np.inf) | |
| nn = np.argsort(d, axis=1)[:, :k] | |
| same = (MAP_GROUP_IDX[nn] == MAP_GROUP_IDX[:, None]).sum(axis=1) | |
| pos = [] | |
| for g in range(len(MAP_GROUPS)): | |
| members = np.where(MAP_GROUP_IDX == g)[0] | |
| pos.append(XY[members[np.argmax(same[members])]]) | |
| return pos | |
| GROUP_LABEL_XY = {name: _group_label_positions(xy) for name, xy in MAP_XY.items()} | |
| def place_points(vecs): | |
| """2D positions of new vectors in both layouts, without changing the reference map.""" | |
| return {LAYOUTS[0]: _UMAP.transform(vecs).astype(float), LAYOUTS[1]: (vecs - _PCA_MU) @ _PCA_AXES.T} | |
| MAX_PROMPT_CHARS = 200 | |
| MAX_SHARED_PROMPTS = 300 | |
| MIN_SECONDS_BETWEEN = 1.5 | |
| ADMIN_KEY = os.environ.get("ADMIN_KEY", "") | |
| class SharedPromptStore: | |
| """Prompts from all visitors, kept in memory only (gone when the Space restarts).""" | |
| def __init__(self): | |
| self.lock = threading.Lock() | |
| self.items = [] | |
| self.version = 0 | |
| self.last_add = {} | |
| self.next_id = 1 | |
| def add(self, text, owner): | |
| vec = st_embed([text]) | |
| xy = place_points(vec) | |
| sims = MAP_X @ vec[0] | |
| order = np.argsort(-sims)[:10] | |
| item = dict(text=text, owner=owner, vec=vec[0], xy={k: v[0] for k, v in xy.items()}, | |
| nbrs=[(int(i), float(sims[i])) for i in order]) | |
| with self.lock: | |
| item["id"] = self.next_id | |
| self.next_id += 1 | |
| self.items.append(item) | |
| del self.items[:-MAX_SHARED_PROMPTS] | |
| self.last_add[owner] = time.time() | |
| self.version += 1 | |
| return item | |
| def too_soon(self, owner): | |
| return time.time() - self.last_add.get(owner, 0) < MIN_SECONDS_BETWEEN | |
| def remove_last_of(self, owner): | |
| with self.lock: | |
| for k in range(len(self.items) - 1, -1, -1): | |
| if self.items[k]["owner"] == owner: | |
| del self.items[k] | |
| self.version += 1 | |
| return True | |
| return False | |
| def remove_last(self): | |
| with self.lock: | |
| if self.items: | |
| self.items.pop() | |
| self.version += 1 | |
| return True | |
| return False | |
| def clear(self): | |
| with self.lock: | |
| self.items = [] | |
| self.version += 1 | |
| def snapshot(self): | |
| with self.lock: | |
| return self.version, list(self.items) | |
| STORE = SharedPromptStore() | |
| def group_vote(item): | |
| votes = np.bincount(MAP_GROUP_IDX[[i for i, _ in item["nbrs"]]], minlength=len(MAP_GROUPS)) | |
| return MAP_GROUPS[int(np.argmax(votes))], int(votes.max()) | |
| def prompt_map_fig(layout, show_labels, show_lines, owner, items): | |
| XY = MAP_XY[layout] | |
| fig = go.Figure() | |
| for g, gname in enumerate(MAP_GROUPS): | |
| sel = np.where(MAP_GROUP_IDX == g)[0] | |
| fig.add_trace(go.Scatter( | |
| x=XY[sel, 0], y=XY[sel, 1], mode="markers+text" if show_labels else "markers", name=gname, | |
| text=[MAP_WORDS[i] for i in sel], textposition="top center", | |
| textfont=dict(size=10, color=INK2), customdata=[gname] * len(sel), | |
| hovertemplate="<b>%{text}</b><br>%{customdata}<extra></extra>", | |
| marker=dict(size=8, color=SERIES[g % len(SERIES)], opacity=0.9, line=dict(color=SURFACE, width=1)), | |
| )) | |
| # group names as direct labels (so colour is not the only cue), placed where each group is densest | |
| for g, gname in enumerate(MAP_GROUPS): | |
| lx, ly = GROUP_LABEL_XY[layout][g] | |
| fig.add_annotation(x=lx, y=ly, text=f"<b>{html.escape(gname)}</b>", showarrow=False, yshift=14, | |
| font=dict(size=13, color=INK), bgcolor="rgba(252,252,251,0.75)", borderpad=2) | |
| mine = [it for it in items if it["owner"] == owner] | |
| others = [it for it in items if it["owner"] != owner] | |
| focus = mine[-1] if mine else None | |
| if show_lines and focus is not None: | |
| p = focus["xy"][layout] | |
| xs, ys = [], [] | |
| for i, _ in focus["nbrs"][:5]: | |
| xs += [p[0], XY[i, 0], None] | |
| ys += [p[1], XY[i, 1], None] | |
| fig.add_trace(go.Scatter(x=xs, y=ys, mode="lines", line=dict(color=INK2, width=1.5, dash="dot"), | |
| hoverinfo="skip", name="5 nearest words (in 384 dimensions)")) | |
| idx = [i for i, _ in focus["nbrs"][:5]] | |
| fig.add_trace(go.Scatter( | |
| x=XY[idx, 0], y=XY[idx, 1], mode="markers+text", showlegend=False, | |
| text=[f"<b>{MAP_WORDS[i]}</b>" for i in idx], textposition="top center", | |
| textfont=dict(size=12, color=INK), hoverinfo="skip", | |
| marker=dict(size=13, color="rgba(0,0,0,0)", line=dict(color=INK, width=1.5)))) | |
| def prompt_trace(its, name, symbol, size, color, bold): | |
| if not its: | |
| return | |
| hover = [] | |
| for it in its: | |
| near = ", ".join(f"{MAP_WORDS[i]} {s:.2f}" for i, s in it["nbrs"][:5]) | |
| hover.append(f"<b>{html.escape(short(it['text'], 60))}</b><br>nearest: {html.escape(near)}") | |
| labels = [(f"<b>{html.escape(short(it['text'], 32))}</b>" if bold else html.escape(short(it["text"], 28))) | |
| for it in its] | |
| fig.add_trace(go.Scatter( | |
| x=[it["xy"][layout][0] for it in its], y=[it["xy"][layout][1] for it in its], | |
| mode="markers+text", name=name, text=labels, | |
| textposition="bottom center", textfont=dict(size=12 if bold else 11, color=INK), | |
| hovertext=hover, hoverinfo="text", | |
| marker=dict(symbol=symbol, size=size, color=color, line=dict(color=SURFACE, width=1.5)), | |
| )) | |
| prompt_trace(others, f"prompts from others ({len(others)})", "diamond", 12, MUTED, False) | |
| prompt_trace(mine, f"your prompts ({len(mine)})", "star", 18, INK, True) | |
| if layout == LAYOUTS[1]: | |
| xt = f"PCA axis 1 ({100 * PCA_VAR[0]:.0f}% of variance)" | |
| yt = f"PCA axis 2 ({100 * PCA_VAR[1]:.0f}% of variance)" | |
| title = "PCA: a flat shadow of 384 dimensions. Distances are honest but groups overlap." | |
| else: | |
| xt = yt = "UMAP axes have no meaning, only neighbourhoods do" | |
| title = "UMAP: groups pulled apart so neighbourhoods are visible. Distances between clusters mean little." | |
| style(fig, title, 760, legend=True) | |
| fig.update_layout(uirevision=layout, legend=dict(orientation="h", y=-0.05, x=0, xanchor="left", | |
| yanchor="top", font=dict(size=12)), | |
| margin=dict(l=10, r=10, t=56, b=80), dragmode="pan") | |
| fig.update_xaxes(title=dict(text=xt, font=dict(color=MUTED)), showticklabels=False, zeroline=False, | |
| showgrid=False) | |
| fig.update_yaxes(title=dict(text=yt, font=dict(color=MUTED)), showticklabels=False, zeroline=False, | |
| showgrid=False) | |
| if layout == LAYOUTS[1]: # PCA distances are meaningful, so keep the aspect ratio honest | |
| fig.update_yaxes(scaleanchor="x", scaleratio=1) | |
| return fig | |
| def prompt_map_md(owner, items): | |
| mine = [it for it in items if it["owner"] == owner] | |
| owners = {it["owner"] for it in items} | |
| md = f"**{len(items)} prompt{'s' if len(items) != 1 else ''} on the map** from {len(owners)} visitor" \ | |
| f"{'s' if len(owners) != 1 else ''}." | |
| if mine: | |
| it = mine[-1] | |
| near = ", ".join(f"{MAP_WORDS[i]} ({s:.2f}, {MAP_GROUPS[MAP_GROUP_IDX[i]]})" for i, s in it["nbrs"][:5]) | |
| g, n = group_vote(it) | |
| md += (f"\n\nYour latest prompt **“{html.escape(it['text'])}”** is closest to: {near}. " | |
| f"{n} of its 10 nearest words are in **{g}**. The numbers are cosine similarities in the full " | |
| f"384-dimensional space: that is where the dotted lines point, wherever the 2D layout puts them.") | |
| return md | |
| def render_prompt_map(layout, show_labels, show_lines, owner): | |
| version, items = STORE.snapshot() | |
| return prompt_map_fig(layout, show_labels, show_lines, owner, items), prompt_map_md(owner, items), version | |
| def add_prompt(text, layout, show_labels, show_lines, owner): | |
| text = " ".join((text or "").split())[:MAX_PROMPT_CHARS] | |
| if not text: | |
| gr.Warning("Type a word or a prompt first.") | |
| return (gr.skip(),) * 3 + (gr.skip(),) | |
| if not owner: | |
| owner = uuid.uuid4().hex | |
| if STORE.too_soon(owner): | |
| gr.Warning("One moment: you can add a prompt every couple of seconds.") | |
| return (gr.skip(),) * 3 + (gr.skip(),) | |
| STORE.add(text, owner) | |
| return render_prompt_map(layout, show_labels, show_lines, owner) + ("",) | |
| def undo_prompt(layout, show_labels, show_lines, owner): | |
| if not STORE.remove_last_of(owner): | |
| gr.Info("You have no prompts on the map yet.") | |
| return render_prompt_map(layout, show_labels, show_lines, owner) | |
| def refresh_if_changed(layout, show_labels, show_lines, owner, seen_version): | |
| version, _ = STORE.snapshot() | |
| if version == seen_version: | |
| return gr.skip(), gr.skip(), gr.skip() | |
| return render_prompt_map(layout, show_labels, show_lines, owner) | |
| def admin_action(key, action, layout, show_labels, show_lines, owner): | |
| if not ADMIN_KEY: | |
| return "Teacher controls are off: set a Space secret called `ADMIN_KEY` to enable them.", \ | |
| *render_prompt_map(layout, show_labels, show_lines, owner) | |
| if key != ADMIN_KEY: | |
| return "Wrong key.", gr.skip(), gr.skip(), gr.skip() | |
| if action == "clear": | |
| STORE.clear() | |
| msg = "All prompts removed." | |
| else: | |
| msg = "Most recent prompt removed." if STORE.remove_last() else "The map has no prompts." | |
| return msg, *render_prompt_map(layout, show_labels, show_lines, owner) | |
| def ensure_owner(owner): | |
| return owner or uuid.uuid4().hex | |
| MAP_EXAMPLES = [ | |
| "Mach das Licht in der Küche aus", "Will it snow tomorrow?", "I'm so nervous about my exam", | |
| "a cosy reading corner", "Ich hätte gern einen Kaffee", "neon poster for a techno party", | |
| "my cat sleeps all day", "Wie komme ich zum Bahnhof?", "my phone battery died", | |
| ] | |
| # --------------------------------------------------------------------------- | |
| # Tab 1: Tokens & vectors | |
| # --------------------------------------------------------------------------- | |
| VECTOR_KINDS = [ | |
| "Token embedding (what the token is)", | |
| "Position embedding (where it is)", | |
| "Sum: what layer 1 actually receives", | |
| ] | |
| def tokens_view(text, kind): | |
| text = text or "" | |
| ids = tok.encode(text) | |
| truncated = len(ids) > MAX_TEXT_TOKENS | |
| ids = ids[:MAX_TEXT_TOKENS] | |
| if not ids: | |
| return "<p>Type some text above.</p>", "", empty_fig("No tokens yet") | |
| chips = [] | |
| for n, i in enumerate(ids): | |
| tint = CHIP_TINTS[n % len(CHIP_TINTS)] | |
| chips.append( | |
| f'<span class="tok-chip" style="background:{tint}" title="token #{n}, id {i}">' | |
| f'<span class="tok-text">{html.escape(show_tok(i))}</span>' | |
| f'<span class="tok-id">{i}</span></span>' | |
| ) | |
| chips_html = '<div class="tok-row">' + "".join(chips) + "</div>" | |
| n_words = len(text.split()) | |
| stats = ( | |
| f"**{len(text)} characters, {n_words} words → {len(ids)} tokens**" | |
| + (f" (showing the first {MAX_TEXT_TOKENS})" if truncated else "") | |
| + f"\n\nThe vocabulary has **{WTE.shape[0]:,} tokens**. Each token id selects one row of the " | |
| f"embedding table: a list of **{D_MODEL} numbers**. The whole table holds " | |
| f"{WTE.size / 1e6:.1f} M numbers, about {100 * WTE.size / N_PARAMS:.0f}% of all " | |
| f"{N_PARAMS / 1e6:.0f} M parameters of GPT-2." | |
| ) | |
| if any("\ufffd" in VOCAB[i] for i in ids): | |
| stats += ("\n\nTokens like `[F0 9F]` are **raw bytes**. Characters GPT-2 never learned as a whole " | |
| "(most emoji, rare letters) are stored as several bytes, and the tokenizer falls back to them.") | |
| if kind == VECTOR_KINDS[0]: | |
| Z = WTE[ids] | |
| elif kind == VECTOR_KINDS[1]: | |
| Z = WPE[: len(ids)] | |
| else: | |
| Z = WTE[ids] + WPE[: len(ids)] | |
| lim = float(np.percentile(np.abs(Z), 99)) or 1.0 | |
| labels = [f"{show_tok(i, maxlen=14)}" + (f" (pos {n})" if kind != VECTOR_KINDS[0] else "") | |
| for n, i in enumerate(ids)] | |
| fig = go.Figure(go.Heatmap( | |
| z=Z, y=list(range(len(ids))), x=list(range(D_MODEL)), zmid=0, zmin=-lim, zmax=lim, | |
| colorscale=DIV_SCALE, customdata=np.array(labels, dtype=object)[:, None].repeat(D_MODEL, 1), | |
| hovertemplate="%{customdata}<br>dimension %{x}<br>value %{z:.3f}<extra></extra>", | |
| colorbar=dict(thickness=12, tickfont=dict(color=MUTED), title=dict(text="value", font=dict(color=INK2))), | |
| )) | |
| style(fig, f"{kind}: one row of {D_MODEL} numbers per token", max(260, 90 + 22 * len(ids))) | |
| fig.update_yaxes(tickvals=list(range(len(ids))), ticktext=labels, autorange="reversed", showgrid=False) | |
| fig.update_xaxes(title=dict(text="dimension (0 to 767)", font=dict(color=MUTED)), showgrid=False) | |
| return chips_html, stats, fig | |
| # --------------------------------------------------------------------------- | |
| # Tab 2: Nearest neighbours & vector arithmetic | |
| # --------------------------------------------------------------------------- | |
| def word_vector(word, lead_space): | |
| w = word.strip() | |
| ids = tok.encode((" " + w) if lead_space else w) | |
| if not ids: | |
| return None, [], "" | |
| note = "" | |
| if len(ids) > 1: | |
| parts = " + ".join(f"`{show_tok(i)}`" for i in ids) | |
| note = (f"`{w}` is not a single token, it is split into {parts}. " | |
| f"Using the average of those vectors (a rough approximation).") | |
| return WTE[ids].mean(axis=0), ids, note | |
| def nearest(vec, k, exclude_ids=(), exclude_keys=(), hide_variants=True): | |
| sims = WTE_UNIT @ (vec / (np.linalg.norm(vec) + 1e-9)) | |
| out, seen = [], set() | |
| for i in np.argsort(-sims): | |
| if not WORD_MASK[i] or i in exclude_ids or NORM_KEY[i] in exclude_keys: | |
| continue | |
| if hide_variants: | |
| if NORM_KEY[i] in seen: | |
| continue | |
| seen.add(NORM_KEY[i]) | |
| out.append((int(i), float(sims[i]))) | |
| if len(out) >= k: | |
| break | |
| return out | |
| def bar_fig(results, title): | |
| labels = [show_tok(i) for i, _ in results] | |
| vals = [s for _, s in results] | |
| fig = go.Figure(go.Bar( | |
| x=vals, y=list(range(len(vals))), orientation="h", marker=dict(color=SERIES[0], line=dict(width=0)), | |
| customdata=labels, hovertemplate="%{customdata}<br>cosine similarity %{x:.3f}<extra></extra>", | |
| )) | |
| style(fig, title, max(300, 80 + 30 * len(vals))) | |
| fig.update_yaxes(tickvals=list(range(len(vals))), ticktext=labels, autorange="reversed", showgrid=False, | |
| tickfont=dict(color=INK, size=13)) | |
| fig.update_xaxes(title=dict(text="cosine similarity (1 = same direction)", font=dict(color=MUTED)), | |
| range=[0, 1], gridcolor=GRID) | |
| fig.update_traces(marker_cornerradius=4) | |
| return fig | |
| def neighbours_view(word, k, lead_space, hide_variants): | |
| if not (word or "").strip(): | |
| return empty_fig("Type a word"), "" | |
| vec, ids, note = word_vector(word, lead_space) | |
| if vec is None: | |
| return empty_fig("Could not tokenize that"), "" | |
| res = nearest(vec, int(k), exclude_ids=set(ids), exclude_keys={word.strip().lower()}, | |
| hide_variants=hide_variants) | |
| lookup = " + ".join(f"`{show_tok(i)}`" for i in ids) | |
| md = f"Looked up {lookup} (token id {', '.join(map(str, ids))}). Searching {int(WORD_MASK.sum()):,} whole-word tokens." | |
| if note: | |
| md += "\n\n" + note | |
| return bar_fig(res, f"Closest tokens to “{word.strip()}” in embedding space"), md | |
| def parse_expression(expr): | |
| terms = [] | |
| for sign, word in re.findall(r"([+-]?)\s*([^+\-]+)", expr or ""): | |
| word = word.strip() | |
| if word: | |
| terms.append((-1.0 if sign == "-" else 1.0, word)) | |
| return terms | |
| def arithmetic_view(expr, k, lead_space, exclude_inputs, hide_variants): | |
| terms = parse_expression(expr) | |
| if not terms: | |
| return empty_fig("Type an expression like: king - man + woman"), "" | |
| vec = np.zeros(D_MODEL, dtype=np.float32) | |
| ids_all, notes = set(), [] | |
| for sign, w in terms: | |
| v, ids, note = word_vector(w, lead_space) | |
| if v is None: | |
| continue | |
| vec += sign * v | |
| ids_all.update(ids) | |
| if note: | |
| notes.append(note) | |
| ex_ids = ids_all if exclude_inputs else set() | |
| ex_keys = {w.lower() for _, w in terms} if exclude_inputs else set() | |
| res = nearest(vec, int(k), exclude_ids=ex_ids, exclude_keys=ex_keys, hide_variants=hide_variants) | |
| pretty = " ".join(("−" if s < 0 else "+") + " " + w for s, w in terms).lstrip("+ ") | |
| md = ("Input words are **excluded** from the results (the standard trick in word-analogy demos). " | |
| "Untick the box to see what is really closest: often one of the input words itself." | |
| if exclude_inputs else | |
| "Input words are **included**. Often the closest vector is simply one of the inputs, " | |
| "because adding and subtracting only nudges the starting vector.") | |
| if notes: | |
| md += "\n\n" + "\n\n".join(notes) | |
| return bar_fig(res, f"Closest tokens to: {pretty}"), md | |
| # --------------------------------------------------------------------------- | |
| # Tab 3: Semantic map | |
| # --------------------------------------------------------------------------- | |
| SOURCES = [ | |
| "GPT-2 token embeddings (static lookup)", | |
| "GPT-2 hidden states (contextual, pick a layer)", | |
| "Multilingual MiniLM sentence-embedding model", | |
| ] | |
| def embed_items(items, source, layer): | |
| if source == SOURCES[0]: | |
| return np.stack([WTE[tok.encode(" " + s.strip()) or [BOS]].mean(axis=0) for s in items]) | |
| if source == SOURCES[1]: | |
| L = int(layer) | |
| vecs = [] | |
| for s in items: | |
| H = hidden_states([BOS] + tok.encode(s))[L, 1:] | |
| vecs.append(((H - REF_MU[L]) / REF_SD[L]).mean(axis=0)) | |
| return np.stack(vecs) | |
| return st_embed(items) | |
| def semantic_map_view(text, source, layer, three_d=False): | |
| groups = parse_groups(text) | |
| items, gidx = [], [] | |
| for g, (_, its) in enumerate(groups): | |
| for it in its: | |
| items.append(it) | |
| gidx.append(g) | |
| items, gidx = items[:MAX_MAP_ITEMS], gidx[:MAX_MAP_ITEMS] | |
| if len(items) < 3: | |
| msg = "Enter at least 3 words or sentences, one per line." | |
| return empty_fig(msg), empty_fig(msg), "" | |
| X = embed_items(items, source, layer) | |
| k = 3 if three_d else 2 | |
| P, var = pca(X, k) | |
| gnames = [g for g, _ in groups] | |
| src = source.split(" (")[0] if source != SOURCES[1] else f"GPT-2 hidden states, layer {int(layer)}" | |
| scatter = scatter_groups(P, items, gidx, gnames, f"{src}: {X.shape[1]} dimensions flattened to {k}", var) | |
| C = cosine_matrix(X) | |
| labels = [f"{short(s, 22)}" for s in items] | |
| heat = similarity_heatmap(C, labels, items, "Cosine similarity between every pair") | |
| kept = 100 * var.sum() | |
| same = np.equal.outer(gidx, gidx) | |
| off = ~np.eye(len(items), dtype=bool) | |
| md = (f"**{len(items)} items, {X.shape[1]}-dimensional vectors.** The {k}D map keeps {kept:.0f}% of the " | |
| f"variation, so {100 - kept:.0f}% is lost in the flattening: trust the similarity matrix more than " | |
| f"distances on the map.") | |
| if len(groups) > 1 and (same & off).any() and (~same).any(): | |
| md += (f"\n\nAverage similarity **within** groups: {C[same & off].mean():.2f}, " | |
| f"**between** groups: {C[~same].mean():.2f}. The bigger the gap, the better this " | |
| f"embedding separates your groups.") | |
| return scatter, heat, md | |
| # --------------------------------------------------------------------------- | |
| # Tab 4: Contextual embeddings | |
| # --------------------------------------------------------------------------- | |
| def find_word(ids, word): | |
| """Position of the LAST token of the first occurrence of `word` (skipping BOS).""" | |
| w = word.strip() | |
| variants = [] | |
| for v in [w, w.lower(), w.capitalize()]: | |
| for c in (" " + v, v): | |
| enc = tok.encode(c) | |
| if enc and enc not in variants: | |
| variants.append(enc) | |
| best = None | |
| for enc in variants: | |
| n = len(enc) | |
| for i in range(1, len(ids) - n + 1): | |
| if ids[i:i + n] == enc: | |
| if best is None or i < best[0]: | |
| best = (i, i + n - 1, enc) | |
| break | |
| return best | |
| def contextual_view(word, text, normalize, layer): | |
| word = (word or "").strip() | |
| groups = parse_groups(text) | |
| if not word or not groups: | |
| msg = "Enter a word and some sentences that contain it." | |
| return empty_fig(msg), empty_fig(msg), empty_fig(msg), "" | |
| vecs, names, gidx, used, skipped = [], [], [], [], [] | |
| for g, (_, sents) in enumerate(groups): | |
| for s in sents: | |
| ids = [BOS] + tok.encode(s) | |
| hit = find_word(ids, word) | |
| if hit is None: | |
| skipped.append(s) | |
| continue | |
| start, pos, enc = hit | |
| H = hidden_states(ids)[:, pos] # (13, 768) | |
| vecs.append(H) | |
| names.append(s) | |
| gidx.append(g) | |
| used.append((s, enc, pos)) | |
| if len(vecs) < 2: | |
| msg = f"Need at least 2 sentences containing “{word}”." | |
| return empty_fig(msg), empty_fig(msg), empty_fig(msg), "" | |
| V = np.stack(vecs, axis=1) # (13, N, 768) | |
| if normalize: | |
| V = (V - REF_MU[:, None]) / REF_SD[:, None] | |
| C = cosine_matrix(V) # (13, N, N) | |
| N = len(names) | |
| same = np.equal.outer(gidx, gidx) | |
| off = ~np.eye(N, dtype=bool) | |
| L = int(layer) | |
| layers = list(range(N_LAYERS + 1)) | |
| gnames = [g for g, _ in groups] | |
| # Line chart across layers | |
| fig = go.Figure() | |
| multi = len(set(gidx)) > 1 | |
| for i in range(N): | |
| for j in range(i + 1, N): | |
| sg = same[i, j] | |
| color = SERIES[0] if (sg or not multi) else SERIES[1] | |
| fig.add_trace(go.Scatter( | |
| x=layers, y=C[:, i, j], mode="lines", line=dict(color=color, width=1), opacity=0.28, | |
| hovertemplate=f"{html.escape(short(names[i], 30))}<br>vs. {html.escape(short(names[j], 30))}" | |
| "<br>layer %{x}: %{y:.3f}<extra></extra>", showlegend=False, | |
| )) | |
| if multi: | |
| within = np.array([C[l][same & off].mean() for l in layers]) | |
| between = np.array([C[l][~same].mean() for l in layers]) | |
| fig.add_trace(go.Scatter(x=layers, y=within, mode="lines+markers", name="same group (average)", | |
| line=dict(color=SERIES[0], width=3), marker=dict(size=8))) | |
| fig.add_trace(go.Scatter(x=layers, y=between, mode="lines+markers", name="different groups (average)", | |
| line=dict(color=SERIES[1], width=3), marker=dict(size=8))) | |
| else: | |
| avg = np.array([C[l][off].mean() for l in layers]) | |
| fig.add_trace(go.Scatter(x=layers, y=avg, mode="lines+markers", name="average of all pairs", | |
| line=dict(color=SERIES[0], width=3), marker=dict(size=8))) | |
| fig.add_vline(x=L, line=dict(color=MUTED, dash="dot", width=1)) | |
| style(fig, f"How similar is “{word}” to itself across sentences, layer by layer?", 430, legend=True) | |
| fig.update_xaxes(title=dict(text="layer (0 = input embedding, 12 = last layer)", font=dict(color=MUTED)), | |
| tickvals=layers, range=[-0.3, N_LAYERS + 0.3]) | |
| fig.update_yaxes(title=dict(text="cosine similarity", font=dict(color=MUTED))) | |
| labels = [f"{n + 1}. {short(s, 22)}" for n, s in enumerate(names)] | |
| heat = similarity_heatmap(C[L], labels, names, f"Similarity of “{word}” between sentences, layer {L}") | |
| P, var = pca(V[L], 2) | |
| scatter = scatter_groups(P, [f"{n + 1}. {s}" for n, s in enumerate(names)], gidx, gnames, | |
| f"“{word}” vectors at layer {L}, flattened to 2D", var, | |
| point_labels=[str(n + 1) for n in range(N)]) | |
| rows = [] | |
| for n, (s, enc, pos) in enumerate(used): | |
| piece = " + ".join(f"`{show_tok(i)}`" for i in enc) | |
| rows.append(f"{n + 1}. token {piece} at position {pos}") | |
| md = "**Which token was used:** " + "; ".join(rows) + "." | |
| if any(len(e) > 1 for _, e, _ in used): | |
| md += " For words split into several tokens, the last piece is used (it has seen the whole word)." | |
| if skipped: | |
| md += "\n\n**Skipped (word not found):** " + "; ".join(f"“{short(s, 40)}”" for s in skipped) | |
| return fig, heat, scatter, md | |
| # --------------------------------------------------------------------------- | |
| # Defaults and copy | |
| # --------------------------------------------------------------------------- | |
| DEFAULT_TOKENS_TEXT = "Hello hello HELLO! Students in Nürnberg are learning how tokenization works." | |
| DEFAULT_MAP_TEXT = """# colours | |
| red | |
| green | |
| blue | |
| yellow | |
| # animals | |
| dog | |
| cat | |
| horse | |
| rabbit | |
| # cities | |
| Paris | |
| Berlin | |
| Tokyo | |
| Madrid | |
| # verbs | |
| run | |
| jump | |
| swim | |
| walk""" | |
| SMART_HOME_MAP_TEXT = """# lights | |
| Turn off the lights in the kitchen | |
| Dim the living room lamp | |
| Switch on the bedroom light | |
| # weather | |
| Will it rain tomorrow? | |
| What's the weather like this weekend? | |
| How cold is it outside? | |
| # cooking | |
| How long do I boil an egg? | |
| Give me a recipe for pasta | |
| What can I cook with potatoes?""" | |
| DEFAULT_CTX_WORD = "bank" | |
| DEFAULT_CTX_TEXT = """# river bank | |
| We had a picnic on the grassy river bank | |
| The fisherman sat on the muddy bank | |
| Ducks swam near the river bank | |
| # money bank | |
| She deposited her salary at the bank | |
| The robbers broke into the bank | |
| He asked his bank for a loan""" | |
| CTX_EXAMPLE_2_WORD = "light" | |
| CTX_EXAMPLE_2_TEXT = """# light = lamp | |
| Please turn on the light | |
| The kitchen light is broken | |
| He switched off the light | |
| # light = not heavy | |
| This suitcase is very light | |
| My new laptop is really light | |
| The jacket feels surprisingly light""" | |
| INTRO = f""" | |
| # Inside GPT-2: embeddings | |
| How does a language model turn text into numbers it can compute with? This app opens up **GPT-2 small** | |
| ({N_PARAMS / 1e6:.0f} M parameters, {D_MODEL}-dimensional vectors) and a small **sentence-embedding model**. | |
| GPT-2 is small and old (2019), but today's chat models start the same way: text becomes tokens, tokens become | |
| vectors (**embeddings**), and similar meanings end up as nearby vectors. | |
| """ + (f"Companion app: [Inside GPT-2: attention]({COMPANION_URL})" if COMPANION_URL else "") | |
| CSS = f""" | |
| .gradio-container {{ max-width: 1180px !important; }} | |
| .tok-row {{ display:flex; flex-wrap:wrap; gap:6px 4px; padding:6px 0; }} | |
| .tok-chip {{ display:inline-flex; flex-direction:column; align-items:center; padding:4px 7px 3px; | |
| border-radius:6px; font-family:{FONT}; line-height:1.15; }} | |
| .tok-text {{ font-size:17px; color:{INK}; white-space:pre; }} | |
| .tok-id {{ font-size:10.5px; color:{INK2}; font-variant-numeric:tabular-nums; }} | |
| """ | |
| # Plots are designed for a light background: pin the light theme. This runs in <head>, before the | |
| # app starts, so the one-time redirect to ?__theme=light does not interrupt any requests. | |
| FORCE_LIGHT_HEAD = """ | |
| <script> | |
| (() => { | |
| const url = new URL(window.location); | |
| if (url.searchParams.get('__theme') !== 'light') { | |
| url.searchParams.set('__theme', 'light'); | |
| window.location.replace(url.href); | |
| } | |
| })(); | |
| </script> | |
| """ | |
| THEME = gr.themes.Base( | |
| primary_hue=gr.themes.colors.blue, | |
| neutral_hue=gr.themes.colors.stone, | |
| font=[gr.themes.GoogleFont("Inter"), "system-ui", "sans-serif"], | |
| ) | |
| # --------------------------------------------------------------------------- | |
| # UI | |
| # --------------------------------------------------------------------------- | |
| with gr.Blocks(title="Inside GPT-2: embeddings") as demo: | |
| gr.Markdown(INTRO) | |
| with gr.Tabs(): | |
| # ---- Shared prompt map | |
| with gr.Tab("Prompt map"): | |
| gr.Markdown( | |
| "Every coloured dot is a word (hover to read it). Its position comes from its **embedding**: 384 numbers produced " | |
| "by a multilingual sentence-embedding model, flattened to 2D. Words with similar meanings get " | |
| "similar numbers, so they land near each other. \n" | |
| "**Type a word or a whole prompt, in any language.** It is turned into 384 numbers the same way " | |
| "and placed on the map next to the words it is most similar to. Everybody using this page shares " | |
| "the map: your prompts are black stars, other people's are grey diamonds. \n" | |
| "*Note: everything you add is visible to everyone who has this page open, until the app " | |
| "restarts. Please don't type anything personal.*" | |
| ) | |
| with gr.Row(equal_height=True): | |
| pm_text = gr.Textbox(label="Your word or prompt (any language)", scale=4, | |
| placeholder="e.g. Mach das Licht aus, a cosy reading corner, dragon", | |
| max_length=MAX_PROMPT_CHARS) | |
| pm_add = gr.Button("Add to map", variant="primary", scale=1) | |
| pm_undo = gr.Button("Remove my last", scale=1) | |
| pm_ex = gr.Examples([[e] for e in MAP_EXAMPLES], [pm_text], label="Try one of these") | |
| with gr.Row(): | |
| pm_layout = gr.Radio(LAYOUTS, value=LAYOUTS[0], label="Layout", scale=2) | |
| pm_labels = gr.Checkbox(value=False, label="Show all word labels", scale=1) | |
| pm_lines = gr.Checkbox(value=True, label="Lines to my prompt's 5 nearest words", scale=1) | |
| pm_live = gr.Checkbox(value=True, label="Live updates from others", scale=1) | |
| pm_md = gr.Markdown() | |
| pm_plot = gr.Plot(show_label=False) | |
| gr.Markdown( | |
| f"**UMAP or PCA?** Both squash 384 dimensions into 2, and both lose information. " | |
| f"*PCA* is a flat shadow: it keeps the two directions in which the {len(MAP_WORDS)} words differ " | |
| f"most ({100 * PCA_VAR.sum():.0f}% of the variation) and drops the rest, so the groups overlap. " | |
| f"*UMAP* bends and stretches the space so that every word stays close to its nearest neighbours. " | |
| f"The groups separate nicely, but the size of a cluster and the gaps between clusters mean little. " | |
| f"The dotted lines always point to the true nearest words in 384 dimensions: when they are long, " | |
| f"the 2D map is distorting. \n" | |
| f"*Why UMAP and not t-SNE?* On these words both made equally good maps. UMAP can place new prompts " | |
| f"onto a finished map, while t-SNE has to recompute the whole map (or guess), and in a test UMAP " | |
| f"put held-out words into the right group a little more often." | |
| ) | |
| with gr.Accordion("Teacher controls", open=False): | |
| gr.Markdown("Needs the `ADMIN_KEY` secret of this Space." if ADMIN_KEY else | |
| "Off. To enable, add a secret called `ADMIN_KEY` in the Space settings.") | |
| with gr.Row(): | |
| pm_key = gr.Textbox(label="Admin key", type="password", scale=2) | |
| pm_rm_last = gr.Button("Remove the most recent prompt", scale=1) | |
| pm_clear = gr.Button("Clear all prompts", variant="stop", scale=1) | |
| pm_admin_msg = gr.Markdown() | |
| pm_owner = gr.BrowserState("", storage_key="inside-gpt2-prompt-map-owner") | |
| pm_seen = gr.State(-1) | |
| pm_timer = gr.Timer(4) | |
| pm_view = [pm_layout, pm_labels, pm_lines, pm_owner] | |
| pm_out = [pm_plot, pm_md, pm_seen] | |
| for trig in (pm_add.click, pm_text.submit): | |
| trig(add_prompt, [pm_text] + pm_view, pm_out + [pm_text]) | |
| pm_ex.load_input_event.then(add_prompt, [pm_text] + pm_view, pm_out + [pm_text]) | |
| pm_undo.click(undo_prompt, pm_view, pm_out) | |
| for c in (pm_layout, pm_labels, pm_lines): | |
| c.input(render_prompt_map, pm_view, pm_out) | |
| pm_timer.tick(refresh_if_changed, pm_view + [pm_seen], pm_out) | |
| pm_live.input(lambda v: gr.Timer(active=v), pm_live, pm_timer) | |
| pm_rm_last.click(lambda k, *v: admin_action(k, "last", *v), [pm_key] + pm_view, [pm_admin_msg] + pm_out) | |
| pm_clear.click(lambda k, *v: admin_action(k, "clear", *v), [pm_key] + pm_view, [pm_admin_msg] + pm_out) | |
| # ---- Tokens & vectors | |
| with gr.Tab("Tokens → vectors"): | |
| gr.Markdown( | |
| "**Step 1: text becomes tokens.** GPT-2 does not see letters or words. A tokenizer " | |
| "chops the text into pieces from a fixed vocabulary, and every piece has an id. \n" | |
| "**Step 2: tokens become vectors.** Each id picks a row from a big table of learned " | |
| "numbers (the *embedding*). A second table adds information about the *position* in " | |
| "the text. Their sum is what the first layer receives. \n" | |
| "*Try:* the same word with different capitalisation, a word with or without a space " | |
| "in front (`␣` marks a space), a German word, a long rare word, an emoji." | |
| ) | |
| t_text = gr.Textbox(value=DEFAULT_TOKENS_TEXT, label="Text", lines=2) | |
| t_chips = gr.HTML() | |
| t_stats = gr.Markdown() | |
| t_kind = gr.Radio(VECTOR_KINDS, value=VECTOR_KINDS[0], label="Which vectors to show") | |
| t_plot = gr.Plot(label="Embedding vectors") | |
| gr.Markdown( | |
| "Blue = positive, red = negative. No single number means anything on its own: " | |
| "meaning lives in the *pattern* across all 768 dimensions. Notice that repeated " | |
| "tokens get identical rows in the token embedding, and that the position embeddings " | |
| "change smoothly from one position to the next (except position 0, which looks quite " | |
| "different from all the others)." | |
| ) | |
| t_text.change(tokens_view, [t_text, t_kind], [t_chips, t_stats, t_plot]) | |
| t_kind.change(tokens_view, [t_text, t_kind], [t_chips, t_stats, t_plot]) | |
| # ---- Neighbours & arithmetic | |
| with gr.Tab("Neighbours & arithmetic"): | |
| gr.Markdown( | |
| "Every token is a point in a 768-dimensional space. Tokens that are used in similar " | |
| "ways end up pointing in similar **directions**. We measure this with *cosine " | |
| "similarity*: 1 = same direction, 0 = unrelated (at right angles). \n" | |
| "Nobody programmed these relationships. They are a side effect of training GPT-2 to " | |
| "predict the next token." | |
| ) | |
| with gr.Row(): | |
| with gr.Column(): | |
| gr.Markdown("### Nearest neighbours") | |
| n_word = gr.Textbox(value="king", label="Word") | |
| n_k = gr.Slider(5, 25, value=12, step=1, label="How many neighbours") | |
| n_btn = gr.Button("Find neighbours", variant="primary") | |
| n_plot = gr.Plot() | |
| n_md = gr.Markdown() | |
| with gr.Column(): | |
| gr.Markdown("### Vector arithmetic") | |
| a_expr = gr.Textbox(value="king - man + woman", label="Expression (use + and -)") | |
| a_k = gr.Slider(5, 25, value=12, step=1, label="How many results") | |
| a_ex = gr.Checkbox(value=True, label="Exclude the input words from the results") | |
| a_btn = gr.Button("Calculate", variant="primary") | |
| a_plot = gr.Plot() | |
| a_md = gr.Markdown() | |
| with gr.Row(): | |
| lead = gr.Checkbox(value=True, label="Treat words as mid-sentence (adds a leading space: " | |
| "“king” → “␣king”, which is a different token)") | |
| hide_var = gr.Checkbox(value=True, label="Hide spelling variants " | |
| "(“␣King”, “King”, “king” count as one)") | |
| n_inputs = [n_word, n_k, lead, hide_var] | |
| a_inputs = [a_expr, a_k, lead, a_ex, hide_var] | |
| for trig in (n_btn.click, n_word.submit): | |
| trig(neighbours_view, n_inputs, [n_plot, n_md]) | |
| for trig in (a_btn.click, a_expr.submit): | |
| trig(arithmetic_view, a_inputs, [a_plot, a_md]) | |
| for c in (lead, hide_var): | |
| c.change(neighbours_view, n_inputs, [n_plot, n_md]) | |
| c.change(arithmetic_view, a_inputs, [a_plot, a_md]) | |
| a_ex.change(arithmetic_view, a_inputs, [a_plot, a_md]) | |
| with gr.Row(): | |
| n_ex = gr.Examples([["king"], ["cat"], ["Paris"], ["happy"], ["blue"], ["design"]], | |
| [n_word], label="Neighbour examples") | |
| a_ex_list = gr.Examples([["king - man + woman"], ["Paris - France + Germany"], | |
| ["walking - walk + swim"], ["bigger - big + small"], | |
| ["Berlin - Germany + Japan"]], | |
| [a_expr], label="Arithmetic examples") | |
| n_ex.load_input_event.then(neighbours_view, n_inputs, [n_plot, n_md]) | |
| a_ex_list.load_input_event.then(arithmetic_view, a_inputs, [a_plot, a_md]) | |
| # ---- Compare your own lists | |
| with gr.Tab("Compare your own lists"): | |
| gr.Markdown( | |
| "Enter words or sentences, **one per line**. Start a group with a line like " | |
| "`# animals` (or separate groups with an empty line); groups get colours. \n" | |
| "The vectors have hundreds of dimensions, so to draw them we flatten them to 2D " | |
| "with PCA, which keeps the two directions along which your items differ the most. \n" | |
| "*Compare the three sources:* GPT-2 was trained to predict text, not to compare " | |
| "sentences. A dedicated **sentence-embedding model** like MiniLM is trained exactly " | |
| "for that, which is why it is what search and RAG systems use." | |
| ) | |
| with gr.Row(): | |
| with gr.Column(scale=2): | |
| m_text = gr.Textbox(value=DEFAULT_MAP_TEXT, lines=14, label="Items") | |
| with gr.Column(scale=3): | |
| m_src = gr.Radio(SOURCES, value=SOURCES[0], label="Where do the vectors come from?") | |
| m_layer = gr.Slider(0, N_LAYERS, value=6, step=1, label="GPT-2 layer", visible=False) | |
| m_3d = gr.Checkbox(value=False, label="3D map (drag to rotate, keeps more information)") | |
| m_btn = gr.Button("Draw map", variant="primary") | |
| m_md = gr.Markdown() | |
| m_ex = gr.Examples( | |
| [[DEFAULT_MAP_TEXT, SOURCES[0]], [SMART_HOME_MAP_TEXT, SOURCES[0]], | |
| [SMART_HOME_MAP_TEXT, SOURCES[2]]], | |
| [m_text, m_src], label="Examples", | |
| example_labels=["Words in 4 groups (GPT-2 static)", | |
| "Smart home requests (GPT-2 static)", | |
| "Smart home requests (MiniLM)"]) | |
| with gr.Row(): | |
| m_scatter = gr.Plot() | |
| m_heat = gr.Plot() | |
| m_inputs = [m_text, m_src, m_layer, m_3d] | |
| m_outputs = [m_scatter, m_heat, m_md] | |
| m_btn.click(semantic_map_view, m_inputs, m_outputs) | |
| m_src.change(lambda s: gr.update(visible=s == SOURCES[1]), m_src, m_layer) | |
| m_src.input(semantic_map_view, m_inputs, m_outputs) | |
| m_layer.release(semantic_map_view, m_inputs, m_outputs) | |
| m_3d.input(semantic_map_view, m_inputs, m_outputs) | |
| m_ex.load_input_event.then(semantic_map_view, m_inputs, m_outputs) | |
| # ---- Contextual embeddings | |
| with gr.Tab("Same word, different meaning"): | |
| gr.Markdown( | |
| "At the input (layer 0) a word gets practically the **same** vector in every " | |
| "sentence: the same token embedding, plus a small signal for its position. Then every " | |
| "layer mixes in information from the other tokens. Watch the vectors for the same " | |
| "word drift apart when the meaning differs. \n" | |
| "**Important:** GPT-2 reads left to right. A token can only be influenced by the words " | |
| "*before* it, so put the clue (“river”, “money”) before the target word." | |
| ) | |
| with gr.Row(): | |
| with gr.Column(scale=2): | |
| c_word = gr.Textbox(value=DEFAULT_CTX_WORD, label="Target word") | |
| c_text = gr.Textbox(value=DEFAULT_CTX_TEXT, lines=10, | |
| label="Sentences (one per line, # starts a group)") | |
| c_norm = gr.Checkbox(value=True, label="Normalise dimensions (recommended)") | |
| c_btn = gr.Button("Compare", variant="primary") | |
| c_ex = gr.Examples([[DEFAULT_CTX_WORD, DEFAULT_CTX_TEXT], | |
| [CTX_EXAMPLE_2_WORD, CTX_EXAMPLE_2_TEXT]], | |
| [c_word, c_text], label="Examples", | |
| example_labels=["bank: river vs. money", | |
| "light: lamp vs. not heavy"]) | |
| with gr.Column(scale=3): | |
| c_line = gr.Plot() | |
| c_layer = gr.Slider(0, N_LAYERS, value=8, step=1, | |
| label="Layer for the two views below") | |
| with gr.Row(): | |
| c_heat = gr.Plot() | |
| c_scatter = gr.Plot() | |
| c_md = gr.Markdown() | |
| gr.Markdown( | |
| "*About “normalise dimensions”:* GPT-2's internal vectors share a big common offset " | |
| "and a handful of huge “outlier” dimensions. Without correction, every pair looks " | |
| "~0.9 similar. We subtract the typical value of each dimension and divide by its " | |
| "typical spread (measured on a neutral reference text). Untick it to see the raw " | |
| "numbers. Layer 12 includes GPT-2's final normalisation step." | |
| ) | |
| c_inputs = [c_word, c_text, c_norm, c_layer] | |
| c_outputs = [c_line, c_heat, c_scatter, c_md] | |
| c_btn.click(contextual_view, c_inputs, c_outputs) | |
| c_word.submit(contextual_view, c_inputs, c_outputs) | |
| c_norm.change(contextual_view, c_inputs, c_outputs) | |
| c_layer.release(contextual_view, c_inputs, c_outputs) | |
| c_ex.load_input_event.then(contextual_view, c_inputs, c_outputs) | |
| gr.Markdown( | |
| "<small>Models: [GPT-2 small](https://huggingface.co/openai-community/gpt2) (OpenAI, 2019) and " | |
| "[paraphrase-multilingual-MiniLM-L12-v2](https://huggingface.co/sentence-transformers/" | |
| "paraphrase-multilingual-MiniLM-L12-v2). Everything runs on CPU. Prompts on the shared map are kept " | |
| "in memory only and disappear when the app restarts.</small>" | |
| ) | |
| # Fill every view once on page load | |
| demo.load(ensure_owner, pm_owner, pm_owner).then(render_prompt_map, pm_view, pm_out) | |
| demo.load(tokens_view, [t_text, t_kind], [t_chips, t_stats, t_plot]) | |
| demo.load(neighbours_view, n_inputs, [n_plot, n_md]) | |
| demo.load(arithmetic_view, a_inputs, [a_plot, a_md]) | |
| demo.load(semantic_map_view, m_inputs, m_outputs) | |
| demo.load(contextual_view, c_inputs, c_outputs) | |
| if __name__ == "__main__": | |
| demo.launch(theme=THEME, css=CSS, head=FORCE_LIGHT_HEAD) | |