Spaces:
Running
Running
File size: 23,449 Bytes
d5b23e9 a9b254f d5b23e9 | 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 | import csv
import io
from pathlib import Path
import numpy as np
import plotly.graph_objects as go
import streamlit as st
from plotly.colors import qualitative
from embeddings import Model, embedding_name, load_tokenizer, nearest_tokens, search_embeddings
from relationships import relationship_figure
from relationship_ui import render_relationship_controls
from model_picker import show_model_picker
from hub_ui import render_hub_loader
from upload_ui import render_upload_loader
from app_environment import hosted_mode, remove_session_directory, UPLOAD_ROOT, PRIVATE_CACHE_ROOT
st.set_page_config(page_title='Token Visualiser', page_icon='🔬', layout='wide', initial_sidebar_state='collapsed')
hosted = hosted_mode()
if st.session_state.pop('clear_hub_token', False):
st.session_state.pop('hf_token', None)
if 'pending_chart_view' in st.session_state:
st.session_state.chart_view = st.session_state.pop('pending_chart_view')
if 'pending_model' in st.session_state:
pending_model = st.session_state.pop('pending_model')
if not hosted:
st.session_state.model_location = pending_model
def clear_model():
# Keep the last browsing location, but release model data and all analysis state.
model = st.session_state.get('model')
if model is not None:
model.close()
for key, root in [('upload_directory', UPLOAD_ROOT), ('private_hub_cache', PRIVATE_CACHE_ROOT)]:
if key in st.session_state:
try:
remove_session_directory(st.session_state[key], root)
except OSError:
pass # Files still held by a disconnected worker disappear on restart.
for key in list(st.session_state):
if key not in {'picker_directory', 'picker_address'}:
del st.session_state[key]
def activate_model(location, tokenizer_location=None, display_name=None):
model = Model(location)
try:
p = model.path
auto = (p if p.is_dir() else p.parent) / 'tokenizer.json'
selected_tokenizer = tokenizer_location or (auto if auto.exists() else None)
tok = load_tokenizer(selected_tokenizer) if selected_tokenizer else None
except Exception:
model.close()
raise
st.session_state.model = model
st.session_state.tok = tok
st.session_state.loaded = str(model.path)
st.session_state.model_display_name = display_name or model.path.name
st.session_state.nearest_results = {}
st.session_state.clear_hub_token = True
title_col, action_col = st.columns([6, 1], vertical_alignment='center')
if 'model' in st.session_state:
title_col.subheader('Token Visualiser')
else:
title_col.title('Token Visualiser')
if 'model' in st.session_state:
action_col.button('Clear model', key='clear_model', on_click=clear_model, use_container_width=True)
if 'model' not in st.session_state:
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')
if 'model' not in st.session_state:
_, loader, _ = st.columns([1, 3, 1])
with loader, st.container(border=True):
st.header('Load a model')
st.write('Choose a suggested model, paste a Hugging Face link, or upload weights. Models load on this Space.'
if hosted else 'Open a local model or download weights from Hugging Face to explore token embeddings.')
if hosted:
hub_tab, local_tab = st.tabs(['Hugging Face', 'Upload files'])
else:
local_tab, hub_tab = st.tabs(['Local files', 'Hugging Face'])
with local_tab:
if hosted:
render_upload_loader(activate_model)
if not hosted:
with local_tab:
if st.button('Open model…', key='open_model', use_container_width=True):
st.session_state.picker_open = True
if st.session_state.get('picker_open', False):
show_model_picker()
location = st.text_input('Model file or folder', placeholder=r'C:\Models\gemma.gguf', key='model_location')
with st.expander('Tokenizer (optional)'):
tokenizer_path = st.text_input('Tokenizer JSON or folder', key='tokenizer_location',
help='Use the tokenizer from the exact same model. Enables text tokenization without added special tokens. Detected automatically beside the model when available.')
load = st.button('Load model', key='load_model', type='primary', disabled=not location.strip(), use_container_width=True)
st.caption('GGUF · F32/F16/BF16 safetensors · Sharded model folders')
st.caption('Files stay on this computer.')
if load:
try:
with st.spinner('Reading model metadata…'):
activate_model(location.strip().strip('"'), tokenizer_path)
st.rerun()
except Exception as e:
st.error(f'Could not load model: {e}')
with hub_tab:
render_hub_loader(activate_model)
st.stop()
model = st.session_state.model
st.markdown('<style>' + Path(__file__).with_name('viewer.css').read_text(encoding='utf-8') + '</style>', unsafe_allow_html=True)
chart_pane = st.container(key='chart_pane', height='stretch', border=True)
chart_slot = chart_pane.empty()
with st.container(key='controls_pane'):
with st.container(key='control_columns'):
token_panel, neighbour_panel, calculation_panel, details_panel = st.columns([1.15, 1.6, 1.15, 1.05], gap='medium')
with details_panel:
st.subheader('Chart & model')
st.caption(st.session_state.get('model_display_name', Path(st.session_state.loaded).name))
names = sorted(model.tensors, key=lambda n: (not embedding_name(n), n))
name = st.selectbox('Embedding tensor', names)
if not embedding_name(name):
st.warning('This tensor may not contain input token embeddings.')
chart_view = st.selectbox('Chart view', ['Dimensions', 'Relationship 2D', 'Relationship 3D'], key='chart_view')
style = st.radio('Chart style', ['Line', 'Scatter'], horizontal=True)
shape = model.tensors[name][2]
vocab = model.vocab
tok = st.session_state.tok
get_row = lambda i: model.row(name, i)
read_rows = lambda start, stop: model.rows(name, start, stop)
with details_panel:
a, b = st.columns(2)
a.metric('Vocabulary rows', f'{shape[0]:,}')
b.metric('Values per token', f'{shape[1]:,}')
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.')
with token_panel:
ids = []
labels = {}
comparison_scope = f'{st.session_state.loaded}:{name}'
calculation_key = f'calculations:{comparison_scope}'
st.subheader('Compare tokens')
st.caption('Search, select and add tokens. Each curve has its own colour.')
if tok is not None:
text = st.text_input('Type text', 'hello world')
try:
encoded = tok.encode(text, add_special_tokens=False)
st.caption('Tokenized pieces: ' + ' · '.join(f'{piece!r} (ID {i})' for piece, i in zip(encoded.tokens, encoded.ids)))
choices = list(range(len(encoded.ids)))
positions = st.multiselect('Select tokens to plot', choices, default=choices[:2],
format_func=lambda p: f'{p + 1}: {encoded.tokens[p]!r} · ID {encoded.ids[p]}')
for p in positions:
i = encoded.ids[p]
if i not in ids:
ids.append(i)
labels[i] = f'{encoded.tokens[p]} · {i}'
except Exception as e:
st.error(f'Tokenization failed: {e}')
elif vocab is not None:
query = st.text_input('Find a vocabulary token', 'hello', help='Searches stored token spellings, not text tokenization. Spaces may appear as ▁ or Ġ.')
matches = [i for i, word in enumerate(vocab) if query in word][:200] if query else list(range(min(200, len(vocab))))
matches.sort(key=lambda i: (vocab[i] != query, len(vocab[i]), i))
# The chart selection lives independently of changing search-result widgets.
selection_key = f'chart_tokens:{comparison_scope}'
if selection_key not in st.session_state:
st.session_state[selection_key] = matches[:1]
selected = [i for i in st.session_state[selection_key] if 0 <= i < min(shape[0], len(vocab))]
result, add = st.columns([2.3, 1.7], vertical_alignment='bottom')
candidate = result.selectbox('Matching token', matches, key=f'match:{comparison_scope}:{query}',
format_func=lambda i: f'{vocab[i]!r} · ID {i}')
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):
selected.append(candidate)
st.session_state[selection_key] = selected
ids = st.multiselect('Tokens on chart', selected, default=selected,
key=f'chart_selection:{comparison_scope}:{tuple(selected)}',
format_func=lambda i: f'{vocab[i]!r} · ID {i}')
if ids != selected:
st.session_state[selection_key] = ids
st.rerun()
st.caption('Search for another token and click Add to chart to compare it. Remove a token with the × beside its name.')
labels = {i: f'{vocab[i]} · {i}' for i in ids}
if not matches:
st.info('No matching vocabulary entries. Try a substring or enter an ID below.')
else:
st.info('Add the matching tokenizer.json to type text. You can inspect any token by ID below.')
ids = list(ids)
manual = st.text_input('Additional token IDs (comma-separated)', placeholder='0, 42, 100', key='manual_ids')
try:
for part in manual.split(','):
if part.strip():
i = int(part.strip())
if not 0 <= i < shape[0]:
raise ValueError(f'ID {i} is outside this tensor’s row range.')
if i not in ids:
ids.append(i)
labels.setdefault(i, f'ID {i}')
except ValueError as e:
st.error(str(e))
st.stop()
def token_label(i):
if vocab is not None and i < len(vocab):
return f'{vocab[i]!r} · ID {i}'
if tok is not None:
piece = tok.id_to_token(i)
if piece is not None:
return f'{piece!r} · ID {i}'
return f'ID {i}'
extra_key = f'added_neighbours:{comparison_scope}'
extra_ids = [i for i in st.session_state.get(extra_key, []) if i not in ids and 0 <= i < shape[0]]
if extra_ids:
kept = st.multiselect('Added neighbours on chart', extra_ids, default=extra_ids,
format_func=token_label, key=f'added_neighbour_selection:{comparison_scope}:{tuple(extra_ids)}')
if kept != extra_ids:
st.session_state[extra_key] = kept
st.rerun()
for i in kept:
ids.append(i)
labels[i] = token_label(i)
if not ids and not st.session_state.get(calculation_key):
chart_slot.info('Select a token in the controls below to see its embedding values.')
st.stop()
if len(ids) > 20:
st.warning('Select at most 20 tokens for a readable comparison.')
st.stop()
def add_neighbour(i):
added = list(st.session_state.get(extra_key, []))
if i not in added:
added.append(i)
st.session_state[extra_key] = added
try:
vectors = {i: get_row(i) for i in ids}
except Exception as e:
chart_slot.error(f'Could not read embedding: {e}')
st.stop()
calculations = st.session_state.setdefault(calculation_key, [])
for calculation in calculations:
vectors[calculation['id']] = calculation['values']
labels[calculation['id']] = calculation['label']
def remove_calculation(calculation_id):
st.session_state[calculation_key] = [c for c in st.session_state[calculation_key] if c['id'] != calculation_id]
def operand_label(key):
return labels[key] if isinstance(key, str) else token_label(key)
def expression_label(key):
if isinstance(key, str):
return '(' + labels[key].removeprefix('calc: ') + ')'
if vocab is not None and key < len(vocab):
return repr(vocab[key])
if tok is not None and tok.id_to_token(key) is not None:
return repr(tok.id_to_token(key))
return f'ID {key}'
def save_calculation(values, label):
label = label if label.startswith('calc: ') else 'calc: ' + label
serial = st.session_state.get('calculation_serial', 0) + 1
while label in labels.values():
label += f' [{serial}]'
st.session_state.calculation_serial = serial
calculations.append({'id': f'calc:{serial}', 'label': label, 'values': np.asarray(values).copy()})
st.rerun()
with calculation_panel:
st.subheader('Vector tools')
arithmetic_tab, relationship_tab = st.tabs(['Calculations', 'Relationships'])
with arithmetic_tab:
st.caption('Calculate A − B or A + B, dimension by dimension. Results become derived curves labelled calc:.')
operands = list(vectors)
left_operand = st.selectbox('Vector A', operands, format_func=operand_label, key=f'calc_a:{comparison_scope}')
operation = st.selectbox('Operation', ['Subtract (A − B)', 'Add (A + B)'], key=f'calc_operation:{comparison_scope}')
right_operand = st.selectbox('Vector B', operands, index=min(1, len(operands) - 1),
format_func=operand_label, key=f'calc_b:{comparison_scope}')
result_name = st.text_input('Result name (optional)', placeholder='e.g. hello minus world', key=f'calc_name:{comparison_scope}')
if st.button('Calculate & add to chart', key='add_calculation', type='primary',
disabled=len(vectors) >= 20, use_container_width=True):
subtract = operation.startswith('Subtract')
values = vectors[left_operand] - vectors[right_operand] if subtract else vectors[left_operand] + vectors[right_operand]
symbol = '−' if subtract else '+'
expression = f'{expression_label(left_operand)} {symbol} {expression_label(right_operand)}'
label = 'calc: ' + (result_name.strip() or expression)
save_calculation(values, label)
if calculations:
st.caption('Calculated curves stay available when you change the selected tokens. They are included in CSV exports.')
for calculation in calculations:
label_col, remove_col = st.columns([3, 1.2], vertical_alignment='center')
label_col.text(calculation['label'])
remove_col.button('Remove', key=f'remove_calculation:{calculation["id"]}',
on_click=remove_calculation, args=(calculation['id'],))
if len(vectors) >= 20:
st.caption('The chart supports up to 20 token and calculated curves in total.')
with relationship_tab:
analysis = render_relationship_controls(vectors, operand_label, comparison_scope, save_calculation)
if len(vectors) > 20:
token_panel.warning('Remove a token or calculated curve to keep the chart within its 20-curve limit.')
st.stop()
chart_ids = list(vectors)
colour_key = f'token_colours:{comparison_scope}'
colours = st.session_state.setdefault(colour_key, {})
# Retain colours across searches, keeping every currently selected token distinct.
used = set()
for i in chart_ids:
if i not in colours or colours[i] in used:
colours[i] = next(c for c in qualitative.Light24 if c not in used and
c not in {colours.get(other) for other in chart_ids if other != i})
used.add(colours[i])
details_panel.caption(f'Tokens: {len(ids)} · Calculations: {len(calculations)}')
if chart_view == 'Dimensions':
fig = go.Figure()
for i, values in vectors.items():
fig.add_trace(go.Scattergl(x=np.arange(len(values)), y=values,
mode='lines' if style == 'Line' else 'markers', name=labels[i],
line=dict(color=colours[i], width=2 if isinstance(i, str) else 1.5,
dash='dash' if isinstance(i, str) else 'solid'),
marker=dict(color=colours[i], size=4),
hovertemplate='Dimension %{x}<br>Value %{y:.7f}<extra>%{fullData.name}</extra>'))
fig.update_layout(autosize=True, xaxis_title='Embedding dimension (zero-based)', yaxis_title='Stored embedding value',
template='plotly_dark', hovermode='x', margin=dict(l=20, r=20, t=20, b=30),
legend=dict(orientation='h', y=1.02, yanchor='bottom', title_text='Tokens'), showlegend=True,
uirevision=f'{comparison_scope}:{style}')
fig.add_hline(y=0, line_width=1, line_color='gray')
chart_slot.plotly_chart(fig, width='stretch', height='stretch', config={'scrollZoom': True}, key='embedding_chart')
elif analysis is None:
chart_slot.info('Open Relationships in Vector tools, choose your inputs, then click Show relationship.')
else:
fig = relationship_figure(analysis, 2 if chart_view == 'Relationship 2D' else 3, vectors, labels)
chart_slot.plotly_chart(fig, width='stretch', height='stretch', key='embedding_chart', config={'scrollZoom': True})
with neighbour_panel:
st.subheader('Closest tokens')
search_mode = st.selectbox('Search mode', ['Nearest tokens', 'Analogy answers'], key=f'neighbour_mode:{comparison_scope}')
results = st.session_state.setdefault('nearest_results', {})
eligible = True
report_id = None
if search_mode == 'Nearest tokens':
source_id = st.selectbox('Find neighbours of', chart_ids, format_func=operand_label,
key=f'neighbour_source:{comparison_scope}')
query_vector = vectors[source_id]
excluded = [source_id] if isinstance(source_id, int) else []
result_key = ('ranked', comparison_scope, source_id)
row_key = str(source_id)
search_label = 'Find closest tokens'
st.caption('Cosine similarity uses the full vector, including calculated queries.')
elif analysis is None:
st.info('Show a relationship in Vector tools before searching for analogy answers.')
eligible = False
else:
query_vector = analysis['metrics']['prediction']
excluded = analysis['exclude_ids']
expected = analysis['expected']
report_id = expected['key'] if expected and isinstance(expected['key'], int) else None
result_key = ('analogy', comparison_scope, analysis['id'])
row_key = f'analogy:{analysis["id"]}'
search_label = 'Refresh answers'
st.caption('Search near C + mean(B − A), excluding the input tokens. Results refer to the last shown relationship.')
if eligible:
count_col, button_col = st.columns([1, 2], vertical_alignment='bottom')
neighbour_count = count_col.number_input('Results', min_value=1, max_value=50, value=10, step=1)
search = button_col.button(search_label, type='primary', key='find_neighbours')
requested = st.session_state.pop(f'run_analogy:{comparison_scope}', False) if search_mode == 'Analogy answers' else False
previous_search = any(isinstance(key, tuple) and len(key) == 3 and key[:2] == (comparison_scope, source_id)
for key in results) if search_mode == 'Nearest tokens' else False
if search or requested or (result_key not in results and previous_search):
bar = st.progress(0, text='Scanning embedding vectors…')
try:
found = search_embeddings(read_rows, shape[0], query_vector, count=50, exclude_ids=excluded, report_id=report_id,
progress=lambda done, total: bar.progress(done / total, text=f'Scanning embeddings: {done:,} / {total:,}'))
results[result_key] = found if search_mode == 'Analogy answers' else found['neighbours']
except Exception as e:
st.error(f'Could not find neighbours: {e}')
finally:
bar.empty()
if result_key in results:
found = results[result_key]
if search_mode == 'Analogy answers':
neighbours = found['neighbours'][:int(neighbour_count)]
if report_id is not None:
if found['expected_rank'] is not None:
st.caption(f'Expected {token_label(report_id)}: rank {found["expected_rank"]:,} of {found["valid_count"]:,}, cosine {found["expected_score"]:.6f}.')
else:
st.caption('Expected D is excluded as an input token, or has a zero/nonfinite embedding.')
else:
neighbours = found[:int(neighbour_count)]
if neighbours:
st.caption(f'Showing {len(neighbours)} neighbours. Change Results to show more or fewer without searching again.')
with st.container(border=False):
header = st.columns([.35, 1.7, 1, 1.8])
for column, title in zip(header, ['#', 'Token', 'Cosine', '']):
if title:
column.markdown(f'**{title}**')
for rank, (i, score) in enumerate(neighbours, 1):
rank_col, token_col, score_col, action_col = st.columns([.35, 1.7, 1, 1.8], vertical_alignment='center')
rank_col.text(str(rank))
token_col.text(token_label(i))
score_col.write(f'{score:.6f}')
action_col.button('On chart' if i in ids else 'Add to chart',
key=f'add_neighbour:{comparison_scope}:{row_key}:{i}',
disabled=i in ids or len(vectors) >= 20, on_click=add_neighbour, args=(i,), use_container_width=True)
if len(vectors) >= 20:
st.caption('The chart has reached its 20-curve limit. Remove a curve before adding another.')
st.caption('Cosine similarity ranges from −1 to 1. Nearby tokens can reflect spelling, boundaries or usage as well as meaning.')
else:
st.info('No other eligible tokens with finite, nonzero embeddings were found.')
with details_panel:
st.dataframe([{'Token': labels[i], 'Min': float(v.min()), 'Max': float(v.max()),
'Mean': float(v.mean()), 'Std dev': float(v.std()), 'L2 norm': float(np.linalg.norm(v))}
for i, v in vectors.items()], use_container_width=True, hide_index=True)
output = io.StringIO()
writer = csv.writer(output)
writer.writerow(['dimension'] + [labels[i] for i in chart_ids])
writer.writerows([d] + [float(vectors[i][d]) for i in chart_ids] for d in range(shape[1]))
st.download_button('Download values as CSV', output.getvalue(), 'token_embeddings.csv', 'text/csv')
with st.expander('Inspect exact values'):
st.dataframe({'dimension': np.arange(shape[1]), **{labels[i]: vectors[i] for i in chart_ids}}, use_container_width=True)
|