Spaces:
Sleeping
Sleeping
Commit ·
3d71d04
1
Parent(s): 3c5f2fd
Fixed ablation experiments, drilled down on which pre-loaded ones to run
Browse files- .claude/settings.local.json +2 -1
- .context/data/lessons.md +6 -0
- .context/data/sessions.md +7 -0
- plans.md +1 -0
- showcase-plan.md +23 -52
- tests/reproduce_ablation.py +9 -11
- tests/test_ablation_hook_placement.py +105 -0
- tests/test_head_categories.py +65 -0
- utils/__init__.py +0 -2
- utils/beam_search.py +36 -36
- utils/model_patterns.py +58 -254
.claude/settings.local.json
CHANGED
|
@@ -7,7 +7,8 @@
|
|
| 7 |
"Bash(git push:*)",
|
| 8 |
"Bash(grep -E '\\\\.\\(py|json|yaml|yml\\)$')",
|
| 9 |
"Bash(find /c/Users/cdpea/OneDrive/Documents/GradProject -name *.json -type f)",
|
| 10 |
-
"Bash(grep:*)"
|
|
|
|
| 11 |
]
|
| 12 |
}
|
| 13 |
}
|
|
|
|
| 7 |
"Bash(git push:*)",
|
| 8 |
"Bash(grep -E '\\\\.\\(py|json|yaml|yml\\)$')",
|
| 9 |
"Bash(find /c/Users/cdpea/OneDrive/Documents/GradProject -name *.json -type f)",
|
| 10 |
+
"Bash(grep:*)",
|
| 11 |
+
"Bash(python3:*)"
|
| 12 |
]
|
| 13 |
}
|
| 14 |
}
|
.context/data/lessons.md
CHANGED
|
@@ -26,3 +26,9 @@
|
|
| 26 |
**Root cause**: `from_pretrained()` without `torch_dtype=torch.float32` loads models in native dtype (float16/bfloat16). On CPU, these dtypes cause numerical instability and dtype mismatches in logit lens. GPT-2 Small happened to be natively float32, masking the bug.
|
| 27 |
**Fix**: Created centralized `load_model_for_inference()` with forced float32 + weight-tying check
|
| 28 |
**Rule going forward**: Always specify `torch_dtype=torch.float32` when loading models for CPU inference. Never scatter `from_pretrained` across multiple call sites — use a single loader.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
**Root cause**: `from_pretrained()` without `torch_dtype=torch.float32` loads models in native dtype (float16/bfloat16). On CPU, these dtypes cause numerical instability and dtype mismatches in logit lens. GPT-2 Small happened to be natively float32, masking the bug.
|
| 27 |
**Fix**: Created centralized `load_model_for_inference()` with forced float32 + weight-tying check
|
| 28 |
**Rule going forward**: Always specify `torch_dtype=torch.float32` when loading models for CPU inference. Never scatter `from_pretrained` across multiple call sites — use a single loader.
|
| 29 |
+
|
| 30 |
+
## 2026-03-21 — Key mismatch between raw JSON and enriched helper return values
|
| 31 |
+
**What happened**: Category buttons all showed "(0)" heads despite data existing
|
| 32 |
+
**Root cause**: `get_active_head_summary()` returns categories with key `'heads'`, but the raw JSON file (`head_categories.json`) uses `'top_heads'`. Code in app.py used the raw key against the enriched object.
|
| 33 |
+
**Fix**: Changed `cat_data.get('top_heads', [])` to `cat_data.get('heads', [])` in app.py
|
| 34 |
+
**Rule going forward**: When consuming data from a helper function, check the helper's return schema — don't assume it mirrors the raw data file's keys.
|
.context/data/sessions.md
CHANGED
|
@@ -33,3 +33,10 @@
|
|
| 33 |
**Work done**: Implemented category-based bulk head selection for the ablation panel. Added `head-categories-store` (dcc.Store), populated it from `update_pipeline_content` using existing `get_active_head_summary()` data. New `render_category_buttons` callback renders one button per category with pattern-matching IDs. Extended `manage_ablation_heads` callback with new Input/State to handle category clicks (append + deduplicate). Added placeholder div in ablation_panel.py. All 113 tests pass.
|
| 34 |
**Files changed**: app.py, components/ablation_panel.py
|
| 35 |
**Open threads**: Uncommitted — needs commit and manual verification.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
**Work done**: Implemented category-based bulk head selection for the ablation panel. Added `head-categories-store` (dcc.Store), populated it from `update_pipeline_content` using existing `get_active_head_summary()` data. New `render_category_buttons` callback renders one button per category with pattern-matching IDs. Extended `manage_ablation_heads` callback with new Input/State to handle category clicks (append + deduplicate). Added placeholder div in ablation_panel.py. All 113 tests pass.
|
| 34 |
**Files changed**: app.py, components/ablation_panel.py
|
| 35 |
**Open threads**: Uncommitted — needs commit and manual verification.
|
| 36 |
+
|
| 37 |
+
## 2026-03-21 — Category Button Bug Fix + Showcase Plan Finalization
|
| 38 |
+
**Area**: Ablation UX bug fix, showcase preparation
|
| 39 |
+
**Work done**: Fixed key mismatch bug in category buttons (`'top_heads'` → `'heads'` in app.py:708). The `categories_store` builder was reading from the raw JSON key instead of the enriched return key from `get_active_head_summary()`, causing all buttons to show "(0)". Updated showcase-plan.md with tested experiment results.
|
| 40 |
+
**Commits**: 3c5f2fd (category buttons), uncommitted (bug fix)
|
| 41 |
+
**Testing results**: User confirmed: repetition prompt ablated by induction + duplicate-token heads; chef and bat prompts ablated by previous token heads alone.
|
| 42 |
+
**Open threads**: Uncommitted bug fix. Showcase prep complete.
|
plans.md
CHANGED
|
@@ -1,3 +1,4 @@
|
|
| 1 |
- ~~make it more obvious that this is an experiment (pre-loaded things to ask)~~ done (018a9f2)
|
|
|
|
| 2 |
- make sure chatbot explains simpler
|
| 3 |
- glossary button move
|
|
|
|
| 1 |
- ~~make it more obvious that this is an experiment (pre-loaded things to ask)~~ done (018a9f2)
|
| 2 |
+
- ~~category buttons showing (0) heads~~ fixed (key mismatch: `top_heads` → `heads` in app.py:708)
|
| 3 |
- make sure chatbot explains simpler
|
| 4 |
- glossary button move
|
showcase-plan.md
CHANGED
|
@@ -90,15 +90,9 @@ Use **5-8 generated tokens** — fast enough to keep the demo snappy, long enoug
|
|
| 90 |
|
| 91 |
GPT-2's induction heads cluster heavily in Layer 5. This is actually a bonus for the demo — you can say *"all the pattern-matching lives in one layer"* and ablate multiple heads in the same layer with a few clicks.
|
| 92 |
|
| 93 |
-
**
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
- [ ] Ablate L5-H1 alone → does the prediction change?
|
| 97 |
-
- [ ] Ablate L5-H1 + L5-H5 together → stronger effect?
|
| 98 |
-
- [ ] Ablate L5-H0 + L5-H1 + L5-H5 (triple knockout in one layer) → maximum disruption
|
| 99 |
-
- [ ] Also try L6-H9 (score 0.30) and L7-H10 (score 0.28) if Layer 5 ablation alone isn't enough
|
| 100 |
-
- [ ] Find the **minimal ablation** that visibly changes the output (ideal = one click, big change)
|
| 101 |
-
- [ ] Record before/after outputs
|
| 102 |
|
| 103 |
**Narration by age:**
|
| 104 |
- Elementary: *"We broke its memory! It forgot what it already read!"*
|
|
@@ -123,12 +117,9 @@ GPT-2's induction heads cluster heavily in Layer 5. This is actually a bonus for
|
|
| 123 |
|
| 124 |
L4-H11 at 0.97 is remarkable — it's almost entirely dedicated to "look at the word right before me." This makes it a great single-click ablation target.
|
| 125 |
|
| 126 |
-
**
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
- [ ] Ablate L4-H11 + L2-H2 → more degradation?
|
| 130 |
-
- [ ] Generate ~8 tokens — does output become nonsensical?
|
| 131 |
-
- [ ] **If effect is subtle, deprioritize** — move Experiment 3 to backup
|
| 132 |
|
| 133 |
**Narration:** *"This part of the AI's brain reads one word at a time, left to right — like you do. This one head spends 97% of its effort just looking at the previous word. What happens if we turn it off?"*
|
| 134 |
|
|
@@ -149,11 +140,9 @@ This layers on top of Experiment 1 for engaged groups who want more.
|
|
| 149 |
|
| 150 |
Note: GPT-2's duplicate-token heads are in early layers (0-1) while induction heads are in Layer 5. This makes a nice story: *"The early layers spot the repetition, the middle layers use it to predict what comes next."*
|
| 151 |
|
| 152 |
-
**
|
| 153 |
-
|
| 154 |
-
-
|
| 155 |
-
- [ ] Try full combo: all induction + all duplicate heads → maximum "brain damage"
|
| 156 |
-
- [ ] Record the most dramatic combo
|
| 157 |
|
| 158 |
**Narration:** *"First we turned off pattern matching. Now let's ALSO turn off the part that notices repeated words... watch what happens."*
|
| 159 |
|
|
@@ -169,51 +158,33 @@ Note: GPT-2's duplicate-token heads are in early layers (0-1) while induction he
|
|
| 169 |
- Great for the "Reveal" step (step 3 in demo loop) — pure attention visualization wow
|
| 170 |
- Less about ablation, more about "look what the AI notices"
|
| 171 |
|
| 172 |
-
**
|
| 173 |
-
- [ ] Run inference → prediction should be flying-context (field, trees, fence, etc.)
|
| 174 |
-
- [ ] In BertViz, find which heads strongly connect "bat" → "flew"
|
| 175 |
-
- [ ] *"The AI figured out this is a flying bat, not a baseball bat — look at how it connects 'bat' to 'flew'."*
|
| 176 |
-
|
| 177 |
-
**Optional ablation:** If you find a head that strongly links "bat" → "flew", try ablating it — does the prediction shift to baseball context? Would be extraordinary if it works, but may not. Test it.
|
| 178 |
|
| 179 |
-
-
|
| 180 |
-
|
| 181 |
-
#### Pre-Testing Workflow
|
| 182 |
|
| 183 |
-
**
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
3. Run Experiment 2 — test ablation combos, record outputs (L4-H11 at 0.97 is especially promising)
|
| 187 |
-
4. Run Experiment 4 — note attention patterns
|
| 188 |
-
5. Pick primary demo based on most dramatic, reliable difference
|
| 189 |
|
| 190 |
-
|
| 191 |
-
6. For champion demo: find the **single-head ablation** that produces the biggest change
|
| 192 |
-
7. Note exact before/after outputs
|
| 193 |
-
8. Practice narration 3x
|
| 194 |
-
9. If single-head isn't dramatic enough, prepare a 2-head combo
|
| 195 |
-
|
| 196 |
-
**Round 3: Backup ready** (10 min)
|
| 197 |
-
10. Prepare second demo (different prompt, different head category)
|
| 198 |
-
11. Memorize which heads to click for both demos
|
| 199 |
|
| 200 |
-
#### Quick Reference Card
|
| 201 |
|
| 202 |
-
| Demo | Prompt |
|
| 203 |
-
|------|--------|---------------
|
| 204 |
-
| Primary |
|
| 205 |
-
| Backup
|
|
|
|
| 206 |
|
| 207 |
---
|
| 208 |
|
| 209 |
### Pre-Showcase Prep Checklist
|
| 210 |
|
| 211 |
-
- [
|
|
|
|
| 212 |
- [ ] Practice the 2-minute loop 3-5 times until it's smooth and natural
|
| 213 |
- [ ] Test the monitor setup — make sure text is readable from 4-5 feet away (increase font/zoom if needed)
|
| 214 |
-
- [ ] Verify the ablation demo works reliably with your chosen sentences (don't want a dud in front of students)
|
| 215 |
- [ ] Print QR code large enough to scan from arm's length
|
| 216 |
-
- [ ] Have a backup sentence ready in case a student's suggestion produces boring attention patterns
|
| 217 |
- [ ] Check that dark mode / light mode looks good on the external monitor under showcase lighting
|
| 218 |
|
| 219 |
---
|
|
|
|
| 90 |
|
| 91 |
GPT-2's induction heads cluster heavily in Layer 5. This is actually a bonus for the demo — you can say *"all the pattern-matching lives in one layer"* and ablate multiple heads in the same layer with a few clicks.
|
| 92 |
|
| 93 |
+
**How to ablate:** Use the "Induction" and "Duplicate Token" category buttons to select all heads at once.
|
| 94 |
+
|
| 95 |
+
**Confirmed result:** Ablating induction + duplicate-token heads together successfully breaks pattern completion.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 96 |
|
| 97 |
**Narration by age:**
|
| 98 |
- Elementary: *"We broke its memory! It forgot what it already read!"*
|
|
|
|
| 117 |
|
| 118 |
L4-H11 at 0.97 is remarkable — it's almost entirely dedicated to "look at the word right before me." This makes it a great single-click ablation target.
|
| 119 |
|
| 120 |
+
**How to ablate:** Use the "Previous Token" category button to select all heads at once.
|
| 121 |
+
|
| 122 |
+
**Confirmed result:** Ablating previous-token heads successfully breaks grammar/coherence.
|
|
|
|
|
|
|
|
|
|
| 123 |
|
| 124 |
**Narration:** *"This part of the AI's brain reads one word at a time, left to right — like you do. This one head spends 97% of its effort just looking at the previous word. What happens if we turn it off?"*
|
| 125 |
|
|
|
|
| 140 |
|
| 141 |
Note: GPT-2's duplicate-token heads are in early layers (0-1) while induction heads are in Layer 5. This makes a nice story: *"The early layers spot the repetition, the middle layers use it to predict what comes next."*
|
| 142 |
|
| 143 |
+
**How to ablate:** Use both the "Induction" and "Duplicate Token" category buttons.
|
| 144 |
+
|
| 145 |
+
**Confirmed result:** Combined induction + duplicate-token ablation produces maximum disruption — the model completely loses pattern completion ability.
|
|
|
|
|
|
|
| 146 |
|
| 147 |
**Narration:** *"First we turned off pattern matching. Now let's ALSO turn off the part that notices repeated words... watch what happens."*
|
| 148 |
|
|
|
|
| 158 |
- Great for the "Reveal" step (step 3 in demo loop) — pure attention visualization wow
|
| 159 |
- Less about ablation, more about "look what the AI notices"
|
| 160 |
|
| 161 |
+
**How to ablate:** Use the "Previous Token" category button.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 162 |
|
| 163 |
+
**Confirmed result:** Ablating previous-token heads successfully disrupts this prompt.
|
|
|
|
|
|
|
| 164 |
|
| 165 |
+
**Talking points:**
|
| 166 |
+
- *"The AI figured out this is a flying bat, not a baseball bat — look at how it connects 'bat' to 'flew'."*
|
| 167 |
+
- Show attention visualization first (the "Reveal" step), then ablate for engaged groups
|
|
|
|
|
|
|
|
|
|
| 168 |
|
| 169 |
+
---
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 170 |
|
| 171 |
+
#### Quick Reference Card
|
| 172 |
|
| 173 |
+
| Demo | Prompt | Ablate (Category Buttons) | Effect |
|
| 174 |
+
|------|--------|---------------------------|--------|
|
| 175 |
+
| Primary | `The cat sat on the mat. The cat sat on the` | Induction + Duplicate Token | Breaks pattern completion |
|
| 176 |
+
| Backup 1 | `The chef gave the waiter a generous tip because` | Previous Token | Breaks grammar/coherence |
|
| 177 |
+
| Backup 2 | `The bat flew over the` | Previous Token | Disrupts ambiguity resolution |
|
| 178 |
|
| 179 |
---
|
| 180 |
|
| 181 |
### Pre-Showcase Prep Checklist
|
| 182 |
|
| 183 |
+
- [x] Identify pre-loaded sentences that produce visually striking attention patterns (3 confirmed — see Quick Reference Card)
|
| 184 |
+
- [x] Verify ablation demos work reliably with chosen sentences (all 3 tested and confirmed)
|
| 185 |
- [ ] Practice the 2-minute loop 3-5 times until it's smooth and natural
|
| 186 |
- [ ] Test the monitor setup — make sure text is readable from 4-5 feet away (increase font/zoom if needed)
|
|
|
|
| 187 |
- [ ] Print QR code large enough to scan from arm's length
|
|
|
|
| 188 |
- [ ] Check that dark mode / light mode looks good on the external monitor under showcase lighting
|
| 189 |
|
| 190 |
---
|
tests/reproduce_ablation.py
CHANGED
|
@@ -8,7 +8,7 @@ from transformers import AutoModelForCausalLM, AutoTokenizer
|
|
| 8 |
# Add project root to path
|
| 9 |
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
|
| 10 |
|
| 11 |
-
from utils.model_patterns import execute_forward_pass,
|
| 12 |
|
| 13 |
def test_ablation_changes_output():
|
| 14 |
"""
|
|
@@ -55,32 +55,30 @@ def test_ablation_changes_output():
|
|
| 55 |
baseline_top_prob = baseline_result['actual_output']['probability']
|
| 56 |
print(f"Baseline Output: '{baseline_top_token}' ({baseline_top_prob:.4f})")
|
| 57 |
|
| 58 |
-
# 2. Ablated Run (Layer 0, Head 0)
|
| 59 |
print("Running ablation (L0H0)...")
|
| 60 |
-
ablation_result =
|
| 61 |
model, tokenizer, prompt, config,
|
| 62 |
-
|
| 63 |
-
ablate_head_indices=[0]
|
| 64 |
)
|
| 65 |
ablated_top_token = ablation_result['actual_output']['token']
|
| 66 |
ablated_top_prob = ablation_result['actual_output']['probability']
|
| 67 |
print(f"Ablated Output: '{ablated_top_token}' ({ablated_top_prob:.4f})")
|
| 68 |
-
|
| 69 |
# 3. Assertions
|
| 70 |
# We expect the probability to change, even if the token doesn't (depending on head importance)
|
| 71 |
# Ideally, exact logit match should be false.
|
| 72 |
-
|
| 73 |
# Check if probabilities are different (using a small epsilon)
|
| 74 |
prob_diff = abs(baseline_top_prob - ablated_top_prob)
|
| 75 |
print(f"Probability Difference: {prob_diff}")
|
| 76 |
-
|
| 77 |
-
# We assert that there IS a difference.
|
| 78 |
# Note: If L0H0 is completely useless, this might fail. But usually it does something.
|
| 79 |
assert prob_diff > 1e-6, "Ablation of L0H0 did not change the top token probability at all!"
|
| 80 |
|
| 81 |
# Verify that the structure returned contains ablation info
|
| 82 |
-
assert ablation_result['
|
| 83 |
-
assert ablation_result['ablated_heads'] == [0]
|
| 84 |
|
| 85 |
if __name__ == "__main__":
|
| 86 |
test_ablation_changes_output()
|
|
|
|
| 8 |
# Add project root to path
|
| 9 |
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
|
| 10 |
|
| 11 |
+
from utils.model_patterns import execute_forward_pass, execute_forward_pass_with_multi_layer_head_ablation, load_model_and_get_patterns
|
| 12 |
|
| 13 |
def test_ablation_changes_output():
|
| 14 |
"""
|
|
|
|
| 55 |
baseline_top_prob = baseline_result['actual_output']['probability']
|
| 56 |
print(f"Baseline Output: '{baseline_top_token}' ({baseline_top_prob:.4f})")
|
| 57 |
|
| 58 |
+
# 2. Ablated Run (Layer 0, Head 0) using multi-layer function with single entry
|
| 59 |
print("Running ablation (L0H0)...")
|
| 60 |
+
ablation_result = execute_forward_pass_with_multi_layer_head_ablation(
|
| 61 |
model, tokenizer, prompt, config,
|
| 62 |
+
heads_by_layer={0: [0]}
|
|
|
|
| 63 |
)
|
| 64 |
ablated_top_token = ablation_result['actual_output']['token']
|
| 65 |
ablated_top_prob = ablation_result['actual_output']['probability']
|
| 66 |
print(f"Ablated Output: '{ablated_top_token}' ({ablated_top_prob:.4f})")
|
| 67 |
+
|
| 68 |
# 3. Assertions
|
| 69 |
# We expect the probability to change, even if the token doesn't (depending on head importance)
|
| 70 |
# Ideally, exact logit match should be false.
|
| 71 |
+
|
| 72 |
# Check if probabilities are different (using a small epsilon)
|
| 73 |
prob_diff = abs(baseline_top_prob - ablated_top_prob)
|
| 74 |
print(f"Probability Difference: {prob_diff}")
|
| 75 |
+
|
| 76 |
+
# We assert that there IS a difference.
|
| 77 |
# Note: If L0H0 is completely useless, this might fail. But usually it does something.
|
| 78 |
assert prob_diff > 1e-6, "Ablation of L0H0 did not change the top token probability at all!"
|
| 79 |
|
| 80 |
# Verify that the structure returned contains ablation info
|
| 81 |
+
assert ablation_result['ablated_heads_by_layer'] == {0: [0]}
|
|
|
|
| 82 |
|
| 83 |
if __name__ == "__main__":
|
| 84 |
test_ablation_changes_output()
|
tests/test_ablation_hook_placement.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Tests for scientifically accurate head ablation via pre-projection hooking.
|
| 3 |
+
|
| 4 |
+
Verifies that ablation hooks are placed on the INPUT to c_proj (pre-projection),
|
| 5 |
+
where per-head dimensions are still separable, rather than on the OUTPUT of the
|
| 6 |
+
attention module (post-projection), where heads are mixed.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
import sys
|
| 10 |
+
import os
|
| 11 |
+
import torch
|
| 12 |
+
import torch.nn as nn
|
| 13 |
+
import pytest
|
| 14 |
+
from transformers import AutoModelForCausalLM, AutoTokenizer
|
| 15 |
+
|
| 16 |
+
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
|
| 17 |
+
|
| 18 |
+
from utils.model_patterns import _find_output_proj_submodule
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
@pytest.fixture(scope="module")
|
| 22 |
+
def gpt2_model_and_tokenizer():
|
| 23 |
+
"""Load GPT-2 once for all tests in this module."""
|
| 24 |
+
try:
|
| 25 |
+
model = AutoModelForCausalLM.from_pretrained("gpt2", torch_dtype=torch.float32)
|
| 26 |
+
model.eval()
|
| 27 |
+
tokenizer = AutoTokenizer.from_pretrained("gpt2")
|
| 28 |
+
return model, tokenizer
|
| 29 |
+
except Exception as e:
|
| 30 |
+
pytest.skip(f"Could not load GPT-2: {e}")
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
class TestFindOutputProjSubmodule:
|
| 34 |
+
def test_find_output_proj_gpt2(self, gpt2_model_and_tokenizer):
|
| 35 |
+
"""GPT-2 attention modules should have c_proj as the output projection."""
|
| 36 |
+
model, _ = gpt2_model_and_tokenizer
|
| 37 |
+
attn_module = model.transformer.h[0].attn
|
| 38 |
+
name, submodule = _find_output_proj_submodule(attn_module)
|
| 39 |
+
assert name == "c_proj"
|
| 40 |
+
assert submodule is attn_module.c_proj
|
| 41 |
+
|
| 42 |
+
def test_find_output_proj_unknown_raises(self):
|
| 43 |
+
"""A plain nn.Module with no recognized projection children should raise ValueError."""
|
| 44 |
+
plain_module = nn.Module()
|
| 45 |
+
plain_module.add_module("some_layer", nn.Linear(10, 10))
|
| 46 |
+
with pytest.raises(ValueError, match="No output projection found"):
|
| 47 |
+
_find_output_proj_submodule(plain_module)
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
class TestPreHookPlacement:
|
| 51 |
+
def test_pre_hook_zeros_correct_dims(self, gpt2_model_and_tokenizer):
|
| 52 |
+
"""Pre-hook on c_proj receives input where per-head dims are separable.
|
| 53 |
+
Zeroing head 0 dims [0:64] should leave [64:768] untouched."""
|
| 54 |
+
model, tokenizer = gpt2_model_and_tokenizer
|
| 55 |
+
captured_input = {}
|
| 56 |
+
|
| 57 |
+
def capture_pre_hook(module, args):
|
| 58 |
+
captured_input['x'] = args[0].clone()
|
| 59 |
+
return None # Don't modify
|
| 60 |
+
|
| 61 |
+
hook = model.transformer.h[0].attn.c_proj.register_forward_pre_hook(capture_pre_hook)
|
| 62 |
+
try:
|
| 63 |
+
inputs = tokenizer("The cat sat on the mat", return_tensors="pt")
|
| 64 |
+
with torch.no_grad():
|
| 65 |
+
model(**inputs, use_cache=False)
|
| 66 |
+
finally:
|
| 67 |
+
hook.remove()
|
| 68 |
+
|
| 69 |
+
x = captured_input['x']
|
| 70 |
+
# Shape should be [batch, seq, 768]
|
| 71 |
+
assert x.shape[-1] == 768
|
| 72 |
+
# Verify per-head structure: zeroing [0:64] leaves [64:768] intact
|
| 73 |
+
x_modified = x.clone()
|
| 74 |
+
x_modified[:, :, 0:64] = 0.0
|
| 75 |
+
# The rest should be exactly equal
|
| 76 |
+
assert torch.equal(x_modified[:, :, 64:], x[:, :, 64:])
|
| 77 |
+
# The zeroed part should actually be zero
|
| 78 |
+
assert torch.all(x_modified[:, :, 0:64] == 0.0)
|
| 79 |
+
|
| 80 |
+
def test_ablation_changes_output(self, gpt2_model_and_tokenizer):
|
| 81 |
+
"""Ablating head 0 at layer 0 via pre-hook on c_proj should change logits."""
|
| 82 |
+
model, tokenizer = gpt2_model_and_tokenizer
|
| 83 |
+
prompt = "The quick brown fox jumps over the"
|
| 84 |
+
inputs = tokenizer(prompt, return_tensors="pt")
|
| 85 |
+
|
| 86 |
+
# Baseline
|
| 87 |
+
with torch.no_grad():
|
| 88 |
+
baseline_logits = model(**inputs, use_cache=False).logits
|
| 89 |
+
|
| 90 |
+
# Ablated: pre-hook zeros head 0 on layer 0's c_proj
|
| 91 |
+
def ablation_pre_hook(module, args):
|
| 92 |
+
x = args[0].clone()
|
| 93 |
+
x[:, :, 0:64] = 0.0
|
| 94 |
+
return (x,)
|
| 95 |
+
|
| 96 |
+
hook = model.transformer.h[0].attn.c_proj.register_forward_pre_hook(ablation_pre_hook)
|
| 97 |
+
try:
|
| 98 |
+
with torch.no_grad():
|
| 99 |
+
ablated_logits = model(**inputs, use_cache=False).logits
|
| 100 |
+
finally:
|
| 101 |
+
hook.remove()
|
| 102 |
+
|
| 103 |
+
# Logits must differ
|
| 104 |
+
assert not torch.allclose(baseline_logits, ablated_logits, atol=1e-6), \
|
| 105 |
+
"Ablation via pre-hook on c_proj did not change logits"
|
tests/test_head_categories.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Test that head_categories.json is correctly parsed into category stores."""
|
| 2 |
+
import json
|
| 3 |
+
import os
|
| 4 |
+
import pytest
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
@pytest.fixture
|
| 8 |
+
def head_categories():
|
| 9 |
+
json_path = os.path.join(os.path.dirname(__file__), '..', 'utils', 'head_categories.json')
|
| 10 |
+
with open(json_path, 'r') as f:
|
| 11 |
+
return json.load(f)
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def build_categories_store(head_categories_data):
|
| 15 |
+
"""Replicate the category store construction from app.py."""
|
| 16 |
+
categories_store = {}
|
| 17 |
+
for cat_key, cat_data in head_categories_data['categories'].items():
|
| 18 |
+
categories_store[cat_key] = {
|
| 19 |
+
'display_name': cat_data.get('display_name', cat_key),
|
| 20 |
+
'heads': [{'layer': h['layer'], 'head': h['head']}
|
| 21 |
+
for h in cat_data.get('top_heads', [])]
|
| 22 |
+
}
|
| 23 |
+
return categories_store
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
class TestHeadCategoriesStore:
|
| 27 |
+
def test_all_categories_have_heads(self, head_categories):
|
| 28 |
+
"""Every category in the JSON must produce a non-empty heads list."""
|
| 29 |
+
for model_key, model_data in head_categories.items():
|
| 30 |
+
if 'categories' not in model_data:
|
| 31 |
+
continue
|
| 32 |
+
store = build_categories_store(model_data)
|
| 33 |
+
for cat_key, cat_val in store.items():
|
| 34 |
+
assert len(cat_val['heads']) > 0, (
|
| 35 |
+
f"Category '{cat_key}' in model '{model_key}' has no heads — "
|
| 36 |
+
f"check that the JSON field name matches the parsing code"
|
| 37 |
+
)
|
| 38 |
+
|
| 39 |
+
def test_heads_have_layer_and_head_keys(self, head_categories):
|
| 40 |
+
"""Each head entry must have 'layer' and 'head' integer fields."""
|
| 41 |
+
for model_key, model_data in head_categories.items():
|
| 42 |
+
if 'categories' not in model_data:
|
| 43 |
+
continue
|
| 44 |
+
store = build_categories_store(model_data)
|
| 45 |
+
for cat_key, cat_val in store.items():
|
| 46 |
+
for h in cat_val['heads']:
|
| 47 |
+
assert 'layer' in h and 'head' in h, (
|
| 48 |
+
f"Head entry in '{cat_key}' missing layer/head keys"
|
| 49 |
+
)
|
| 50 |
+
assert isinstance(h['layer'], int)
|
| 51 |
+
assert isinstance(h['head'], int)
|
| 52 |
+
|
| 53 |
+
def test_field_name_is_top_heads_not_heads(self, head_categories):
|
| 54 |
+
"""Guard against regression: JSON field must be 'top_heads', not 'heads'."""
|
| 55 |
+
for model_key, model_data in head_categories.items():
|
| 56 |
+
if 'categories' not in model_data:
|
| 57 |
+
continue
|
| 58 |
+
for cat_key, cat_data in model_data['categories'].items():
|
| 59 |
+
assert 'top_heads' in cat_data, (
|
| 60 |
+
f"Category '{cat_key}' uses wrong field name — expected 'top_heads'"
|
| 61 |
+
)
|
| 62 |
+
# 'heads' should NOT exist as a field in the JSON
|
| 63 |
+
assert 'heads' not in cat_data, (
|
| 64 |
+
f"Category '{cat_key}' has ambiguous 'heads' field — use 'top_heads' only"
|
| 65 |
+
)
|
utils/__init__.py
CHANGED
|
@@ -2,7 +2,6 @@ from .model_patterns import (load_model_for_inference, load_model_and_get_patter
|
|
| 2 |
execute_forward_pass,
|
| 3 |
logit_lens_transformation, extract_layer_data,
|
| 4 |
generate_bertviz_html,
|
| 5 |
-
execute_forward_pass_with_head_ablation,
|
| 6 |
execute_forward_pass_with_multi_layer_head_ablation,
|
| 7 |
merge_token_probabilities,
|
| 8 |
compute_global_top5_tokens, compute_per_position_top5,
|
|
@@ -20,7 +19,6 @@ __all__ = [
|
|
| 20 |
'load_model_for_inference',
|
| 21 |
'load_model_and_get_patterns',
|
| 22 |
'execute_forward_pass',
|
| 23 |
-
'execute_forward_pass_with_head_ablation',
|
| 24 |
'execute_forward_pass_with_multi_layer_head_ablation',
|
| 25 |
'evaluate_sequence_ablation',
|
| 26 |
'logit_lens_transformation',
|
|
|
|
| 2 |
execute_forward_pass,
|
| 3 |
logit_lens_transformation, extract_layer_data,
|
| 4 |
generate_bertviz_html,
|
|
|
|
| 5 |
execute_forward_pass_with_multi_layer_head_ablation,
|
| 6 |
merge_token_probabilities,
|
| 7 |
compute_global_top5_tokens, compute_per_position_top5,
|
|
|
|
| 19 |
'load_model_for_inference',
|
| 20 |
'load_model_and_get_patterns',
|
| 21 |
'execute_forward_pass',
|
|
|
|
| 22 |
'execute_forward_pass_with_multi_layer_head_ablation',
|
| 23 |
'evaluate_sequence_ablation',
|
| 24 |
'logit_lens_transformation',
|
utils/beam_search.py
CHANGED
|
@@ -7,40 +7,37 @@ import torch.nn.functional as F
|
|
| 7 |
from typing import List, Dict, Any, Optional
|
| 8 |
import re
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
"""
|
| 12 |
-
Create a hook that zeros out specific attention
|
|
|
|
| 13 |
"""
|
| 14 |
-
def
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
hidden_states = output[0]
|
| 18 |
-
else:
|
| 19 |
-
hidden_states = output
|
| 20 |
-
|
| 21 |
-
# Check if we need to convert from list/tuple (some models might behave oddly)
|
| 22 |
-
if not isinstance(hidden_states, torch.Tensor):
|
| 23 |
-
return output # Safety skip
|
| 24 |
-
|
| 25 |
-
batch_size, seq_len, hidden_dim = hidden_states.shape
|
| 26 |
-
head_dim = hidden_dim // num_heads
|
| 27 |
-
|
| 28 |
-
# Reshape to [batch, seq_len, num_heads, head_dim]
|
| 29 |
-
# We need to clone to modify safetly
|
| 30 |
-
hidden_states_reshaped = hidden_states.view(batch_size, seq_len, num_heads, head_dim).clone()
|
| 31 |
-
|
| 32 |
-
# Zero out specified heads
|
| 33 |
for head_idx in head_indices:
|
| 34 |
if 0 <= head_idx < num_heads:
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
if isinstance(output, tuple):
|
| 41 |
-
return (ablated_hidden,) + output[1:]
|
| 42 |
-
return ablated_hidden
|
| 43 |
-
return hook
|
| 44 |
|
| 45 |
def _apply_ablation_hooks(model, ablation_config: Dict[int, List[int]]) -> List[Any]:
|
| 46 |
"""
|
|
@@ -89,12 +86,15 @@ def _apply_ablation_hooks(model, ablation_config: Dict[int, List[int]]) -> List[
|
|
| 89 |
# Sort by length
|
| 90 |
candidates.sort(key=lambda x: len(x[0]))
|
| 91 |
target_name, target_module = candidates[0]
|
| 92 |
-
|
| 93 |
-
# Register hook
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
|
|
|
|
|
|
|
|
|
| 98 |
else:
|
| 99 |
print(f"Warning: Could not find attention module for layer {layer_num} in beam search.")
|
| 100 |
|
|
|
|
| 7 |
from typing import List, Dict, Any, Optional
|
| 8 |
import re
|
| 9 |
|
| 10 |
+
_OUTPUT_PROJ_NAMES = ['c_proj', 'o_proj', 'out_proj', 'dense']
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def _find_output_proj_submodule(attn_module, attn_module_name: str = ""):
|
| 14 |
+
"""Find output projection submodule within an attention module.
|
| 15 |
+
Returns (name, submodule). Raises ValueError if not found."""
|
| 16 |
+
children = dict(attn_module.named_children())
|
| 17 |
+
for proj_name in _OUTPUT_PROJ_NAMES:
|
| 18 |
+
if proj_name in children:
|
| 19 |
+
return proj_name, children[proj_name]
|
| 20 |
+
raise ValueError(
|
| 21 |
+
f"No output projection found in {attn_module_name or type(attn_module).__name__}. "
|
| 22 |
+
f"Children: {list(children.keys())}. Expected one of: {_OUTPUT_PROJ_NAMES}"
|
| 23 |
+
)
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def _make_head_ablation_pre_hook(head_indices: List[int], num_heads: int):
|
| 27 |
"""
|
| 28 |
+
Create a pre-hook on the output projection that zeros out specific attention
|
| 29 |
+
heads BEFORE projection mixing, where per-head dims are still separable.
|
| 30 |
"""
|
| 31 |
+
def pre_hook(module, args):
|
| 32 |
+
x = args[0].clone()
|
| 33 |
+
head_dim = x.shape[-1] // num_heads
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
for head_idx in head_indices:
|
| 35 |
if 0 <= head_idx < num_heads:
|
| 36 |
+
start = head_idx * head_dim
|
| 37 |
+
end = (head_idx + 1) * head_dim
|
| 38 |
+
x[:, :, start:end] = 0.0
|
| 39 |
+
return (x,)
|
| 40 |
+
return pre_hook
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
|
| 42 |
def _apply_ablation_hooks(model, ablation_config: Dict[int, List[int]]) -> List[Any]:
|
| 43 |
"""
|
|
|
|
| 86 |
# Sort by length
|
| 87 |
candidates.sort(key=lambda x: len(x[0]))
|
| 88 |
target_name, target_module = candidates[0]
|
| 89 |
+
|
| 90 |
+
# Register pre-hook on output projection (before heads are mixed)
|
| 91 |
+
try:
|
| 92 |
+
_, proj_mod = _find_output_proj_submodule(target_module, target_name)
|
| 93 |
+
hooks.append(proj_mod.register_forward_pre_hook(
|
| 94 |
+
_make_head_ablation_pre_hook(head_indices, num_heads)
|
| 95 |
+
))
|
| 96 |
+
except ValueError as e:
|
| 97 |
+
print(f"Warning: {e}")
|
| 98 |
else:
|
| 99 |
print(f"Warning: Could not find attention module for layer {layer_num} in beam search.")
|
| 100 |
|
utils/model_patterns.py
CHANGED
|
@@ -7,6 +7,22 @@ from typing import Dict, List, Tuple, Any, Optional
|
|
| 7 |
from transformers import AutoModelForCausalLM, AutoTokenizer
|
| 8 |
|
| 9 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
def load_model_for_inference(model_name: str):
|
| 11 |
"""Load model with float32 dtype for CPU stability and verify weight tying."""
|
| 12 |
model = AutoModelForCausalLM.from_pretrained(
|
|
@@ -405,160 +421,6 @@ def execute_forward_pass(model, tokenizer, prompt: str, config: Dict[str, Any],
|
|
| 405 |
return result
|
| 406 |
|
| 407 |
|
| 408 |
-
def execute_forward_pass_with_head_ablation(model, tokenizer, prompt: str, config: Dict[str, Any],
|
| 409 |
-
ablate_layer_num: int, ablate_head_indices: List[int]) -> Dict[str, Any]:
|
| 410 |
-
"""
|
| 411 |
-
Execute forward pass with specific attention heads zeroed out.
|
| 412 |
-
|
| 413 |
-
Args:
|
| 414 |
-
model: Loaded transformer model
|
| 415 |
-
tokenizer: Loaded tokenizer
|
| 416 |
-
prompt: Input text prompt
|
| 417 |
-
config: Dict with module lists like {"attention_modules": [...], "block_modules": [...], ...}
|
| 418 |
-
ablate_layer_num: Layer number containing heads to ablate
|
| 419 |
-
ablate_head_indices: List of head indices to zero out (e.g., [0, 2, 5])
|
| 420 |
-
|
| 421 |
-
Returns:
|
| 422 |
-
JSON-serializable dict with captured activations (with ablated heads)
|
| 423 |
-
"""
|
| 424 |
-
print(f"Executing forward pass with head ablation: Layer {ablate_layer_num}, Heads {ablate_head_indices}")
|
| 425 |
-
|
| 426 |
-
# Extract module lists from config
|
| 427 |
-
attention_modules = config.get("attention_modules", [])
|
| 428 |
-
block_modules = config.get("block_modules", [])
|
| 429 |
-
norm_parameters = config.get("norm_parameters", [])
|
| 430 |
-
logit_lens_parameter = config.get("logit_lens_parameter")
|
| 431 |
-
|
| 432 |
-
all_modules = attention_modules + block_modules
|
| 433 |
-
if not all_modules:
|
| 434 |
-
return {"error": "No modules specified"}
|
| 435 |
-
|
| 436 |
-
# Find the target attention module for the layer to ablate
|
| 437 |
-
target_attention_module = None
|
| 438 |
-
for mod_name in attention_modules:
|
| 439 |
-
layer_match = re.search(r'\.(\d+)(?:\.|$)', mod_name)
|
| 440 |
-
if layer_match and int(layer_match.group(1)) == ablate_layer_num:
|
| 441 |
-
target_attention_module = mod_name
|
| 442 |
-
break
|
| 443 |
-
|
| 444 |
-
if not target_attention_module:
|
| 445 |
-
return {"error": f"Could not find attention module for layer {ablate_layer_num}"}
|
| 446 |
-
|
| 447 |
-
# Prepare inputs
|
| 448 |
-
inputs = tokenizer(prompt, return_tensors="pt")
|
| 449 |
-
|
| 450 |
-
# Register hooks directly on the original model (avoids PyVene module renaming issues)
|
| 451 |
-
captured = {}
|
| 452 |
-
name_to_module = dict(model.named_modules())
|
| 453 |
-
|
| 454 |
-
def make_hook(mod_name: str):
|
| 455 |
-
return lambda module, inputs, output: captured.update({mod_name: {"output": safe_to_serializable(output)}})
|
| 456 |
-
|
| 457 |
-
# Create head ablation hook that both ablates and captures
|
| 458 |
-
def head_ablation_hook(module, input, output):
|
| 459 |
-
"""Zero out specific attention heads in the output AND capture it."""
|
| 460 |
-
ablated_output = output # Default to original output
|
| 461 |
-
|
| 462 |
-
if isinstance(output, tuple):
|
| 463 |
-
# Attention modules typically return (hidden_states, attention_weights, ...)
|
| 464 |
-
hidden_states = output[0] # [batch, seq_len, hidden_dim]
|
| 465 |
-
|
| 466 |
-
# Convert to tensor if needed
|
| 467 |
-
if not isinstance(hidden_states, torch.Tensor):
|
| 468 |
-
hidden_states = torch.tensor(hidden_states)
|
| 469 |
-
|
| 470 |
-
batch_size, seq_len, hidden_dim = hidden_states.shape
|
| 471 |
-
|
| 472 |
-
# Determine head dimension
|
| 473 |
-
# Assuming hidden_dim = num_heads * head_dim
|
| 474 |
-
# We need to get num_heads from the model config
|
| 475 |
-
num_heads = model.config.num_attention_heads
|
| 476 |
-
head_dim = hidden_dim // num_heads
|
| 477 |
-
|
| 478 |
-
# Reshape to [batch, seq_len, num_heads, head_dim]
|
| 479 |
-
hidden_states_reshaped = hidden_states.view(batch_size, seq_len, num_heads, head_dim)
|
| 480 |
-
|
| 481 |
-
# Zero out specified heads
|
| 482 |
-
for head_idx in ablate_head_indices:
|
| 483 |
-
if 0 <= head_idx < num_heads:
|
| 484 |
-
hidden_states_reshaped[:, :, head_idx, :] = 0.0
|
| 485 |
-
|
| 486 |
-
# Reshape back to [batch, seq_len, hidden_dim]
|
| 487 |
-
ablated_hidden = hidden_states_reshaped.view(batch_size, seq_len, hidden_dim)
|
| 488 |
-
|
| 489 |
-
# Reconstruct output tuple
|
| 490 |
-
if len(output) > 1:
|
| 491 |
-
ablated_output = (ablated_hidden,) + output[1:]
|
| 492 |
-
else:
|
| 493 |
-
ablated_output = (ablated_hidden,)
|
| 494 |
-
|
| 495 |
-
# Capture the ablated output (CRITICAL: this was missing!)
|
| 496 |
-
captured.update({target_attention_module: {"output": safe_to_serializable(ablated_output)}})
|
| 497 |
-
|
| 498 |
-
return ablated_output
|
| 499 |
-
|
| 500 |
-
# Register hooks
|
| 501 |
-
hooks = []
|
| 502 |
-
for mod_name in all_modules:
|
| 503 |
-
if mod_name in name_to_module:
|
| 504 |
-
if mod_name == target_attention_module:
|
| 505 |
-
# Apply head ablation hook
|
| 506 |
-
hooks.append(name_to_module[mod_name].register_forward_hook(head_ablation_hook))
|
| 507 |
-
else:
|
| 508 |
-
# Regular capture hook
|
| 509 |
-
hooks.append(name_to_module[mod_name].register_forward_hook(make_hook(mod_name)))
|
| 510 |
-
|
| 511 |
-
# Execute forward pass
|
| 512 |
-
with torch.no_grad():
|
| 513 |
-
model_output = model(**inputs, use_cache=False)
|
| 514 |
-
|
| 515 |
-
# Remove hooks
|
| 516 |
-
for hook in hooks:
|
| 517 |
-
hook.remove()
|
| 518 |
-
|
| 519 |
-
# Separate outputs by type
|
| 520 |
-
attention_outputs = {}
|
| 521 |
-
block_outputs = {}
|
| 522 |
-
|
| 523 |
-
for mod_name, output in captured.items():
|
| 524 |
-
if 'attn' in mod_name or 'attention' in mod_name:
|
| 525 |
-
attention_outputs[mod_name] = output
|
| 526 |
-
else:
|
| 527 |
-
block_outputs[mod_name] = output
|
| 528 |
-
|
| 529 |
-
# Capture normalization parameters
|
| 530 |
-
all_params = dict(model.named_parameters())
|
| 531 |
-
norm_data = [safe_to_serializable(all_params[p]) for p in norm_parameters if p in all_params]
|
| 532 |
-
|
| 533 |
-
# Extract predicted token from model output
|
| 534 |
-
actual_output = None
|
| 535 |
-
global_top5_tokens = []
|
| 536 |
-
try:
|
| 537 |
-
output_token, output_prob = get_actual_model_output(model_output, tokenizer)
|
| 538 |
-
actual_output = {"token": output_token, "probability": output_prob}
|
| 539 |
-
global_top5_tokens = compute_global_top5_tokens(model_output, tokenizer, top_k=5)
|
| 540 |
-
except Exception as e:
|
| 541 |
-
print(f"Warning: Could not extract model output: {e}")
|
| 542 |
-
|
| 543 |
-
# Build output dictionary
|
| 544 |
-
result = {
|
| 545 |
-
"model": getattr(model.config, "name_or_path", "unknown"),
|
| 546 |
-
"prompt": prompt,
|
| 547 |
-
"input_ids": safe_to_serializable(inputs["input_ids"]),
|
| 548 |
-
"attention_modules": list(attention_outputs.keys()),
|
| 549 |
-
"attention_outputs": attention_outputs,
|
| 550 |
-
"block_modules": list(block_outputs.keys()),
|
| 551 |
-
"block_outputs": block_outputs,
|
| 552 |
-
"norm_parameters": norm_parameters,
|
| 553 |
-
"norm_data": norm_data,
|
| 554 |
-
"actual_output": actual_output,
|
| 555 |
-
"global_top5_tokens": global_top5_tokens,
|
| 556 |
-
"ablated_layer": ablate_layer_num,
|
| 557 |
-
"ablated_heads": ablate_head_indices
|
| 558 |
-
}
|
| 559 |
-
|
| 560 |
-
return result
|
| 561 |
-
|
| 562 |
|
| 563 |
def execute_forward_pass_with_multi_layer_head_ablation(model, tokenizer, prompt: str, config: Dict[str, Any],
|
| 564 |
heads_by_layer: Dict[int, List[int]], original_prompt: Optional[str] = None) -> Dict[str, Any]:
|
|
@@ -622,75 +484,34 @@ def execute_forward_pass_with_multi_layer_head_ablation(model, tokenizer, prompt
|
|
| 622 |
def make_hook(mod_name: str):
|
| 623 |
return lambda module, inputs, output: captured.update({mod_name: {"output": safe_to_serializable(output)}})
|
| 624 |
|
| 625 |
-
# Create
|
| 626 |
-
def
|
| 627 |
-
"""
|
| 628 |
-
def
|
| 629 |
-
|
| 630 |
-
|
| 631 |
-
|
| 632 |
-
|
| 633 |
-
|
| 634 |
-
|
| 635 |
-
|
| 636 |
-
|
| 637 |
-
|
| 638 |
-
|
| 639 |
-
|
| 640 |
-
|
| 641 |
-
# Determine head dimension
|
| 642 |
-
num_heads = model.config.num_attention_heads
|
| 643 |
-
head_dim = hidden_dim // num_heads
|
| 644 |
-
|
| 645 |
-
# Reshape to [batch, seq_len, num_heads, head_dim]
|
| 646 |
-
hidden_states_reshaped = hidden_states.view(batch_size, seq_len, num_heads, head_dim)
|
| 647 |
-
|
| 648 |
-
# Zero out specified heads
|
| 649 |
-
for head_idx in ablate_head_indices:
|
| 650 |
-
if 0 <= head_idx < num_heads:
|
| 651 |
-
hidden_states_reshaped[:, :, head_idx, :] = 0.0
|
| 652 |
-
|
| 653 |
-
# Reshape back to [batch, seq_len, hidden_dim]
|
| 654 |
-
ablated_hidden = hidden_states_reshaped.view(batch_size, seq_len, hidden_dim)
|
| 655 |
-
|
| 656 |
-
# Reconstruct output tuple
|
| 657 |
-
if len(output) > 1:
|
| 658 |
-
# Check for attention weights (usually index 2 if output_attentions=True)
|
| 659 |
-
if len(output) > 2:
|
| 660 |
-
attn_weights = output[2] # [batch, heads, seq, seq]
|
| 661 |
-
if isinstance(attn_weights, torch.Tensor):
|
| 662 |
-
# Zero out specified heads in attention weights too
|
| 663 |
-
# Clone to avoid in-place modification errors if any
|
| 664 |
-
attn_weights_mod = attn_weights.clone()
|
| 665 |
-
for head_idx in ablate_head_indices:
|
| 666 |
-
if 0 <= head_idx < num_heads:
|
| 667 |
-
attn_weights_mod[:, head_idx, :, :] = 0.0
|
| 668 |
-
|
| 669 |
-
# Reconstruct tuple with modified weights
|
| 670 |
-
ablated_output = (ablated_hidden, output[1], attn_weights_mod) + output[3:]
|
| 671 |
-
else:
|
| 672 |
-
ablated_output = (ablated_hidden,) + output[1:]
|
| 673 |
-
else:
|
| 674 |
-
ablated_output = (ablated_hidden,) + output[1:]
|
| 675 |
-
else:
|
| 676 |
-
ablated_output = (ablated_hidden,)
|
| 677 |
-
|
| 678 |
-
# Capture the ablated output
|
| 679 |
-
captured.update({target_mod_name: {"output": safe_to_serializable(ablated_output)}})
|
| 680 |
-
|
| 681 |
-
return ablated_output
|
| 682 |
-
return head_ablation_hook
|
| 683 |
-
|
| 684 |
# Register hooks
|
| 685 |
hooks = []
|
| 686 |
for mod_name in all_modules:
|
| 687 |
if mod_name in name_to_module:
|
| 688 |
if mod_name in target_modules_to_heads:
|
| 689 |
-
#
|
| 690 |
-
|
| 691 |
-
|
| 692 |
-
|
|
|
|
| 693 |
))
|
|
|
|
|
|
|
| 694 |
else:
|
| 695 |
# Regular capture hook
|
| 696 |
hooks.append(name_to_module[mod_name].register_forward_hook(make_hook(mod_name)))
|
|
@@ -826,42 +647,19 @@ def evaluate_sequence_ablation(model, tokenizer, sequence_text: str, config: Dic
|
|
| 826 |
# Let's manually register hooks here for simplicity and control
|
| 827 |
hooks = []
|
| 828 |
|
| 829 |
-
def
|
| 830 |
-
|
| 831 |
-
|
| 832 |
-
|
| 833 |
-
hidden_states = output[0]
|
| 834 |
-
else:
|
| 835 |
-
hidden_states = output
|
| 836 |
-
|
| 837 |
-
# Assume hidden_states is [batch, seq, hidden]
|
| 838 |
-
# Reshape, zero out heads, Reshape back
|
| 839 |
-
if not isinstance(hidden_states, torch.Tensor):
|
| 840 |
-
if isinstance(hidden_states, list): hidden_states = torch.tensor(hidden_states)
|
| 841 |
-
|
| 842 |
-
# Move to device if needed? They should be on device.
|
| 843 |
-
|
| 844 |
num_heads = model.config.num_attention_heads
|
| 845 |
-
head_dim =
|
| 846 |
-
|
| 847 |
-
|
| 848 |
-
|
| 849 |
-
|
| 850 |
-
|
| 851 |
-
|
| 852 |
-
|
| 853 |
-
# Clone to be safe
|
| 854 |
-
reshaped = reshaped.clone()
|
| 855 |
-
|
| 856 |
-
for h_idx in head_indices:
|
| 857 |
-
reshaped[..., h_idx, :] = 0
|
| 858 |
-
|
| 859 |
-
ablated_hidden = reshaped.view(hidden_states.shape)
|
| 860 |
-
|
| 861 |
-
if isinstance(output, tuple):
|
| 862 |
-
return (ablated_hidden,) + output[1:]
|
| 863 |
-
return ablated_hidden
|
| 864 |
-
return hook
|
| 865 |
|
| 866 |
# Hook for Layer Ablation (Identity/Skip or Zero)
|
| 867 |
# We'll use Identity (Skip Layer) as a simpler approximation of "removing logic"
|
|
@@ -885,16 +683,22 @@ def evaluate_sequence_ablation(model, tokenizer, sequence_text: str, config: Dic
|
|
| 885 |
|
| 886 |
# Simple heuristic: find 'layers.X.self_attn' or 'h.X.attn'
|
| 887 |
target_module = None
|
|
|
|
| 888 |
for name, mod in model.named_modules():
|
| 889 |
# Check for standard patterns
|
| 890 |
# layer_num is int
|
| 891 |
if f"layers.{layer_num}.self_attn" in name or f"h.{layer_num}.attn" in name or f"blocks.{layer_num}.attn" in name:
|
| 892 |
if "k_proj" not in name and "v_proj" not in name and "q_proj" not in name: # avoid submodules
|
| 893 |
target_module = mod
|
|
|
|
| 894 |
break
|
| 895 |
-
|
| 896 |
if target_module:
|
| 897 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 898 |
else:
|
| 899 |
print(f"Warning: Could not find attention module for layer {layer_num}")
|
| 900 |
|
|
|
|
| 7 |
from transformers import AutoModelForCausalLM, AutoTokenizer
|
| 8 |
|
| 9 |
|
| 10 |
+
_OUTPUT_PROJ_NAMES = ['c_proj', 'o_proj', 'out_proj', 'dense']
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def _find_output_proj_submodule(attn_module, attn_module_name: str = ""):
|
| 14 |
+
"""Find output projection submodule within an attention module.
|
| 15 |
+
Returns (name, submodule). Raises ValueError if not found."""
|
| 16 |
+
children = dict(attn_module.named_children())
|
| 17 |
+
for proj_name in _OUTPUT_PROJ_NAMES:
|
| 18 |
+
if proj_name in children:
|
| 19 |
+
return proj_name, children[proj_name]
|
| 20 |
+
raise ValueError(
|
| 21 |
+
f"No output projection found in {attn_module_name or type(attn_module).__name__}. "
|
| 22 |
+
f"Children: {list(children.keys())}. Expected one of: {_OUTPUT_PROJ_NAMES}"
|
| 23 |
+
)
|
| 24 |
+
|
| 25 |
+
|
| 26 |
def load_model_for_inference(model_name: str):
|
| 27 |
"""Load model with float32 dtype for CPU stability and verify weight tying."""
|
| 28 |
model = AutoModelForCausalLM.from_pretrained(
|
|
|
|
| 421 |
return result
|
| 422 |
|
| 423 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 424 |
|
| 425 |
def execute_forward_pass_with_multi_layer_head_ablation(model, tokenizer, prompt: str, config: Dict[str, Any],
|
| 426 |
heads_by_layer: Dict[int, List[int]], original_prompt: Optional[str] = None) -> Dict[str, Any]:
|
|
|
|
| 484 |
def make_hook(mod_name: str):
|
| 485 |
return lambda module, inputs, output: captured.update({mod_name: {"output": safe_to_serializable(output)}})
|
| 486 |
|
| 487 |
+
# Create pre-hook factory for ablation on output projection input
|
| 488 |
+
def make_head_ablation_pre_hook(ablate_head_indices: List[int]):
|
| 489 |
+
"""Pre-hook on output projection: zeros head slices BEFORE projection mixing."""
|
| 490 |
+
def pre_hook(module, args):
|
| 491 |
+
x = args[0].clone()
|
| 492 |
+
num_heads = model.config.num_attention_heads
|
| 493 |
+
head_dim = x.shape[-1] // num_heads
|
| 494 |
+
for head_idx in ablate_head_indices:
|
| 495 |
+
if 0 <= head_idx < num_heads:
|
| 496 |
+
start = head_idx * head_dim
|
| 497 |
+
end = (head_idx + 1) * head_dim
|
| 498 |
+
x[:, :, start:end] = 0.0
|
| 499 |
+
return (x,)
|
| 500 |
+
return pre_hook
|
| 501 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 502 |
# Register hooks
|
| 503 |
hooks = []
|
| 504 |
for mod_name in all_modules:
|
| 505 |
if mod_name in name_to_module:
|
| 506 |
if mod_name in target_modules_to_heads:
|
| 507 |
+
# Ablation pre-hook on output projection (before heads are mixed)
|
| 508 |
+
attn_mod = name_to_module[mod_name]
|
| 509 |
+
_, proj_mod = _find_output_proj_submodule(attn_mod, mod_name)
|
| 510 |
+
hooks.append(proj_mod.register_forward_pre_hook(
|
| 511 |
+
make_head_ablation_pre_hook(target_modules_to_heads[mod_name])
|
| 512 |
))
|
| 513 |
+
# Capture hook on attn module (captures post-ablation output naturally)
|
| 514 |
+
hooks.append(attn_mod.register_forward_hook(make_hook(mod_name)))
|
| 515 |
else:
|
| 516 |
# Regular capture hook
|
| 517 |
hooks.append(name_to_module[mod_name].register_forward_hook(make_hook(mod_name)))
|
|
|
|
| 647 |
# Let's manually register hooks here for simplicity and control
|
| 648 |
hooks = []
|
| 649 |
|
| 650 |
+
def head_ablation_pre_hook_factory(head_indices):
|
| 651 |
+
"""Pre-hook on output projection: zeros head slices BEFORE projection mixing."""
|
| 652 |
+
def pre_hook(module, args):
|
| 653 |
+
x = args[0].clone()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 654 |
num_heads = model.config.num_attention_heads
|
| 655 |
+
head_dim = x.shape[-1] // num_heads
|
| 656 |
+
for head_idx in head_indices:
|
| 657 |
+
if 0 <= head_idx < num_heads:
|
| 658 |
+
start = head_idx * head_dim
|
| 659 |
+
end = (head_idx + 1) * head_dim
|
| 660 |
+
x[:, :, start:end] = 0.0
|
| 661 |
+
return (x,)
|
| 662 |
+
return pre_hook
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 663 |
|
| 664 |
# Hook for Layer Ablation (Identity/Skip or Zero)
|
| 665 |
# We'll use Identity (Skip Layer) as a simpler approximation of "removing logic"
|
|
|
|
| 683 |
|
| 684 |
# Simple heuristic: find 'layers.X.self_attn' or 'h.X.attn'
|
| 685 |
target_module = None
|
| 686 |
+
target_name = None
|
| 687 |
for name, mod in model.named_modules():
|
| 688 |
# Check for standard patterns
|
| 689 |
# layer_num is int
|
| 690 |
if f"layers.{layer_num}.self_attn" in name or f"h.{layer_num}.attn" in name or f"blocks.{layer_num}.attn" in name:
|
| 691 |
if "k_proj" not in name and "v_proj" not in name and "q_proj" not in name: # avoid submodules
|
| 692 |
target_module = mod
|
| 693 |
+
target_name = name
|
| 694 |
break
|
| 695 |
+
|
| 696 |
if target_module:
|
| 697 |
+
try:
|
| 698 |
+
_, proj_mod = _find_output_proj_submodule(target_module, target_name)
|
| 699 |
+
hooks.append(proj_mod.register_forward_pre_hook(head_ablation_pre_hook_factory(head_indices)))
|
| 700 |
+
except ValueError as e:
|
| 701 |
+
print(f"Warning: {e}")
|
| 702 |
else:
|
| 703 |
print(f"Warning: Could not find attention module for layer {layer_num}")
|
| 704 |
|