Mattral's picture
Create app.py
f395864 verified
Raw
History Blame Contribute Delete
16.7 kB
"""
Induction Circuit Stability Under Fine-Tuning -- Hugging Face Spaces app.
Self-contained Gradio dashboard. Uses Plotly for interactive attention heatmaps.
All other panels use Matplotlib. No external src/ package required.
Performance targets: cold-start < 3s on HF CPU Basic, inference < 500ms.
"""
from __future__ import annotations
import logging
import os
import time
from pathlib import Path
from typing import Optional
import gradio as gr
import matplotlib
import matplotlib.patches as mpatches
import matplotlib.pyplot as plt
import numpy as np
import onnxruntime as ort
import plotly.graph_objects as go
matplotlib.use("Agg")
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s | %(levelname)-8s | %(name)s | %(message)s",
datefmt="%Y-%m-%dT%H:%M:%S",
)
logger = logging.getLogger(__name__)
ONNX_DIR = Path("onnx_models")
PRE_PATH = ONNX_DIR / "model_pre.onnx"
POST_PATH = ONNX_DIR / "model_post.onnx"
CIRCUIT_THRESHOLD = 0.5
MAX_SEQ_LEN = 64
LABEL_FONTSIZE = 9
TITLE_FONTSIZE = 11
_sessions: dict[str, Optional[ort.InferenceSession]] = {"pre": None, "post": None}
_tokenizer = None
_load_times: dict[str, float] = {}
def _load_session(state: str) -> tuple[Optional[ort.InferenceSession], str]:
"""Load or return cached ONNX InferenceSession."""
global _sessions, _load_times
if _sessions[state] is not None:
return _sessions[state], ""
path = PRE_PATH if state == "pre" else POST_PATH
if not path.exists():
msg = (
f"ONNX model not found: {path}. "
"Upload model_pre.onnx and model_post.onnx to the onnx_models/ directory. "
"See HUGGINGFACE_SETUP.md in the GitHub repo."
)
logger.error(msg)
return None, msg
t0 = time.perf_counter()
opts = ort.SessionOptions()
opts.inter_op_num_threads = 2
opts.intra_op_num_threads = 2
session = ort.InferenceSession(
str(path), sess_options=opts, providers=["CPUExecutionProvider"]
)
elapsed = time.perf_counter() - t0
_load_times[state] = elapsed
logger.info("ONNX session loaded (%s) in %.3f s", state, elapsed)
_sessions[state] = session
return session, ""
def _get_tokenizer():
"""Lazy-load the correct tokenizer for attn-only-2l (DECISION-006).
attn-only-2l uses NeelNanda/gpt-neox-tokenizer-digits, NOT gpt2.
Using gpt2 feeds wrong token IDs to the ONNX model.
"""
global _tokenizer
if _tokenizer is None:
from transformers import AutoTokenizer
_tokenizer = AutoTokenizer.from_pretrained(
"NeelNanda/gpt-neox-tokenizer-digits"
)
if _tokenizer.pad_token is None:
_tokenizer.pad_token = _tokenizer.eos_token
logger.info("Tokenizer loaded: NeelNanda/gpt-neox-tokenizer-digits")
return _tokenizer
def tokenise(text: str) -> tuple[np.ndarray, int, list[str]]:
"""Tokenise text, pad to MAX_SEQ_LEN. Returns (ids, seq_len, token_labels)."""
tok = _get_tokenizer()
ids = tok.encode(text, add_special_tokens=True)[:MAX_SEQ_LEN]
seq_len = len(ids)
ids_padded = ids + [tok.pad_token_id] * (MAX_SEQ_LEN - seq_len)
labels = [tok.decode([t]).replace("\n", "\u21b5").replace(" ", "\u00b7") for t in ids]
return np.array([ids_padded], dtype=np.int64), seq_len, labels
def run_inference(
token_ids: np.ndarray, state: str
) -> tuple[Optional[list[np.ndarray]], Optional[np.ndarray], str]:
"""Run ONNX inference. Returns (attn_patterns, induction_scores, error_msg)."""
session, err = _load_session(state)
if session is None:
return None, None, err
t0 = time.perf_counter()
outputs = session.run(None, {"input_ids": token_ids})
logger.debug("Inference (%s): %.3f s", state, time.perf_counter() - t0)
attn_patterns = [out[0] for out in outputs[:-1]]
induction_scores: np.ndarray = outputs[-1]
return attn_patterns, induction_scores, ""
# ---------------------------------------------------------------------------
# Plotly interactive attention heatmap (spec Section 7: "interactive, zoomable")
# ---------------------------------------------------------------------------
def build_attention_heatmap_plotly(
attn: np.ndarray,
labels: list[str],
layer: int,
head: int,
model_state: str,
) -> go.Figure:
"""Interactive, zoomable attention heatmap (scroll to zoom, drag to pan).
Satisfies spec Section 7: "Attention pattern heatmap for each head
(interactive, zoomable)."
"""
fig = go.Figure(
data=go.Heatmap(
z=attn,
x=labels,
y=labels,
colorscale="Viridis",
zmin=0.0,
zmax=1.0,
colorbar=dict(title="Attention weight", thickness=15),
hovertemplate=(
"Query: %{y}<br>Key: %{x}<br>Weight: %{z:.4f}<extra></extra>"
),
)
)
fig.update_layout(
title=dict(
text=f"Attention: L{layer}H{head} -- {model_state}",
font=dict(size=13),
),
xaxis=dict(
title="Key position", tickfont=dict(size=9),
tickangle=-45, automargin=True,
),
yaxis=dict(
title="Query position", tickfont=dict(size=9),
autorange="reversed",
),
height=460,
margin=dict(l=80, r=40, t=60, b=100),
paper_bgcolor="rgba(0,0,0,0)",
plot_bgcolor="rgba(0,0,0,0)",
)
return fig
# ---------------------------------------------------------------------------
# Matplotlib induction score grid
# ---------------------------------------------------------------------------
def build_induction_score_grid(
induction_scores: np.ndarray,
circuit_heads: list[tuple[int, int]],
model_state: str,
) -> plt.Figure:
"""Per-head induction score heatmap with circuit heads outlined in yellow."""
n_layers, n_heads = induction_scores.shape
fig, ax = plt.subplots(figsize=(max(5, n_heads * 0.65), max(2.5, n_layers * 0.85)))
im = ax.imshow(induction_scores, cmap="viridis", vmin=0, vmax=1, aspect="auto")
plt.colorbar(im, ax=ax, label="Induction score", fraction=0.06)
ax.set_xticks(range(n_heads))
ax.set_xticklabels([f"H{h}" for h in range(n_heads)], fontsize=LABEL_FONTSIZE)
ax.set_yticks(range(n_layers))
ax.set_yticklabels([f"L{ll}" for ll in range(n_layers)], fontsize=LABEL_FONTSIZE)
for ll in range(n_layers):
for h in range(n_heads):
v = induction_scores[ll, h]
ax.text(h, ll, f"{v:.2f}", ha="center", va="center",
fontsize=7, color="white" if v < 0.5 else "black")
if (ll, h) in circuit_heads:
ax.add_patch(mpatches.Rectangle(
(h - 0.5, ll - 0.5), 1, 1,
linewidth=2.5, edgecolor="yellow", facecolor="none", zorder=5,
))
ax.set_title(
f"Per-head induction scores ({model_state})\n"
f"Yellow = circuit head (IS >= {CIRCUIT_THRESHOLD})",
fontsize=TITLE_FONTSIZE,
)
plt.tight_layout()
return fig
# ---------------------------------------------------------------------------
# Matplotlib circuit diagram
# ---------------------------------------------------------------------------
def build_circuit_diagram(
circuit_heads: list[tuple[int, int]],
n_layers: int,
n_heads: int,
model_state: str,
) -> plt.Figure:
"""Schematic circuit diagram showing previous-token -> induction head flow."""
fig, ax = plt.subplots(figsize=(max(9, n_heads * 1.1), 5))
ax.set_xlim(-0.7, n_heads - 0.3)
ax.set_ylim(-0.8, n_layers + 0.8)
ax.axis("off")
ax.set_title(
f"Induction circuit -- {model_state}\n"
f"Orange = circuit member (attribution >= {CIRCUIT_THRESHOLD})",
fontsize=TITLE_FONTSIZE, pad=12,
)
circuit_set = set(circuit_heads)
layer_y = {ll: float(ll) for ll in range(n_layers)}
for h in range(n_heads):
ax.plot([float(h), float(h)],
[layer_y[0] + 0.32, layer_y[n_layers - 1] - 0.32],
color="#aaaaaa", linewidth=0.9, linestyle="--", zorder=1)
for ll in range(n_layers):
for h in range(n_heads):
in_c = (ll, h) in circuit_set
box = mpatches.FancyBboxPatch(
(float(h) - 0.28, layer_y[ll] - 0.28), 0.56, 0.56,
boxstyle="round,pad=0.05",
facecolor="#FF8C00" if in_c else "#B0C4DE",
edgecolor="#CC5500" if in_c else "#4682B4",
linewidth=2.5 if in_c else 1.0, zorder=3,
)
ax.add_patch(box)
ax.text(float(h), layer_y[ll], f"L{ll}H{h}",
ha="center", va="center", fontsize=7,
fontweight="bold" if in_c else "normal", zorder=4)
for l0, h0 in circuit_set:
for l1, h1 in circuit_set:
if l1 <= l0:
continue
ax.annotate("", xy=(float(h1), layer_y[l1] - 0.32),
xytext=(float(h0), layer_y[l0] + 0.32),
arrowprops=dict(arrowstyle="->", color="#CC5500", lw=2.0),
zorder=2)
for ll in range(n_layers):
role = "Previous-token heads" if ll == 0 else "Induction heads"
ax.text(-0.55, layer_y[ll], f"Layer {ll}\n({role})",
ha="right", va="center", fontsize=LABEL_FONTSIZE - 1, fontweight="bold")
ax.legend(handles=[
mpatches.Patch(facecolor="#FF8C00", edgecolor="#CC5500",
label=f"Circuit head (attr >= {CIRCUIT_THRESHOLD})"),
mpatches.Patch(facecolor="#B0C4DE", edgecolor="#4682B4",
label="Non-circuit head"),
], loc="upper right", fontsize=LABEL_FONTSIZE)
plt.tight_layout()
return fig
# ---------------------------------------------------------------------------
# Main inference pipeline
# ---------------------------------------------------------------------------
def analyse_text(
input_text: str,
model_state: str,
selected_layer: int,
selected_head: int,
) -> tuple:
"""Main Gradio callback: tokenise -> infer -> build 3 figures + status."""
state_key = "pre" if "Pre" in model_state else "post"
if not input_text.strip():
return None, None, None, "Please enter some text."
t0 = time.perf_counter()
token_ids, seq_len, token_labels = tokenise(input_text)
attn_patterns, induction_scores, err = run_inference(token_ids, state_key)
if err:
return None, None, None, f"Error: {err}"
head_attn = attn_patterns[selected_layer][selected_head, :seq_len, :seq_len]
circuit_heads = [
(ll, h)
for ll in range(induction_scores.shape[0])
for h in range(induction_scores.shape[1])
if induction_scores[ll, h] >= CIRCUIT_THRESHOLD
]
n_layers = induction_scores.shape[0]
n_heads = induction_scores.shape[1]
fig_attn = build_attention_heatmap_plotly(
head_attn, token_labels, selected_layer, selected_head, model_state
)
fig_ind = build_induction_score_grid(induction_scores, circuit_heads, model_state)
fig_circ = build_circuit_diagram(circuit_heads, n_layers, n_heads, model_state)
elapsed = time.perf_counter() - t0
load_note = (f" (model load: {_load_times[state_key]:.2f}s)"
if state_key in _load_times else "")
status = (
f"{model_state} | tokens={seq_len} | circuit heads={len(circuit_heads)} | "
f"mean IS={induction_scores.mean():.3f} | time: {elapsed:.2f}s{load_note}"
)
return fig_attn, fig_ind, fig_circ, status
# ---------------------------------------------------------------------------
# "What am I seeing?" -- exactly 3 sentences (spec Section 7)
# ---------------------------------------------------------------------------
WHAT_AM_I_SEEING = (
"**What am I seeing?** "
"Induction heads implement copy-and-complete: if the model has seen "
"A->B before, an induction head at the second A attends to B and "
"predicts it next -- this is the primary in-context learning mechanism "
"in small transformers (Olsson et al., 2022). "
"The **attention heatmap** (Plotly: scroll to zoom, drag to pan) shows which "
"tokens each head attends to; the **induction score grid** rates each head "
"0-1 with yellow borders on circuit members (attribution >= 0.5); and the "
"**circuit diagram** shows information flow from previous-token heads "
"(layer 0) to induction heads (layer 1) via the residual stream. "
"Toggle Pre- vs Post-fine-tuning to see whether the circuit changes "
"after Python code training, and check the status bar for circuit "
"membership count and mean induction score."
)
EXAMPLE_INPUTS = [
["def fibonacci(n): return fibonacci(n-1) + fibonacci(n-2)", "Pre-fine-tuning", 1, 0],
["The cat sat on the mat. The cat", "Post-fine-tuning", 1, 0],
["import numpy as np\nx = np.array([1, 2, 3])\ny = np", "Post-fine-tuning", 1, 3],
["a b c d e a b c d e a", "Pre-fine-tuning", 1, 0],
]
# ---------------------------------------------------------------------------
# Gradio UI
# ---------------------------------------------------------------------------
def build_interface() -> gr.Blocks:
"""Build and return the Gradio Blocks application."""
with gr.Blocks(
title="Induction Circuit Visualiser",
theme=gr.themes.Soft(primary_hue="blue", secondary_hue="indigo"),
css=".gradio-container { max-width: 1200px !important }",
) as demo:
gr.Markdown("# Induction Circuit Stability Under Fine-Tuning")
gr.Markdown(WHAT_AM_I_SEEING)
gr.Markdown("---")
with gr.Row():
with gr.Column(scale=1, min_width=300):
input_text = gr.Textbox(
label="Input text",
placeholder="Type or paste text here...",
lines=4,
info="Up to 64 tokens. Try code, prose, or repetition (a b c a b c).",
)
model_state = gr.Radio(
choices=["Pre-fine-tuning", "Post-fine-tuning"],
value="Pre-fine-tuning",
label="Model state",
info="Pre = pretrained weights. Post = after Python code fine-tuning.",
)
with gr.Row():
sel_layer = gr.Slider(
minimum=0, maximum=1, step=1, value=1,
label="Layer (attention heatmap)",
info="0 = previous-token heads | 1 = induction heads",
)
sel_head = gr.Slider(
minimum=0, maximum=7, step=1, value=0,
label="Head",
)
run_btn = gr.Button("Analyse", variant="primary")
status_box = gr.Textbox(
label="Status", interactive=False, lines=2,
)
with gr.Column(scale=2):
with gr.Tab("Attention Pattern (interactive)"):
attn_plot = gr.Plot(
label="Attention heatmap -- scroll to zoom, drag to pan"
)
with gr.Tab("Induction Score Grid"):
ind_plot = gr.Plot(label="Per-head induction scores")
with gr.Tab("Circuit Diagram"):
circuit_plot = gr.Plot(label="Circuit structure")
gr.Examples(
examples=EXAMPLE_INPUTS,
inputs=[input_text, model_state, sel_layer, sel_head],
label="Quick examples (click to run)",
cache_examples=False,
)
gr.Markdown(
"_Source: "
"[Mattral/Mechanistic-Interpretability-Study-Induction-Circuit-Stability-Under-Fine-Tuning]"
"(https://github.com/Mattral/Mechanistic-Interpretability-Study-Induction-Circuit-Stability-Under-Fine-Tuning)"
" | Paper: [ArXiv TBD]_"
)
run_btn.click(
fn=analyse_text,
inputs=[input_text, model_state, sel_layer, sel_head],
outputs=[attn_plot, ind_plot, circuit_plot, status_box],
)
input_text.submit(
fn=analyse_text,
inputs=[input_text, model_state, sel_layer, sel_head],
outputs=[attn_plot, ind_plot, circuit_plot, status_box],
)
return demo
if __name__ == "__main__":
demo = build_interface()
demo.launch(
server_name="0.0.0.0",
server_port=int(os.environ.get("PORT", 7860)),
show_error=True,
)