lymnal commited on
Commit
80f1cb1
·
verified ·
1 Parent(s): 409f616

feat: initial HF Spaces deploy of NeuroScope backend

Browse files
Files changed (7) hide show
  1. .dockerignore +17 -0
  2. Dockerfile +24 -0
  3. README.md +41 -7
  4. main.py +264 -0
  5. model.py +92 -0
  6. requirements.txt +15 -0
  7. research.py +470 -0
.dockerignore ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ __pycache__/
2
+ *.py[cod]
3
+ *$py.class
4
+ *.so
5
+ .pytest_cache/
6
+ .mypy_cache/
7
+ .venv/
8
+ venv/
9
+ env/
10
+ .env
11
+ .env.local
12
+ .DS_Store
13
+ *.swp
14
+ .idea/
15
+ .vscode/
16
+ .git/
17
+ .gitignore
Dockerfile ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ ENV PYTHONUNBUFFERED=1 \
4
+ PIP_NO_CACHE_DIR=1 \
5
+ TRANSFORMERS_CACHE=/app/.cache/huggingface \
6
+ HF_HOME=/app/.cache/huggingface
7
+
8
+ RUN apt-get update && apt-get install -y --no-install-recommends \
9
+ build-essential \
10
+ git \
11
+ && rm -rf /var/lib/apt/lists/*
12
+
13
+ WORKDIR /app
14
+
15
+ COPY requirements.txt .
16
+ RUN pip install --no-cache-dir -r requirements.txt
17
+
18
+ COPY main.py model.py research.py ./
19
+
20
+ RUN mkdir -p /app/.cache/huggingface && chmod -R 777 /app/.cache
21
+
22
+ EXPOSE 7860
23
+
24
+ CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860", "--timeout-keep-alive", "120"]
README.md CHANGED
@@ -1,12 +1,46 @@
1
  ---
2
- title: Neuroscope Api
3
- emoji: 🐢
4
- colorFrom: green
5
- colorTo: indigo
6
  sdk: docker
 
7
  pinned: false
8
- license: mit
9
- short_description: 'Browser-native mechanistic interpretability backend '
10
  ---
11
 
12
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: NeuroScope API
3
+ emoji: 🧠
4
+ colorFrom: blue
5
+ colorTo: purple
6
  sdk: docker
7
+ app_port: 7860
8
  pinned: false
 
 
9
  ---
10
 
11
+ # NeuroScope API
12
+
13
+ FastAPI + TransformerLens backend for the NeuroScope interpretability toolkit.
14
+
15
+ Exposes activation-extraction endpoints (logit lens, attention patterns, gradient-based token importance, steering vectors, PCA trajectories) for the NeuroScope frontend.
16
+
17
+ ## Endpoints
18
+
19
+ | Endpoint | Purpose |
20
+ |---|---|
21
+ | `POST /load` | Load a HuggingFace model (default: `gpt2-small`) |
22
+ | `POST /logit-lens` | Layer-by-layer next-token predictions |
23
+ | `POST /attention` | Attention pattern for a given (layer, head) |
24
+ | `POST /gradients` | Token-level gradient magnitudes w.r.t. a target token |
25
+ | `POST /steering-vector` | Difference-of-means vector from contrastive prompts |
26
+ | `POST /generate-steered` | Generation with a steering vector injected at a layer |
27
+ | `POST /ablate-direction` | Generation with a direction projected out of the residual stream (`h' = h − (h·d̂)d̂`) |
28
+ | `POST /pca-trajectories` | 3D PCA of residual stream across layers + tokens |
29
+ | `GET /contrastive-pairs` | Built-in sentiment contrastive prompt pairs |
30
+
31
+ Interactive docs at `/docs` once the Space is live.
32
+
33
+ ## Configuration
34
+
35
+ Set the following Space Variable/Secret in **Settings → Variables and secrets**:
36
+
37
+ - `ALLOWED_ORIGINS` — comma-separated CORS origins (e.g. `https://neuroscope.vercel.app,http://localhost:3001`)
38
+
39
+ ## Local development
40
+
41
+ ```bash
42
+ pip install -r requirements.txt
43
+ uvicorn main:app --reload --port 8000
44
+ ```
45
+
46
+ Frontend repo: [clearbox_ai on GitHub](https://github.com/ethan-sam/clearbox_ai).
main.py ADDED
@@ -0,0 +1,264 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ NeuroScope-Web API Server
3
+
4
+ FastAPI endpoints that expose Moon's research functions to the React frontend.
5
+ This replaces the browser-based transformers.js worker with server-side
6
+ TransformerLens inference.
7
+
8
+ Run with: uvicorn main:app --reload --port 8000
9
+ API docs: http://localhost:8000/docs
10
+ """
11
+
12
+ import os
13
+ from typing import List, Optional
14
+ from fastapi import FastAPI, HTTPException
15
+ from fastapi.middleware.cors import CORSMiddleware
16
+ from pydantic import BaseModel, Field
17
+
18
+ import model
19
+ import research
20
+
21
+ # -----------------------------------------------------------------------------
22
+ # App Setup
23
+ # -----------------------------------------------------------------------------
24
+
25
+ app = FastAPI(
26
+ title="NeuroScope-Web API",
27
+ description="Moon's interpretability research powered by TransformerLens",
28
+ version="0.1.0",
29
+ )
30
+
31
+ # CORS origins are configurable so the same image runs locally and on HF Spaces.
32
+ # In production, set ALLOWED_ORIGINS to your deployed frontend URL(s), e.g.
33
+ # ALLOWED_ORIGINS=https://neuroscope.vercel.app,https://preview.neuroscope.vercel.app
34
+ _default_origins = "http://localhost:3000,http://localhost:3001"
35
+ ALLOWED_ORIGINS = [
36
+ origin.strip()
37
+ for origin in os.environ.get("ALLOWED_ORIGINS", _default_origins).split(",")
38
+ if origin.strip()
39
+ ]
40
+
41
+ app.add_middleware(
42
+ CORSMiddleware,
43
+ allow_origins=ALLOWED_ORIGINS,
44
+ allow_methods=["*"],
45
+ allow_headers=["*"],
46
+ )
47
+
48
+
49
+ # -----------------------------------------------------------------------------
50
+ # Request/Response Models
51
+ # -----------------------------------------------------------------------------
52
+ # Pydantic models define the shape of data going in and out of endpoints
53
+
54
+ class LoadRequest(BaseModel):
55
+ model_name: str = Field(default="gpt2-small", description="Model to load")
56
+
57
+
58
+ class PromptRequest(BaseModel):
59
+ prompt: str = Field(..., description="Input text to analyze")
60
+
61
+
62
+ class GradientRequest(BaseModel):
63
+ prompt: str
64
+ target_token: str = Field(..., description="Token to compute gradients toward")
65
+
66
+
67
+ class AttentionRequest(BaseModel):
68
+ prompt: str
69
+ layer: int = Field(ge=0, le=11, description="Layer index (0-11 for GPT-2)")
70
+ head: int = Field(ge=0, le=11, description="Head index (0-11 for GPT-2)")
71
+
72
+
73
+ class SteeringRequest(BaseModel):
74
+ positive_prompts: List[str] = Field(..., min_length=1)
75
+ negative_prompts: List[str] = Field(..., min_length=1)
76
+ layer: int = Field(default=6, ge=0, le=11, description="Layer for extraction")
77
+
78
+
79
+ class SteeredGenerationRequest(BaseModel):
80
+ prompt: str
81
+ steering_vector: List[float]
82
+ alpha: float = Field(default=1.0, description="Steering strength")
83
+ layer: int = Field(default=6, ge=0, le=11)
84
+ max_new_tokens: int = Field(default=30, ge=1, le=100)
85
+
86
+
87
+ class AblationRequest(BaseModel):
88
+ prompt: str
89
+ direction: List[float] = Field(
90
+ ..., description="Direction to project out of the residual stream"
91
+ )
92
+ layer: int = Field(default=6, ge=0, le=11, description="Layer for ablation")
93
+ max_new_tokens: int = Field(default=30, ge=1, le=100)
94
+
95
+
96
+ # -----------------------------------------------------------------------------
97
+ # Endpoints
98
+ # -----------------------------------------------------------------------------
99
+
100
+ @app.get("/")
101
+ async def health():
102
+ """Simple health check - useful for verifying the server is running."""
103
+ return {"status": "ok", "service": "neuroscope-api"}
104
+
105
+
106
+ @app.post("/load")
107
+ async def load_model(req: LoadRequest):
108
+ """
109
+ Load a model into memory. Must be called before other endpoints.
110
+
111
+ GPT-2 small (~500MB) takes a few seconds to load on first call.
112
+ Subsequent calls with the same model return immediately.
113
+ """
114
+ try:
115
+ result = model.load_model(req.model_name)
116
+ return result
117
+ except Exception as e:
118
+ raise HTTPException(status_code=500, detail=str(e))
119
+
120
+
121
+ @app.post("/logit-lens")
122
+ async def logit_lens(req: PromptRequest):
123
+ """
124
+ Run logit lens analysis on a prompt.
125
+
126
+ Shows what the model would predict if we stopped at each layer.
127
+ This reveals how predictions refine through the network.
128
+
129
+ Moon's Eiffel Tower example: layers 0-10 predict "the",
130
+ layer 11 finally predicts "Paris".
131
+ """
132
+ try:
133
+ return research.logit_lens(req.prompt)
134
+ except RuntimeError as e:
135
+ raise HTTPException(status_code=400, detail=str(e))
136
+
137
+
138
+ @app.post("/attention")
139
+ async def attention_pattern(req: AttentionRequest):
140
+ """
141
+ Get attention weights for a specific layer and head.
142
+
143
+ Returns a matrix showing how each token attends to others.
144
+ Useful for finding interesting attention patterns like:
145
+ - Previous token heads (copying behavior)
146
+ - Position heads (attending to specific positions)
147
+ - Induction heads (in-context learning)
148
+ """
149
+ try:
150
+ return research.get_attention_pattern(req.prompt, req.layer, req.head)
151
+ except RuntimeError as e:
152
+ raise HTTPException(status_code=400, detail=str(e))
153
+
154
+
155
+ @app.post("/gradients")
156
+ async def token_gradients(req: GradientRequest):
157
+ """
158
+ Compute gradient-based token importance.
159
+
160
+ Shows which input tokens most influence the target prediction.
161
+ High gradient norm = modifying this token has big effect.
162
+
163
+ This is the foundation for adversarial attacks:
164
+ to change "Paris" → "Rome", focus on high-gradient tokens.
165
+ """
166
+ try:
167
+ return research.compute_token_gradients(req.prompt, req.target_token)
168
+ except RuntimeError as e:
169
+ raise HTTPException(status_code=400, detail=str(e))
170
+
171
+
172
+ @app.post("/steering-vector")
173
+ async def steering_vector(req: SteeringRequest):
174
+ """
175
+ Compute a steering vector from contrastive prompts.
176
+
177
+ The vector points from negative → positive in activation space.
178
+ Add it during generation to steer toward positive sentiment.
179
+ Subtract it to steer toward negative sentiment.
180
+
181
+ Moon's default: use layer 6, which balances semantic content
182
+ with malleability.
183
+ """
184
+ try:
185
+ return research.extract_steering_vector(
186
+ req.positive_prompts,
187
+ req.negative_prompts,
188
+ req.layer,
189
+ )
190
+ except RuntimeError as e:
191
+ raise HTTPException(status_code=400, detail=str(e))
192
+
193
+
194
+ @app.get("/contrastive-pairs")
195
+ async def contrastive_pairs():
196
+ """
197
+ Get Moon's curated sentiment pairs.
198
+
199
+ These are validated to tokenize to the same length,
200
+ which is required for computing steering vectors.
201
+ """
202
+ pairs = research.get_contrastive_pairs()
203
+ return {
204
+ "pairs": [{"positive": p, "negative": n} for p, n in pairs],
205
+ "count": len(pairs),
206
+ }
207
+
208
+
209
+ @app.post("/generate-steered")
210
+ async def generate_steered(req: SteeredGenerationRequest):
211
+ """
212
+ Generate text with a steering vector injected at a specific layer.
213
+ Returns both steered and baseline outputs for comparison.
214
+ """
215
+ try:
216
+ return research.generate_steered(
217
+ req.prompt,
218
+ req.steering_vector,
219
+ req.alpha,
220
+ req.layer,
221
+ req.max_new_tokens,
222
+ )
223
+ except RuntimeError as e:
224
+ raise HTTPException(status_code=400, detail=str(e))
225
+
226
+
227
+ @app.post("/ablate-direction")
228
+ async def ablate_direction(req: AblationRequest):
229
+ """
230
+ Generate text with a direction projected out of the residual stream.
231
+
232
+ Implements h' = h - (h · d̂) d̂ at the chosen layer. This is the causal
233
+ counterpart to /generate-steered: where steering ADDS a scaled vector,
234
+ ablation REMOVES the component along the direction. Standard primitive
235
+ for testing claims like "direction d mediates behavior X" (Arditi et al.).
236
+
237
+ Returns ablated and baseline generations for side-by-side comparison.
238
+ """
239
+ try:
240
+ return research.ablate_along_direction(
241
+ req.prompt,
242
+ req.direction,
243
+ req.layer,
244
+ req.max_new_tokens,
245
+ )
246
+ except RuntimeError as e:
247
+ raise HTTPException(status_code=400, detail=str(e))
248
+
249
+
250
+ @app.post("/pca-trajectories")
251
+ async def pca_trajectories(req: PromptRequest):
252
+ """
253
+ Get 3D PCA coordinates for all tokens across all layers.
254
+
255
+ This powers Moon's interactive 3D visualization showing
256
+ how token representations evolve through the network.
257
+
258
+ Returns x, y, z coordinates for each (token, layer) pair,
259
+ ready for Plotly or Three.js rendering on the frontend.
260
+ """
261
+ try:
262
+ return research.compute_pca_trajectories(req.prompt)
263
+ except RuntimeError as e:
264
+ raise HTTPException(status_code=400, detail=str(e))
model.py ADDED
@@ -0,0 +1,92 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Model Manager - TransformerLens wrapper
3
+
4
+ Provides a singleton HookedTransformer instance for the backend.
5
+ This replaces Moon's manual HuggingFace setup with cleaner abstractions.
6
+ """
7
+
8
+ from typing import Optional, Dict, Any
9
+ import torch
10
+ from transformer_lens import HookedTransformer
11
+
12
+ # Global model instance (avoids reloading ~500MB on each request)
13
+ _model: Optional[HookedTransformer] = None
14
+ _model_name: Optional[str] = None
15
+
16
+
17
+ def get_device() -> str:
18
+ """Detect best available device."""
19
+ if torch.cuda.is_available():
20
+ return "cuda"
21
+ elif torch.backends.mps.is_available():
22
+ return "mps"
23
+ return "cpu"
24
+
25
+
26
+ def load_model(name: str = "gpt2-small") -> Dict[str, Any]:
27
+ """
28
+ Load a HookedTransformer model.
29
+
30
+ Moon's notebooks used:
31
+ model = GPT2LMHeadModel.from_pretrained("gpt2")
32
+ model.config.output_attentions = True
33
+
34
+ TransformerLens equivalent:
35
+ model = HookedTransformer.from_pretrained("gpt2-small")
36
+ # Attentions and hidden states captured automatically via run_with_cache()
37
+ """
38
+ global _model, _model_name
39
+
40
+ if _model is not None and _model_name == name:
41
+ return {
42
+ "status": "already_loaded",
43
+ "model_name": name,
44
+ "n_layers": _model.cfg.n_layers,
45
+ "d_model": _model.cfg.d_model,
46
+ }
47
+
48
+ device = get_device()
49
+ _model = HookedTransformer.from_pretrained(name, device=device)
50
+ _model_name = name
51
+
52
+ return {
53
+ "status": "loaded",
54
+ "model_name": name,
55
+ "device": device,
56
+ "n_layers": _model.cfg.n_layers,
57
+ "d_model": _model.cfg.d_model,
58
+ "n_heads": _model.cfg.n_heads,
59
+ "d_vocab": _model.cfg.d_vocab,
60
+ }
61
+
62
+
63
+ def get_model() -> HookedTransformer:
64
+ """Get loaded model, raise if none."""
65
+ if _model is None:
66
+ raise RuntimeError("No model loaded. Call load_model() first.")
67
+ return _model
68
+
69
+
70
+ def run_with_cache(prompt: str):
71
+ """
72
+ Run inference and capture all activations.
73
+
74
+ Moon's notebooks did this manually:
75
+ outputs = model(**inputs, output_hidden_states=True)
76
+ hidden_states = outputs.hidden_states
77
+ attentions = outputs.attentions
78
+
79
+ TransformerLens:
80
+ logits, cache = model.run_with_cache(prompt)
81
+ # cache["blocks.0.hook_resid_post"] = residual after layer 0
82
+ # cache["blocks.0.attn.hook_pattern"] = attention pattern layer 0
83
+
84
+ Returns:
85
+ tokens: list of string tokens
86
+ logits: output logits tensor
87
+ cache: ActivationCache with all intermediate activations
88
+ """
89
+ model = get_model()
90
+ tokens = model.to_str_tokens(prompt)
91
+ logits, cache = model.run_with_cache(prompt)
92
+ return tokens, logits, cache
requirements.txt ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # NeuroScope-Web Backend
2
+ # TransformerLens-powered interpretability engine
3
+
4
+ # Core ML
5
+ torch==2.3.0
6
+ transformer-lens==2.11.0
7
+
8
+ # API
9
+ fastapi==0.115.0
10
+ uvicorn[standard]==0.30.6
11
+ pydantic==2.9.2
12
+
13
+ # Analysis
14
+ numpy==1.26.4
15
+ scikit-learn==1.5.2
research.py ADDED
@@ -0,0 +1,470 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Research Functions - Moon's interpretability logic powered by TransformerLens
3
+
4
+ This module contains the core research functions from Moon's notebooks,
5
+ adapted to use TransformerLens's cleaner cache API instead of manual hooks.
6
+
7
+ The research questions and analysis logic are Moon's - we just swapped
8
+ the plumbing from raw HuggingFace to TransformerLens.
9
+ """
10
+
11
+ from typing import List, Dict, Any, Tuple
12
+ import torch
13
+ import torch.nn.functional as F
14
+ from sklearn.decomposition import PCA
15
+
16
+ from model import get_model, run_with_cache
17
+
18
+
19
+ # -----------------------------------------------------------------------------
20
+ # Logit Lens
21
+ # -----------------------------------------------------------------------------
22
+ # Moon's original: manually grabbed hidden states, multiplied by lm_head.weight
23
+ # TransformerLens: we use the cache and model.unembed() or direct W_U access
24
+
25
+ def logit_lens(prompt: str, top_k: int = 5) -> Dict[str, Any]:
26
+ """
27
+ Apply the unembedding matrix to each layer's residual stream.
28
+
29
+ This answers: "If we stopped the model at layer L, what would it predict?"
30
+
31
+ The idea (from nostalgebraist's blog) is that each layer refines the
32
+ prediction. Early layers often predict generic tokens like "the",
33
+ while later layers converge on the contextually correct answer.
34
+
35
+ Moon's notebook showed this beautifully with the Eiffel Tower example:
36
+ layers 0-10 all predicted "the", but layer 11 finally predicted "Paris".
37
+
38
+ Math: logits_L = hidden_state_L @ W_U.T
39
+ where W_U is the unembedding matrix (vocab x hidden_dim)
40
+ """
41
+ model = get_model()
42
+ tokens, logits, cache = run_with_cache(prompt)
43
+
44
+ # W_U maps from hidden dimension to vocabulary
45
+ # In GPT-2, this is tied to the embedding matrix
46
+ W_U = model.W_U # shape: [d_model, d_vocab]
47
+
48
+ layer_predictions = []
49
+
50
+ for layer_idx in range(model.cfg.n_layers + 1):
51
+ # Layer 0 is the embedding, layers 1-12 are transformer blocks
52
+ # TransformerLens uses "blocks.X.hook_resid_post" for post-layer residuals
53
+ if layer_idx == 0:
54
+ # Embedding layer - before any transformer blocks
55
+ resid = cache["hook_embed"] + cache["hook_pos_embed"]
56
+ else:
57
+ # After transformer block (layer_idx - 1)
58
+ resid = cache[f"blocks.{layer_idx - 1}.hook_resid_post"]
59
+
60
+ # We only care about the last token position (next token prediction)
61
+ last_token_resid = resid[0, -1, :] # shape: [d_model]
62
+
63
+ # Project to vocabulary space
64
+ # Note: Moon's code skipped layer norm here for "raw" analysis
65
+ # For prediction parity with the model, you'd apply ln_final first
66
+ vocab_logits = last_token_resid @ W_U # shape: [d_vocab]
67
+ probs = F.softmax(vocab_logits, dim=-1)
68
+
69
+ # Get top-k predictions
70
+ top_probs, top_indices = torch.topk(probs, top_k)
71
+ predictions = [
72
+ {"token": model.to_string(idx.item()), "prob": round(p.item(), 4)}
73
+ for p, idx in zip(top_probs, top_indices)
74
+ ]
75
+
76
+ layer_name = "Embed" if layer_idx == 0 else f"Layer {layer_idx - 1}"
77
+ layer_predictions.append({"layer": layer_name, "top_k": predictions})
78
+
79
+ return {"prompt": prompt, "tokens": tokens, "predictions": layer_predictions}
80
+
81
+
82
+ # -----------------------------------------------------------------------------
83
+ # Attention Patterns
84
+ # -----------------------------------------------------------------------------
85
+ # Moon's notebook visualized these as heatmaps to see what tokens attend to what
86
+
87
+ def get_attention_pattern(prompt: str, layer: int, head: int) -> Dict[str, Any]:
88
+ """
89
+ Extract attention weights for a specific layer and head.
90
+
91
+ The attention pattern shows how each token "looks at" other tokens.
92
+ Shape is [seq_len, seq_len] where entry [i,j] is how much token i
93
+ attends to token j.
94
+
95
+ Moon used these to identify interesting heads - some heads attend to
96
+ the previous token (useful for copying), others attend to specific
97
+ syntactic positions.
98
+
99
+ GPT-2 small has 12 layers x 12 heads = 144 attention patterns to explore!
100
+ """
101
+ model = get_model()
102
+ tokens, logits, cache = run_with_cache(prompt)
103
+
104
+ # TransformerLens stores attention patterns at this hook point
105
+ # Shape: [batch, n_heads, seq_len, seq_len]
106
+ attn_pattern = cache[f"blocks.{layer}.attn.hook_pattern"]
107
+
108
+ # Extract the specific head we want
109
+ head_pattern = attn_pattern[0, head].cpu().tolist() # [seq_len, seq_len]
110
+
111
+ return {
112
+ "prompt": prompt,
113
+ "tokens": tokens,
114
+ "layer": layer,
115
+ "head": head,
116
+ "pattern": head_pattern,
117
+ }
118
+
119
+
120
+ # -----------------------------------------------------------------------------
121
+ # Gradient Analysis (Token Susceptibility)
122
+ # -----------------------------------------------------------------------------
123
+ # This is Moon's "Foundation for Adversarial Attacks" section
124
+ # It tells us which input tokens most influence a target prediction
125
+
126
+ def compute_token_gradients(prompt: str, target_token: str) -> Dict[str, Any]:
127
+ """
128
+ Compute how much each input token influences the target prediction.
129
+
130
+ Moon's insight: if you want to steer the model from predicting "Paris"
131
+ to predicting "Rome", which input tokens should you modify?
132
+
133
+ The gradient norm tells us the "susceptibility" of each position.
134
+ High gradient norm = changing this token has big impact on the target.
135
+
136
+ Moon's example showed that "iff" (from Eiffel), "city", and "Tower"
137
+ had the highest gradients when trying to change the prediction to Rome.
138
+ This makes intuitive sense - these are the most "French" tokens.
139
+
140
+ Math: We compute d(loss)/d(embedding) where loss = -log P(target)
141
+ """
142
+ model = get_model()
143
+
144
+ # Tokenize
145
+ tokens_tensor = model.to_tokens(prompt) # [1, seq_len]
146
+ str_tokens = model.to_str_tokens(prompt)
147
+ target_id = model.to_single_token(target_token)
148
+
149
+ # Get embeddings with gradient tracking
150
+ # We need to manually build the forward pass to get gradients on embeddings
151
+ embed = model.embed(tokens_tensor) # [1, seq_len, d_model]
152
+ pos_embed = model.pos_embed(tokens_tensor)
153
+
154
+ # Combine and enable gradients
155
+ # We keep a reference to this tensor since we want gradients w.r.t. it
156
+ input_resid = (embed + pos_embed).detach().requires_grad_(True)
157
+
158
+ # Forward through transformer blocks
159
+ resid = input_resid
160
+ for block in model.blocks:
161
+ resid = block(resid)
162
+
163
+ # Final layer norm and unembedding
164
+ resid = model.ln_final(resid)
165
+ logits = resid @ model.W_U # [1, seq_len, d_vocab]
166
+
167
+ # Loss: negative log probability of target token at last position
168
+ last_logits = logits[0, -1, :]
169
+ log_probs = F.log_softmax(last_logits, dim=-1)
170
+ loss = -log_probs[target_id]
171
+
172
+ # Backpropagate
173
+ loss.backward()
174
+
175
+ # The gradient on input_resid tells us sensitivity per position
176
+ # We take the L2 norm across the hidden dimension
177
+ grad_norms = input_resid.grad[0].norm(dim=-1).tolist() # [seq_len]
178
+
179
+ # Normalize for easier interpretation (0 to 1 scale)
180
+ max_norm = max(grad_norms)
181
+ normalized = [g / max_norm if max_norm > 0 else 0 for g in grad_norms]
182
+
183
+ return {
184
+ "prompt": prompt,
185
+ "target_token": target_token,
186
+ "tokens": str_tokens,
187
+ "gradient_norms": [
188
+ {"token": t, "norm": round(g, 4), "normalized": round(n, 4)}
189
+ for t, g, n in zip(str_tokens, grad_norms, normalized)
190
+ ],
191
+ }
192
+
193
+
194
+ # -----------------------------------------------------------------------------
195
+ # Steering Vectors
196
+ # -----------------------------------------------------------------------------
197
+ # Moon's steering_vectors.ipynb - the core of activation engineering
198
+
199
+ def get_contrastive_pairs() -> List[Tuple[str, str]]:
200
+ """
201
+ Moon's curated contrastive pairs for sentiment steering.
202
+
203
+ These pairs are designed so that:
204
+ 1. They differ only in sentiment (positive vs negative)
205
+ 2. They tokenize to the same length (critical for subtraction!)
206
+
207
+ Moon validated each pair's token length in the notebook.
208
+ """
209
+ return [
210
+ ("I think this movie is amazing", "I think this movie is terrible"),
211
+ ("The food at this restaurant is delicious", "The food at this restaurant is disgusting"),
212
+ ("I am feeling very happy today", "I am feeling very sad today"),
213
+ ("The product quality is excellent", "The product quality is awful"),
214
+ ("My experience was wonderful", "My experience was horrible"),
215
+ ("He is a very kind person", "He is a very mean person"),
216
+ ("The weather is beautiful", "The weather is nasty"),
217
+ ("This solution is perfect", "This solution is useless"),
218
+ ]
219
+
220
+
221
+ def extract_steering_vector(
222
+ positive_prompts: List[str],
223
+ negative_prompts: List[str],
224
+ layer: int,
225
+ ) -> Dict[str, Any]:
226
+ """
227
+ Compute a steering vector from contrastive examples.
228
+
229
+ Moon's formula: v_steering = mean(h_positive) - mean(h_negative)
230
+
231
+ This vector points in the direction of "positiveness" in activation space.
232
+ Adding it during generation steers toward positive sentiment.
233
+ Subtracting it steers toward negative sentiment.
234
+
235
+ We extract from the last token position because GPT-2 aggregates
236
+ context causally - the last token "knows" the full sequence.
237
+
238
+ Layer choice matters:
239
+ - Early layers (0-3): Low-level features, less semantic
240
+ - Middle layers (4-8): Good for semantic steering
241
+ - Late layers (9-11): Close to output, can be unstable
242
+ Moon typically used layer 6 as a good default.
243
+ """
244
+ model = get_model()
245
+
246
+ def get_last_token_activation(prompt: str, layer_idx: int) -> torch.Tensor:
247
+ """Extract residual stream at layer for the last token."""
248
+ _, _, cache = run_with_cache(prompt)
249
+ resid = cache[f"blocks.{layer_idx}.hook_resid_post"]
250
+ return resid[0, -1, :] # [d_model]
251
+
252
+ # Collect activations for both sets
253
+ pos_activations = [get_last_token_activation(p, layer) for p in positive_prompts]
254
+ neg_activations = [get_last_token_activation(n, layer) for n in negative_prompts]
255
+
256
+ # Compute means
257
+ mean_pos = torch.stack(pos_activations).mean(dim=0)
258
+ mean_neg = torch.stack(neg_activations).mean(dim=0)
259
+
260
+ # Steering vector: direction from negative to positive
261
+ steering_vector = mean_pos - mean_neg
262
+
263
+ return {
264
+ "layer": layer,
265
+ "n_positive": len(positive_prompts),
266
+ "n_negative": len(negative_prompts),
267
+ "vector_norm": round(steering_vector.norm().item(), 4),
268
+ "vector": steering_vector.tolist(),
269
+ }
270
+
271
+
272
+ # -----------------------------------------------------------------------------
273
+ # PCA Trajectories
274
+ # -----------------------------------------------------------------------------
275
+ # Moon's 3D visualization of how token representations evolve through layers
276
+
277
+ def compute_pca_trajectories(prompt: str) -> Dict[str, Any]:
278
+ """
279
+ Project all token representations through layers into 3D space.
280
+
281
+ Moon's insight: tokens start at similar positions (embeddings) and
282
+ diverge as they pass through layers. The trajectories reveal how
283
+ the model processes different tokens.
284
+
285
+ Questions this helps answer:
286
+ - Do semantically related tokens stay close? (e.g., "Eiffel" and "Tower")
287
+ - Where do trajectories diverge? Which layer differentiates roles?
288
+ - Do function words (the, is) behave differently from content words?
289
+
290
+ Moon noted that PC1 often captures ~97% of variance, suggesting
291
+ the residual stream has a dominant direction (likely related to
292
+ predicting the next token).
293
+ """
294
+ model = get_model()
295
+ tokens, logits, cache = run_with_cache(prompt)
296
+
297
+ # Collect all representations: every token at every layer
298
+ all_vectors = []
299
+ metadata = []
300
+
301
+ for layer_idx in range(model.cfg.n_layers + 1):
302
+ if layer_idx == 0:
303
+ resid = cache["hook_embed"] + cache["hook_pos_embed"]
304
+ else:
305
+ resid = cache[f"blocks.{layer_idx - 1}.hook_resid_post"]
306
+
307
+ for token_idx, token_str in enumerate(tokens):
308
+ vec = resid[0, token_idx, :].cpu().numpy()
309
+ all_vectors.append(vec)
310
+ metadata.append({
311
+ "token": token_str,
312
+ "token_idx": token_idx,
313
+ "layer": layer_idx,
314
+ })
315
+
316
+ # Fit PCA on all vectors together (unified coordinate space)
317
+ import numpy as np
318
+ vectors_matrix = np.stack(all_vectors)
319
+ pca = PCA(n_components=3)
320
+ coords_3d = pca.fit_transform(vectors_matrix)
321
+
322
+ # Attach coordinates to metadata
323
+ results = []
324
+ for i, meta in enumerate(metadata):
325
+ results.append({
326
+ **meta,
327
+ "x": round(float(coords_3d[i, 0]), 4),
328
+ "y": round(float(coords_3d[i, 1]), 4),
329
+ "z": round(float(coords_3d[i, 2]), 4),
330
+ })
331
+
332
+ return {
333
+ "prompt": prompt,
334
+ "tokens": tokens,
335
+ "variance_explained": [round(v, 4) for v in pca.explained_variance_ratio_],
336
+ "trajectories": results,
337
+ }
338
+
339
+
340
+ # -----------------------------------------------------------------------------
341
+ # Steered Generation
342
+ # -----------------------------------------------------------------------------
343
+
344
+ def generate_steered(
345
+ prompt: str,
346
+ steering_vector: List[float],
347
+ alpha: float,
348
+ layer: int,
349
+ max_new_tokens: int = 30,
350
+ ) -> Dict[str, Any]:
351
+ """
352
+ Generate text with a steering vector injected at the specified layer.
353
+
354
+ The steering vector is added to the residual stream during the forward pass:
355
+ h_steered = h_original + alpha * v_steering
356
+
357
+ Positive alpha steers toward the positive direction (e.g., positive sentiment).
358
+ Negative alpha steers toward the negative direction.
359
+ """
360
+ model = get_model()
361
+
362
+ tokens = model.to_tokens(prompt)
363
+
364
+ steering_tensor = torch.tensor(steering_vector, dtype=torch.float32, device=model.cfg.device)
365
+
366
+ def steering_hook(activation, hook):
367
+ # activation shape: [batch, seq_len, d_model]
368
+ # Add the steering vector (scaled by alpha) to all token positions
369
+ activation[:, :, :] = activation[:, :, :] + alpha * steering_tensor
370
+ return activation
371
+
372
+ # Generate with the hook active at the specified layer
373
+ hook_name = f"blocks.{layer}.hook_resid_post"
374
+
375
+ with model.hooks(fwd_hooks=[(hook_name, steering_hook)]):
376
+ output = model.generate(
377
+ tokens,
378
+ max_new_tokens=max_new_tokens,
379
+ temperature=0.7,
380
+ do_sample=True,
381
+ )
382
+
383
+ generated_text = model.to_string(output[0])
384
+
385
+ # Also generate without steering for comparison
386
+ baseline_output = model.generate(
387
+ tokens,
388
+ max_new_tokens=max_new_tokens,
389
+ temperature=0.7,
390
+ do_sample=True,
391
+ )
392
+ baseline_text = model.to_string(baseline_output[0])
393
+
394
+ return {
395
+ "prompt": prompt,
396
+ "layer": layer,
397
+ "alpha": alpha,
398
+ "steered_text": generated_text,
399
+ "baseline_text": baseline_text,
400
+ }
401
+
402
+
403
+ # -----------------------------------------------------------------------------
404
+ # Direction Ablation (Projection Removal)
405
+ # -----------------------------------------------------------------------------
406
+ # The causal counterpart to steering: instead of ADDING a vector, REMOVE the
407
+ # component of the residual stream that lies along the direction. This is the
408
+ # standard primitive for testing causal claims of the form "direction d
409
+ # mediates behavior X" (Arditi et al., 2024 — refusal direction).
410
+
411
+ def ablate_along_direction(
412
+ prompt: str,
413
+ direction: List[float],
414
+ layer: int,
415
+ max_new_tokens: int = 30,
416
+ ) -> Dict[str, Any]:
417
+ """
418
+ Generate text with the projection along `direction` removed at `layer`.
419
+
420
+ Hook math: h' = h - (h · d̂) d̂ where d̂ = direction / ||direction||
421
+
422
+ Zeros the residual stream's component along d̂ at every token position of
423
+ the chosen layer. Contrast with generate_steered, which adds alpha * v.
424
+
425
+ Returns both ablated and baseline generations so the caller can show a
426
+ side-by-side. Sampling temperature matches generate_steered for parity.
427
+ """
428
+ model = get_model()
429
+
430
+ direction_tensor = torch.tensor(
431
+ direction, dtype=torch.float32, device=model.cfg.device
432
+ )
433
+ norm = direction_tensor.norm()
434
+ if norm.item() < 1e-8:
435
+ raise RuntimeError("direction has near-zero norm; cannot ablate")
436
+ unit_direction = direction_tensor / norm
437
+
438
+ tokens = model.to_tokens(prompt)
439
+
440
+ def ablation_hook(activation, hook):
441
+ # activation shape: [batch, seq_len, d_model]
442
+ # Project each position's residual onto d̂, subtract that component.
443
+ coeffs = (activation * unit_direction).sum(dim=-1, keepdim=True)
444
+ activation[:, :, :] = activation - coeffs * unit_direction
445
+ return activation
446
+
447
+ hook_name = f"blocks.{layer}.hook_resid_post"
448
+
449
+ with model.hooks(fwd_hooks=[(hook_name, ablation_hook)]):
450
+ ablated_output = model.generate(
451
+ tokens,
452
+ max_new_tokens=max_new_tokens,
453
+ temperature=0.7,
454
+ do_sample=True,
455
+ )
456
+
457
+ baseline_output = model.generate(
458
+ tokens,
459
+ max_new_tokens=max_new_tokens,
460
+ temperature=0.7,
461
+ do_sample=True,
462
+ )
463
+
464
+ return {
465
+ "prompt": prompt,
466
+ "layer": layer,
467
+ "direction_norm_before": round(float(norm.item()), 4),
468
+ "ablated_text": model.to_string(ablated_output[0]),
469
+ "baseline_text": model.to_string(baseline_output[0]),
470
+ }