Spaces:
Running
Running
Create app.py
Browse files
app.py
ADDED
|
@@ -0,0 +1,432 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import csv
|
| 2 |
+
import io
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
|
| 5 |
+
import numpy as np
|
| 6 |
+
import plotly.graph_objects as go
|
| 7 |
+
import streamlit as st
|
| 8 |
+
from plotly.colors import qualitative
|
| 9 |
+
|
| 10 |
+
from embeddings import Model, embedding_name, load_tokenizer, nearest_tokens, search_embeddings
|
| 11 |
+
from relationships import relationship_figure
|
| 12 |
+
from relationship_ui import render_relationship_controls
|
| 13 |
+
from model_picker import show_model_picker
|
| 14 |
+
from hub_ui import render_hub_loader
|
| 15 |
+
from upload_ui import render_upload_loader
|
| 16 |
+
from app_environment import hosted_mode, remove_session_directory, UPLOAD_ROOT, PRIVATE_CACHE_ROOT
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
st.set_page_config(page_title='Token Visualiser', page_icon='🔬', layout='wide', initial_sidebar_state='collapsed')
|
| 20 |
+
hosted = hosted_mode()
|
| 21 |
+
if st.session_state.pop('clear_hub_token', False):
|
| 22 |
+
st.session_state.pop('hf_token', None)
|
| 23 |
+
if 'pending_chart_view' in st.session_state:
|
| 24 |
+
st.session_state.chart_view = st.session_state.pop('pending_chart_view')
|
| 25 |
+
if 'pending_model' in st.session_state:
|
| 26 |
+
pending_model = st.session_state.pop('pending_model')
|
| 27 |
+
if not hosted:
|
| 28 |
+
st.session_state.model_location = pending_model
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def clear_model():
|
| 32 |
+
# Keep the last browsing location, but release model data and all analysis state.
|
| 33 |
+
model = st.session_state.get('model')
|
| 34 |
+
if model is not None:
|
| 35 |
+
model.close()
|
| 36 |
+
for key, root in [('upload_directory', UPLOAD_ROOT), ('private_hub_cache', PRIVATE_CACHE_ROOT)]:
|
| 37 |
+
if key in st.session_state:
|
| 38 |
+
try:
|
| 39 |
+
remove_session_directory(st.session_state[key], root)
|
| 40 |
+
except OSError:
|
| 41 |
+
pass # Files still held by a disconnected worker disappear on restart.
|
| 42 |
+
for key in list(st.session_state):
|
| 43 |
+
if key not in {'picker_directory', 'picker_address'}:
|
| 44 |
+
del st.session_state[key]
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def activate_model(location, tokenizer_location=None, display_name=None):
|
| 48 |
+
model = Model(location)
|
| 49 |
+
try:
|
| 50 |
+
p = model.path
|
| 51 |
+
auto = (p if p.is_dir() else p.parent) / 'tokenizer.json'
|
| 52 |
+
selected_tokenizer = tokenizer_location or (auto if auto.exists() else None)
|
| 53 |
+
tok = load_tokenizer(selected_tokenizer) if selected_tokenizer else None
|
| 54 |
+
except Exception:
|
| 55 |
+
model.close()
|
| 56 |
+
raise
|
| 57 |
+
st.session_state.model = model
|
| 58 |
+
st.session_state.tok = tok
|
| 59 |
+
st.session_state.loaded = str(model.path)
|
| 60 |
+
st.session_state.model_display_name = display_name or model.path.name
|
| 61 |
+
st.session_state.nearest_results = {}
|
| 62 |
+
st.session_state.clear_hub_token = True
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
title_col, action_col = st.columns([6, 1], vertical_alignment='center')
|
| 66 |
+
if 'model' in st.session_state:
|
| 67 |
+
title_col.subheader('Token Visualiser')
|
| 68 |
+
else:
|
| 69 |
+
title_col.title('Token Visualiser')
|
| 70 |
+
if 'model' in st.session_state:
|
| 71 |
+
action_col.button('Clear model', key='clear_model', on_click=clear_model, use_container_width=True)
|
| 72 |
+
if 'model' not in st.session_state:
|
| 73 |
+
st.caption('Explore the numbers inside a token. Models run in this Space · No GPU required' if hosted else 'Explore the numbers inside a token. Local model files · No GPU required')
|
| 74 |
+
|
| 75 |
+
if 'model' not in st.session_state:
|
| 76 |
+
_, loader, _ = st.columns([1, 3, 1])
|
| 77 |
+
with loader, st.container(border=True):
|
| 78 |
+
st.header('Load a model')
|
| 79 |
+
st.write('Open a local model or download weights from Hugging Face to explore token embeddings.')
|
| 80 |
+
if hosted:
|
| 81 |
+
hub_tab, local_tab = st.tabs(['Hugging Face', 'Upload files'])
|
| 82 |
+
else:
|
| 83 |
+
local_tab, hub_tab = st.tabs(['Local files', 'Hugging Face'])
|
| 84 |
+
with local_tab:
|
| 85 |
+
if hosted:
|
| 86 |
+
render_upload_loader(activate_model)
|
| 87 |
+
if not hosted:
|
| 88 |
+
with local_tab:
|
| 89 |
+
if st.button('Open model…', key='open_model', use_container_width=True):
|
| 90 |
+
st.session_state.picker_open = True
|
| 91 |
+
if st.session_state.get('picker_open', False):
|
| 92 |
+
show_model_picker()
|
| 93 |
+
location = st.text_input('Model file or folder', placeholder=r'C:\Models\gemma.gguf', key='model_location')
|
| 94 |
+
with st.expander('Tokenizer (optional)'):
|
| 95 |
+
tokenizer_path = st.text_input('Tokenizer JSON or folder', key='tokenizer_location',
|
| 96 |
+
help='Use the tokenizer from the exact same model. Enables text tokenization without added special tokens. Detected automatically beside the model when available.')
|
| 97 |
+
load = st.button('Load model', key='load_model', type='primary', disabled=not location.strip(), use_container_width=True)
|
| 98 |
+
st.caption('GGUF · F32/F16/BF16 safetensors · Sharded model folders')
|
| 99 |
+
st.caption('Files stay on this computer.')
|
| 100 |
+
if load:
|
| 101 |
+
try:
|
| 102 |
+
with st.spinner('Reading model metadata…'):
|
| 103 |
+
activate_model(location.strip().strip('"'), tokenizer_path)
|
| 104 |
+
st.rerun()
|
| 105 |
+
except Exception as e:
|
| 106 |
+
st.error(f'Could not load model: {e}')
|
| 107 |
+
with hub_tab:
|
| 108 |
+
render_hub_loader(activate_model)
|
| 109 |
+
st.stop()
|
| 110 |
+
|
| 111 |
+
model = st.session_state.model
|
| 112 |
+
st.markdown('<style>' + Path(__file__).with_name('viewer.css').read_text(encoding='utf-8') + '</style>', unsafe_allow_html=True)
|
| 113 |
+
chart_pane = st.container(key='chart_pane', height='stretch', border=True)
|
| 114 |
+
chart_slot = chart_pane.empty()
|
| 115 |
+
with st.container(key='controls_pane'):
|
| 116 |
+
with st.container(key='control_columns'):
|
| 117 |
+
token_panel, neighbour_panel, calculation_panel, details_panel = st.columns([1.15, 1.6, 1.15, 1.05], gap='medium')
|
| 118 |
+
|
| 119 |
+
with details_panel:
|
| 120 |
+
st.subheader('Chart & model')
|
| 121 |
+
st.caption(st.session_state.get('model_display_name', Path(st.session_state.loaded).name))
|
| 122 |
+
names = sorted(model.tensors, key=lambda n: (not embedding_name(n), n))
|
| 123 |
+
name = st.selectbox('Embedding tensor', names)
|
| 124 |
+
if not embedding_name(name):
|
| 125 |
+
st.warning('This tensor may not contain input token embeddings.')
|
| 126 |
+
chart_view = st.selectbox('Chart view', ['Dimensions', 'Relationship 2D', 'Relationship 3D'], key='chart_view')
|
| 127 |
+
style = st.radio('Chart style', ['Line', 'Scatter'], horizontal=True)
|
| 128 |
+
shape = model.tensors[name][2]
|
| 129 |
+
vocab = model.vocab
|
| 130 |
+
tok = st.session_state.tok
|
| 131 |
+
get_row = lambda i: model.row(name, i)
|
| 132 |
+
read_rows = lambda start, stop: model.rows(name, start, stop)
|
| 133 |
+
with details_panel:
|
| 134 |
+
a, b = st.columns(2)
|
| 135 |
+
a.metric('Vocabulary rows', f'{shape[0]:,}')
|
| 136 |
+
b.metric('Values per token', f'{shape[1]:,}')
|
| 137 |
+
st.caption('Stored input embeddings, before layer processing. Drag the chart to zoom; double-click to reset. Click a legend entry to hide or show a token.')
|
| 138 |
+
|
| 139 |
+
with token_panel:
|
| 140 |
+
ids = []
|
| 141 |
+
labels = {}
|
| 142 |
+
comparison_scope = f'{st.session_state.loaded}:{name}'
|
| 143 |
+
calculation_key = f'calculations:{comparison_scope}'
|
| 144 |
+
st.subheader('Compare tokens')
|
| 145 |
+
st.caption('Search, select and add tokens. Each curve has its own colour.')
|
| 146 |
+
if tok is not None:
|
| 147 |
+
text = st.text_input('Type text', 'hello world')
|
| 148 |
+
try:
|
| 149 |
+
encoded = tok.encode(text, add_special_tokens=False)
|
| 150 |
+
st.caption('Tokenized pieces: ' + ' · '.join(f'{piece!r} (ID {i})' for piece, i in zip(encoded.tokens, encoded.ids)))
|
| 151 |
+
choices = list(range(len(encoded.ids)))
|
| 152 |
+
positions = st.multiselect('Select tokens to plot', choices, default=choices[:2],
|
| 153 |
+
format_func=lambda p: f'{p + 1}: {encoded.tokens[p]!r} · ID {encoded.ids[p]}')
|
| 154 |
+
for p in positions:
|
| 155 |
+
i = encoded.ids[p]
|
| 156 |
+
if i not in ids:
|
| 157 |
+
ids.append(i)
|
| 158 |
+
labels[i] = f'{encoded.tokens[p]} · {i}'
|
| 159 |
+
except Exception as e:
|
| 160 |
+
st.error(f'Tokenization failed: {e}')
|
| 161 |
+
elif vocab is not None:
|
| 162 |
+
query = st.text_input('Find a vocabulary token', 'hello', help='Searches stored token spellings, not text tokenization. Spaces may appear as ▁ or Ġ.')
|
| 163 |
+
matches = [i for i, word in enumerate(vocab) if query in word][:200] if query else list(range(min(200, len(vocab))))
|
| 164 |
+
matches.sort(key=lambda i: (vocab[i] != query, len(vocab[i]), i))
|
| 165 |
+
# The chart selection lives independently of changing search-result widgets.
|
| 166 |
+
selection_key = f'chart_tokens:{comparison_scope}'
|
| 167 |
+
if selection_key not in st.session_state:
|
| 168 |
+
st.session_state[selection_key] = matches[:1]
|
| 169 |
+
selected = [i for i in st.session_state[selection_key] if 0 <= i < min(shape[0], len(vocab))]
|
| 170 |
+
result, add = st.columns([2.3, 1.7], vertical_alignment='bottom')
|
| 171 |
+
candidate = result.selectbox('Matching token', matches, key=f'match:{comparison_scope}:{query}',
|
| 172 |
+
format_func=lambda i: f'{vocab[i]!r} · ID {i}')
|
| 173 |
+
if add.button('Add to chart', type='primary', disabled=candidate is None or candidate in selected or len(selected) + len(st.session_state.get(calculation_key, [])) >= 20):
|
| 174 |
+
selected.append(candidate)
|
| 175 |
+
st.session_state[selection_key] = selected
|
| 176 |
+
ids = st.multiselect('Tokens on chart', selected, default=selected,
|
| 177 |
+
key=f'chart_selection:{comparison_scope}:{tuple(selected)}',
|
| 178 |
+
format_func=lambda i: f'{vocab[i]!r} · ID {i}')
|
| 179 |
+
if ids != selected:
|
| 180 |
+
st.session_state[selection_key] = ids
|
| 181 |
+
st.rerun()
|
| 182 |
+
st.caption('Search for another token and click Add to chart to compare it. Remove a token with the × beside its name.')
|
| 183 |
+
labels = {i: f'{vocab[i]} · {i}' for i in ids}
|
| 184 |
+
if not matches:
|
| 185 |
+
st.info('No matching vocabulary entries. Try a substring or enter an ID below.')
|
| 186 |
+
else:
|
| 187 |
+
st.info('Add the matching tokenizer.json to type text. You can inspect any token by ID below.')
|
| 188 |
+
|
| 189 |
+
ids = list(ids)
|
| 190 |
+
manual = st.text_input('Additional token IDs (comma-separated)', placeholder='0, 42, 100', key='manual_ids')
|
| 191 |
+
try:
|
| 192 |
+
for part in manual.split(','):
|
| 193 |
+
if part.strip():
|
| 194 |
+
i = int(part.strip())
|
| 195 |
+
if not 0 <= i < shape[0]:
|
| 196 |
+
raise ValueError(f'ID {i} is outside this tensor’s row range.')
|
| 197 |
+
if i not in ids:
|
| 198 |
+
ids.append(i)
|
| 199 |
+
labels.setdefault(i, f'ID {i}')
|
| 200 |
+
except ValueError as e:
|
| 201 |
+
st.error(str(e))
|
| 202 |
+
st.stop()
|
| 203 |
+
def token_label(i):
|
| 204 |
+
if vocab is not None and i < len(vocab):
|
| 205 |
+
return f'{vocab[i]!r} · ID {i}'
|
| 206 |
+
if tok is not None:
|
| 207 |
+
piece = tok.id_to_token(i)
|
| 208 |
+
if piece is not None:
|
| 209 |
+
return f'{piece!r} · ID {i}'
|
| 210 |
+
return f'ID {i}'
|
| 211 |
+
|
| 212 |
+
|
| 213 |
+
extra_key = f'added_neighbours:{comparison_scope}'
|
| 214 |
+
extra_ids = [i for i in st.session_state.get(extra_key, []) if i not in ids and 0 <= i < shape[0]]
|
| 215 |
+
if extra_ids:
|
| 216 |
+
kept = st.multiselect('Added neighbours on chart', extra_ids, default=extra_ids,
|
| 217 |
+
format_func=token_label, key=f'added_neighbour_selection:{comparison_scope}:{tuple(extra_ids)}')
|
| 218 |
+
if kept != extra_ids:
|
| 219 |
+
st.session_state[extra_key] = kept
|
| 220 |
+
st.rerun()
|
| 221 |
+
for i in kept:
|
| 222 |
+
ids.append(i)
|
| 223 |
+
labels[i] = token_label(i)
|
| 224 |
+
if not ids and not st.session_state.get(calculation_key):
|
| 225 |
+
chart_slot.info('Select a token in the controls below to see its embedding values.')
|
| 226 |
+
st.stop()
|
| 227 |
+
if len(ids) > 20:
|
| 228 |
+
st.warning('Select at most 20 tokens for a readable comparison.')
|
| 229 |
+
st.stop()
|
| 230 |
+
|
| 231 |
+
|
| 232 |
+
def add_neighbour(i):
|
| 233 |
+
added = list(st.session_state.get(extra_key, []))
|
| 234 |
+
if i not in added:
|
| 235 |
+
added.append(i)
|
| 236 |
+
st.session_state[extra_key] = added
|
| 237 |
+
|
| 238 |
+
|
| 239 |
+
try:
|
| 240 |
+
vectors = {i: get_row(i) for i in ids}
|
| 241 |
+
except Exception as e:
|
| 242 |
+
chart_slot.error(f'Could not read embedding: {e}')
|
| 243 |
+
st.stop()
|
| 244 |
+
|
| 245 |
+
calculations = st.session_state.setdefault(calculation_key, [])
|
| 246 |
+
for calculation in calculations:
|
| 247 |
+
vectors[calculation['id']] = calculation['values']
|
| 248 |
+
labels[calculation['id']] = calculation['label']
|
| 249 |
+
|
| 250 |
+
|
| 251 |
+
def remove_calculation(calculation_id):
|
| 252 |
+
st.session_state[calculation_key] = [c for c in st.session_state[calculation_key] if c['id'] != calculation_id]
|
| 253 |
+
|
| 254 |
+
|
| 255 |
+
def operand_label(key):
|
| 256 |
+
return labels[key] if isinstance(key, str) else token_label(key)
|
| 257 |
+
|
| 258 |
+
|
| 259 |
+
def expression_label(key):
|
| 260 |
+
if isinstance(key, str):
|
| 261 |
+
return '(' + labels[key].removeprefix('calc: ') + ')'
|
| 262 |
+
if vocab is not None and key < len(vocab):
|
| 263 |
+
return repr(vocab[key])
|
| 264 |
+
if tok is not None and tok.id_to_token(key) is not None:
|
| 265 |
+
return repr(tok.id_to_token(key))
|
| 266 |
+
return f'ID {key}'
|
| 267 |
+
|
| 268 |
+
|
| 269 |
+
def save_calculation(values, label):
|
| 270 |
+
label = label if label.startswith('calc: ') else 'calc: ' + label
|
| 271 |
+
serial = st.session_state.get('calculation_serial', 0) + 1
|
| 272 |
+
while label in labels.values():
|
| 273 |
+
label += f' [{serial}]'
|
| 274 |
+
st.session_state.calculation_serial = serial
|
| 275 |
+
calculations.append({'id': f'calc:{serial}', 'label': label, 'values': np.asarray(values).copy()})
|
| 276 |
+
st.rerun()
|
| 277 |
+
|
| 278 |
+
|
| 279 |
+
with calculation_panel:
|
| 280 |
+
st.subheader('Vector tools')
|
| 281 |
+
arithmetic_tab, relationship_tab = st.tabs(['Calculations', 'Relationships'])
|
| 282 |
+
with arithmetic_tab:
|
| 283 |
+
st.caption('Calculate A − B or A + B, dimension by dimension. Results become derived curves labelled calc:.')
|
| 284 |
+
operands = list(vectors)
|
| 285 |
+
left_operand = st.selectbox('Vector A', operands, format_func=operand_label, key=f'calc_a:{comparison_scope}')
|
| 286 |
+
operation = st.selectbox('Operation', ['Subtract (A − B)', 'Add (A + B)'], key=f'calc_operation:{comparison_scope}')
|
| 287 |
+
right_operand = st.selectbox('Vector B', operands, index=min(1, len(operands) - 1),
|
| 288 |
+
format_func=operand_label, key=f'calc_b:{comparison_scope}')
|
| 289 |
+
result_name = st.text_input('Result name (optional)', placeholder='e.g. hello minus world', key=f'calc_name:{comparison_scope}')
|
| 290 |
+
if st.button('Calculate & add to chart', key='add_calculation', type='primary',
|
| 291 |
+
disabled=len(vectors) >= 20, use_container_width=True):
|
| 292 |
+
subtract = operation.startswith('Subtract')
|
| 293 |
+
values = vectors[left_operand] - vectors[right_operand] if subtract else vectors[left_operand] + vectors[right_operand]
|
| 294 |
+
symbol = '−' if subtract else '+'
|
| 295 |
+
expression = f'{expression_label(left_operand)} {symbol} {expression_label(right_operand)}'
|
| 296 |
+
label = 'calc: ' + (result_name.strip() or expression)
|
| 297 |
+
save_calculation(values, label)
|
| 298 |
+
if calculations:
|
| 299 |
+
st.caption('Calculated curves stay available when you change the selected tokens. They are included in CSV exports.')
|
| 300 |
+
for calculation in calculations:
|
| 301 |
+
label_col, remove_col = st.columns([3, 1.2], vertical_alignment='center')
|
| 302 |
+
label_col.text(calculation['label'])
|
| 303 |
+
remove_col.button('Remove', key=f'remove_calculation:{calculation["id"]}',
|
| 304 |
+
on_click=remove_calculation, args=(calculation['id'],))
|
| 305 |
+
if len(vectors) >= 20:
|
| 306 |
+
st.caption('The chart supports up to 20 token and calculated curves in total.')
|
| 307 |
+
|
| 308 |
+
with relationship_tab:
|
| 309 |
+
analysis = render_relationship_controls(vectors, operand_label, comparison_scope, save_calculation)
|
| 310 |
+
|
| 311 |
+
if len(vectors) > 20:
|
| 312 |
+
token_panel.warning('Remove a token or calculated curve to keep the chart within its 20-curve limit.')
|
| 313 |
+
st.stop()
|
| 314 |
+
chart_ids = list(vectors)
|
| 315 |
+
colour_key = f'token_colours:{comparison_scope}'
|
| 316 |
+
colours = st.session_state.setdefault(colour_key, {})
|
| 317 |
+
# Retain colours across searches, keeping every currently selected token distinct.
|
| 318 |
+
used = set()
|
| 319 |
+
for i in chart_ids:
|
| 320 |
+
if i not in colours or colours[i] in used:
|
| 321 |
+
colours[i] = next(c for c in qualitative.Light24 if c not in used and
|
| 322 |
+
c not in {colours.get(other) for other in chart_ids if other != i})
|
| 323 |
+
used.add(colours[i])
|
| 324 |
+
details_panel.caption(f'Tokens: {len(ids)} · Calculations: {len(calculations)}')
|
| 325 |
+
if chart_view == 'Dimensions':
|
| 326 |
+
fig = go.Figure()
|
| 327 |
+
for i, values in vectors.items():
|
| 328 |
+
fig.add_trace(go.Scattergl(x=np.arange(len(values)), y=values,
|
| 329 |
+
mode='lines' if style == 'Line' else 'markers', name=labels[i],
|
| 330 |
+
line=dict(color=colours[i], width=2 if isinstance(i, str) else 1.5,
|
| 331 |
+
dash='dash' if isinstance(i, str) else 'solid'),
|
| 332 |
+
marker=dict(color=colours[i], size=4),
|
| 333 |
+
hovertemplate='Dimension %{x}<br>Value %{y:.7f}<extra>%{fullData.name}</extra>'))
|
| 334 |
+
fig.update_layout(autosize=True, xaxis_title='Embedding dimension (zero-based)', yaxis_title='Stored embedding value',
|
| 335 |
+
template='plotly_dark', hovermode='x', margin=dict(l=20, r=20, t=20, b=30),
|
| 336 |
+
legend=dict(orientation='h', y=1.02, yanchor='bottom', title_text='Tokens'), showlegend=True,
|
| 337 |
+
uirevision=f'{comparison_scope}:{style}')
|
| 338 |
+
fig.add_hline(y=0, line_width=1, line_color='gray')
|
| 339 |
+
chart_slot.plotly_chart(fig, width='stretch', height='stretch', config={'scrollZoom': True}, key='embedding_chart')
|
| 340 |
+
|
| 341 |
+
elif analysis is None:
|
| 342 |
+
chart_slot.info('Open Relationships in Vector tools, choose your inputs, then click Show relationship.')
|
| 343 |
+
else:
|
| 344 |
+
fig = relationship_figure(analysis, 2 if chart_view == 'Relationship 2D' else 3, vectors, labels)
|
| 345 |
+
chart_slot.plotly_chart(fig, width='stretch', height='stretch', key='embedding_chart', config={'scrollZoom': True})
|
| 346 |
+
|
| 347 |
+
with neighbour_panel:
|
| 348 |
+
st.subheader('Closest tokens')
|
| 349 |
+
search_mode = st.selectbox('Search mode', ['Nearest tokens', 'Analogy answers'], key=f'neighbour_mode:{comparison_scope}')
|
| 350 |
+
results = st.session_state.setdefault('nearest_results', {})
|
| 351 |
+
eligible = True
|
| 352 |
+
report_id = None
|
| 353 |
+
if search_mode == 'Nearest tokens':
|
| 354 |
+
source_id = st.selectbox('Find neighbours of', chart_ids, format_func=operand_label,
|
| 355 |
+
key=f'neighbour_source:{comparison_scope}')
|
| 356 |
+
query_vector = vectors[source_id]
|
| 357 |
+
excluded = [source_id] if isinstance(source_id, int) else []
|
| 358 |
+
result_key = ('ranked', comparison_scope, source_id)
|
| 359 |
+
row_key = str(source_id)
|
| 360 |
+
search_label = 'Find closest tokens'
|
| 361 |
+
st.caption('Cosine similarity uses the full vector, including calculated queries.')
|
| 362 |
+
elif analysis is None:
|
| 363 |
+
st.info('Show a relationship in Vector tools before searching for analogy answers.')
|
| 364 |
+
eligible = False
|
| 365 |
+
else:
|
| 366 |
+
query_vector = analysis['metrics']['prediction']
|
| 367 |
+
excluded = analysis['exclude_ids']
|
| 368 |
+
expected = analysis['expected']
|
| 369 |
+
report_id = expected['key'] if expected and isinstance(expected['key'], int) else None
|
| 370 |
+
result_key = ('analogy', comparison_scope, analysis['id'])
|
| 371 |
+
row_key = f'analogy:{analysis["id"]}'
|
| 372 |
+
search_label = 'Refresh answers'
|
| 373 |
+
st.caption('Search near C + mean(B − A), excluding the input tokens. Results refer to the last shown relationship.')
|
| 374 |
+
if eligible:
|
| 375 |
+
count_col, button_col = st.columns([1, 2], vertical_alignment='bottom')
|
| 376 |
+
neighbour_count = count_col.number_input('Results', min_value=1, max_value=50, value=10, step=1)
|
| 377 |
+
search = button_col.button(search_label, type='primary', key='find_neighbours')
|
| 378 |
+
requested = st.session_state.pop(f'run_analogy:{comparison_scope}', False) if search_mode == 'Analogy answers' else False
|
| 379 |
+
previous_search = any(isinstance(key, tuple) and len(key) == 3 and key[:2] == (comparison_scope, source_id)
|
| 380 |
+
for key in results) if search_mode == 'Nearest tokens' else False
|
| 381 |
+
if search or requested or (result_key not in results and previous_search):
|
| 382 |
+
bar = st.progress(0, text='Scanning embedding vectors…')
|
| 383 |
+
try:
|
| 384 |
+
found = search_embeddings(read_rows, shape[0], query_vector, count=50, exclude_ids=excluded, report_id=report_id,
|
| 385 |
+
progress=lambda done, total: bar.progress(done / total, text=f'Scanning embeddings: {done:,} / {total:,}'))
|
| 386 |
+
results[result_key] = found if search_mode == 'Analogy answers' else found['neighbours']
|
| 387 |
+
except Exception as e:
|
| 388 |
+
st.error(f'Could not find neighbours: {e}')
|
| 389 |
+
finally:
|
| 390 |
+
bar.empty()
|
| 391 |
+
if result_key in results:
|
| 392 |
+
found = results[result_key]
|
| 393 |
+
if search_mode == 'Analogy answers':
|
| 394 |
+
neighbours = found['neighbours'][:int(neighbour_count)]
|
| 395 |
+
if report_id is not None:
|
| 396 |
+
if found['expected_rank'] is not None:
|
| 397 |
+
st.caption(f'Expected {token_label(report_id)}: rank {found["expected_rank"]:,} of {found["valid_count"]:,}, cosine {found["expected_score"]:.6f}.')
|
| 398 |
+
else:
|
| 399 |
+
st.caption('Expected D is excluded as an input token, or has a zero/nonfinite embedding.')
|
| 400 |
+
else:
|
| 401 |
+
neighbours = found[:int(neighbour_count)]
|
| 402 |
+
if neighbours:
|
| 403 |
+
st.caption(f'Showing {len(neighbours)} neighbours. Change Results to show more or fewer without searching again.')
|
| 404 |
+
with st.container(border=False):
|
| 405 |
+
header = st.columns([.35, 1.7, 1, 1.8])
|
| 406 |
+
for column, title in zip(header, ['#', 'Token', 'Cosine', '']):
|
| 407 |
+
if title:
|
| 408 |
+
column.markdown(f'**{title}**')
|
| 409 |
+
for rank, (i, score) in enumerate(neighbours, 1):
|
| 410 |
+
rank_col, token_col, score_col, action_col = st.columns([.35, 1.7, 1, 1.8], vertical_alignment='center')
|
| 411 |
+
rank_col.text(str(rank))
|
| 412 |
+
token_col.text(token_label(i))
|
| 413 |
+
score_col.write(f'{score:.6f}')
|
| 414 |
+
action_col.button('On chart' if i in ids else 'Add to chart',
|
| 415 |
+
key=f'add_neighbour:{comparison_scope}:{row_key}:{i}',
|
| 416 |
+
disabled=i in ids or len(vectors) >= 20, on_click=add_neighbour, args=(i,), use_container_width=True)
|
| 417 |
+
if len(vectors) >= 20:
|
| 418 |
+
st.caption('The chart has reached its 20-curve limit. Remove a curve before adding another.')
|
| 419 |
+
st.caption('Cosine similarity ranges from −1 to 1. Nearby tokens can reflect spelling, boundaries or usage as well as meaning.')
|
| 420 |
+
else:
|
| 421 |
+
st.info('No other eligible tokens with finite, nonzero embeddings were found.')
|
| 422 |
+
with details_panel:
|
| 423 |
+
st.dataframe([{'Token': labels[i], 'Min': float(v.min()), 'Max': float(v.max()),
|
| 424 |
+
'Mean': float(v.mean()), 'Std dev': float(v.std()), 'L2 norm': float(np.linalg.norm(v))}
|
| 425 |
+
for i, v in vectors.items()], use_container_width=True, hide_index=True)
|
| 426 |
+
output = io.StringIO()
|
| 427 |
+
writer = csv.writer(output)
|
| 428 |
+
writer.writerow(['dimension'] + [labels[i] for i in chart_ids])
|
| 429 |
+
writer.writerows([d] + [float(vectors[i][d]) for i in chart_ids] for d in range(shape[1]))
|
| 430 |
+
st.download_button('Download values as CSV', output.getvalue(), 'token_embeddings.csv', 'text/csv')
|
| 431 |
+
with st.expander('Inspect exact values'):
|
| 432 |
+
st.dataframe({'dimension': np.arange(shape[1]), **{labels[i]: vectors[i] for i in chart_ids}}, use_container_width=True)
|