-
Notifications
You must be signed in to change notification settings - Fork 14
Expand file tree
/
Copy pathjacobian_lens_chat.py
More file actions
203 lines (170 loc) · 7.99 KB
/
Copy pathjacobian_lens_chat.py
File metadata and controls
203 lines (170 loc) · 7.99 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
"""Live, interactive Jacobian-lens (J-space) visualizer.
A chat page: send a message, the reply streams in, and every token is hoverable —
hovering shows the top-k tokens the model is *disposed to say* there (the J-lens
readout ``unembed(J_l @ h)``) across all captured layers; click to pin it. The
readout streams onto the tokens as the reply generates. Messages are editable /
deletable, top-k and response length are adjustable, and thinking can be toggled.
Rather than send the lens on every message, this **registers** the readout hook
on the server once (the fitted lens is uploaded a single time), then the page
pulls the readout from ``/v1/hooks/collect`` (correlated by request id), polling
during generation so it streams in. Re-run this generator if the server restarts
(it re-registers the hook and rewrites the page).
The readout is the same projection ``jacobian_lens.py`` applies (transport by
``J_l`` -> RMSNorm -> unembed -> top-k). The hook is self-contained: it resolves
the final-norm name and decodes the top-k ids to strings on the worker, so the
page renders strings directly.
VLLM_USE_V2_MODEL_RUNNER=0 vllm serve Qwen/Qwen3-1.7B --port 8000
python examples/jacobian_lens_chat.py --lens lens.pt --base-url http://localhost:8000
# open jacobian_lens_chat.html (set --html-base-url to your forwarded port)
Single node assumed (get_parameter gathers locally; no prefetch).
Reference: Anthropic, "Verbalizable Representations Form a Global Workspace in
Language Models" (https://transformer-circuits.pub/2026/workspace).
"""
from __future__ import annotations
import argparse
from pathlib import Path
from vllm_lens import Hook
from vllm_lens.client import VLLMLensClient
from jacobian_lens import _norm_params, load_lens
def _build_hook(jacobians, layers, rms_eps, vocab_size, k, model_name) -> Hook:
"""A self-contained readout hook (registered on the server, run per request).
Cloudpickle serializes it by value (defined in ``__main__``), so it must not
reference the ``jacobian_lens`` module: it resolves the final-norm name and
decodes the top-k ids to strings on the worker itself. Per forward pass it
stores ``t_{layer}_{pass}`` (top-k strings) and ``p_{layer}_{pass}`` (probs).
"""
def jspace_hook(ctx, h):
import torch
from transformers import AutoTokenizer
cache = jspace_hook.__dict__
tok = cache.get("tok")
if tok is None:
tok = AutoTokenizer.from_pretrained(model_name)
cache["tok"] = tok
norm_name = cache.get("norm_name")
if norm_name is None:
m, parts = ctx.model, []
get_lm = getattr(m, "get_language_model", None)
lm = None
if callable(get_lm):
try:
lm = get_lm()
except (AttributeError, NotImplementedError):
lm = None
if lm is not None and lm is not m:
for name, mod in m.named_modules():
if mod is lm:
parts.append(name)
m = lm
break
inner = getattr(m, "model", None)
if inner is not None and hasattr(inner, "norm"):
parts += ["model", "norm", "weight"]
elif hasattr(m, "norm"):
parts += ["norm", "weight"]
norm_name = ".".join(parts)
cache["norm_name"] = norm_name
lm_w = ctx.get_parameter("lm_head.weight")
norm_w = ctx.get_parameter(norm_name)
# get_parameter() is a collective (TP all_gather), so every rank calls
# it — but only ONE rank per TP group may save: hidden states are
# replicated across TP ranks, and /collect's cross-rank merge would
# otherwise concatenate tp_size identical copies of each position,
# misaligning the readout against the token sequence.
from vllm.distributed.parallel_state import get_tp_group
if get_tp_group().rank_in_group != 0:
return None
with torch.no_grad():
x = h.float()
if ctx.layer_idx in jacobians:
J = jacobians[ctx.layer_idx].to(x.device).float()
x = x @ J.T
x = x * torch.rsqrt(x.pow(2).mean(-1, keepdim=True) + rms_eps)
logits = (x * norm_w.float()) @ lm_w.float().T
if vocab_size is not None:
logits = logits[..., :vocab_size]
probs, idx = logits.softmax(-1).topk(k, dim=-1)
rows = idx.cpu().tolist()
toks = [[tok.decode([i]) for i in row] for row in rows]
n = sum(1 for key in ctx.saved if key.startswith(f"t_{ctx.layer_idx}_"))
ctx.saved[f"t_{ctx.layer_idx}_{n}"] = toks
ctx.saved[f"p_{ctx.layer_idx}_{n}"] = probs.float().cpu().tolist()
return None
return Hook(fn=jspace_hook, layer_indices=list(layers))
def _write_html(layers, k, html_base_url, model, prompt, out):
import json
template = (Path(__file__).parent / "jacobian_lens_chat.html").read_text()
replacements = {
"$BASE_URL": json.dumps(html_base_url),
"$MODEL": json.dumps(model),
"$LAYERS": json.dumps(list(layers)),
"$TOPK": json.dumps(k),
"$INITIAL_PROMPT": json.dumps(prompt),
}
html = template
for key, value in replacements.items():
html = html.replace(key, value)
Path(out).write_text(html)
print(f"wrote {out}")
def main():
ap = argparse.ArgumentParser(description="Interactive J-space visualizer")
ap.add_argument(
"--lens", required=True, help="fitted lens .pt (jacobian_lens_fit.py)"
)
ap.add_argument(
"--base-url", default="http://localhost:8000", help="server URL for setup."
)
ap.add_argument(
"--layers",
default=None,
help="comma-separated layers to capture (default: all fitted).",
)
ap.add_argument(
"--k",
type=int,
default=25,
help="top-k tokens captured per position; the page's K control displays "
"up to this many (registered once, so K changes are instant/client-side).",
)
ap.add_argument("--prompt", default="Tell me about the Eiffel Tower.")
ap.add_argument(
"--out", default="jacobian_lens_chat", help="output basename (.html appended)."
)
ap.add_argument(
"--html-base-url",
default=None,
help="URL the PAGE calls at runtime (default: same as --base-url). Set to "
"your forwarded port, e.g. http://localhost:8123, or '' for same origin.",
)
args = ap.parse_args()
jacobians, _fitted = load_lens(args.lens)
if args.layers:
want = [int(x) for x in args.layers.split(",")]
jacobians = {lyr: jacobians[lyr] for lyr in want if lyr in jacobians}
layers = sorted(jacobians)
if not layers:
raise SystemExit("no layers to capture (check --lens / --layers)")
client = VLLMLensClient(args.base_url)
rms_eps, vocab_size = _norm_params(client.model)
jacobians = {lyr: v.half() for lyr, v in jacobians.items()}
# Register the readout hook on the server once (uploads the lens a single
# time). It then applies to every request; the page reads results back via
# /v1/hooks/collect. clear with client.clear_hooks() when done.
hook = _build_hook(jacobians, layers, rms_eps, vocab_size, args.k, client.model)
print(
f"registering J-lens hook (layers {layers}, top-{args.k}) on {args.base_url} ..."
)
client.clear_hooks() # replace any hook from a previous run (register appends)
client.register_hooks([hook])
print("registered.")
html_base = args.base_url if args.html_base_url is None else args.html_base_url
_write_html(
layers, args.k, html_base, client.model, args.prompt, f"{args.out}.html"
)
print(
"Open the HTML and type. Note: the hook is registered on the server and "
"applies to ALL requests until cleared (client.clear_hooks() / POST "
"/v1/hooks/clear); re-run this script if the server restarts."
)
if __name__ == "__main__":
main()