Spaces:
Running
Running
deploy
Browse files- README.md +39 -13
- __pycache__/app.cpython-312.pyc +0 -0
- app.py +1028 -0
- requirements.txt +7 -0
README.md
CHANGED
|
@@ -1,13 +1,39 @@
|
|
| 1 |
-
---
|
| 2 |
-
title: Bertographer
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
-
sdk: gradio
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
---
|
| 12 |
-
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: Bertographer
|
| 3 |
+
emoji: 🔭
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: green
|
| 6 |
+
sdk: gradio
|
| 7 |
+
app_file: app.py
|
| 8 |
+
pinned: false
|
| 9 |
+
license: mit
|
| 10 |
+
short_description: Mechanistic-interpretability cockpit for encoder models
|
| 11 |
+
---
|
| 12 |
+
|
| 13 |
+
# Bertographer
|
| 14 |
+
|
| 15 |
+
A mechanistic-interpretability **cockpit for encoder *classifiers*** (BERT /
|
| 16 |
+
RoBERTa / DeBERTa / DistilBERT / ELECTRA), framed in **production-observability**
|
| 17 |
+
language rather than lab notation.
|
| 18 |
+
|
| 19 |
+
Where a decoder probe reads a model's next-token futures, Bertographer reads an
|
| 20 |
+
encoder's **decision**: for every layer and attention head it projects the
|
| 21 |
+
pooling token's register into two spaces —
|
| 22 |
+
|
| 23 |
+
- **vocab** (logit-lens via the tied word embeddings) — what token this register "says"
|
| 24 |
+
- **class** (the model's own classification head) — which label this head votes for
|
| 25 |
+
|
| 26 |
+
…and lets an operator intervene (**mute / scale a head**) and watch the
|
| 27 |
+
prediction move. The framing is the engine room, not the notebook:
|
| 28 |
+
|
| 29 |
+
- input = a **trace** · the layer stack = the **waterfall** · heads = **spans / voters**
|
| 30 |
+
- mute / scale = the **counterfactual** ("what if the model hadn't seen this evidence?")
|
| 31 |
+
- compare = a **trace‑vs‑trace diff** ("what differed between this request and the last?")
|
| 32 |
+
|
| 33 |
+
Default model: `cardiffnlp/twitter-roberta-base-sentiment-latest` (3‑class
|
| 34 |
+
sentiment). Architecture is auto‑discovered; per‑head views degrade gracefully
|
| 35 |
+
when a model's attention output isn't a clean per‑head concatenation.
|
| 36 |
+
|
| 37 |
+
---
|
| 38 |
+
|
| 39 |
+
Built by **James J. Davison**, with Claude (Anthropic) as coding collaborator. **Responsibility for the code and its design choices rests with the human author.** MIT licensed. © 2026 James J. Davison.
|
__pycache__/app.cpython-312.pyc
ADDED
|
Binary file (72.2 kB). View file
|
|
|
app.py
ADDED
|
@@ -0,0 +1,1028 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Bertographer — Gradio Space: a mechanistic-interpretability cockpit for
|
| 2 |
+
encoder *classifiers* (BERT / RoBERTa / DeBERTa / DistilBERT / ELECTRA).
|
| 3 |
+
|
| 4 |
+
The decoder sibling (Cartogemma) reads a causal LM's next-token futures.
|
| 5 |
+
Bertographer reads an encoder classifier's *decision*: for every layer and
|
| 6 |
+
attention head, it projects the pooling token's register into TWO spaces —
|
| 7 |
+
|
| 8 |
+
· vocab (logit lens via tied word_embeddings) → what token this register "says"
|
| 9 |
+
· class (the model's own classification head) → which label this head votes
|
| 10 |
+
|
| 11 |
+
…and lets an operator intervene (mute / scale a head, fire a trigger) and watch
|
| 12 |
+
the prediction move. Framed for the engine room, not the lab:
|
| 13 |
+
|
| 14 |
+
input = a trace layers = the waterfall heads = spans / voters
|
| 15 |
+
mute/scale = the counterfactual ("what if the model hadn't seen this evidence?")
|
| 16 |
+
cmp = trace-vs-trace diff ("what differed between this request and the last?")
|
| 17 |
+
|
| 18 |
+
Default model: cardiffnlp/twitter-roberta-base-sentiment-latest (3-class sentiment).
|
| 19 |
+
Architecture is auto-discovered; per-head views degrade gracefully when a model's
|
| 20 |
+
attention output projection isn't a clean per-head concatenation.
|
| 21 |
+
|
| 22 |
+
Methodology lifted from the cartographer family:
|
| 23 |
+
cartographer/mechanistic_work/negation/deberta_cartographer.py (encoder cell: tok|class|cos)
|
| 24 |
+
cartographer/mechanistic_work/twitter_roberta/capture_*.py (4 projection spaces, per-head)
|
| 25 |
+
cartographer/mechanistic_work/twitter_roberta/intervene_sentiment.py (head-slot interventions)
|
| 26 |
+
gradiographer/cartogemma/app.py (Gradio cockpit + REPL + CSS)
|
| 27 |
+
"""
|
| 28 |
+
|
| 29 |
+
import os
|
| 30 |
+
import re
|
| 31 |
+
import sys
|
| 32 |
+
import json
|
| 33 |
+
import unicodedata
|
| 34 |
+
from dataclasses import dataclass, field
|
| 35 |
+
|
| 36 |
+
import torch
|
| 37 |
+
import torch.nn as nn
|
| 38 |
+
import torch.nn.functional as F
|
| 39 |
+
import gradio as gr
|
| 40 |
+
from transformers import AutoModelForSequenceClassification, AutoTokenizer, set_seed
|
| 41 |
+
|
| 42 |
+
# ── Config ──────────────────────────────────────────────────────────────────
|
| 43 |
+
DEFAULT_MODEL_ID = os.environ.get("BERTOGRAPHER_MODEL",
|
| 44 |
+
"cardiffnlp/twitter-roberta-base-sentiment-latest")
|
| 45 |
+
HF_TOKEN = os.environ.get("HF_TOKEN") or os.environ.get("HUGGING_FACE_HUB_TOKEN")
|
| 46 |
+
SEED = 42
|
| 47 |
+
DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
|
| 48 |
+
|
| 49 |
+
# ── HEAT palette for token-probability (mirrors cartographer3) ──────────────
|
| 50 |
+
HEAT_HEX = ["#59749C", "#948863", "#ECA60F", "#FFCF67", "#219C7F", "#E0D05B", "#64D32A"]
|
| 51 |
+
|
| 52 |
+
def heat_color(p: float) -> str:
|
| 53 |
+
if p >= 0.85: return HEAT_HEX[6]
|
| 54 |
+
if p >= 0.70: return HEAT_HEX[5]
|
| 55 |
+
if p >= 0.50: return HEAT_HEX[4]
|
| 56 |
+
if p >= 0.30: return HEAT_HEX[3]
|
| 57 |
+
if p >= 0.15: return HEAT_HEX[2]
|
| 58 |
+
if p >= 0.05: return HEAT_HEX[1]
|
| 59 |
+
return HEAT_HEX[0]
|
| 60 |
+
|
| 61 |
+
# Class colors: semantic for sentiment, palette cycle otherwise.
|
| 62 |
+
_CLASS_PALETTE = ["#64D32A", "#FFCF67", "#21C5C5", "#E78BFF", "#ff6b6b", "#9aa0ff", "#E0D05B"]
|
| 63 |
+
def class_color(label_name: str, idx: int) -> str:
|
| 64 |
+
n = label_name.lower()
|
| 65 |
+
if n.startswith("pos") or n in ("entailment", "positive", "label_2"):
|
| 66 |
+
return "#64D32A"
|
| 67 |
+
if n.startswith("neg") or n in ("contradiction", "negative", "label_0"):
|
| 68 |
+
return "#ff6b6b"
|
| 69 |
+
if n.startswith("neu") or n in ("neutral", "label_1"):
|
| 70 |
+
return "#FFCF67"
|
| 71 |
+
return _CLASS_PALETTE[idx % len(_CLASS_PALETTE)]
|
| 72 |
+
|
| 73 |
+
def char_width(ch: str) -> int:
|
| 74 |
+
cat = unicodedata.category(ch)
|
| 75 |
+
if cat in ("Mn", "Me"): return 0
|
| 76 |
+
eaw = unicodedata.east_asian_width(ch)
|
| 77 |
+
if eaw in ("W", "F"): return 2
|
| 78 |
+
return 1
|
| 79 |
+
|
| 80 |
+
def display_width(s: str) -> int:
|
| 81 |
+
return sum(char_width(c) for c in s)
|
| 82 |
+
|
| 83 |
+
def fmt_tok(tok: str, max_w: int = 7) -> str:
|
| 84 |
+
tok = tok.replace("\n", "\\n").replace("\r", "\\r").replace("\t", "\\t")
|
| 85 |
+
tok = tok.lstrip("▁").lstrip("Ġ") # SP / GPT-2 leading-space markers
|
| 86 |
+
if not tok:
|
| 87 |
+
tok = "·"
|
| 88 |
+
w, out = 0, []
|
| 89 |
+
for ch in tok:
|
| 90 |
+
cw = char_width(ch)
|
| 91 |
+
if w + cw > max_w: break
|
| 92 |
+
out.append(ch); w += cw
|
| 93 |
+
return "".join(out)
|
| 94 |
+
|
| 95 |
+
def html_escape(s: str) -> str:
|
| 96 |
+
return s.replace("&", "&").replace("<", "<").replace(">", ">")
|
| 97 |
+
|
| 98 |
+
def preprocess_tweet(text: str) -> str:
|
| 99 |
+
"""Cardiff TweetEval normalization — applied only for twitter-* models."""
|
| 100 |
+
text = re.sub(r"@\w+", "@user", text)
|
| 101 |
+
text = re.sub(r"http\S+", "http", text)
|
| 102 |
+
return text
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
# ── Architecture resolver ───────────────────────────────────────────────────
|
| 106 |
+
# An encoder classifier I can fully read needs three things:
|
| 107 |
+
# 1. an encoder block stack (ModuleList of layers), each with an attention
|
| 108 |
+
# output projection whose INPUT is the per-head concatenation
|
| 109 |
+
# (attention.output.dense for BERT/RoBERTa/DeBERTa/ELECTRA, attention.out_lin
|
| 110 |
+
# for DistilBERT). This gives per-head decomposition (Tier-2).
|
| 111 |
+
# 2. word_embeddings, for the tied-embedding logit lens (vocab projection).
|
| 112 |
+
# 3. a classification head I can replay on a single pooled vector.
|
| 113 |
+
# When (1) doesn't decompose cleanly I degrade to Tier-1 (full-layer only).
|
| 114 |
+
|
| 115 |
+
_BASE_ATTRS = ("roberta", "bert", "deberta", "distilbert", "electra",
|
| 116 |
+
"xlm_roberta", "camembert", "mpnet", "albert")
|
| 117 |
+
|
| 118 |
+
def _resolve_base(model):
|
| 119 |
+
for a in _BASE_ATTRS:
|
| 120 |
+
b = getattr(model, a, None)
|
| 121 |
+
if b is not None:
|
| 122 |
+
return b
|
| 123 |
+
# generic: first child module exposing embeddings + an encoder/transformer
|
| 124 |
+
for _, mod in model.named_children():
|
| 125 |
+
if hasattr(mod, "embeddings") and (hasattr(mod, "encoder") or hasattr(mod, "transformer")):
|
| 126 |
+
return mod
|
| 127 |
+
return model
|
| 128 |
+
|
| 129 |
+
def _resolve_layers(base):
|
| 130 |
+
enc = getattr(base, "encoder", None)
|
| 131 |
+
if enc is not None and hasattr(enc, "layer") and isinstance(enc.layer, nn.ModuleList):
|
| 132 |
+
return enc.layer
|
| 133 |
+
tr = getattr(base, "transformer", None) # DistilBERT
|
| 134 |
+
if tr is not None and hasattr(tr, "layer") and isinstance(tr.layer, nn.ModuleList):
|
| 135 |
+
return tr.layer
|
| 136 |
+
# ALBERT: encoder.albert_layer_groups[0].albert_layers (shared) — best effort
|
| 137 |
+
if enc is not None and hasattr(enc, "albert_layer_groups"):
|
| 138 |
+
grp = enc.albert_layer_groups[0]
|
| 139 |
+
if hasattr(grp, "albert_layers"):
|
| 140 |
+
return grp.albert_layers
|
| 141 |
+
# generic: longest attention-bearing ModuleList
|
| 142 |
+
best = None
|
| 143 |
+
for _, mod in base.named_modules():
|
| 144 |
+
if isinstance(mod, nn.ModuleList) and len(mod) and hasattr(mod[0], "attention"):
|
| 145 |
+
if best is None or len(mod) > len(best):
|
| 146 |
+
best = mod
|
| 147 |
+
if best is None:
|
| 148 |
+
raise RuntimeError("Could not locate an encoder layer stack for this model.")
|
| 149 |
+
return best
|
| 150 |
+
|
| 151 |
+
def _resolve_attn_out(layer):
|
| 152 |
+
"""The attention output projection whose input is per-head-concatenated."""
|
| 153 |
+
attn = getattr(layer, "attention", None)
|
| 154 |
+
if attn is not None:
|
| 155 |
+
out = getattr(attn, "output", None)
|
| 156 |
+
if out is not None and hasattr(out, "dense"): # BERT/RoBERTa/DeBERTa/ELECTRA
|
| 157 |
+
return out.dense
|
| 158 |
+
if hasattr(attn, "out_lin"): # DistilBERT
|
| 159 |
+
return attn.out_lin
|
| 160 |
+
if hasattr(attn, "dense"):
|
| 161 |
+
return attn.dense
|
| 162 |
+
return None
|
| 163 |
+
|
| 164 |
+
def _resolve_word_embeddings(base, model):
|
| 165 |
+
emb = getattr(base, "embeddings", None)
|
| 166 |
+
if emb is not None and hasattr(emb, "word_embeddings"):
|
| 167 |
+
return emb.word_embeddings
|
| 168 |
+
ie = model.get_input_embeddings()
|
| 169 |
+
return ie
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
class ClassHead:
|
| 173 |
+
"""Replays a model's classification head on a single pooled [hidden] vector.
|
| 174 |
+
Adapts across the five common encoder-classifier head shapes."""
|
| 175 |
+
def __init__(self, model):
|
| 176 |
+
self.kind = None
|
| 177 |
+
self.fns = []
|
| 178 |
+
clf = getattr(model, "classifier", None)
|
| 179 |
+
cname = type(clf).__name__ if clf is not None else ""
|
| 180 |
+
|
| 181 |
+
if hasattr(model, "pre_classifier") and clf is not None:
|
| 182 |
+
# DistilBERT: pre_classifier(Linear) -> ReLU -> classifier(Linear)
|
| 183 |
+
self.kind = "distilbert"
|
| 184 |
+
pre, c = model.pre_classifier, clf
|
| 185 |
+
self.fns = [lambda x: torch.relu(pre(x)), c]
|
| 186 |
+
elif clf is not None and hasattr(clf, "out_proj") and hasattr(clf, "dense"):
|
| 187 |
+
# RoBERTa(tanh) / ELECTRA(gelu) classification head
|
| 188 |
+
act = torch.tanh
|
| 189 |
+
if "electra" in cname.lower() or "electra" in type(model).__name__.lower():
|
| 190 |
+
act = F.gelu
|
| 191 |
+
d, op = clf.dense, clf.out_proj
|
| 192 |
+
self.kind = "roberta_head"
|
| 193 |
+
self.fns = [lambda x, d=d, act=act: act(d(x)), op]
|
| 194 |
+
elif getattr(model, "pooler", None) is not None and clf is not None:
|
| 195 |
+
# BERT pooler(dense+tanh) or DeBERTa ContextPooler(dense+gelu) -> classifier(Linear)
|
| 196 |
+
pooler = model.pooler
|
| 197 |
+
pname = type(pooler).__name__.lower()
|
| 198 |
+
act = F.gelu if "context" in pname or "deberta" in pname else torch.tanh
|
| 199 |
+
dense = getattr(pooler, "dense", None)
|
| 200 |
+
if dense is not None:
|
| 201 |
+
self.kind = "pooled"
|
| 202 |
+
self.fns = [lambda x, d=dense, act=act: act(d(x)), clf]
|
| 203 |
+
else:
|
| 204 |
+
self.kind = "linear"; self.fns = [clf]
|
| 205 |
+
elif clf is not None:
|
| 206 |
+
self.kind = "linear"; self.fns = [clf]
|
| 207 |
+
else:
|
| 208 |
+
raise RuntimeError("Could not locate a classification head for this model.")
|
| 209 |
+
|
| 210 |
+
# weight vectors of the final projection, for cosine-to-class
|
| 211 |
+
final = self.fns[-1]
|
| 212 |
+
self.final_weight = final.weight.detach() if hasattr(final, "weight") else None
|
| 213 |
+
|
| 214 |
+
def logits(self, vec: torch.Tensor) -> torch.Tensor:
|
| 215 |
+
x = vec
|
| 216 |
+
if x.dim() == 1:
|
| 217 |
+
x = x.unsqueeze(0)
|
| 218 |
+
for f in self.fns:
|
| 219 |
+
x = f(x)
|
| 220 |
+
return x # [1, num_labels]
|
| 221 |
+
|
| 222 |
+
def probs(self, vec: torch.Tensor):
|
| 223 |
+
with torch.no_grad():
|
| 224 |
+
return F.softmax(self.logits(vec).float(), dim=-1)[0]
|
| 225 |
+
|
| 226 |
+
def cosine(self, vec: torch.Tensor, class_idx: int) -> float:
|
| 227 |
+
if self.final_weight is None or class_idx >= self.final_weight.shape[0]:
|
| 228 |
+
return 0.0
|
| 229 |
+
v = vec.squeeze().float()
|
| 230 |
+
c = self.final_weight[class_idx].float()
|
| 231 |
+
return float(F.cosine_similarity(v.unsqueeze(0), c.unsqueeze(0)).item())
|
| 232 |
+
|
| 233 |
+
|
| 234 |
+
@dataclass
|
| 235 |
+
class Snapshot:
|
| 236 |
+
"""One forward pass, shared by every pane."""
|
| 237 |
+
class_logits: torch.Tensor # [num_labels]
|
| 238 |
+
class_probs: torch.Tensor # [num_labels]
|
| 239 |
+
hidden_states: tuple # len L+1, each [1, seq, hidden]
|
| 240 |
+
head_inputs: dict = field(default_factory=dict) # layer -> attn-out input [1, seq, H*hd]
|
| 241 |
+
pred: int = 0
|
| 242 |
+
margin: float = 0.0
|
| 243 |
+
entropy: float = 0.0 # over class distribution (bits)
|
| 244 |
+
|
| 245 |
+
|
| 246 |
+
# ── The Probe ───────────────────────────────────────────────────────────────
|
| 247 |
+
class EncoderProbe:
|
| 248 |
+
def __init__(self, model_id: str = None):
|
| 249 |
+
model_id = model_id or DEFAULT_MODEL_ID
|
| 250 |
+
self.model_id = model_id
|
| 251 |
+
print(f"[*] Loading {model_id} on {DEVICE}...")
|
| 252 |
+
kw = {"token": HF_TOKEN} if HF_TOKEN else {}
|
| 253 |
+
self.tokenizer = AutoTokenizer.from_pretrained(model_id, **kw)
|
| 254 |
+
self.model = AutoModelForSequenceClassification.from_pretrained(model_id, **kw)
|
| 255 |
+
self.model.to(DEVICE).eval()
|
| 256 |
+
self.is_tweet = "twitter" in model_id.lower()
|
| 257 |
+
|
| 258 |
+
cfg = self.model.config
|
| 259 |
+
self.base = _resolve_base(self.model)
|
| 260 |
+
self.layers = _resolve_layers(self.base)
|
| 261 |
+
self.num_layers = len(self.layers)
|
| 262 |
+
self.num_heads = int(getattr(cfg, "num_attention_heads", 0)) or 1
|
| 263 |
+
self.hidden_size = int(getattr(cfg, "hidden_size", 0)) or 0
|
| 264 |
+
self.head_dim = self.hidden_size // self.num_heads if self.num_heads else 0
|
| 265 |
+
self.word_emb = _resolve_word_embeddings(self.base, self.model)
|
| 266 |
+
self.vocab_size = self.word_emb.weight.shape[0]
|
| 267 |
+
self.head = ClassHead(self.model)
|
| 268 |
+
|
| 269 |
+
self.num_labels = int(getattr(cfg, "num_labels", 0)) or self.head.logits(
|
| 270 |
+
torch.zeros(self.hidden_size, device=DEVICE)).shape[-1]
|
| 271 |
+
id2label = getattr(cfg, "id2label", None) or {i: f"LABEL_{i}" for i in range(self.num_labels)}
|
| 272 |
+
self.label_names = [str(id2label.get(i, f"LABEL_{i}")) for i in range(self.num_labels)]
|
| 273 |
+
self.label_codes = self._make_codes(self.label_names)
|
| 274 |
+
|
| 275 |
+
# Tier-2 (per-head) support: attn-out input must decompose as heads*head_dim.
|
| 276 |
+
o0 = _resolve_attn_out(self.layers[0])
|
| 277 |
+
self._attn_outs = [_resolve_attn_out(l) for l in self.layers]
|
| 278 |
+
self.head_scan_supported = bool(
|
| 279 |
+
o0 is not None and self.head_dim
|
| 280 |
+
and o0.weight.shape[1] == self.num_heads * self.head_dim
|
| 281 |
+
)
|
| 282 |
+
self.arch_name = type(self.model).__name__
|
| 283 |
+
|
| 284 |
+
print(f"[*] {self.arch_name}: {self.num_layers}L x {self.num_heads}H, "
|
| 285 |
+
f"head_dim={self.head_dim}, hidden={self.hidden_size}, vocab={self.vocab_size}, "
|
| 286 |
+
f"labels={self.label_names}, head_scan="
|
| 287 |
+
f"{'yes' if self.head_scan_supported else 'NO (Tier-1)'}")
|
| 288 |
+
|
| 289 |
+
self.input_ids = None
|
| 290 |
+
self.attn_mask = None
|
| 291 |
+
self.pos = 0
|
| 292 |
+
self.snapshot = None
|
| 293 |
+
self.scaled_heads = {} # (layer, head) -> scale (0.0 == muted)
|
| 294 |
+
self._iv_handles = []
|
| 295 |
+
self.full_log = []
|
| 296 |
+
|
| 297 |
+
@staticmethod
|
| 298 |
+
def _make_codes(names):
|
| 299 |
+
codes, seen = [], set()
|
| 300 |
+
for n in names:
|
| 301 |
+
base = re.sub(r"[^A-Za-z0-9]", "", n).upper() or "X"
|
| 302 |
+
c = base[:3]
|
| 303 |
+
i = 1
|
| 304 |
+
while c in seen:
|
| 305 |
+
c = (base[:2] + str(i))[:3]; i += 1
|
| 306 |
+
seen.add(c); codes.append(c)
|
| 307 |
+
return codes
|
| 308 |
+
|
| 309 |
+
# ── architecture accessors ──
|
| 310 |
+
def attn_out(self, li):
|
| 311 |
+
return self._attn_outs[li]
|
| 312 |
+
|
| 313 |
+
def vocab_logits(self, vec):
|
| 314 |
+
"""Tied-embedding logit lens — encoder classifiers ship no lm_head."""
|
| 315 |
+
return F.linear(vec, self.word_emb.weight.to(vec.dtype))
|
| 316 |
+
|
| 317 |
+
def set_seed(self):
|
| 318 |
+
set_seed(SEED)
|
| 319 |
+
|
| 320 |
+
def load_input(self, text: str, text_pair: str = None):
|
| 321 |
+
text = text or ""
|
| 322 |
+
if self.is_tweet:
|
| 323 |
+
text = preprocess_tweet(text)
|
| 324 |
+
if text_pair:
|
| 325 |
+
text_pair = preprocess_tweet(text_pair)
|
| 326 |
+
enc = self.tokenizer(text, text_pair, return_tensors="pt",
|
| 327 |
+
truncation=True, max_length=512)
|
| 328 |
+
self.input_ids = enc["input_ids"].to(DEVICE)
|
| 329 |
+
self.attn_mask = enc.get("attention_mask")
|
| 330 |
+
if self.attn_mask is not None:
|
| 331 |
+
self.attn_mask = self.attn_mask.to(DEVICE)
|
| 332 |
+
self.pos = 0
|
| 333 |
+
self.full_log.append({"type": "load", "text": text[:200]})
|
| 334 |
+
|
| 335 |
+
# ── intervention hooks (operate on per-head slots of the attn-out input) ──
|
| 336 |
+
def _install_iv_hooks(self):
|
| 337 |
+
for h in self._iv_handles:
|
| 338 |
+
h.remove()
|
| 339 |
+
self._iv_handles = []
|
| 340 |
+
if not self.scaled_heads or not self.head_scan_supported:
|
| 341 |
+
return
|
| 342 |
+
by_layer = {}
|
| 343 |
+
for (l, h), s in self.scaled_heads.items():
|
| 344 |
+
by_layer.setdefault(l, {})[h] = s
|
| 345 |
+
hd = self.head_dim
|
| 346 |
+
for li, scales in by_layer.items():
|
| 347 |
+
mod = self.attn_out(li)
|
| 348 |
+
if mod is None:
|
| 349 |
+
continue
|
| 350 |
+
def make_hook(scales, hd=hd):
|
| 351 |
+
def fn(module, args):
|
| 352 |
+
x = args[0].clone()
|
| 353 |
+
for h, s in scales.items():
|
| 354 |
+
x[:, :, h * hd:(h + 1) * hd] *= s
|
| 355 |
+
return (x,) + args[1:]
|
| 356 |
+
return fn
|
| 357 |
+
self._iv_handles.append(mod.register_forward_pre_hook(make_hook(scales)))
|
| 358 |
+
|
| 359 |
+
def scale_head(self, layer, head, scale):
|
| 360 |
+
if not self.head_scan_supported:
|
| 361 |
+
return f"per-head intervention unsupported for {self.arch_name} (Tier-1 only)"
|
| 362 |
+
if not (0 <= layer < self.num_layers): return f"layer {layer} out of range"
|
| 363 |
+
if not (0 <= head < self.num_heads): return f"head {head} out of range"
|
| 364 |
+
if scale == 1.0:
|
| 365 |
+
self.scaled_heads.pop((layer, head), None)
|
| 366 |
+
else:
|
| 367 |
+
self.scaled_heads[(layer, head)] = float(scale)
|
| 368 |
+
self._install_iv_hooks()
|
| 369 |
+
self.full_log.append({"type": "scale", "layer": layer, "head": head, "scale": scale})
|
| 370 |
+
verb = "MUTED" if scale == 0.0 else (f"x{scale}" if scale != 1.0 else "cleared")
|
| 371 |
+
return f"L{layer}H{head} {verb}"
|
| 372 |
+
|
| 373 |
+
def clear_interventions(self):
|
| 374 |
+
n = len(self.scaled_heads)
|
| 375 |
+
self.scaled_heads.clear()
|
| 376 |
+
self._install_iv_hooks()
|
| 377 |
+
self.full_log.append({"type": "clear_iv"})
|
| 378 |
+
return f"cleared {n} intervention(s)"
|
| 379 |
+
|
| 380 |
+
# ── single shared forward pass ──
|
| 381 |
+
def forward_snapshot(self):
|
| 382 |
+
if self.input_ids is None:
|
| 383 |
+
self.snapshot = None
|
| 384 |
+
return None
|
| 385 |
+
captured = {}
|
| 386 |
+
handles = []
|
| 387 |
+
if self.head_scan_supported:
|
| 388 |
+
# capture the (already intervention-scaled) input to each attn-out proj
|
| 389 |
+
for li in range(self.num_layers):
|
| 390 |
+
op = self.attn_out(li)
|
| 391 |
+
if op is not None:
|
| 392 |
+
handles.append(op.register_forward_pre_hook(
|
| 393 |
+
(lambda li: (lambda m, a: captured.__setitem__(li, a[0].detach())))(li)))
|
| 394 |
+
try:
|
| 395 |
+
with torch.no_grad():
|
| 396 |
+
out = self.model(self.input_ids, attention_mask=self.attn_mask,
|
| 397 |
+
output_hidden_states=True)
|
| 398 |
+
finally:
|
| 399 |
+
for h in handles:
|
| 400 |
+
h.remove()
|
| 401 |
+
logits = out.logits[0].float()
|
| 402 |
+
probs = F.softmax(logits, dim=-1)
|
| 403 |
+
sp = torch.sort(probs, descending=True).values
|
| 404 |
+
margin = float((sp[0] - sp[1]).item()) if probs.numel() > 1 else float(sp[0].item())
|
| 405 |
+
ent = float(-(probs * torch.log2(probs + 1e-12)).sum().item())
|
| 406 |
+
self.snapshot = Snapshot(
|
| 407 |
+
class_logits=logits, class_probs=probs, hidden_states=out.hidden_states,
|
| 408 |
+
head_inputs=captured, pred=int(probs.argmax().item()),
|
| 409 |
+
margin=margin, entropy=ent,
|
| 410 |
+
)
|
| 411 |
+
return self.snapshot
|
| 412 |
+
|
| 413 |
+
# ── per-head register projections at self.pos ──
|
| 414 |
+
def _project_vec(self, vec, width=3):
|
| 415 |
+
"""Project a [hidden] register → (top tokens, class probs, top class, cos)."""
|
| 416 |
+
with torch.no_grad():
|
| 417 |
+
vl = self.vocab_logits(vec)
|
| 418 |
+
vp = F.softmax(vl, dim=-1)
|
| 419 |
+
tp, ti = torch.topk(vp, width)
|
| 420 |
+
toks = [(self.tokenizer.decode([ti[i].item()]), float(tp[i].item()))
|
| 421 |
+
for i in range(width)]
|
| 422 |
+
cp = self.head.probs(vec)
|
| 423 |
+
top_c = int(cp.argmax().item())
|
| 424 |
+
cos = self.head.cosine(vec, top_c)
|
| 425 |
+
return {"tokens": toks, "class_probs": cp.cpu(), "top_class": top_c, "cosine": cos}
|
| 426 |
+
|
| 427 |
+
def scan_heads(self, target_layers=None, target_heads=None, width=3):
|
| 428 |
+
snap = self.snapshot
|
| 429 |
+
if snap is None:
|
| 430 |
+
return None
|
| 431 |
+
if target_layers is None: target_layers = list(range(self.num_layers))
|
| 432 |
+
if target_heads is None: target_heads = list(range(self.num_heads))
|
| 433 |
+
target_layers = [l for l in target_layers if 0 <= l < self.num_layers]
|
| 434 |
+
target_heads = [h for h in target_heads if 0 <= h < self.num_heads]
|
| 435 |
+
pos = min(self.pos, snap.hidden_states[0].shape[1] - 1)
|
| 436 |
+
|
| 437 |
+
rows = {li: {} for li in target_layers}
|
| 438 |
+
# Layer column: full residual at this position
|
| 439 |
+
for li in target_layers:
|
| 440 |
+
vec = snap.hidden_states[li + 1][0, pos, :]
|
| 441 |
+
rows[li]["Layer"] = self._project_vec(vec, width)
|
| 442 |
+
|
| 443 |
+
if not (self.head_scan_supported and snap.head_inputs):
|
| 444 |
+
return {"rows": rows, "heads": [], "pos": pos}
|
| 445 |
+
|
| 446 |
+
for li in target_layers:
|
| 447 |
+
inp = snap.head_inputs.get(li)
|
| 448 |
+
if inp is None:
|
| 449 |
+
continue
|
| 450 |
+
wo = self.attn_out(li).weight # [hidden, hidden]
|
| 451 |
+
wv = wo.view(wo.shape[0], self.num_heads, self.head_dim)
|
| 452 |
+
x = inp[0, pos, :].view(self.num_heads, self.head_dim) # [H, hd]
|
| 453 |
+
# proj[h] = Wo[:, h, :] @ x[h] → [H, hidden]
|
| 454 |
+
proj_all = torch.einsum("khd,hd->hk", wv.to(x.dtype), x)
|
| 455 |
+
for hi in target_heads:
|
| 456 |
+
rows[li][hi] = self._project_vec(proj_all[hi], width)
|
| 457 |
+
return {"rows": rows, "heads": target_heads, "pos": pos}
|
| 458 |
+
|
| 459 |
+
# ── per-(head,layer) trace of one vocab token's rank ──
|
| 460 |
+
def vocab_rank_trace(self, token_str, rank_lo=0, rank_hi=None):
|
| 461 |
+
snap = self.snapshot
|
| 462 |
+
if snap is None or not (self.head_scan_supported and snap.head_inputs):
|
| 463 |
+
return "unsupported" if snap is not None else None
|
| 464 |
+
if token_str.isdigit():
|
| 465 |
+
tid = int(token_str); name = self.tokenizer.decode([tid])
|
| 466 |
+
else:
|
| 467 |
+
ids = self.tokenizer.encode(token_str, add_special_tokens=False)
|
| 468 |
+
if not ids: return None
|
| 469 |
+
tid = ids[0]; name = token_str
|
| 470 |
+
if rank_hi is None: rank_hi = self.vocab_size
|
| 471 |
+
pos = min(self.pos, snap.hidden_states[0].shape[1] - 1)
|
| 472 |
+
rows, index = [], []
|
| 473 |
+
for li in sorted(snap.head_inputs.keys()):
|
| 474 |
+
wo = self.attn_out(li).weight
|
| 475 |
+
wv = wo.view(wo.shape[0], self.num_heads, self.head_dim)
|
| 476 |
+
x = snap.head_inputs[li][0, pos, :].view(self.num_heads, self.head_dim)
|
| 477 |
+
proj_all = torch.einsum("khd,hd->hk", wv.to(x.dtype), x)
|
| 478 |
+
for hi in range(self.num_heads):
|
| 479 |
+
rows.append(proj_all[hi]); index.append((hi, li))
|
| 480 |
+
ranks = {}
|
| 481 |
+
if rows:
|
| 482 |
+
with torch.no_grad():
|
| 483 |
+
logits = self.vocab_logits(torch.stack(rows, 0)) # [N, vocab]
|
| 484 |
+
target = logits[:, tid].unsqueeze(1)
|
| 485 |
+
rk = (logits > target).sum(dim=1).add(1).tolist()
|
| 486 |
+
for (hi, li), r in zip(index, rk):
|
| 487 |
+
ranks[(hi, li)] = int(r)
|
| 488 |
+
return {"name": name, "tid": tid, "rank_lo": rank_lo, "rank_hi": rank_hi,
|
| 489 |
+
"ranks": ranks, "mode": "rank"}
|
| 490 |
+
|
| 491 |
+
# ── per-(head,layer) probability of one class ──
|
| 492 |
+
def class_prob_trace(self, class_idx):
|
| 493 |
+
snap = self.snapshot
|
| 494 |
+
if snap is None or not (self.head_scan_supported and snap.head_inputs):
|
| 495 |
+
return "unsupported" if snap is not None else None
|
| 496 |
+
if not (0 <= class_idx < self.num_labels):
|
| 497 |
+
return None
|
| 498 |
+
pos = min(self.pos, snap.hidden_states[0].shape[1] - 1)
|
| 499 |
+
vals = {}
|
| 500 |
+
for li in sorted(snap.head_inputs.keys()):
|
| 501 |
+
wo = self.attn_out(li).weight
|
| 502 |
+
wv = wo.view(wo.shape[0], self.num_heads, self.head_dim)
|
| 503 |
+
x = snap.head_inputs[li][0, pos, :].view(self.num_heads, self.head_dim)
|
| 504 |
+
proj_all = torch.einsum("khd,hd->hk", wv.to(x.dtype), x)
|
| 505 |
+
for hi in range(self.num_heads):
|
| 506 |
+
vals[(hi, li)] = float(self.head.probs(proj_all[hi])[class_idx].item())
|
| 507 |
+
return {"class_idx": class_idx, "name": self.label_names[class_idx],
|
| 508 |
+
"vals": vals, "mode": "class"}
|
| 509 |
+
|
| 510 |
+
|
| 511 |
+
# ── HTML renderers ──────────────────────────────────────────────────────────
|
| 512 |
+
CELL_W = 6
|
| 513 |
+
|
| 514 |
+
def render_input(p: EncoderProbe) -> str:
|
| 515 |
+
if p is None or p.input_ids is None:
|
| 516 |
+
return "<div class='pane-body'><i>No trace loaded. Enter text and click Load.</i></div>"
|
| 517 |
+
toks = p.tokenizer.convert_ids_to_tokens(p.input_ids[0])
|
| 518 |
+
special = set(p.tokenizer.all_special_tokens)
|
| 519 |
+
parts = [f"<div class='dim'>[{len(toks)} tokens · inspecting pos {p.pos}]</div>",
|
| 520 |
+
"<div class='context-text'>"]
|
| 521 |
+
for i, t in enumerate(toks):
|
| 522 |
+
disp = html_escape(t.replace("▁", "·").replace("Ġ", "·"))
|
| 523 |
+
if i == p.pos:
|
| 524 |
+
parts.append(f"<span class='pos-here'>[{i}:{disp}]</span> ")
|
| 525 |
+
elif t in special:
|
| 526 |
+
parts.append(f"<span class='dim'>[{i}:{disp}]</span> ")
|
| 527 |
+
else:
|
| 528 |
+
parts.append(f"<span class='tokn'><span class='dim'>{i}:</span>{disp}</span> ")
|
| 529 |
+
parts.append("</div>")
|
| 530 |
+
return "<div class='pane-body scroll'>" + "".join(parts) + "</div>"
|
| 531 |
+
|
| 532 |
+
def _cell(p, cell):
|
| 533 |
+
tok, tp = cell["tokens"][0]
|
| 534 |
+
ci = cell["top_class"]
|
| 535 |
+
cp = float(cell["class_probs"][ci].item())
|
| 536 |
+
code = p.label_codes[ci]
|
| 537 |
+
tc = heat_color(tp)
|
| 538 |
+
cc = class_color(p.label_names[ci], ci)
|
| 539 |
+
return (f"<td class='hm-cell'>"
|
| 540 |
+
f"<span style='color:{tc}'>{html_escape(fmt_tok(tok, CELL_W))}</span>"
|
| 541 |
+
f"<span class='cls' style='color:{cc}'>{code}{int(cp*100):02d}</span>"
|
| 542 |
+
f"<span class='cos'>.{int(abs(cell['cosine'])*99):02d}</span></td>")
|
| 543 |
+
|
| 544 |
+
def render_headmap(p: EncoderProbe, data: dict) -> str:
|
| 545 |
+
if not data:
|
| 546 |
+
return ("<div class='pane-body'><i>No scan yet. <code>h *</code> scans all "
|
| 547 |
+
"layers, <code>h L</code> one layer.</i></div>")
|
| 548 |
+
rows_data = data["rows"]
|
| 549 |
+
heads = data["heads"]
|
| 550 |
+
layers = sorted(rows_data.keys())
|
| 551 |
+
if not layers:
|
| 552 |
+
return "<div class='pane-body'><i>Empty scan.</i></div>"
|
| 553 |
+
|
| 554 |
+
hdr = ["<tr><th class='lay'>Lay</th>"]
|
| 555 |
+
for h in heads: hdr.append(f"<th class='ps'>H{h}</th>")
|
| 556 |
+
hdr.append("<th class='full'>Layer</th></tr>")
|
| 557 |
+
sub = ["<tr class='subhdr'><th></th>"]
|
| 558 |
+
if heads:
|
| 559 |
+
sub.append(f"<th class='ps' colspan='{len(heads)}'>per-head span "
|
| 560 |
+
f"(head→o_proj→vocab|class)</th>")
|
| 561 |
+
sub.append("<th class='full'>residual</th></tr>")
|
| 562 |
+
|
| 563 |
+
body = []
|
| 564 |
+
for l in layers:
|
| 565 |
+
r = [f"<tr><td class='lay'>L{l}</td>"]
|
| 566 |
+
row = rows_data[l]
|
| 567 |
+
for h in heads:
|
| 568 |
+
r.append(_cell(p, row[h]) if h in row else "<td class='hm-cell dim'>·</td>")
|
| 569 |
+
r.append(_cell(p, row["Layer"]).replace("hm-cell", "hm-cell full")
|
| 570 |
+
if "Layer" in row else "<td class='hm-cell full dim'>·</td>")
|
| 571 |
+
r.append("</tr>")
|
| 572 |
+
body.append("".join(r))
|
| 573 |
+
|
| 574 |
+
note = ""
|
| 575 |
+
if not heads:
|
| 576 |
+
note = ("<div class='dim' style='padding:2px 0'>per-head decomposition unavailable "
|
| 577 |
+
"for this architecture — full-layer residual only (Tier-1)</div>")
|
| 578 |
+
return (f"<div class='pane-body scroll'>{note}"
|
| 579 |
+
f"<table class='headmap'>{''.join(sub)}{''.join(hdr)}{''.join(body)}</table>"
|
| 580 |
+
f"<div class='legend dim'>cell: <b>tok</b> (logit-lens) · "
|
| 581 |
+
f"<b>CODE pp</b> (head's class vote) · <b>.cos</b> (cosine to that class)</div></div>")
|
| 582 |
+
|
| 583 |
+
def render_prediction(p: EncoderProbe) -> str:
|
| 584 |
+
if p is None or p.snapshot is None:
|
| 585 |
+
return "<div class='pane-body'><i>Load a trace to see the prediction.</i></div>"
|
| 586 |
+
s = p.snapshot
|
| 587 |
+
pred = s.pred
|
| 588 |
+
rows = ["<div class='pane-body scroll'>"]
|
| 589 |
+
rows.append(f"<div class='predline'>prediction "
|
| 590 |
+
f"<b style='color:{class_color(p.label_names[pred], pred)}'>"
|
| 591 |
+
f"{html_escape(p.label_names[pred])}</b> "
|
| 592 |
+
f"<span class='dim'>({s.class_probs[pred]*100:.1f}%) · "
|
| 593 |
+
f"margin {s.margin*100:.1f}pp · H={s.entropy:.2f} bits</span></div>")
|
| 594 |
+
rows.append("<table class='probs'>")
|
| 595 |
+
for i in range(p.num_labels):
|
| 596 |
+
pr = float(s.class_probs[i].item())
|
| 597 |
+
c = class_color(p.label_names[i], i)
|
| 598 |
+
bar = int(round(pr * 28))
|
| 599 |
+
mark = " ◀" if i == pred else ""
|
| 600 |
+
rows.append(
|
| 601 |
+
f"<tr><td class='plabel' style='color:{c}'>{html_escape(p.label_names[i])}</td>"
|
| 602 |
+
f"<td class='pbar'><span style='color:{c}'>{'█'*bar}{'·'*(28-bar)}</span></td>"
|
| 603 |
+
f"<td class='ppct'>{pr*100:5.1f}%{mark}</td></tr>")
|
| 604 |
+
rows.append("</table>")
|
| 605 |
+
|
| 606 |
+
# final-layer per-head vote tally (the "span attributes" view)
|
| 607 |
+
if p.head_scan_supported and s.head_inputs:
|
| 608 |
+
li = p.num_layers - 1
|
| 609 |
+
inp = s.head_inputs.get(li)
|
| 610 |
+
if inp is not None:
|
| 611 |
+
pos = min(p.pos, s.hidden_states[0].shape[1] - 1)
|
| 612 |
+
wo = p.attn_out(li).weight
|
| 613 |
+
wv = wo.view(wo.shape[0], p.num_heads, p.head_dim)
|
| 614 |
+
x = inp[0, pos, :].view(p.num_heads, p.head_dim)
|
| 615 |
+
proj_all = torch.einsum("khd,hd->hk", wv.to(x.dtype), x)
|
| 616 |
+
cells = []
|
| 617 |
+
for hi in range(p.num_heads):
|
| 618 |
+
ci = int(p.head.probs(proj_all[hi]).argmax().item())
|
| 619 |
+
c = class_color(p.label_names[ci], ci)
|
| 620 |
+
cells.append(f"<span class='vote' style='color:{c}' title='H{hi}'>"
|
| 621 |
+
f"{p.label_codes[ci]}</span>")
|
| 622 |
+
rows.append(f"<div class='votes'><span class='dim'>final-layer head votes "
|
| 623 |
+
f"(L{li}):</span> {' '.join(cells)}</div>")
|
| 624 |
+
rows.append("</div>")
|
| 625 |
+
return "".join(rows)
|
| 626 |
+
|
| 627 |
+
def render_trace(p: EncoderProbe, td: dict) -> str:
|
| 628 |
+
if not td:
|
| 629 |
+
return ("<div class='pane-body'><i>Trace one signal across heads×layers: "
|
| 630 |
+
"<code>spark <token></code> (vocab rank) or "
|
| 631 |
+
"<code>class <label></code> (vote probability).</i></div>")
|
| 632 |
+
n_layers, n_heads = p.num_layers, p.num_heads
|
| 633 |
+
band = 8
|
| 634 |
+
head_colors = ["#64D32A", "#FFCF67", "#21C5C5", "#E78BFF", "#ff6b6b", "#9aa0ff"]
|
| 635 |
+
lines = []
|
| 636 |
+
hdr = " " + "".join(f"{l:>3}" for l in range(n_layers))
|
| 637 |
+
|
| 638 |
+
if td["mode"] == "rank":
|
| 639 |
+
rank_lo, rank_hi = td["rank_lo"], td["rank_hi"]
|
| 640 |
+
span = max(rank_hi - rank_lo, 1)
|
| 641 |
+
ranks = td["ranks"]
|
| 642 |
+
lines.append(f"vocab rank of '{html_escape(td['name'])}' (id={td['tid']}) — "
|
| 643 |
+
f"{rank_lo} (top) → {rank_hi} (bottom)")
|
| 644 |
+
lines.append(f"<span class='dim'>{html_escape(hdr)}</span>")
|
| 645 |
+
lines.append(f" {'─'*(n_layers*3)}")
|
| 646 |
+
for hi in range(n_heads):
|
| 647 |
+
color = head_colors[hi % len(head_colors)]
|
| 648 |
+
grid = [[" "]*n_layers for _ in range(band)]
|
| 649 |
+
for li in range(n_layers):
|
| 650 |
+
r = ranks.get((hi, li), rank_hi + 1)
|
| 651 |
+
if r < rank_lo: grid[0][li] = "▲"
|
| 652 |
+
elif r > rank_hi: grid[band-1][li] = "▼"
|
| 653 |
+
else:
|
| 654 |
+
row = int((r - rank_lo)/span*(band-1))
|
| 655 |
+
grid[max(0, min(band-1, row))][li] = "●"
|
| 656 |
+
_emit_band(lines, grid, band, hi, n_layers, color, rank_lo, rank_hi, span)
|
| 657 |
+
if hi < n_heads - 1: lines.append(f" {'┈'*(n_layers*3)}")
|
| 658 |
+
lines.append(f" {'─'*(n_layers*3)}")
|
| 659 |
+
else:
|
| 660 |
+
vals = td["vals"]
|
| 661 |
+
c = class_color(td["name"], td["class_idx"])
|
| 662 |
+
lines.append(f"P({html_escape(td['name'])}) per head — 1.0 (top) → 0.0 (bottom)")
|
| 663 |
+
lines.append(f"<span class='dim'>{html_escape(hdr)}</span>")
|
| 664 |
+
lines.append(f" {'─'*(n_layers*3)}")
|
| 665 |
+
for hi in range(n_heads):
|
| 666 |
+
grid = [[" "]*n_layers for _ in range(band)]
|
| 667 |
+
for li in range(n_layers):
|
| 668 |
+
v = vals.get((hi, li), 0.0)
|
| 669 |
+
row = int((1.0 - v)*(band-1))
|
| 670 |
+
grid[max(0, min(band-1, row))][li] = "●"
|
| 671 |
+
_emit_band(lines, grid, band, hi, n_layers, c, 1.0, 0.0, 1.0, pct=True)
|
| 672 |
+
if hi < n_heads - 1: lines.append(f" {'┈'*(n_layers*3)}")
|
| 673 |
+
lines.append(f" {'─'*(n_layers*3)}")
|
| 674 |
+
return f"<div class='pane-body scroll'><pre class='spark'>{chr(10).join(lines)}</pre></div>"
|
| 675 |
+
|
| 676 |
+
def _emit_band(lines, grid, band, hi, n_layers, color, top, bot, span, pct=False):
|
| 677 |
+
for ri in range(band):
|
| 678 |
+
if ri == 0:
|
| 679 |
+
lab = f"H{hi:<2}{(top if not pct else 1.0):>6}│" if not pct else f"H{hi:<2}{1.0:>6.2f}│"
|
| 680 |
+
elif ri == band - 1:
|
| 681 |
+
lab = f" {bot:>6}│" if not pct else f" {0.0:>6.2f}│"
|
| 682 |
+
else:
|
| 683 |
+
lab = " │"
|
| 684 |
+
cells = []
|
| 685 |
+
for li in range(n_layers):
|
| 686 |
+
ch = grid[ri][li]
|
| 687 |
+
if ch in ("●", "▲", "▼"):
|
| 688 |
+
cells.append(f"<span style='color:{color}'> {ch} </span>")
|
| 689 |
+
else:
|
| 690 |
+
cells.append(f" {ch} ")
|
| 691 |
+
lines.append(html_escape(lab) + "".join(cells))
|
| 692 |
+
|
| 693 |
+
def render_summary(s) -> str:
|
| 694 |
+
if s.probe is None:
|
| 695 |
+
return "<div class='summary dim'>no model loaded</div>"
|
| 696 |
+
p = s.probe
|
| 697 |
+
snap = p.snapshot
|
| 698 |
+
n_tok = p.input_ids.shape[1] if p.input_ids is not None else 0
|
| 699 |
+
tier = "T2" if p.head_scan_supported else "T1"
|
| 700 |
+
pred = (f"<b style='color:{class_color(p.label_names[snap.pred], snap.pred)}'>"
|
| 701 |
+
f"{html_escape(p.label_names[snap.pred])}</b> {snap.class_probs[snap.pred]*100:.0f}%"
|
| 702 |
+
if snap else "—")
|
| 703 |
+
iv = (f"<span style='color:#ff6b6b'>iv {len(p.scaled_heads)}</span>"
|
| 704 |
+
if p.scaled_heads else "iv 0")
|
| 705 |
+
items = [
|
| 706 |
+
f"<b>{html_escape(p.arch_name)}</b> <span class='dim'>{html_escape(p.model_id)}</span>",
|
| 707 |
+
f"<span class='dim'>tier</span> {tier}",
|
| 708 |
+
f"<span class='dim'>L</span>{p.num_layers} <span class='dim'>×H</span>{p.num_heads}",
|
| 709 |
+
f"<span class='dim'>tok</span> {n_tok}",
|
| 710 |
+
f"<span class='dim'>pos</span> {p.pos}",
|
| 711 |
+
f"<span class='dim'>pred</span> {pred}",
|
| 712 |
+
f"<span class='dim'>H</span>=<b>{snap.entropy:.2f}</b>" if snap else "",
|
| 713 |
+
iv,
|
| 714 |
+
]
|
| 715 |
+
return "<div class='summary'>" + " <span class='sep'>·</span> ".join(x for x in items if x) + "</div>"
|
| 716 |
+
|
| 717 |
+
def status_html(s) -> str:
|
| 718 |
+
if not s.transcript:
|
| 719 |
+
return "<div class='pane-body dim'>Ready.</div>"
|
| 720 |
+
rows = []
|
| 721 |
+
for cmd, st in s.transcript[-22:]:
|
| 722 |
+
rows.append(f"<div><span class='prompt'>›</span> <code>{html_escape(cmd)}</code> "
|
| 723 |
+
f"<span class='dim'>— {html_escape(st)}</span></div>")
|
| 724 |
+
return f"<div class='pane-body scroll'>{''.join(rows)}</div>"
|
| 725 |
+
|
| 726 |
+
|
| 727 |
+
# ── Session state ───────────────────────────────────────────────────────────
|
| 728 |
+
class Session:
|
| 729 |
+
def __init__(self):
|
| 730 |
+
self.probe = None
|
| 731 |
+
self.scan_data = None
|
| 732 |
+
self.trace_data = None
|
| 733 |
+
self.transcript = []
|
| 734 |
+
self.cmp = None # (label_names, probs, pred) of a compared trace
|
| 735 |
+
self.loaded_model = None
|
| 736 |
+
self.loaded_text = None
|
| 737 |
+
self.loaded_pair = None
|
| 738 |
+
|
| 739 |
+
def load(self, model_id, text, pair, force=False):
|
| 740 |
+
changed = (force or self.probe is None or self.loaded_model != model_id
|
| 741 |
+
or self.loaded_text != text or self.loaded_pair != pair)
|
| 742 |
+
if not changed:
|
| 743 |
+
return False
|
| 744 |
+
if self.probe is None or self.loaded_model != model_id:
|
| 745 |
+
self.probe = EncoderProbe(model_id)
|
| 746 |
+
self.probe.set_seed()
|
| 747 |
+
self.probe.scaled_heads.clear(); self.probe._install_iv_hooks()
|
| 748 |
+
self.probe.load_input(text, pair or None)
|
| 749 |
+
self.scan_data = None; self.trace_data = None; self.transcript = []; self.cmp = None
|
| 750 |
+
self.loaded_model, self.loaded_text, self.loaded_pair = model_id, text, pair
|
| 751 |
+
return True
|
| 752 |
+
|
| 753 |
+
def ensure(self, model_id, text=""):
|
| 754 |
+
if self.probe is None:
|
| 755 |
+
self.load(model_id, text, None)
|
| 756 |
+
|
| 757 |
+
|
| 758 |
+
def panes(s):
|
| 759 |
+
return (render_summary(s), render_input(s.probe),
|
| 760 |
+
render_headmap(s.probe, s.scan_data),
|
| 761 |
+
render_prediction(s.probe), render_trace(s.probe, s.trace_data),
|
| 762 |
+
status_html(s))
|
| 763 |
+
|
| 764 |
+
def _refresh(s, rescan=True):
|
| 765 |
+
if s.probe is None or s.probe.input_ids is None:
|
| 766 |
+
return
|
| 767 |
+
s.probe.forward_snapshot()
|
| 768 |
+
if rescan and s.scan_data is not None:
|
| 769 |
+
layers = sorted(s.scan_data["rows"].keys())
|
| 770 |
+
heads = s.scan_data["heads"] or None
|
| 771 |
+
s.scan_data = s.probe.scan_heads(target_layers=layers, target_heads=heads)
|
| 772 |
+
|
| 773 |
+
|
| 774 |
+
# ── command handling ────────────────────────────────────────────────────────
|
| 775 |
+
def initial_load(model_id, text, pair, s):
|
| 776 |
+
rebuilt = s.load(model_id, text, pair or None)
|
| 777 |
+
_refresh(s, rescan=False)
|
| 778 |
+
s.scan_data = s.probe.scan_heads()
|
| 779 |
+
tier = "Tier-2 (per-head)" if s.probe.head_scan_supported else "Tier-1 only"
|
| 780 |
+
s.transcript.append(("(load)", f"{'loaded' if rebuilt else 'ready'} {s.probe.arch_name} "
|
| 781 |
+
f"{s.probe.num_layers}L×{s.probe.num_heads}H · "
|
| 782 |
+
f"labels {','.join(s.probe.label_codes)} · {tier}"))
|
| 783 |
+
return panes(s)
|
| 784 |
+
|
| 785 |
+
def _resolve_class_arg(p, arg):
|
| 786 |
+
arg = arg.strip()
|
| 787 |
+
if arg.isdigit():
|
| 788 |
+
return int(arg)
|
| 789 |
+
al = arg.lower()
|
| 790 |
+
for i, (n, c) in enumerate(zip(p.label_names, p.label_codes)):
|
| 791 |
+
if al == c.lower() or n.lower().startswith(al) or al in n.lower():
|
| 792 |
+
return i
|
| 793 |
+
return None
|
| 794 |
+
|
| 795 |
+
def handle(cmd, s, model_id):
|
| 796 |
+
cmd = (cmd or "").strip()
|
| 797 |
+
if not cmd:
|
| 798 |
+
return panes(s)
|
| 799 |
+
s.ensure(model_id)
|
| 800 |
+
p = s.probe
|
| 801 |
+
st = "ok"
|
| 802 |
+
try:
|
| 803 |
+
lc = cmd.lower()
|
| 804 |
+
if lc.startswith("h "):
|
| 805 |
+
parts = cmd.split()
|
| 806 |
+
tl = None if parts[1] == "*" else [int(parts[1])]
|
| 807 |
+
th = None
|
| 808 |
+
if len(parts) > 2:
|
| 809 |
+
th = None if parts[2] == "*" else [int(parts[2])]
|
| 810 |
+
if p.snapshot is None: p.forward_snapshot()
|
| 811 |
+
s.scan_data = p.scan_heads(target_layers=tl, target_heads=th)
|
| 812 |
+
st = f"scan layers={parts[1]} heads={parts[2] if len(parts)>2 else '*'}"
|
| 813 |
+
|
| 814 |
+
elif lc.startswith("pos "):
|
| 815 |
+
p.pos = max(0, int(cmd[4:].strip()))
|
| 816 |
+
_refresh(s); st = f"pos={p.pos}"
|
| 817 |
+
|
| 818 |
+
elif lc.startswith("spark "):
|
| 819 |
+
rest = cmd[6:].strip().split()
|
| 820 |
+
tok = rest[0]; rl, rh = 0, None
|
| 821 |
+
if len(rest) > 1 and ":" in rest[1]:
|
| 822 |
+
a, b = rest[1].split(":"); rl, rh = int(a), int(b)
|
| 823 |
+
if p.snapshot is None: p.forward_snapshot()
|
| 824 |
+
td = p.vocab_rank_trace(tok, rl, rh)
|
| 825 |
+
if td == "unsupported": st = f"rank trace unsupported ({p.arch_name}, Tier-1)"
|
| 826 |
+
elif td is None: st = f"token '{tok}' not in vocab"
|
| 827 |
+
else: s.trace_data = td; st = f"spark '{tok}'"
|
| 828 |
+
|
| 829 |
+
elif lc.startswith("class "):
|
| 830 |
+
ci = _resolve_class_arg(p, cmd[6:])
|
| 831 |
+
if ci is None: st = f"unknown class '{cmd[6:].strip()}' (have {','.join(p.label_codes)})"
|
| 832 |
+
else:
|
| 833 |
+
if p.snapshot is None: p.forward_snapshot()
|
| 834 |
+
td = p.class_prob_trace(ci)
|
| 835 |
+
if td == "unsupported": st = f"class trace unsupported ({p.arch_name}, Tier-1)"
|
| 836 |
+
else: s.trace_data = td; st = f"class {p.label_names[ci]}"
|
| 837 |
+
|
| 838 |
+
elif lc.startswith("mute "):
|
| 839 |
+
a = cmd.split(); st = p.scale_head(int(a[1]), int(a[2]), 0.0); _refresh(s)
|
| 840 |
+
elif lc.startswith("scale "):
|
| 841 |
+
a = cmd.split(); st = p.scale_head(int(a[1]), int(a[2]), float(a[3])); _refresh(s)
|
| 842 |
+
elif lc.startswith("unmute "):
|
| 843 |
+
a = cmd.split(); st = p.scale_head(int(a[1]), int(a[2]), 1.0); _refresh(s)
|
| 844 |
+
elif lc in ("clear", "unmute all"):
|
| 845 |
+
st = p.clear_interventions(); _refresh(s)
|
| 846 |
+
|
| 847 |
+
elif lc.startswith("cmp "):
|
| 848 |
+
other = cmd[4:].strip()
|
| 849 |
+
base_ids, base_mask, base_pos = p.input_ids, p.attn_mask, p.pos
|
| 850 |
+
base_pred = p.snapshot.pred if p.snapshot else None
|
| 851 |
+
base_probs = p.snapshot.class_probs.clone() if p.snapshot else None
|
| 852 |
+
p.load_input(other); p.forward_snapshot()
|
| 853 |
+
diff = []
|
| 854 |
+
if base_probs is not None:
|
| 855 |
+
for i in range(p.num_labels):
|
| 856 |
+
d = float(p.snapshot.class_probs[i] - base_probs[i])
|
| 857 |
+
diff.append(f"{p.label_codes[i]}{d*100:+.0f}")
|
| 858 |
+
new_pred = p.label_names[p.snapshot.pred]
|
| 859 |
+
old_pred = p.label_names[base_pred] if base_pred is not None else "?"
|
| 860 |
+
s.cmp = (new_pred, diff)
|
| 861 |
+
# restore the primary trace
|
| 862 |
+
p.input_ids, p.attn_mask, p.pos = base_ids, base_mask, base_pos
|
| 863 |
+
p.forward_snapshot()
|
| 864 |
+
if s.scan_data is not None:
|
| 865 |
+
s.scan_data = p.scan_heads(target_layers=sorted(s.scan_data["rows"].keys()),
|
| 866 |
+
target_heads=s.scan_data["heads"] or None)
|
| 867 |
+
st = f"cmp: {old_pred}→{new_pred} Δ[{' '.join(diff)}]"
|
| 868 |
+
|
| 869 |
+
elif lc == "s":
|
| 870 |
+
from datetime import datetime
|
| 871 |
+
fn = f"/tmp/bertographer_{datetime.now():%Y%m%d_%H%M%S}.json"
|
| 872 |
+
try:
|
| 873 |
+
with open(fn, "w") as f: json.dump(p.full_log, f, indent=2)
|
| 874 |
+
st = f"saved {fn}"
|
| 875 |
+
except Exception as e:
|
| 876 |
+
st = f"save failed: {e}"
|
| 877 |
+
elif lc in ("r", "refresh"):
|
| 878 |
+
_refresh(s); st = "refreshed"
|
| 879 |
+
elif lc in ("help", "?"):
|
| 880 |
+
st = "see help line under the command bar"
|
| 881 |
+
else:
|
| 882 |
+
st = f"unknown: {cmd}"
|
| 883 |
+
except Exception as e:
|
| 884 |
+
st = f"error: {type(e).__name__}: {e}"
|
| 885 |
+
s.transcript.append((cmd, st))
|
| 886 |
+
return panes(s)
|
| 887 |
+
|
| 888 |
+
|
| 889 |
+
# ── Gradio UI ───────────────────────────────────────────────────────────────
|
| 890 |
+
CSS = """
|
| 891 |
+
.gradio-container { max-width: 100% !important; padding: 6px 10px !important; }
|
| 892 |
+
.gradio-container .main { padding: 0 !important; gap: 4px !important; }
|
| 893 |
+
.gradio-container .gap, .gradio-container .form { gap: 4px !important; }
|
| 894 |
+
.gradio-container .block { padding: 3px !important; border-radius: 4px !important; }
|
| 895 |
+
.gradio-container .prose { margin: 0 !important; }
|
| 896 |
+
#hdr h1 { font-size: 18px !important; margin: 0 !important; line-height: 1.2 !important; }
|
| 897 |
+
#hdr p, #hdr em { font-size: 11px !important; margin: 0 !important; color: #8b949e !important; line-height: 1.25 !important; }
|
| 898 |
+
#hdr { margin: 0 !important; padding: 4px 0 !important; }
|
| 899 |
+
#topbar { gap: 6px !important; }
|
| 900 |
+
#topbar textarea, #topbar input[type="text"] { font-size: 12px !important; padding: 4px 6px !important; min-height: 28px !important; line-height: 1.3 !important; }
|
| 901 |
+
#topbar label, #topbar span[data-testid="block-label"] { font-size: 10px !important; padding: 0 0 2px 0 !important; margin: 0 !important; text-transform: uppercase; letter-spacing: .5px; color: #8b949e !important; }
|
| 902 |
+
#topbar button { min-height: 56px !important; align-self: stretch !important; font-size: 13px !important; }
|
| 903 |
+
#cmdbar textarea, #cmdbar input[type="text"] { font-size: 12px !important; padding: 4px 8px !important; min-height: 28px !important; font-family: 'JetBrains Mono','Fira Code',Consolas,monospace !important; }
|
| 904 |
+
#cmdbar label, #cmdbar span[data-testid="block-label"] { font-size: 10px !important; color: #8b949e !important; margin: 0 !important; }
|
| 905 |
+
#cmdbar button { min-height: 36px !important; font-size: 12px !important; }
|
| 906 |
+
#help { font-size: 10.5px !important; color: #8b949e !important; margin: 2px 0 !important; }
|
| 907 |
+
#help code { font-size: 10.5px !important; padding: 0 2px !important; background: #161b22 !important; }
|
| 908 |
+
.pane { border: 1px solid #30363d; border-radius: 4px; background: #0d1117; color: #c9d1d9; font-family: 'JetBrains Mono','Fira Code',Consolas,monospace; font-size: 12px; }
|
| 909 |
+
.pane h3 { margin: 0; padding: 3px 8px; background: #161b22; border-bottom: 1px solid #30363d; font-size: 10px; letter-spacing: .5px; text-transform: uppercase; color: #8b949e; }
|
| 910 |
+
.pane-body { padding: 5px 8px; max-height: 460px; overflow: auto; }
|
| 911 |
+
.dim { color: #6e7681; }
|
| 912 |
+
.context-text { white-space: pre-wrap; word-break: break-word; margin-top: 4px; line-height: 1.7; }
|
| 913 |
+
.tokn { background: #11161c; border: 1px solid #21262d; border-radius: 3px; padding: 0 3px; }
|
| 914 |
+
.pos-here { background: #0f2418; outline: 1px solid #64D32A; color: #b8f0c4; border-radius: 3px; padding: 0 3px; font-weight: bold; }
|
| 915 |
+
table.headmap { border-collapse: collapse; font-size: 11px; }
|
| 916 |
+
table.headmap th, table.headmap td { padding: 1px 4px; border: 1px solid #21262d; text-align: center; white-space: pre; }
|
| 917 |
+
table.headmap th.ps { background: #112; } table.headmap th.full { background: #133; color: #64D32A; }
|
| 918 |
+
table.headmap td.lay { color: #8b949e; } table.headmap td.full { background: #0a1410; }
|
| 919 |
+
table.headmap tr.subhdr th { background: #161b22; color: #8b949e; font-weight: normal; font-size: 10px; }
|
| 920 |
+
.hm-cell .cls { font-weight: bold; padding-left: 3px; } .hm-cell .cos { color: #6e7681; padding-left: 2px; }
|
| 921 |
+
.legend { padding: 4px 0 0 0; font-size: 10px; }
|
| 922 |
+
.predline { padding: 2px 0 6px 0; font-size: 13px; }
|
| 923 |
+
table.probs { border-collapse: collapse; font-size: 12px; width: 100%; }
|
| 924 |
+
table.probs td { padding: 1px 6px; white-space: pre; }
|
| 925 |
+
table.probs td.plabel { font-weight: bold; } table.probs td.pbar { font-family: monospace; letter-spacing: -1px; }
|
| 926 |
+
table.probs td.ppct { text-align: right; color: #c9d1d9; }
|
| 927 |
+
.votes { margin-top: 8px; font-size: 11px; line-height: 1.6; }
|
| 928 |
+
.vote { display: inline-block; min-width: 22px; text-align: center; background: #11161c; border: 1px solid #21262d; border-radius: 3px; margin: 1px; padding: 0 2px; font-weight: bold; }
|
| 929 |
+
#summary-strip { padding: 0 !important; margin: 0 !important; }
|
| 930 |
+
.summary { font-family: 'JetBrains Mono',Consolas,monospace; font-size: 11px; padding: 4px 8px; background: #0d1117; border: 1px solid #30363d; border-radius: 4px; color: #c9d1d9; line-height: 1.5; }
|
| 931 |
+
.summary .sep { color: #30363d; padding: 0 4px; } .summary .dim { color: #6e7681; }
|
| 932 |
+
#dual-row { align-items: stretch !important; }
|
| 933 |
+
#dual-row > div { display: flex; flex-direction: column; }
|
| 934 |
+
#dual-row #col-pred, #dual-row #col-head { height: 56vh; min-height: 360px; max-height: 660px; }
|
| 935 |
+
#dual-row .pane { height: 100%; display: flex; flex-direction: column; overflow: hidden; }
|
| 936 |
+
#dual-row .pane h3 { flex: 0 0 auto; } #dual-row .pane .pane-body { flex: 1 1 auto; max-height: none !important; overflow: auto; }
|
| 937 |
+
pre.spark { margin: 0; font-size: 11px; line-height: 1.1; }
|
| 938 |
+
.prompt { color: #64D32A; }
|
| 939 |
+
.gradio-container textarea::placeholder, .gradio-container input[type="text"]::placeholder { color: #6e7681 !important; opacity: 1 !important; }
|
| 940 |
+
"""
|
| 941 |
+
|
| 942 |
+
HELP = (
|
| 943 |
+
"`h *` / `h L` / `h L H` head×layer scan · `pos N` inspect token position · "
|
| 944 |
+
"`spark <tok> [lo:hi]` vocab-rank trace · `class <label>` per-head vote trace · "
|
| 945 |
+
"`mute L H` · `scale L H X` · `unmute L H` · `clear` · "
|
| 946 |
+
"`cmp <text>` diff vs another trace · `r` refresh · `s` save"
|
| 947 |
+
)
|
| 948 |
+
|
| 949 |
+
MODEL_PRESETS = [
|
| 950 |
+
"cardiffnlp/twitter-roberta-base-sentiment-latest",
|
| 951 |
+
"cardiffnlp/twitter-roberta-base-emotion",
|
| 952 |
+
"distilbert-base-uncased-finetuned-sst-2-english",
|
| 953 |
+
"textattack/bert-base-uncased-SST-2",
|
| 954 |
+
"MoritzLaurer/DeBERTa-v3-base-mnli-fever-anli",
|
| 955 |
+
"google/electra-base-discriminator",
|
| 956 |
+
"ProsusAI/finbert",
|
| 957 |
+
]
|
| 958 |
+
|
| 959 |
+
def build_ui():
|
| 960 |
+
with gr.Blocks(title="Bertographer", css=CSS, theme=gr.themes.Base()) as demo:
|
| 961 |
+
gr.Markdown(
|
| 962 |
+
"# Bertographer \n*Mechanistic cockpit for encoder **classifiers** — "
|
| 963 |
+
"per-head logit-lens · per-head class votes · vocab/class rank traces · "
|
| 964 |
+
"head interventions — on BERT / RoBERTa / DeBERTa / DistilBERT / ELECTRA. "
|
| 965 |
+
"An input is a **trace**; layers are the **waterfall**; heads are **voters**; "
|
| 966 |
+
"muting a head is the **counterfactual**.*",
|
| 967 |
+
elem_id="hdr")
|
| 968 |
+
|
| 969 |
+
session = gr.State(Session())
|
| 970 |
+
|
| 971 |
+
with gr.Row(elem_id="topbar"):
|
| 972 |
+
model_id = gr.Dropdown(choices=MODEL_PRESETS, value=DEFAULT_MODEL_ID, label="Model",
|
| 973 |
+
allow_custom_value=True, filterable=True, scale=4)
|
| 974 |
+
text = gr.Textbox(label="Text (trace)",
|
| 975 |
+
value="I can't believe how good this turned out — absolutely thrilled.",
|
| 976 |
+
lines=1, max_lines=3, scale=6)
|
| 977 |
+
pair = gr.Textbox(label="Text pair (NLI/optional)", value="", lines=1, max_lines=3, scale=4)
|
| 978 |
+
load_btn = gr.Button("Load", variant="primary", scale=1, min_width=80)
|
| 979 |
+
|
| 980 |
+
summary_pane = gr.HTML(value="<div class='summary dim'>no model loaded</div>", elem_id="summary-strip")
|
| 981 |
+
ctx_pane = gr.HTML(value="<div class='pane'><h3>Trace · tokenized input</h3>"
|
| 982 |
+
"<div class='pane-body'><i>Click <b>Load</b> to begin.</i></div></div>")
|
| 983 |
+
|
| 984 |
+
with gr.Row(elem_id="cmdbar"):
|
| 985 |
+
cmd = gr.Textbox(label="CMD", placeholder=HELP, scale=10, autofocus=True,
|
| 986 |
+
lines=1, max_lines=1)
|
| 987 |
+
run_btn = gr.Button("Run", scale=1, variant="primary", min_width=70)
|
| 988 |
+
gr.Markdown(HELP, elem_id="help")
|
| 989 |
+
|
| 990 |
+
with gr.Row(elem_id="dual-row"):
|
| 991 |
+
with gr.Column(scale=3, min_width=300, elem_id="col-pred"):
|
| 992 |
+
pred_pane = gr.HTML(value="<div class='pane'><h3>Prediction · class probs · head votes</h3>"
|
| 993 |
+
"<div class='pane-body'><i>Appears after Load.</i></div></div>")
|
| 994 |
+
with gr.Column(scale=7, elem_id="col-head"):
|
| 995 |
+
hm_pane = gr.HTML(value="<div class='pane'><h3>Head Map · per-head vocab|class at pos</h3>"
|
| 996 |
+
"<div class='pane-body'><i>Auto-scans after each step.</i></div></div>")
|
| 997 |
+
trace_pane = gr.HTML(value="<div class='pane'><h3>Rank / Vote Trace · per (head,layer)</h3>"
|
| 998 |
+
"<div class='pane-body'><i>Run <code>spark <token></code> or "
|
| 999 |
+
"<code>class <label></code>.</i></div></div>")
|
| 1000 |
+
status = gr.HTML(value="<div class='pane'><h3>Transcript</h3>"
|
| 1001 |
+
"<div class='pane-body dim'>Ready.</div></div>")
|
| 1002 |
+
|
| 1003 |
+
def _wrap(summ, ctx, hm, pred, tr, stt):
|
| 1004 |
+
return (summ,
|
| 1005 |
+
f"<div class='pane'><h3>Trace · tokenized input</h3>{ctx}</div>",
|
| 1006 |
+
f"<div class='pane'><h3>Head Map · per-head vocab|class at pos</h3>{hm}</div>",
|
| 1007 |
+
f"<div class='pane'><h3>Prediction · class probs · head votes</h3>{pred}</div>",
|
| 1008 |
+
f"<div class='pane'><h3>Rank / Vote Trace · per (head,layer)</h3>{tr}</div>",
|
| 1009 |
+
f"<div class='pane'><h3>Transcript</h3>{stt}</div>")
|
| 1010 |
+
|
| 1011 |
+
OUT = [summary_pane, ctx_pane, hm_pane, pred_pane, trace_pane, status]
|
| 1012 |
+
|
| 1013 |
+
def on_load(mid, t, pr, s):
|
| 1014 |
+
summ, ctx, hm, pred, tr, stt = initial_load(mid, t, pr, s)
|
| 1015 |
+
return _wrap(summ, ctx, hm, pred, tr, stt) + (s,)
|
| 1016 |
+
|
| 1017 |
+
def on_cmd(c, s, mid):
|
| 1018 |
+
summ, ctx, hm, pred, tr, stt = handle(c, s, mid)
|
| 1019 |
+
return _wrap(summ, ctx, hm, pred, tr, stt) + ("", s)
|
| 1020 |
+
|
| 1021 |
+
load_btn.click(on_load, inputs=[model_id, text, pair, session], outputs=OUT + [session])
|
| 1022 |
+
run_btn.click(on_cmd, inputs=[cmd, session, model_id], outputs=OUT + [cmd, session])
|
| 1023 |
+
cmd.submit(on_cmd, inputs=[cmd, session, model_id], outputs=OUT + [cmd, session])
|
| 1024 |
+
return demo
|
| 1025 |
+
|
| 1026 |
+
|
| 1027 |
+
if __name__ == "__main__":
|
| 1028 |
+
build_ui().queue().launch()
|
requirements.txt
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
gradio>=4.44
|
| 2 |
+
torch>=2.2
|
| 3 |
+
transformers>=4.45
|
| 4 |
+
sentencepiece
|
| 5 |
+
tiktoken
|
| 6 |
+
protobuf
|
| 7 |
+
accelerate
|