Spaces:
Sleeping
Sleeping
fix: cpu-basic + gradio5 pin + module-level demo + ungated default + no import-time cuda
f7b23ea verified | """ | |
| Gradiographer — live MRI viewer for Gemma-3-1B (and siblings). | |
| Watches tracked words' ranks through each layer/head cell as tokens generate, | |
| with threshold-based suppress/stop interventions. Two projection methods: | |
| - lm : standard logit-lens (final_norm + lm_head); residual-only by default. | |
| - interp : projection-free readout (no lm_head); every head + Layer by default. | |
| Presentation follows small-multiples discipline: every tracked word × method is a | |
| panel on ONE shared color scale, so target-vs-control and lm-vs-interp are honest | |
| visual comparisons rather than separately-autoscaled images. | |
| Local-first, Spaces-compatible. Defaults to google/gemma-3-1b-it. | |
| """ | |
| from __future__ import annotations | |
| import os | |
| import sys | |
| from dataclasses import dataclass | |
| sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) | |
| import gradio as gr | |
| import numpy as np | |
| import plotly.graph_objects as go | |
| from plotly.subplots import make_subplots | |
| from projection_map import load_model, build_col_specs | |
| from mri_live import ( | |
| TrackedWord, | |
| InterventionConfig, | |
| MethodTrace, | |
| stream_trace, | |
| tokenize_word, | |
| word_preview, | |
| make_rank_threshold_trigger, | |
| ) | |
| DEFAULT_MODEL = os.environ.get("GRADIOGRAPHER_MODEL", "google/gemma-3-1b-it") | |
| _METHOD_COLOR = {"lm": "#4cc9f0", "interp": "#ffb703"} # vivid on dark | |
| # Discrete rank bands — finer near the surface, coarse for the buried tail. | |
| # Color: Magma sampled bright(top) -> dark; 'buried' sits near the dark canvas. | |
| BAND_LABELS = ["top", "top-10", "top-100", "top-1k", "top-2.5k", "buried"] | |
| BAND_COLORS = ["#fcfdbf", "#fe9f6d", "#de4968", "#8c2981", "#3b0f70", "#1a1a22"] | |
| BAND_EDGES = [1, 10, 100, 1000, 2500] # upper bound of bands 0..4; >1000 = buried | |
| def _bands(rank2d): | |
| """Map a 2D rank array to band indices 0(top)..5(buried).""" | |
| b = np.full(rank2d.shape, 5.0) | |
| b[rank2d <= 2500] = 4 | |
| b[rank2d <= 1000] = 3 | |
| b[rank2d <= 100] = 2 | |
| b[rank2d <= 10] = 1 | |
| b[rank2d <= 1] = 0 | |
| return b | |
| def _discrete_scale(colors): | |
| """Stepped colorscale so each integer band index renders a flat color.""" | |
| n = len(colors) | |
| cs = [] | |
| for i, c in enumerate(colors): | |
| cs.append([i / n, c]) | |
| cs.append([(i + 1) / n, c]) | |
| return cs | |
| # --------------------------------------------------------------------------- | |
| # Lazy model state | |
| # --------------------------------------------------------------------------- | |
| class ModelBundle: | |
| model: object | |
| tokenizer: object | |
| device: str | |
| num_layers: int | |
| num_heads: int | |
| num_vocab: int | |
| col_labels_default: list[str] | |
| _bundle: ModelBundle | None = None | |
| def get_bundle() -> ModelBundle: | |
| global _bundle | |
| if _bundle is None: | |
| model, tokenizer, device, num_layers, num_heads = load_model(DEFAULT_MODEL) | |
| col_specs, _, _, _ = build_col_specs(num_heads) | |
| default_cols = [c[2] for c in col_specs if c[0] in ("single", "layer")] | |
| num_vocab = int(model.lm_head.weight.shape[0]) | |
| _bundle = ModelBundle(model, tokenizer, device, num_layers, num_heads, | |
| num_vocab, default_cols) | |
| return _bundle | |
| # --------------------------------------------------------------------------- | |
| # UI helpers | |
| # --------------------------------------------------------------------------- | |
| def render_word_preview(words_raw: str) -> str: | |
| if not words_raw.strip(): | |
| return "_(enter one or more comma-separated words — first is the focus, others read as controls)_" | |
| b = get_bundle() | |
| tok = b.tokenizer | |
| lines = [] | |
| for w in [x.strip() for x in words_raw.split(",") if x.strip()]: | |
| prev = word_preview(tok, w) | |
| ws = " | ".join(f"`{repr(s)}`(id {tid})" for tid, s in prev["with_leading_space"]) | |
| ns = " | ".join(f"`{repr(s)}`(id {tid})" for tid, s in prev["no_leading_space"]) | |
| lines.append(f"**{w}**") | |
| lines.append(f"- with leading space: {ws}") | |
| lines.append(f"- no leading space: {ns}") | |
| return "\n".join(lines) | |
| def _empty_fig(title: str = "") -> go.Figure: | |
| f = go.Figure() | |
| f.update_layout(template="plotly_dark", height=320, title=title, | |
| margin=dict(l=40, r=20, t=40, b=40)) | |
| return f | |
| def build_heatmap_grid(hist_by_wm, words, methods, layer_axis) -> go.Figure: | |
| """Small multiples (rows = tracked words, cols = methods) on ONE shared | |
| discrete rank-band scale. Bands are absolute categories (top / top-5 / ... / | |
| buried), so a single legend is both honest and comparable across methods — | |
| no log decoding, no per-panel autoscale. Crisp tiles on a dark field: a | |
| surfaced concept glows like structure on a scan. Each panel collapses cols | |
| to min-rank per layer (best visibility of the tracked word); hover = exact rank.""" | |
| rows, cols = len(words), len(methods) | |
| titles = [f"{w.label} · {m}" for w in words for m in methods] | |
| fig = make_subplots(rows=rows, cols=cols, subplot_titles=titles, | |
| shared_xaxes=True, shared_yaxes=True, | |
| horizontal_spacing=0.09, vertical_spacing=0.14) | |
| ylabels = [f"L{l:02d}" for l in layer_axis] | |
| any_data = False | |
| for r, _w in enumerate(words): | |
| for c, m in enumerate(methods): | |
| hist = hist_by_wm.get((r, m)) | |
| if not hist: | |
| continue | |
| rankmin = np.stack([rm.min(axis=1) for rm in hist], axis=0).T # (layers, steps) | |
| fig.add_trace(go.Heatmap( | |
| z=_bands(rankmin), customdata=rankmin, | |
| y=ylabels, x=[f"t{i}" for i in range(len(hist))], | |
| coloraxis="coloraxis", xgap=1.5, ygap=1.5, zsmooth=False, | |
| hovertemplate="L%{y} · %{x}<br>rank %{customdata}<extra></extra>", | |
| ), row=r + 1, col=c + 1) | |
| any_data = True | |
| if not any_data: | |
| return _empty_fig("rank heatmaps") | |
| fig.update_layout( | |
| template="plotly_dark", | |
| coloraxis=dict( | |
| colorscale=_discrete_scale(BAND_COLORS), cmin=-0.5, cmax=5.5, | |
| colorbar=dict(title="rank<br>band", tickvals=list(range(6)), | |
| ticktext=BAND_LABELS, thickness=16, len=0.92, outlinewidth=0), | |
| ), | |
| height=max(320, 250 * rows), | |
| margin=dict(l=55, r=95, t=56, b=40), | |
| title="MRI — where each word surfaces across layers (x = generation step) · bright = nearer the surface", | |
| ) | |
| fig.update_annotations(font_size=12) | |
| return fig | |
| def build_trace_grid(step_methods, words, methods, layer_axis) -> go.Figure: | |
| """Rows = tracked words; lm vs interp overlaid as two lines per word so the | |
| divergence is read within-panel. Log-rank y, REVERSED so the surface (rank 1) | |
| is at the top; faint dotted band-edges echo the heatmap legend.""" | |
| rows = len(words) | |
| titles = [w.label for w in words] | |
| fig = make_subplots(rows=rows, cols=1, subplot_titles=titles, shared_xaxes=True, | |
| vertical_spacing=0.13) | |
| for r, _w in enumerate(words): | |
| for m in methods: | |
| mt = step_methods.get(m) | |
| if mt is None: | |
| continue | |
| per_layer = mt.rank_matrix(r).min(axis=1) | |
| fig.add_trace(go.Scatter( | |
| x=mt.layers_used, y=per_layer, mode="lines+markers", | |
| name=m, legendgroup=m, showlegend=(r == 0), | |
| line=dict(color=_METHOD_COLOR.get(m), width=2), marker=dict(size=5), | |
| ), row=r + 1, col=1) | |
| for e in BAND_EDGES: | |
| fig.add_hline(y=e, line=dict(color="rgba(255,255,255,0.10)", width=1, dash="dot"), | |
| row=r + 1, col=1) | |
| fig.update_yaxes(type="log", autorange="reversed", title_text="rank", | |
| row=r + 1, col=1) | |
| fig.update_xaxes(title_text="layer", row=rows, col=1) | |
| fig.update_layout(template="plotly_dark", height=max(320, 240 * rows), | |
| margin=dict(l=55, r=20, t=50, b=40), | |
| title="per-layer rank at this step — lm vs interp (higher = nearer the surface)") | |
| fig.update_annotations(font_size=12) | |
| return fig | |
| def _fmt_rank(r: int) -> str: | |
| return f"{round(r/1000)}k" if r >= 1000 else str(r) | |
| def _five_closest(mt: MethodTrace, tracked_idx: int = 0) -> str: | |
| if not mt or not mt.cells: | |
| return "" | |
| cells = sorted(mt.cells, key=lambda c: c.ranks[tracked_idx])[:5] | |
| parts = [] | |
| for c in cells: | |
| loc = f"L{c.layer:02d}·R" if c.col == "Layer" else f"L{c.layer:02d}{c.col}" | |
| parts.append(f"{loc} {_fmt_rank(c.ranks[tracked_idx])}") | |
| return "|".join(parts) | |
| def _log_line(step) -> str: | |
| bits = [f"t{step.step:>2}"] | |
| if step.fwd_ms >= 0: | |
| bits.append(f"fwd {step.fwd_ms:.0f}ms") | |
| for m in ("lm", "interp"): | |
| if m in step.methods: | |
| mt = step.methods[m] | |
| lat = f" {mt.proj_ms:.1f}ms" if mt.proj_ms >= 0 else "" | |
| bits.append(f"{m}{lat} |{_five_closest(mt)}|") | |
| if step.intervened: | |
| bits.append(f"⚠INTERVENED→{step.intervention_action.upper()} ({','.join(step.intervention_methods)})") | |
| return " ".join(bits) | |
| # --------------------------------------------------------------------------- | |
| # Main run generator | |
| # --------------------------------------------------------------------------- | |
| def run_session( | |
| prompt, words_raw, scan_mode, max_new_tokens, scale_mode, layers_str, cols_str, | |
| precise_timing, | |
| threshold_enabled, threshold_word_idx, threshold_layers_str, threshold_cols_str, | |
| threshold_rank_below, threshold_rank_above, intervention_action, | |
| ): | |
| b = get_bundle() | |
| tok = b.tokenizer | |
| words = [w.strip() for w in words_raw.split(",") if w.strip()] | |
| if not words: | |
| yield ("no tracked words provided", _empty_fig(), _empty_fig(), "", {}, "—") | |
| return | |
| tracked = [TrackedWord(text=w, token_ids=tokenize_word(tok, w, True), anchor_idx=0) for w in words] | |
| def _parse_ints(s, default): | |
| s = s.strip() | |
| if not s: | |
| return default | |
| out = [] | |
| for part in s.split(","): | |
| part = part.strip() | |
| step = 1 | |
| if ":" in part: | |
| part, step_s = part.split(":") | |
| step = max(1, int(step_s)) | |
| if "-" in part: | |
| a, z = part.split("-") | |
| out.extend(range(int(a), int(z) + 1, step)) | |
| else: | |
| out.append(int(part)) | |
| return sorted(set(out)) | |
| layer_mask = _parse_ints(layers_str, list(range(b.num_layers))) | |
| methods = {"lm": ("lm",), "interp": ("interp",), "both": ("lm", "interp")}[scan_mode] | |
| method_list = list(methods) | |
| cols_parsed = [c.strip() for c in cols_str.split(",") if c.strip()] | |
| if cols_parsed: | |
| lm_cols = interp_cols = cols_parsed | |
| else: | |
| lm_cols = ["Layer"] # lm_head is expensive -> residual only | |
| interp_cols = None # interp is cheap -> everything | |
| intervention = None | |
| if threshold_enabled: | |
| th_layers = _parse_ints(threshold_layers_str, layer_mask) | |
| th_cols = [c.strip() for c in threshold_cols_str.split(",") if c.strip()] or ["Layer"] | |
| rb = int(threshold_rank_below) if int(threshold_rank_below) > 0 else None | |
| ra = int(threshold_rank_above) if int(threshold_rank_above) > 0 else None | |
| trigger = make_rank_threshold_trigger(int(threshold_word_idx), th_layers, th_cols, rb, ra) | |
| intervention = InterventionConfig(trigger_fn=trigger, action=intervention_action) | |
| hist_by_wm: dict = {} # (word_idx, method) -> list of (layers, cols) matrices | |
| trace_snaps: list = [] # per-step step.methods dict (lightweight; trace figs built lazily) | |
| log_lines: list = [] | |
| generated = "" | |
| # Rebuilding a make_subplots figure costs ~hundreds of ms; doing it every | |
| # token dwarfs the model. So render the heavy figures ~12 times across the | |
| # run (+ a final full render), and stream only log/text on the steps between. | |
| total = int(max_new_tokens) | |
| render_every = max(1, total // 12) | |
| last_hm = _empty_fig("rank heatmaps") | |
| last_tg = _empty_fig("per-layer trace") | |
| layer_axis = layer_mask | |
| def _state(idx): | |
| return {"snaps": trace_snaps, "words": tracked, "methods": method_list, | |
| "layer_axis": layer_axis, "idx": idx} | |
| for i, step in enumerate(stream_trace( | |
| model=b.model, tokenizer=tok, device=b.device, | |
| num_layers=b.num_layers, num_heads=b.num_heads, | |
| prompt=prompt, tracked_words=tracked, max_new_tokens=total, | |
| layer_mask=layer_mask, lm_cols=lm_cols, interp_cols=interp_cols, | |
| methods=methods, scale_mode=scale_mode, intervention=intervention, | |
| precise_timing=bool(precise_timing), | |
| )): | |
| generated += step.token_str | |
| for m, mt in step.methods.items(): | |
| for wi in range(len(tracked)): | |
| hist_by_wm.setdefault((wi, m), []).append(mt.rank_matrix(wi)) | |
| trace_snaps.append(step.methods) | |
| log_lines.append(_log_line(step)) | |
| layer_axis = step.methods[method_list[0]].layers_used | |
| if i % render_every == 0: # throttled live frame | |
| last_hm = build_heatmap_grid(hist_by_wm, tracked, method_list, layer_axis) | |
| last_tg = build_trace_grid(step.methods, tracked, method_list, layer_axis) | |
| idx = len(trace_snaps) - 1 | |
| yield ("\n".join(log_lines), last_hm, last_tg, generated, _state(idx), f"step {idx} / {idx}") | |
| # final full render so the finished state is complete | |
| if trace_snaps: | |
| last_hm = build_heatmap_grid(hist_by_wm, tracked, method_list, layer_axis) | |
| last_tg = build_trace_grid(trace_snaps[-1], tracked, method_list, layer_axis) | |
| idx = len(trace_snaps) - 1 | |
| yield ("\n".join(log_lines), last_hm, last_tg, generated, _state(idx), f"step {idx} / {idx}") | |
| # --------------------------------------------------------------------------- | |
| # Paging — rebuild the per-step trace grid lazily from stored snapshots | |
| # --------------------------------------------------------------------------- | |
| def _page(state, delta): | |
| if not state or not state.get("snaps"): | |
| return _empty_fig(), "—", state | |
| snaps = state["snaps"] | |
| n = len(snaps) | |
| idx = max(0, min(n - 1, state.get("idx", n - 1) + delta)) | |
| state["idx"] = idx | |
| fig = build_trace_grid(snaps[idx], state["words"], state["methods"], state["layer_axis"]) | |
| return fig, f"step {idx} / {n - 1}", state | |
| def page_prev(state): | |
| return _page(state, -1) | |
| def page_next(state): | |
| return _page(state, +1) | |
| # --------------------------------------------------------------------------- | |
| # UI | |
| # --------------------------------------------------------------------------- | |
| def build_ui(): | |
| with gr.Blocks(title="Tokescope — interactive observability for transformer internals") as demo: | |
| gr.Markdown( | |
| "# Tokescope\n" | |
| "### Interactive observability for transformer internals\n" | |
| "Pick a word and watch where the model ranks it as it writes, at every layer. " | |
| "Tokescope surfaces these intermediate signals and lets them be inspected, " | |
| "suppressed, or stopped in real time. An alternative, experimental lightweight " | |
| "vocabulary-projection technique (`interp`) supports real-time exploration with " | |
| "significantly lower overhead than a repeated full-vocabulary decode at every layer." | |
| ) | |
| with gr.Row(): | |
| with gr.Column(scale=1): | |
| prompt = gr.Textbox(label="prompt", lines=3, | |
| value="Give a brief biography of Kurt Cobain and his life, no more than 50 words please.") | |
| words = gr.Textbox(label="tracked words (focus first, then controls)", | |
| value="suicide,elephant") | |
| preview = gr.Markdown() | |
| words.change(render_word_preview, inputs=words, outputs=preview) | |
| with gr.Accordion("generation & scan", open=True): | |
| scan_mode = gr.Radio( | |
| ["lm", "interp", "both"], value="lm", label="projection method", | |
| info="lm = logit-lens (residual only by default); interp = no lm_head (every head + Layer)", | |
| ) | |
| max_new = gr.Slider(1, 80, value=20, step=1, label="max new tokens") | |
| scale_mode = gr.Radio(["raw", "full", "mean"], value="full", label="head scale mode") | |
| layers_str = gr.Textbox( | |
| label="layers", value="0-25:2", | |
| placeholder="blank = all; 'a-z:n' = every nth (default every other, for speed)") | |
| cols_str = gr.Textbox( | |
| label="cols (override)", value="", | |
| placeholder="blank = smart defaults (lm:Layer, interp:all heads+Layer); else forces both") | |
| precise_timing = gr.Checkbox( | |
| label="precise per-method timing (slower — adds GPU syncs)", value=False) | |
| with gr.Accordion("intervention", open=False): | |
| th_enabled = gr.Checkbox(label="enable threshold", value=False) | |
| th_word_idx = gr.Number(label="tracked-word index (0-based)", value=0, precision=0) | |
| th_layers = gr.Textbox(label="layers to watch", value="", placeholder="blank = all") | |
| th_cols = gr.Textbox(label="cols to watch", value="Layer", placeholder="e.g. Layer") | |
| th_below = gr.Number(label="fire if rank <= (0 disables)", value=10, precision=0) | |
| th_above = gr.Number(label="fire if rank >= (0 disables)", value=0, precision=0) | |
| th_action = gr.Radio(["log", "suppress", "stop"], value="log", label="action on trigger") | |
| run_btn = gr.Button("run", variant="primary") | |
| with gr.Column(scale=2): | |
| heatmap_grid = gr.Plot(label="rank heatmaps — small multiples, shared scale") | |
| with gr.Row(): | |
| prev_btn = gr.Button("â—€ prev step", size="sm") | |
| page_label = gr.Markdown("_(run to populate)_") | |
| next_btn = gr.Button("next step â–¶", size="sm") | |
| trace_grid = gr.Plot(label="per-layer rank at selected step") | |
| page_state = gr.State({}) | |
| generated = gr.Textbox(label="generated", lines=3, interactive=False) | |
| log = gr.Textbox(label="trace log — fwd + per-method proj latency + 5 closest cells", | |
| lines=12, interactive=False, max_lines=40) | |
| run_btn.click( | |
| run_session, | |
| inputs=[prompt, words, scan_mode, max_new, scale_mode, layers_str, cols_str, | |
| precise_timing, | |
| th_enabled, th_word_idx, th_layers, th_cols, th_below, th_above, th_action], | |
| outputs=[log, heatmap_grid, trace_grid, generated, page_state, page_label], | |
| ) | |
| prev_btn.click(page_prev, inputs=page_state, outputs=[trace_grid, page_label, page_state]) | |
| next_btn.click(page_next, inputs=page_state, outputs=[trace_grid, page_label, page_state]) | |
| demo.load(render_word_preview, inputs=words, outputs=preview) | |
| return demo | |
| # Module-level `demo` so the HF Spaces runner finds it without guessing. | |
| demo = build_ui() | |
| demo.queue() | |
| if __name__ == "__main__": | |
| demo.launch() | |