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('', 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}
Value %{y:.7f}%{fullData.name}'))
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)