Spaces:
Paused
Paused
File size: 58,332 Bytes
d2e4561 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 949 950 951 952 953 954 955 956 957 958 959 960 961 962 963 964 965 966 967 968 969 970 971 972 973 974 975 976 977 978 979 980 981 982 983 984 985 986 987 988 989 990 991 992 993 994 995 996 997 998 999 1000 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010 1011 1012 1013 1014 1015 1016 1017 1018 1019 1020 1021 1022 1023 1024 1025 1026 1027 1028 1029 1030 1031 1032 1033 1034 1035 1036 1037 1038 1039 1040 1041 1042 1043 1044 1045 1046 1047 1048 1049 1050 1051 1052 1053 1054 1055 1056 1057 1058 1059 1060 1061 1062 1063 1064 1065 1066 1067 1068 1069 1070 1071 1072 1073 1074 1075 1076 1077 1078 1079 1080 1081 1082 1083 1084 1085 1086 1087 1088 1089 1090 1091 1092 1093 1094 1095 1096 1097 1098 1099 1100 1101 1102 1103 1104 1105 1106 1107 1108 1109 1110 1111 1112 1113 1114 1115 1116 1117 1118 1119 1120 1121 1122 1123 1124 1125 1126 1127 1128 1129 1130 1131 1132 1133 1134 1135 1136 1137 1138 1139 1140 1141 1142 1143 1144 1145 1146 1147 1148 1149 1150 1151 1152 1153 1154 1155 1156 1157 1158 1159 1160 1161 1162 1163 1164 1165 1166 1167 1168 1169 1170 1171 1172 1173 1174 1175 1176 1177 1178 1179 1180 1181 1182 1183 1184 1185 1186 1187 1188 1189 1190 1191 1192 1193 1194 1195 1196 1197 1198 1199 1200 1201 1202 1203 1204 1205 1206 1207 1208 1209 1210 1211 1212 1213 1214 1215 1216 1217 1218 1219 1220 1221 1222 1223 1224 1225 1226 1227 1228 1229 1230 1231 1232 1233 1234 1235 1236 | """
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]
@torch.inference_mode()
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
# ---------------------------------------------------------------------------
@torch.inference_mode()
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",
]
@torch.inference_mode()
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)
|