attnread 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- attnread-0.1.0/LICENSE +21 -0
- attnread-0.1.0/MANIFEST.in +11 -0
- attnread-0.1.0/PACKAGE.md +151 -0
- attnread-0.1.0/PKG-INFO +162 -0
- attnread-0.1.0/README.md +140 -0
- attnread-0.1.0/RELEASE.md +54 -0
- attnread-0.1.0/pyproject.toml +34 -0
- attnread-0.1.0/setup.cfg +4 -0
- attnread-0.1.0/src/attnread/__init__.py +4 -0
- attnread-0.1.0/src/attnread/_core.py +215 -0
- attnread-0.1.0/src/attnread/py.typed +0 -0
- attnread-0.1.0/src/attnread.egg-info/PKG-INFO +162 -0
- attnread-0.1.0/src/attnread.egg-info/SOURCES.txt +15 -0
- attnread-0.1.0/src/attnread.egg-info/dependency_links.txt +1 -0
- attnread-0.1.0/src/attnread.egg-info/requires.txt +8 -0
- attnread-0.1.0/src/attnread.egg-info/top_level.txt +1 -0
- attnread-0.1.0/test_attnread.py +196 -0
attnread-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Etha
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
include LICENSE README.md PACKAGE.md RELEASE.md
|
|
2
|
+
recursive-include src/attnread *.py py.typed
|
|
3
|
+
include test_attnread.py
|
|
4
|
+
exclude BLOG.md AGENTS.md compare.py test_compare.py test_value_patch.py value_patch.py
|
|
5
|
+
prune figures
|
|
6
|
+
prune outputs
|
|
7
|
+
prune .venv
|
|
8
|
+
prune .git
|
|
9
|
+
prune __pycache__
|
|
10
|
+
prune .pytest_cache
|
|
11
|
+
global-exclude *.ipynb *.pdf *.png *.jpg *.jpeg *.gif *.webp
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# AttnRead
|
|
2
|
+
|
|
3
|
+
**Recover attention weights without changing the attention backend.**
|
|
4
|
+
|
|
5
|
+
AttnRead is a lightweight Python toolkit for extracting attention patterns from optimized transformer attention implementations, including PyTorch SDPA and FlashAttention 2.
|
|
6
|
+
|
|
7
|
+
Using identity-value passes and activation restoration, AttnRead recovers attention patterns while preserving the model's downstream computation. No fallback to eager attention required.
|
|
8
|
+
|
|
9
|
+
Version **0.1.0**, distributed under the MIT License. The distribution and
|
|
10
|
+
Python import name are both `attnread`.
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
python -m venv .venv
|
|
14
|
+
# Activate the venv and install your preferred CPU/CUDA PyTorch build first.
|
|
15
|
+
python -m pip install .
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
```python
|
|
19
|
+
import torch
|
|
20
|
+
from transformers import AutoModelForCausalLM, AutoTokenizer
|
|
21
|
+
from attnread import extract_attention
|
|
22
|
+
|
|
23
|
+
checkpoint = "Qwen/Qwen3-4B"
|
|
24
|
+
revision = "1cfa9a7208912126459214e8b04321603b3df60c"
|
|
25
|
+
model = AutoModelForCausalLM.from_pretrained(
|
|
26
|
+
checkpoint, revision=revision, dtype=torch.bfloat16,
|
|
27
|
+
attn_implementation="sdpa",
|
|
28
|
+
).to("cuda").eval().requires_grad_(False)
|
|
29
|
+
tokenizer = AutoTokenizer.from_pretrained(checkpoint, revision=revision)
|
|
30
|
+
inputs = tokenizer("Describe the colors of an autumn leaf.",
|
|
31
|
+
return_tensors="pt").to(model.device)
|
|
32
|
+
|
|
33
|
+
patterns, checks = extract_attention(model, inputs, heads=[(0, 3), (12, 7)])
|
|
34
|
+
head = patterns[("text", 0, 3)] # CPU tensor: [batch, query tokens, key tokens].
|
|
35
|
+
assert checks["probe_logit_max_abs"] == 0
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Use the same call with `AutoModelForImageTextToText` and processor outputs:
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
# (layer, head) means text attention; qualify a vision head with its stream.
|
|
42
|
+
patterns, checks = extract_attention(
|
|
43
|
+
vlm, processed_inputs, heads=[(12, 3), ("vision", 4, 2)],
|
|
44
|
+
)
|
|
45
|
+
text = patterns[("text", 12, 3)]
|
|
46
|
+
vision = patterns[("vision", 4, 2)]
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
**Selection.** `heads=None` extracts every discovered text head. A sequence of
|
|
50
|
+
`(layer, head)` or `(stream, layer, head)` addresses extracts only those heads;
|
|
51
|
+
returned keys always use the full triple, in request order. Layers and heads are
|
|
52
|
+
zero-based within each stream, following discovered module order (reported in
|
|
53
|
+
`checks["modules"]`). Use explicit paths for sparse/hybrid stacks or unusual
|
|
54
|
+
module naming. Different streams can have different sequence lengths, so results
|
|
55
|
+
are a dictionary. Probes are grouped by layer and save/transfer only selected
|
|
56
|
+
heads. The underlying model still computes all heads; head selection reduces
|
|
57
|
+
saved memory, not the number of identity probes. Start with one short prefix and
|
|
58
|
+
one head. Unknown, duplicate or out-of-range addresses raise `ValueError`.
|
|
59
|
+
|
|
60
|
+
**Model interface.** Pass an ordinary HF model in evaluation mode and its
|
|
61
|
+
preprocessed tensor input mapping. Discovery recognizes separate `q_proj`,
|
|
62
|
+
`k_proj`, `v_proj` with `o_proj`/`out_proj`, GPT-2's fused `c_attn`/`c_proj`, and
|
|
63
|
+
Qwen3-VL vision `qkv`/`proj`. Vision module paths identify the `vision` stream;
|
|
64
|
+
others belong to `text`. For unusual or ambiguous naming, supply explicit paths:
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
patterns, checks = extract_attention(model, inputs, heads=[("text", 0, 1)],
|
|
68
|
+
attention_modules={"text": ["model.layers.7.self_attn"]})
|
|
69
|
+
# With explicit lists, layer 0 denotes the first entry in that list.
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
This is structural compatibility, not a promise that every HF architecture works.
|
|
73
|
+
The supported operation must be linear in V, with attention weights independent
|
|
74
|
+
of V, and a conventional merged-head input to the output projection. Tested
|
|
75
|
+
families include Qwen3, Llama, Mistral (sliding window), Gemma2 (score softcapping),
|
|
76
|
+
GPT-2, LLaVA/CLIP vision with pixels, and Qwen3-VL packed vision/text. Unsupported
|
|
77
|
+
projection layouts fail explicitly. Cross-attention, nonlinear/value-dependent
|
|
78
|
+
attention, quantized/compiled models, distributed/sharded execution and remote
|
|
79
|
+
NNsight are outside the verified scope. No weights or inputs are downloaded by
|
|
80
|
+
the extraction call. Model weights stay unchanged; each probe restores the genuine
|
|
81
|
+
pre-output-projection activation before the rest of the forward runs.
|
|
82
|
+
|
|
83
|
+
**Inputs and results.** Use nonempty `input_ids` or `inputs_embeds`, no KV cache,
|
|
84
|
+
and `output_attentions=False`. Sequence length comes from the actual attention
|
|
85
|
+
activation, accounting for expanded image tokens. Parameter gradients should be
|
|
86
|
+
disabled for inference to reduce NNsight memory. Do not run concurrent calls on
|
|
87
|
+
the same model. Qwen-VL vision packs patches from all images/frames on one axis;
|
|
88
|
+
its result is `[1, packed_tokens, packed_tokens]`, with separate images/frames
|
|
89
|
+
forming disconnected blocks. Interpret coordinates with the processor's grids;
|
|
90
|
+
these are patch tokens, not merged decoder image tokens.
|
|
91
|
+
|
|
92
|
+
Checks contain module paths/layouts, selected addresses, probe count, and maximum
|
|
93
|
+
absolute logit deltas for traced baselines, probes and a final unpatched pass.
|
|
94
|
+
Inspect the deltas: nondeterministic execution may produce nonzero values even
|
|
95
|
+
without a probe. Recovered weights are finite-precision measurements and are not
|
|
96
|
+
renormalized or guaranteed bit-exact native softmax probabilities. Masks and
|
|
97
|
+
Q/K-only score modifications stay inside the original attention implementation.
|
|
98
|
+
|
|
99
|
+
**Backend.** `sdpa` uses normal PyTorch backend selection. Force math by wrapping
|
|
100
|
+
extraction in `torch.nn.attention.sdpa_kernel(SDPBackend.MATH)`. Package FA2 requires
|
|
101
|
+
a separately installed compatible `flash-attn` and `attn_implementation="flash_attention_2"`.
|
|
102
|
+
The library does not install FA2 or override precision/backend.
|
|
103
|
+
|
|
104
|
+
**Cost.** For each selected layer with prefix T and head dimension d, reconstruction
|
|
105
|
+
needs 1 + ceil(T/d) full forwards, plus two global unpatched forwards. Selected-head
|
|
106
|
+
CPU storage scales as B×selected_heads×T² per layer; this remains quadratic.
|
|
107
|
+
NNsight brings dependencies, but the package itself contains only the extractor.
|
|
108
|
+
|
|
109
|
+
**Compatibility.** Initially constrained to NNsight 0.7.0, Transformers 5.19.x
|
|
110
|
+
and PyTorch 2.14.x; tested on Python 3.11. Experiment pins live in the research repository’s `requirements.txt`;
|
|
111
|
+
the package has no CUDA URLs or plotting dependencies.
|
|
112
|
+
The expanded BF16 GPU validation covers 240 input/backend cases: 20 COCO images,
|
|
113
|
+
20 standalone Qwen prompts, 20 GPT-2 prompts, and the same 20 prompts through the
|
|
114
|
+
VLM text decoder, each with eager, ordinary SDPA and package FA2. It checks selected
|
|
115
|
+
decoder heads at early, middle and final layers, plus packed vision layer/head 0
|
|
116
|
+
for image inputs, against original and independent random V. Direct native eager
|
|
117
|
+
weights provide a separate reference for text heads. These checks do not establish
|
|
118
|
+
every head or every backend combination for every family. Generic adapters are
|
|
119
|
+
also checked in small real HF models on CPU. The research repository
|
|
120
|
+
contains the blog, numerical errors and provenance.
|
|
121
|
+
|
|
122
|
+
See [the first-release guide](RELEASE.md).
|
|
123
|
+
|
|
124
|
+
**Independent validation.** `verify_attention.py` is a research script, outside
|
|
125
|
+
this package. It captures projected V and the native pre-output-projection
|
|
126
|
+
activation with NNsight, then compares that activation with recovered A × V in
|
|
127
|
+
float64. Three held-out Gaussian V replacements per layer additionally test the
|
|
128
|
+
recovered matrix on values never used for extraction. Q/K and restored logits
|
|
129
|
+
are checked for equality. Reversing A's key columns supplies a deliberately
|
|
130
|
+
incorrect pattern as a sensitivity control. Small FP32 models also compare
|
|
131
|
+
against directly returned eager weights. Original-V agreement alone cannot
|
|
132
|
+
uniquely identify A when V has insufficient rank; finite random trials add
|
|
133
|
+
evidence rather than a proof of equality for every possible V.
|
|
134
|
+
|
|
135
|
+
From a separate research repository checkout, on a Slurm GPU allocation with
|
|
136
|
+
that repository's venv and saved benchmark inputs:
|
|
137
|
+
|
|
138
|
+
```bash
|
|
139
|
+
.venv/bin/python verify_attention.py --output outputs/attention-validation
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
The JSON report records errors per stream/layer/head, seeds, input hashes,
|
|
143
|
+
checkpoint revisions and package versions. Adjacent `.pt` files contain A,
|
|
144
|
+
original V/output tensors, and the held-out V/output tensors for auditing.
|
|
145
|
+
|
|
146
|
+
Each backend is additionally compared with the **identity-extracted eager
|
|
147
|
+
pattern** on the same input: both A-versus-A errors and predictions using eager
|
|
148
|
+
A with that backend's actual original/held-out V. This distinguishes within-
|
|
149
|
+
backend recovery consistency from disagreement with eager. For later decoder
|
|
150
|
+
layers, cross-backend matrix differences can include inherited upstream
|
|
151
|
+
activation differences; this comparison does not isolate the local kernel.
|
attnread-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: attnread
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Recover attention weights without changing the attention backend.
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Classifier: Development Status :: 3 - Alpha
|
|
7
|
+
Classifier: Intended Audience :: Science/Research
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
10
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
11
|
+
Requires-Python: >=3.11
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
License-File: LICENSE
|
|
14
|
+
Requires-Dist: nnsight==0.7.0
|
|
15
|
+
Requires-Dist: torch<2.15,>=2.14
|
|
16
|
+
Requires-Dist: transformers<5.20,>=5.19
|
|
17
|
+
Provides-Extra: dev
|
|
18
|
+
Requires-Dist: pytest<10,>=9; extra == "dev"
|
|
19
|
+
Requires-Dist: build<2,>=1.2; extra == "dev"
|
|
20
|
+
Requires-Dist: twine<7,>=6; extra == "dev"
|
|
21
|
+
Dynamic: license-file
|
|
22
|
+
|
|
23
|
+
# AttnRead
|
|
24
|
+
|
|
25
|
+
**Recover attention weights without changing the attention backend.**
|
|
26
|
+
|
|
27
|
+
AttnRead is a lightweight Python toolkit for extracting attention patterns from optimized transformer attention implementations, including PyTorch SDPA and FlashAttention 2.
|
|
28
|
+
|
|
29
|
+
Using identity-value passes and activation restoration, AttnRead recovers attention patterns while preserving the model's downstream computation. No fallback to eager attention required.
|
|
30
|
+
|
|
31
|
+
Version **0.1.0**, distributed under the MIT License. The distribution and
|
|
32
|
+
Python import name are both `attnread`.
|
|
33
|
+
|
|
34
|
+
## Installation
|
|
35
|
+
|
|
36
|
+
Use Python 3.11 or newer in a venv. Install your preferred CPU/CUDA PyTorch
|
|
37
|
+
build first, then install AttnRead:
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
python -m pip install attnread==0.1.0
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Usage
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
import torch
|
|
47
|
+
from transformers import AutoModelForCausalLM, AutoTokenizer
|
|
48
|
+
from attnread import extract_attention
|
|
49
|
+
|
|
50
|
+
checkpoint = "Qwen/Qwen3-4B"
|
|
51
|
+
revision = "1cfa9a7208912126459214e8b04321603b3df60c"
|
|
52
|
+
model = AutoModelForCausalLM.from_pretrained(
|
|
53
|
+
checkpoint, revision=revision, dtype=torch.bfloat16,
|
|
54
|
+
attn_implementation="sdpa",
|
|
55
|
+
).to("cuda").eval().requires_grad_(False)
|
|
56
|
+
tokenizer = AutoTokenizer.from_pretrained(checkpoint, revision=revision)
|
|
57
|
+
inputs = tokenizer("Describe the colors of an autumn leaf.",
|
|
58
|
+
return_tensors="pt").to(model.device)
|
|
59
|
+
|
|
60
|
+
patterns, checks = extract_attention(model, inputs, heads=[(0, 3), (12, 7)])
|
|
61
|
+
head = patterns[("text", 0, 3)] # CPU tensor: [batch, query tokens, key tokens].
|
|
62
|
+
assert checks["probe_logit_max_abs"] == 0
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Use the same call with `AutoModelForImageTextToText` and processor outputs:
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
# (layer, head) means text attention; qualify a vision head with its stream.
|
|
69
|
+
patterns, checks = extract_attention(
|
|
70
|
+
vlm, processed_inputs, heads=[(12, 3), ("vision", 4, 2)],
|
|
71
|
+
)
|
|
72
|
+
text = patterns[("text", 12, 3)]
|
|
73
|
+
vision = patterns[("vision", 4, 2)]
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
**Selection.** `heads=None` extracts every discovered text head. A sequence of
|
|
77
|
+
`(layer, head)` or `(stream, layer, head)` addresses extracts only those heads;
|
|
78
|
+
returned keys always use the full triple, in request order. Layers and heads are
|
|
79
|
+
zero-based within each stream, following discovered module order (reported in
|
|
80
|
+
`checks["modules"]`). Use explicit paths for sparse/hybrid stacks or unusual
|
|
81
|
+
module naming. Different streams can have different sequence lengths, so results
|
|
82
|
+
are a dictionary. Probes are grouped by layer and save/transfer only selected
|
|
83
|
+
heads. The underlying model still computes all heads; head selection reduces
|
|
84
|
+
saved memory, not the number of identity probes. Start with one short prefix and
|
|
85
|
+
one head. Unknown, duplicate or out-of-range addresses raise `ValueError`.
|
|
86
|
+
|
|
87
|
+
**Model interface.** Pass an ordinary HF model in evaluation mode and its
|
|
88
|
+
preprocessed tensor input mapping. Discovery recognizes separate `q_proj`,
|
|
89
|
+
`k_proj`, `v_proj` with `o_proj`/`out_proj`, GPT-2's fused `c_attn`/`c_proj`, and
|
|
90
|
+
Qwen3-VL vision `qkv`/`proj`. Vision module paths identify the `vision` stream;
|
|
91
|
+
others belong to `text`. For unusual or ambiguous naming, supply explicit paths:
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
patterns, checks = extract_attention(model, inputs, heads=[("text", 0, 1)],
|
|
95
|
+
attention_modules={"text": ["model.layers.7.self_attn"]})
|
|
96
|
+
# With explicit lists, layer 0 denotes the first entry in that list.
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
This is structural compatibility, not a promise that every HF architecture works.
|
|
100
|
+
The supported operation must be linear in V, with attention weights independent
|
|
101
|
+
of V, and a conventional merged-head input to the output projection. Tested
|
|
102
|
+
families include Qwen3, Llama, Mistral (sliding window), Gemma2 (score softcapping),
|
|
103
|
+
GPT-2, LLaVA/CLIP vision with pixels, and Qwen3-VL packed vision/text. Unsupported
|
|
104
|
+
projection layouts fail explicitly. Cross-attention, nonlinear/value-dependent
|
|
105
|
+
attention, quantized/compiled models, distributed/sharded execution and remote
|
|
106
|
+
NNsight are outside the verified scope. No weights or inputs are downloaded by
|
|
107
|
+
the extraction call. Model weights stay unchanged; each probe restores the genuine
|
|
108
|
+
pre-output-projection activation before the rest of the forward runs.
|
|
109
|
+
|
|
110
|
+
**Inputs and results.** Use nonempty `input_ids` or `inputs_embeds`, no KV cache,
|
|
111
|
+
and `output_attentions=False`. Sequence length comes from the actual attention
|
|
112
|
+
activation, accounting for expanded image tokens. Parameter gradients should be
|
|
113
|
+
disabled for inference to reduce NNsight memory. Do not run concurrent calls on
|
|
114
|
+
the same model. Qwen-VL vision packs patches from all images/frames on one axis;
|
|
115
|
+
its result is `[1, packed_tokens, packed_tokens]`, with separate images/frames
|
|
116
|
+
forming disconnected blocks. Interpret coordinates with the processor's grids;
|
|
117
|
+
these are patch tokens, not merged decoder image tokens.
|
|
118
|
+
|
|
119
|
+
Checks contain module paths/layouts, selected addresses, probe count, and maximum
|
|
120
|
+
absolute logit deltas for traced baselines, probes and a final unpatched pass.
|
|
121
|
+
Inspect the deltas: nondeterministic execution may produce nonzero values even
|
|
122
|
+
without a probe. Recovered weights are finite-precision measurements and are not
|
|
123
|
+
renormalized or guaranteed bit-exact native softmax probabilities. Masks and
|
|
124
|
+
Q/K-only score modifications stay inside the original attention implementation.
|
|
125
|
+
|
|
126
|
+
**Backend.** `sdpa` uses normal PyTorch backend selection. Force math by wrapping
|
|
127
|
+
extraction in `torch.nn.attention.sdpa_kernel(SDPBackend.MATH)`. Package FA2 requires
|
|
128
|
+
a separately installed compatible `flash-attn` and `attn_implementation="flash_attention_2"`.
|
|
129
|
+
The library does not install FA2 or override precision/backend.
|
|
130
|
+
|
|
131
|
+
**Cost.** For each selected layer with prefix T and head dimension d, reconstruction
|
|
132
|
+
needs 1 + ceil(T/d) full forwards, plus two global unpatched forwards. Selected-head
|
|
133
|
+
CPU storage scales as B×selected_heads×T² per layer; this remains quadratic.
|
|
134
|
+
NNsight brings dependencies, but the package itself contains only the extractor.
|
|
135
|
+
|
|
136
|
+
**Compatibility.** Initially constrained to NNsight 0.7.0, Transformers 5.19.x
|
|
137
|
+
and PyTorch 2.14.x; tested on Python 3.11. Experiment pins live in the research repository’s `requirements.txt`;
|
|
138
|
+
the package has no CUDA URLs or plotting dependencies.
|
|
139
|
+
|
|
140
|
+
## Development
|
|
141
|
+
|
|
142
|
+
From the standalone source directory:
|
|
143
|
+
|
|
144
|
+
```bash
|
|
145
|
+
python -m venv .venv
|
|
146
|
+
source .venv/bin/activate
|
|
147
|
+
# Install your preferred CPU/CUDA PyTorch build first.
|
|
148
|
+
python -m pip install -e '.[dev]'
|
|
149
|
+
python -m pytest -q test_attnread.py
|
|
150
|
+
python -m build
|
|
151
|
+
python -m twine check --strict dist/*
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
On Oscar, run installs, builds and tests in a CPU Slurm allocation; pretrained
|
|
155
|
+
GPU examples require a GPU allocation. The source checkout includes the release
|
|
156
|
+
guide and review notebook; the notebook uses in-memory example inputs and is
|
|
157
|
+
excluded from release archives. No publishing step runs automatically.
|
|
158
|
+
|
|
159
|
+
## License
|
|
160
|
+
|
|
161
|
+
MIT License. Copyright (c) 2026 Etha. The full license text is included in the
|
|
162
|
+
source archive and wheel.
|
attnread-0.1.0/README.md
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# AttnRead
|
|
2
|
+
|
|
3
|
+
**Recover attention weights without changing the attention backend.**
|
|
4
|
+
|
|
5
|
+
AttnRead is a lightweight Python toolkit for extracting attention patterns from optimized transformer attention implementations, including PyTorch SDPA and FlashAttention 2.
|
|
6
|
+
|
|
7
|
+
Using identity-value passes and activation restoration, AttnRead recovers attention patterns while preserving the model's downstream computation. No fallback to eager attention required.
|
|
8
|
+
|
|
9
|
+
Version **0.1.0**, distributed under the MIT License. The distribution and
|
|
10
|
+
Python import name are both `attnread`.
|
|
11
|
+
|
|
12
|
+
## Installation
|
|
13
|
+
|
|
14
|
+
Use Python 3.11 or newer in a venv. Install your preferred CPU/CUDA PyTorch
|
|
15
|
+
build first, then install AttnRead:
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
python -m pip install attnread==0.1.0
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Usage
|
|
22
|
+
|
|
23
|
+
```python
|
|
24
|
+
import torch
|
|
25
|
+
from transformers import AutoModelForCausalLM, AutoTokenizer
|
|
26
|
+
from attnread import extract_attention
|
|
27
|
+
|
|
28
|
+
checkpoint = "Qwen/Qwen3-4B"
|
|
29
|
+
revision = "1cfa9a7208912126459214e8b04321603b3df60c"
|
|
30
|
+
model = AutoModelForCausalLM.from_pretrained(
|
|
31
|
+
checkpoint, revision=revision, dtype=torch.bfloat16,
|
|
32
|
+
attn_implementation="sdpa",
|
|
33
|
+
).to("cuda").eval().requires_grad_(False)
|
|
34
|
+
tokenizer = AutoTokenizer.from_pretrained(checkpoint, revision=revision)
|
|
35
|
+
inputs = tokenizer("Describe the colors of an autumn leaf.",
|
|
36
|
+
return_tensors="pt").to(model.device)
|
|
37
|
+
|
|
38
|
+
patterns, checks = extract_attention(model, inputs, heads=[(0, 3), (12, 7)])
|
|
39
|
+
head = patterns[("text", 0, 3)] # CPU tensor: [batch, query tokens, key tokens].
|
|
40
|
+
assert checks["probe_logit_max_abs"] == 0
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Use the same call with `AutoModelForImageTextToText` and processor outputs:
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
# (layer, head) means text attention; qualify a vision head with its stream.
|
|
47
|
+
patterns, checks = extract_attention(
|
|
48
|
+
vlm, processed_inputs, heads=[(12, 3), ("vision", 4, 2)],
|
|
49
|
+
)
|
|
50
|
+
text = patterns[("text", 12, 3)]
|
|
51
|
+
vision = patterns[("vision", 4, 2)]
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
**Selection.** `heads=None` extracts every discovered text head. A sequence of
|
|
55
|
+
`(layer, head)` or `(stream, layer, head)` addresses extracts only those heads;
|
|
56
|
+
returned keys always use the full triple, in request order. Layers and heads are
|
|
57
|
+
zero-based within each stream, following discovered module order (reported in
|
|
58
|
+
`checks["modules"]`). Use explicit paths for sparse/hybrid stacks or unusual
|
|
59
|
+
module naming. Different streams can have different sequence lengths, so results
|
|
60
|
+
are a dictionary. Probes are grouped by layer and save/transfer only selected
|
|
61
|
+
heads. The underlying model still computes all heads; head selection reduces
|
|
62
|
+
saved memory, not the number of identity probes. Start with one short prefix and
|
|
63
|
+
one head. Unknown, duplicate or out-of-range addresses raise `ValueError`.
|
|
64
|
+
|
|
65
|
+
**Model interface.** Pass an ordinary HF model in evaluation mode and its
|
|
66
|
+
preprocessed tensor input mapping. Discovery recognizes separate `q_proj`,
|
|
67
|
+
`k_proj`, `v_proj` with `o_proj`/`out_proj`, GPT-2's fused `c_attn`/`c_proj`, and
|
|
68
|
+
Qwen3-VL vision `qkv`/`proj`. Vision module paths identify the `vision` stream;
|
|
69
|
+
others belong to `text`. For unusual or ambiguous naming, supply explicit paths:
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
patterns, checks = extract_attention(model, inputs, heads=[("text", 0, 1)],
|
|
73
|
+
attention_modules={"text": ["model.layers.7.self_attn"]})
|
|
74
|
+
# With explicit lists, layer 0 denotes the first entry in that list.
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
This is structural compatibility, not a promise that every HF architecture works.
|
|
78
|
+
The supported operation must be linear in V, with attention weights independent
|
|
79
|
+
of V, and a conventional merged-head input to the output projection. Tested
|
|
80
|
+
families include Qwen3, Llama, Mistral (sliding window), Gemma2 (score softcapping),
|
|
81
|
+
GPT-2, LLaVA/CLIP vision with pixels, and Qwen3-VL packed vision/text. Unsupported
|
|
82
|
+
projection layouts fail explicitly. Cross-attention, nonlinear/value-dependent
|
|
83
|
+
attention, quantized/compiled models, distributed/sharded execution and remote
|
|
84
|
+
NNsight are outside the verified scope. No weights or inputs are downloaded by
|
|
85
|
+
the extraction call. Model weights stay unchanged; each probe restores the genuine
|
|
86
|
+
pre-output-projection activation before the rest of the forward runs.
|
|
87
|
+
|
|
88
|
+
**Inputs and results.** Use nonempty `input_ids` or `inputs_embeds`, no KV cache,
|
|
89
|
+
and `output_attentions=False`. Sequence length comes from the actual attention
|
|
90
|
+
activation, accounting for expanded image tokens. Parameter gradients should be
|
|
91
|
+
disabled for inference to reduce NNsight memory. Do not run concurrent calls on
|
|
92
|
+
the same model. Qwen-VL vision packs patches from all images/frames on one axis;
|
|
93
|
+
its result is `[1, packed_tokens, packed_tokens]`, with separate images/frames
|
|
94
|
+
forming disconnected blocks. Interpret coordinates with the processor's grids;
|
|
95
|
+
these are patch tokens, not merged decoder image tokens.
|
|
96
|
+
|
|
97
|
+
Checks contain module paths/layouts, selected addresses, probe count, and maximum
|
|
98
|
+
absolute logit deltas for traced baselines, probes and a final unpatched pass.
|
|
99
|
+
Inspect the deltas: nondeterministic execution may produce nonzero values even
|
|
100
|
+
without a probe. Recovered weights are finite-precision measurements and are not
|
|
101
|
+
renormalized or guaranteed bit-exact native softmax probabilities. Masks and
|
|
102
|
+
Q/K-only score modifications stay inside the original attention implementation.
|
|
103
|
+
|
|
104
|
+
**Backend.** `sdpa` uses normal PyTorch backend selection. Force math by wrapping
|
|
105
|
+
extraction in `torch.nn.attention.sdpa_kernel(SDPBackend.MATH)`. Package FA2 requires
|
|
106
|
+
a separately installed compatible `flash-attn` and `attn_implementation="flash_attention_2"`.
|
|
107
|
+
The library does not install FA2 or override precision/backend.
|
|
108
|
+
|
|
109
|
+
**Cost.** For each selected layer with prefix T and head dimension d, reconstruction
|
|
110
|
+
needs 1 + ceil(T/d) full forwards, plus two global unpatched forwards. Selected-head
|
|
111
|
+
CPU storage scales as B×selected_heads×T² per layer; this remains quadratic.
|
|
112
|
+
NNsight brings dependencies, but the package itself contains only the extractor.
|
|
113
|
+
|
|
114
|
+
**Compatibility.** Initially constrained to NNsight 0.7.0, Transformers 5.19.x
|
|
115
|
+
and PyTorch 2.14.x; tested on Python 3.11. Experiment pins live in the research repository’s `requirements.txt`;
|
|
116
|
+
the package has no CUDA URLs or plotting dependencies.
|
|
117
|
+
|
|
118
|
+
## Development
|
|
119
|
+
|
|
120
|
+
From the standalone source directory:
|
|
121
|
+
|
|
122
|
+
```bash
|
|
123
|
+
python -m venv .venv
|
|
124
|
+
source .venv/bin/activate
|
|
125
|
+
# Install your preferred CPU/CUDA PyTorch build first.
|
|
126
|
+
python -m pip install -e '.[dev]'
|
|
127
|
+
python -m pytest -q test_attnread.py
|
|
128
|
+
python -m build
|
|
129
|
+
python -m twine check --strict dist/*
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
On Oscar, run installs, builds and tests in a CPU Slurm allocation; pretrained
|
|
133
|
+
GPU examples require a GPU allocation. The source checkout includes the release
|
|
134
|
+
guide and review notebook; the notebook uses in-memory example inputs and is
|
|
135
|
+
excluded from release archives. No publishing step runs automatically.
|
|
136
|
+
|
|
137
|
+
## License
|
|
138
|
+
|
|
139
|
+
MIT License. Copyright (c) 2026 Etha. The full license text is included in the
|
|
140
|
+
source archive and wheel.
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# First PyPI release
|
|
2
|
+
|
|
3
|
+
Release version: **0.1.0**. License: **MIT**. No release/upload is automatic.
|
|
4
|
+
|
|
5
|
+
1. Verify that the chosen distribution name, `attnread`, is
|
|
6
|
+
available on both PyPI and TestPyPI. Hyphens/underscores normalize to the
|
|
7
|
+
same distribution name; the Python import is `attnread`.
|
|
8
|
+
2. Confirm that `LICENSE` is present in both release archives and that their
|
|
9
|
+
metadata contains `License-Expression: MIT` and `License-File: LICENSE`.
|
|
10
|
+
The PyPI README is self-contained. Add repository/documentation URLs only
|
|
11
|
+
once their destinations are publicly accessible. Review the files in the
|
|
12
|
+
wheel/sdist: these are public even if GitHub stays private.
|
|
13
|
+
3. Keep supported models/backends and dependency bounds honest. The first adapter
|
|
14
|
+
recognizes specific separate/fused projection layouts; HF task classes alone
|
|
15
|
+
do not guarantee compatibility. Expand bounds after testing; keep
|
|
16
|
+
exact research environment pins separate from package dependencies.
|
|
17
|
+
4. Run tests and build both artifacts in a venv. Test the installed wheel from a
|
|
18
|
+
directory outside the checkout; also build a wheel from the source archive.
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
python -m pip install -e '.[dev]'
|
|
22
|
+
python -m pytest -q
|
|
23
|
+
python -m build
|
|
24
|
+
python -m twine check --strict dist/*
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
On this HPC system, installs/builds/tests belong in a CPU Slurm allocation.
|
|
28
|
+
Run these commands from this directory. Verify the installed wheel outside the
|
|
29
|
+
source checkout in a separate environment with the declared dependencies; avoid
|
|
30
|
+
changing the research environment. `python -m build` builds the source archive
|
|
31
|
+
and then builds the wheel from that archive.
|
|
32
|
+
|
|
33
|
+
5. Create separate PyPI and TestPyPI accounts, verify email, enable 2FA, and save
|
|
34
|
+
recovery codes. Prefer GitHub Actions Trusted Publishing (OIDC) with a protected
|
|
35
|
+
release environment; it avoids storing a long-lived PyPI token. For a manual
|
|
36
|
+
first upload, use a token through Twine's prompt, never in source or commands.
|
|
37
|
+
6. Upload version 0.1.0 to TestPyPI first, then install that exact artifact.
|
|
38
|
+
Preinstall dependencies from normal PyPI and use `--no-deps` for TestPyPI so
|
|
39
|
+
a test index does not become the source of your dependency graph.
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
python -m twine upload --repository testpypi dist/*
|
|
43
|
+
python -m pip install --index-url https://test.pypi.org/simple/ --no-deps attnread==0.1.0
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
7. Once reviewed, tag the corresponding commit and upload the same verified
|
|
47
|
+
artifacts to PyPI. Every changed artifact needs a new version; uploaded
|
|
48
|
+
filenames cannot be reused, even after deletion. Version 0.1.0 is the first public release. Fix broken releases with a new
|
|
49
|
+
version (and consider yanking the broken one).
|
|
50
|
+
|
|
51
|
+
Official guides: [packaging](https://packaging.python.org/en/latest/tutorials/packaging-projects/),
|
|
52
|
+
[TestPyPI](https://packaging.python.org/en/latest/guides/using-testpypi/),
|
|
53
|
+
[Trusted Publishing](https://docs.pypi.org/trusted-publishers/),
|
|
54
|
+
[PyPI accounts and immutable uploads](https://pypi.org/help/).
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77.0.3", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "attnread"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Recover attention weights without changing the attention backend."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
dependencies = [
|
|
14
|
+
"nnsight==0.7.0",
|
|
15
|
+
"torch>=2.14,<2.15",
|
|
16
|
+
"transformers>=5.19,<5.20",
|
|
17
|
+
]
|
|
18
|
+
classifiers = [
|
|
19
|
+
"Development Status :: 3 - Alpha",
|
|
20
|
+
"Intended Audience :: Science/Research",
|
|
21
|
+
"Programming Language :: Python :: 3",
|
|
22
|
+
"Programming Language :: Python :: 3.11",
|
|
23
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
[project.optional-dependencies]
|
|
27
|
+
dev = ["pytest>=9,<10", "build>=1.2,<2", "twine>=6,<7"]
|
|
28
|
+
|
|
29
|
+
[tool.setuptools.packages.find]
|
|
30
|
+
where = ["src"]
|
|
31
|
+
include = ["attnread*"]
|
|
32
|
+
|
|
33
|
+
[tool.setuptools.package-data]
|
|
34
|
+
attnread = ["py.typed"]
|
attnread-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"""Local attention recovery through NNsight partial-identity value probes."""
|
|
2
|
+
import inspect
|
|
3
|
+
from collections.abc import Mapping, Sequence
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import torch
|
|
8
|
+
from nnsight import NNsight
|
|
9
|
+
|
|
10
|
+
Head = tuple[str, int, int]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class _Spec:
|
|
15
|
+
path: str
|
|
16
|
+
value: str
|
|
17
|
+
output: str
|
|
18
|
+
heads: int
|
|
19
|
+
kv_heads: int
|
|
20
|
+
dim: int
|
|
21
|
+
fused: bool = False
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _spec(path, module):
|
|
25
|
+
if getattr(module, 'is_cross_attention', False) or any(
|
|
26
|
+
part in path.split('.') for part in ('cross_attn', 'encoder_attn', 'crossattention')
|
|
27
|
+
):
|
|
28
|
+
return None
|
|
29
|
+
dim = getattr(module, 'head_dim', None)
|
|
30
|
+
if not isinstance(dim, int) or dim <= 0:
|
|
31
|
+
return None
|
|
32
|
+
for output in ('o_proj', 'out_proj'):
|
|
33
|
+
v = getattr(module, 'v_proj', None)
|
|
34
|
+
o = getattr(module, output, None)
|
|
35
|
+
if all(hasattr(module, name) for name in ('q_proj', 'k_proj')) and v is not None and o is not None:
|
|
36
|
+
n, kv = getattr(o, 'in_features', 0), getattr(v, 'out_features', 0)
|
|
37
|
+
if n > 0 and kv > 0 and n % dim == kv % dim == 0:
|
|
38
|
+
return _Spec(path, 'v_proj', output, n // dim, kv // dim, dim)
|
|
39
|
+
# Fused QKV layouts are explicit: Q, then K, then V, with equal head counts.
|
|
40
|
+
if type(module).__name__ == 'GPT2Attention' and module.c_attn.nf == 3 * module.num_heads * dim:
|
|
41
|
+
return _Spec(path, 'c_attn', 'c_proj', module.num_heads, module.num_heads, dim, True)
|
|
42
|
+
if type(module).__name__ == 'Qwen3VLVisionAttention':
|
|
43
|
+
return _Spec(path, 'qkv', 'proj', module.num_heads, module.num_heads, dim, True)
|
|
44
|
+
return None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _discover(model, attention_modules=None):
|
|
48
|
+
groups = {}
|
|
49
|
+
if attention_modules is not None:
|
|
50
|
+
for stream, paths in attention_modules.items():
|
|
51
|
+
groups[stream] = []
|
|
52
|
+
for path in paths:
|
|
53
|
+
try:
|
|
54
|
+
spec = _spec(path, model.get_submodule(path))
|
|
55
|
+
except AttributeError:
|
|
56
|
+
spec = None
|
|
57
|
+
if spec is None:
|
|
58
|
+
raise ValueError(f'No supported attention projection layout at {path!r}')
|
|
59
|
+
groups[stream].append(spec)
|
|
60
|
+
else:
|
|
61
|
+
for path, module in model.named_modules():
|
|
62
|
+
spec = _spec(path, module)
|
|
63
|
+
if spec is not None:
|
|
64
|
+
stream = 'vision' if any(part == 'visual' or part.startswith('vision')
|
|
65
|
+
for part in path.split('.')) else 'text'
|
|
66
|
+
groups.setdefault(stream, []).append(spec)
|
|
67
|
+
if not any(groups.values()):
|
|
68
|
+
raise ValueError('No supported attention projection layout found; see package compatibility')
|
|
69
|
+
return groups
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _validate_inputs(model, inputs):
|
|
73
|
+
if model.training or any(module.training for module in model.modules()):
|
|
74
|
+
raise ValueError('Use evaluation mode: call model.eval()')
|
|
75
|
+
if inputs.get('past_key_values') is not None:
|
|
76
|
+
raise ValueError('Use an uncached full prefix; past_key_values is unsupported')
|
|
77
|
+
config = getattr(model, 'config', None)
|
|
78
|
+
configs = [config, getattr(config, 'text_config', None), getattr(config, 'vision_config', None)]
|
|
79
|
+
if inputs.get('output_attentions') or any(getattr(c, 'output_attentions', False) for c in configs):
|
|
80
|
+
raise ValueError('output_attentions=True can switch attention backends; disable it')
|
|
81
|
+
ids, embeds = inputs.get('input_ids'), inputs.get('inputs_embeds')
|
|
82
|
+
if not ((isinstance(ids, torch.Tensor) and ids.ndim == 2 and min(ids.shape) > 0) or
|
|
83
|
+
(isinstance(embeds, torch.Tensor) and embeds.ndim == 3 and min(embeds.shape) > 0)):
|
|
84
|
+
raise ValueError('Use nonempty input_ids [batch, tokens] or inputs_embeds [batch, tokens, dim]')
|
|
85
|
+
if hasattr(model, '_orig_mod'):
|
|
86
|
+
raise ValueError('Compiled models are outside the supported scope')
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _envoy(wrapped, path):
|
|
90
|
+
node = wrapped
|
|
91
|
+
for part in path.split('.'):
|
|
92
|
+
node = node[int(part)] if part.isdigit() else getattr(node, part)
|
|
93
|
+
return node
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def extract_attention(
|
|
97
|
+
model: torch.nn.Module,
|
|
98
|
+
inputs: Mapping[str, Any],
|
|
99
|
+
heads: Sequence[tuple[int, int] | Head] | None = None,
|
|
100
|
+
*,
|
|
101
|
+
attention_modules: Mapping[str, Sequence[str]] | None = None,
|
|
102
|
+
) -> tuple[dict[Head, torch.Tensor], dict[str, Any]]:
|
|
103
|
+
"""Recover requested heads; return CPU [B,T,T] tensors and preservation checks.
|
|
104
|
+
|
|
105
|
+
(L,H) addresses text heads; (stream,L,H) also addresses vision/custom streams.
|
|
106
|
+
None selects every text head. Ordinary HF evaluation models only, uncached.
|
|
107
|
+
Partial-identity V probes preserve Q/K, masks, backend and downstream values.
|
|
108
|
+
Vision's packed tokens have a singleton batch axis. Reconstruction is finite
|
|
109
|
+
precision and quadratic in token count; head selection limits saved memory.
|
|
110
|
+
"""
|
|
111
|
+
_validate_inputs(model, inputs)
|
|
112
|
+
specs = _discover(model, attention_modules)
|
|
113
|
+
if heads is None:
|
|
114
|
+
heads = [('text', layer, head) for layer, spec in enumerate(specs.get('text', []))
|
|
115
|
+
for head in range(spec.heads)]
|
|
116
|
+
selected, grouped = [], {}
|
|
117
|
+
for address in heads:
|
|
118
|
+
if not isinstance(address, (tuple, list)) or len(address) not in (2, 3):
|
|
119
|
+
raise ValueError('heads must contain (layer, head) or (stream, layer, head) addresses')
|
|
120
|
+
stream, layer, head = ('text', *address) if len(address) == 2 else address
|
|
121
|
+
if (not isinstance(stream, str) or type(layer) is not int or type(head) is not int or
|
|
122
|
+
stream not in specs or not 0 <= layer < len(specs[stream]) or
|
|
123
|
+
not 0 <= head < specs[stream][layer].heads):
|
|
124
|
+
raise ValueError(f'Invalid heads address: {address!r}')
|
|
125
|
+
key = (stream, layer, head)
|
|
126
|
+
if key in selected:
|
|
127
|
+
raise ValueError(f'Duplicate heads address: {key!r}')
|
|
128
|
+
selected.append(key)
|
|
129
|
+
grouped.setdefault((stream, layer), []).append(head)
|
|
130
|
+
if not selected:
|
|
131
|
+
raise ValueError('heads selection is empty or no text attention was discovered')
|
|
132
|
+
paths = [specs[stream][layer].path for stream, layer in grouped]
|
|
133
|
+
if len(set(paths)) != len(paths):
|
|
134
|
+
raise ValueError('Do not address the same module through multiple stream aliases')
|
|
135
|
+
kwargs = {**inputs, 'use_cache': False, 'return_dict': True}
|
|
136
|
+
if 'logits_to_keep' in inspect.signature(type(model).forward).parameters:
|
|
137
|
+
kwargs['logits_to_keep'] = 1
|
|
138
|
+
checks = {'baseline_logit_max_abs': 0.0, 'probe_logit_max_abs': 0.0,
|
|
139
|
+
'unpatched_logit_max_abs': 0.0, 'heads': selected, 'probes': 0,
|
|
140
|
+
'modules': {stream: [{'path': s.path, 'heads': s.heads, 'head_dim': s.dim,
|
|
141
|
+
'packed': None} for s in group]
|
|
142
|
+
for stream, group in specs.items()}}
|
|
143
|
+
patterns = {}
|
|
144
|
+
with torch.no_grad():
|
|
145
|
+
original = model(**kwargs).logits.detach().clone()
|
|
146
|
+
wrapped = NNsight(model)
|
|
147
|
+
for (stream, layer), chosen in grouped.items():
|
|
148
|
+
spec = specs[stream][layer]
|
|
149
|
+
attn = _envoy(wrapped, spec.path)
|
|
150
|
+
value, output = getattr(attn, spec.value), getattr(attn, spec.output)
|
|
151
|
+
# Save the genuine result first, for restoration before the output projection.
|
|
152
|
+
with wrapped.trace(**kwargs):
|
|
153
|
+
saved_z = output.input.detach().clone().save()
|
|
154
|
+
saved_logits = wrapped.output.logits.detach().clone().save()
|
|
155
|
+
baseline_z = saved_z
|
|
156
|
+
packed = baseline_z.ndim == 2
|
|
157
|
+
if baseline_z.ndim not in (2, 3) or baseline_z.shape[-1] != spec.heads * spec.dim:
|
|
158
|
+
raise ValueError(f'Unsupported merged-head output layout at {spec.path}')
|
|
159
|
+
batch, length = (1, baseline_z.shape[0]) if packed else baseline_z.shape[:2]
|
|
160
|
+
checks['modules'][stream][layer].update(packed=packed, tokens=length, batch=batch)
|
|
161
|
+
checks['baseline_logit_max_abs'] = max(checks['baseline_logit_max_abs'],
|
|
162
|
+
(saved_logits-original).abs().max().item())
|
|
163
|
+
pieces = []
|
|
164
|
+
for offset in range(0, length, spec.dim):
|
|
165
|
+
width = min(spec.dim, length-offset)
|
|
166
|
+
basis = torch.zeros(length, spec.dim, device=baseline_z.device, dtype=baseline_z.dtype)
|
|
167
|
+
basis[offset:offset+width, :width] = torch.eye(width, device=basis.device, dtype=basis.dtype)
|
|
168
|
+
fake_v = basis[:, None, :].expand(length, spec.kv_heads, spec.dim).reshape(length, -1)
|
|
169
|
+
if not packed:
|
|
170
|
+
fake_v = fake_v[None].expand(batch, -1, -1).contiguous()
|
|
171
|
+
with wrapped.trace(**kwargs):
|
|
172
|
+
if spec.fused:
|
|
173
|
+
# Preserve the original Q and K portions of a fused projection.
|
|
174
|
+
value.output = torch.cat([value.output[..., :2*spec.heads*spec.dim], fake_v], dim=-1)
|
|
175
|
+
else:
|
|
176
|
+
value.output = fake_v
|
|
177
|
+
# Slice heads before saving, so unrequested patterns are never transferred.
|
|
178
|
+
block = output.input.reshape(batch, length, spec.heads, spec.dim).permute(
|
|
179
|
+
0, 2, 1, 3)[:, chosen, :, :width].detach().clone().save()
|
|
180
|
+
output.input = baseline_z
|
|
181
|
+
probe_logits = wrapped.output.logits.detach().clone().save()
|
|
182
|
+
pieces.append(block.cpu())
|
|
183
|
+
checks['probe_logit_max_abs'] = max(checks['probe_logit_max_abs'],
|
|
184
|
+
(probe_logits-original).abs().max().item())
|
|
185
|
+
checks['probes'] += 1
|
|
186
|
+
weights = torch.cat(pieces, dim=-1)
|
|
187
|
+
for index, head in enumerate(chosen):
|
|
188
|
+
patterns[(stream, layer, head)] = weights[:, index]
|
|
189
|
+
untouched = model(**kwargs).logits
|
|
190
|
+
checks['unpatched_logit_max_abs'] = (untouched-original).abs().max().item()
|
|
191
|
+
return {key: patterns[key] for key in selected}, checks
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def reconstruct_attention(model, inputs, layers=None, *, heads=None, attention_modules=None):
|
|
195
|
+
"""Research compatibility adapter: return stacked text layers and checks."""
|
|
196
|
+
_validate_inputs(model, inputs)
|
|
197
|
+
if attention_modules is not None and layers is not None:
|
|
198
|
+
raise ValueError('Choose layers or attention_modules, not both')
|
|
199
|
+
explicit = {'text': list(attention_modules)} if attention_modules is not None else None
|
|
200
|
+
specs = _discover(model, explicit).get('text', [])
|
|
201
|
+
layers = list(range(len(specs))) if layers is None else list(layers)
|
|
202
|
+
if not layers or any(type(i) is not int or not 0 <= i < len(specs) for i in layers) or len(set(layers)) != len(layers):
|
|
203
|
+
raise ValueError('layers must contain distinct integer text indices in range')
|
|
204
|
+
if heads is None:
|
|
205
|
+
chosen = list(range(specs[layers[0]].heads))
|
|
206
|
+
else:
|
|
207
|
+
chosen = [heads] if type(heads) is int else list(heads)
|
|
208
|
+
if not chosen or any(type(h) is not int or not 0 <= h < specs[i].heads for i in layers for h in chosen) or len(set(chosen)) != len(chosen):
|
|
209
|
+
raise ValueError('heads must contain distinct integer head indices in range')
|
|
210
|
+
patterns, checks = extract_attention(model, inputs,
|
|
211
|
+
heads=[(i, h) for i in layers for h in chosen], attention_modules=explicit)
|
|
212
|
+
weights = torch.stack([torch.stack([patterns[('text', i, h)] for h in chosen], dim=1)
|
|
213
|
+
for i in layers], dim=1)
|
|
214
|
+
checks.update(layers=layers, heads=chosen, modules=[specs[i].path for i in layers])
|
|
215
|
+
return weights, checks
|
|
File without changes
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: attnread
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Recover attention weights without changing the attention backend.
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Classifier: Development Status :: 3 - Alpha
|
|
7
|
+
Classifier: Intended Audience :: Science/Research
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
10
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
11
|
+
Requires-Python: >=3.11
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
License-File: LICENSE
|
|
14
|
+
Requires-Dist: nnsight==0.7.0
|
|
15
|
+
Requires-Dist: torch<2.15,>=2.14
|
|
16
|
+
Requires-Dist: transformers<5.20,>=5.19
|
|
17
|
+
Provides-Extra: dev
|
|
18
|
+
Requires-Dist: pytest<10,>=9; extra == "dev"
|
|
19
|
+
Requires-Dist: build<2,>=1.2; extra == "dev"
|
|
20
|
+
Requires-Dist: twine<7,>=6; extra == "dev"
|
|
21
|
+
Dynamic: license-file
|
|
22
|
+
|
|
23
|
+
# AttnRead
|
|
24
|
+
|
|
25
|
+
**Recover attention weights without changing the attention backend.**
|
|
26
|
+
|
|
27
|
+
AttnRead is a lightweight Python toolkit for extracting attention patterns from optimized transformer attention implementations, including PyTorch SDPA and FlashAttention 2.
|
|
28
|
+
|
|
29
|
+
Using identity-value passes and activation restoration, AttnRead recovers attention patterns while preserving the model's downstream computation. No fallback to eager attention required.
|
|
30
|
+
|
|
31
|
+
Version **0.1.0**, distributed under the MIT License. The distribution and
|
|
32
|
+
Python import name are both `attnread`.
|
|
33
|
+
|
|
34
|
+
## Installation
|
|
35
|
+
|
|
36
|
+
Use Python 3.11 or newer in a venv. Install your preferred CPU/CUDA PyTorch
|
|
37
|
+
build first, then install AttnRead:
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
python -m pip install attnread==0.1.0
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Usage
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
import torch
|
|
47
|
+
from transformers import AutoModelForCausalLM, AutoTokenizer
|
|
48
|
+
from attnread import extract_attention
|
|
49
|
+
|
|
50
|
+
checkpoint = "Qwen/Qwen3-4B"
|
|
51
|
+
revision = "1cfa9a7208912126459214e8b04321603b3df60c"
|
|
52
|
+
model = AutoModelForCausalLM.from_pretrained(
|
|
53
|
+
checkpoint, revision=revision, dtype=torch.bfloat16,
|
|
54
|
+
attn_implementation="sdpa",
|
|
55
|
+
).to("cuda").eval().requires_grad_(False)
|
|
56
|
+
tokenizer = AutoTokenizer.from_pretrained(checkpoint, revision=revision)
|
|
57
|
+
inputs = tokenizer("Describe the colors of an autumn leaf.",
|
|
58
|
+
return_tensors="pt").to(model.device)
|
|
59
|
+
|
|
60
|
+
patterns, checks = extract_attention(model, inputs, heads=[(0, 3), (12, 7)])
|
|
61
|
+
head = patterns[("text", 0, 3)] # CPU tensor: [batch, query tokens, key tokens].
|
|
62
|
+
assert checks["probe_logit_max_abs"] == 0
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Use the same call with `AutoModelForImageTextToText` and processor outputs:
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
# (layer, head) means text attention; qualify a vision head with its stream.
|
|
69
|
+
patterns, checks = extract_attention(
|
|
70
|
+
vlm, processed_inputs, heads=[(12, 3), ("vision", 4, 2)],
|
|
71
|
+
)
|
|
72
|
+
text = patterns[("text", 12, 3)]
|
|
73
|
+
vision = patterns[("vision", 4, 2)]
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
**Selection.** `heads=None` extracts every discovered text head. A sequence of
|
|
77
|
+
`(layer, head)` or `(stream, layer, head)` addresses extracts only those heads;
|
|
78
|
+
returned keys always use the full triple, in request order. Layers and heads are
|
|
79
|
+
zero-based within each stream, following discovered module order (reported in
|
|
80
|
+
`checks["modules"]`). Use explicit paths for sparse/hybrid stacks or unusual
|
|
81
|
+
module naming. Different streams can have different sequence lengths, so results
|
|
82
|
+
are a dictionary. Probes are grouped by layer and save/transfer only selected
|
|
83
|
+
heads. The underlying model still computes all heads; head selection reduces
|
|
84
|
+
saved memory, not the number of identity probes. Start with one short prefix and
|
|
85
|
+
one head. Unknown, duplicate or out-of-range addresses raise `ValueError`.
|
|
86
|
+
|
|
87
|
+
**Model interface.** Pass an ordinary HF model in evaluation mode and its
|
|
88
|
+
preprocessed tensor input mapping. Discovery recognizes separate `q_proj`,
|
|
89
|
+
`k_proj`, `v_proj` with `o_proj`/`out_proj`, GPT-2's fused `c_attn`/`c_proj`, and
|
|
90
|
+
Qwen3-VL vision `qkv`/`proj`. Vision module paths identify the `vision` stream;
|
|
91
|
+
others belong to `text`. For unusual or ambiguous naming, supply explicit paths:
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
patterns, checks = extract_attention(model, inputs, heads=[("text", 0, 1)],
|
|
95
|
+
attention_modules={"text": ["model.layers.7.self_attn"]})
|
|
96
|
+
# With explicit lists, layer 0 denotes the first entry in that list.
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
This is structural compatibility, not a promise that every HF architecture works.
|
|
100
|
+
The supported operation must be linear in V, with attention weights independent
|
|
101
|
+
of V, and a conventional merged-head input to the output projection. Tested
|
|
102
|
+
families include Qwen3, Llama, Mistral (sliding window), Gemma2 (score softcapping),
|
|
103
|
+
GPT-2, LLaVA/CLIP vision with pixels, and Qwen3-VL packed vision/text. Unsupported
|
|
104
|
+
projection layouts fail explicitly. Cross-attention, nonlinear/value-dependent
|
|
105
|
+
attention, quantized/compiled models, distributed/sharded execution and remote
|
|
106
|
+
NNsight are outside the verified scope. No weights or inputs are downloaded by
|
|
107
|
+
the extraction call. Model weights stay unchanged; each probe restores the genuine
|
|
108
|
+
pre-output-projection activation before the rest of the forward runs.
|
|
109
|
+
|
|
110
|
+
**Inputs and results.** Use nonempty `input_ids` or `inputs_embeds`, no KV cache,
|
|
111
|
+
and `output_attentions=False`. Sequence length comes from the actual attention
|
|
112
|
+
activation, accounting for expanded image tokens. Parameter gradients should be
|
|
113
|
+
disabled for inference to reduce NNsight memory. Do not run concurrent calls on
|
|
114
|
+
the same model. Qwen-VL vision packs patches from all images/frames on one axis;
|
|
115
|
+
its result is `[1, packed_tokens, packed_tokens]`, with separate images/frames
|
|
116
|
+
forming disconnected blocks. Interpret coordinates with the processor's grids;
|
|
117
|
+
these are patch tokens, not merged decoder image tokens.
|
|
118
|
+
|
|
119
|
+
Checks contain module paths/layouts, selected addresses, probe count, and maximum
|
|
120
|
+
absolute logit deltas for traced baselines, probes and a final unpatched pass.
|
|
121
|
+
Inspect the deltas: nondeterministic execution may produce nonzero values even
|
|
122
|
+
without a probe. Recovered weights are finite-precision measurements and are not
|
|
123
|
+
renormalized or guaranteed bit-exact native softmax probabilities. Masks and
|
|
124
|
+
Q/K-only score modifications stay inside the original attention implementation.
|
|
125
|
+
|
|
126
|
+
**Backend.** `sdpa` uses normal PyTorch backend selection. Force math by wrapping
|
|
127
|
+
extraction in `torch.nn.attention.sdpa_kernel(SDPBackend.MATH)`. Package FA2 requires
|
|
128
|
+
a separately installed compatible `flash-attn` and `attn_implementation="flash_attention_2"`.
|
|
129
|
+
The library does not install FA2 or override precision/backend.
|
|
130
|
+
|
|
131
|
+
**Cost.** For each selected layer with prefix T and head dimension d, reconstruction
|
|
132
|
+
needs 1 + ceil(T/d) full forwards, plus two global unpatched forwards. Selected-head
|
|
133
|
+
CPU storage scales as B×selected_heads×T² per layer; this remains quadratic.
|
|
134
|
+
NNsight brings dependencies, but the package itself contains only the extractor.
|
|
135
|
+
|
|
136
|
+
**Compatibility.** Initially constrained to NNsight 0.7.0, Transformers 5.19.x
|
|
137
|
+
and PyTorch 2.14.x; tested on Python 3.11. Experiment pins live in the research repository’s `requirements.txt`;
|
|
138
|
+
the package has no CUDA URLs or plotting dependencies.
|
|
139
|
+
|
|
140
|
+
## Development
|
|
141
|
+
|
|
142
|
+
From the standalone source directory:
|
|
143
|
+
|
|
144
|
+
```bash
|
|
145
|
+
python -m venv .venv
|
|
146
|
+
source .venv/bin/activate
|
|
147
|
+
# Install your preferred CPU/CUDA PyTorch build first.
|
|
148
|
+
python -m pip install -e '.[dev]'
|
|
149
|
+
python -m pytest -q test_attnread.py
|
|
150
|
+
python -m build
|
|
151
|
+
python -m twine check --strict dist/*
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
On Oscar, run installs, builds and tests in a CPU Slurm allocation; pretrained
|
|
155
|
+
GPU examples require a GPU allocation. The source checkout includes the release
|
|
156
|
+
guide and review notebook; the notebook uses in-memory example inputs and is
|
|
157
|
+
excluded from release archives. No publishing step runs automatically.
|
|
158
|
+
|
|
159
|
+
## License
|
|
160
|
+
|
|
161
|
+
MIT License. Copyright (c) 2026 Etha. The full license text is included in the
|
|
162
|
+
source archive and wheel.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
MANIFEST.in
|
|
3
|
+
PACKAGE.md
|
|
4
|
+
README.md
|
|
5
|
+
RELEASE.md
|
|
6
|
+
pyproject.toml
|
|
7
|
+
test_attnread.py
|
|
8
|
+
src/attnread/__init__.py
|
|
9
|
+
src/attnread/_core.py
|
|
10
|
+
src/attnread/py.typed
|
|
11
|
+
src/attnread.egg-info/PKG-INFO
|
|
12
|
+
src/attnread.egg-info/SOURCES.txt
|
|
13
|
+
src/attnread.egg-info/dependency_links.txt
|
|
14
|
+
src/attnread.egg-info/requires.txt
|
|
15
|
+
src/attnread.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
attnread
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
import torch
|
|
3
|
+
from transformers import Qwen3Config, Qwen3ForCausalLM
|
|
4
|
+
from attnread._core import reconstruct_attention
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@pytest.mark.parametrize('operation', ['extract_attention'])
|
|
8
|
+
@pytest.mark.parametrize('case, message', [
|
|
9
|
+
('training', 'evaluation'),
|
|
10
|
+
('cache', 'uncached'),
|
|
11
|
+
('attentions', 'output_attentions'),
|
|
12
|
+
('config_attentions', 'output_attentions'),
|
|
13
|
+
('empty', 'nonempty'),
|
|
14
|
+
])
|
|
15
|
+
def test_unsafe_inputs_are_rejected_before_tracing(operation, case, message):
|
|
16
|
+
import attnread
|
|
17
|
+
model = Qwen3ForCausalLM(Qwen3Config(vocab_size=32, hidden_size=32,
|
|
18
|
+
intermediate_size=48, num_hidden_layers=1, num_attention_heads=2,
|
|
19
|
+
num_key_value_heads=1, head_dim=16)).eval()
|
|
20
|
+
model.set_attn_implementation('eager')
|
|
21
|
+
inputs = {'input_ids': torch.tensor([[1, 2, 3]])}
|
|
22
|
+
if case == 'training':
|
|
23
|
+
model.train()
|
|
24
|
+
elif case == 'cache':
|
|
25
|
+
inputs['past_key_values'] = object()
|
|
26
|
+
elif case == 'attentions':
|
|
27
|
+
inputs['output_attentions'] = True
|
|
28
|
+
elif case == 'config_attentions':
|
|
29
|
+
model.config.output_attentions = True
|
|
30
|
+
elif case == 'empty':
|
|
31
|
+
inputs['input_ids'] = torch.empty((1, 0), dtype=torch.long)
|
|
32
|
+
with pytest.raises(ValueError, match=message):
|
|
33
|
+
getattr(attnread, operation)(model, inputs)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@pytest.mark.parametrize('layers', [[], [-1], [1], [0, 0], [0.5], [True]])
|
|
37
|
+
def test_invalid_layer_selection_is_rejected(layers):
|
|
38
|
+
model = Qwen3ForCausalLM(Qwen3Config(vocab_size=32, hidden_size=32,
|
|
39
|
+
intermediate_size=48, num_hidden_layers=1, num_attention_heads=2,
|
|
40
|
+
num_key_value_heads=1, head_dim=16)).eval()
|
|
41
|
+
with pytest.raises(ValueError, match='layers'):
|
|
42
|
+
reconstruct_attention(model, {'input_ids': torch.tensor([[1, 2]])}, layers)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def make_hf_model(family):
|
|
47
|
+
import transformers as hf
|
|
48
|
+
common = dict(vocab_size=40, hidden_size=48, intermediate_size=64,
|
|
49
|
+
num_hidden_layers=2, num_attention_heads=4,
|
|
50
|
+
num_key_value_heads=2, head_dim=16,
|
|
51
|
+
bos_token_id=1, eos_token_id=2, pad_token_id=0)
|
|
52
|
+
if family == 'gpt2':
|
|
53
|
+
config = hf.GPT2Config(vocab_size=40, n_embd=32, n_head=2, n_layer=2,
|
|
54
|
+
n_positions=32, bos_token_id=1, eos_token_id=2,
|
|
55
|
+
resid_pdrop=0, embd_pdrop=0, attn_pdrop=0)
|
|
56
|
+
elif family == 'llava':
|
|
57
|
+
config = hf.LlavaConfig(
|
|
58
|
+
text_config=hf.LlamaConfig(**common),
|
|
59
|
+
vision_config=hf.CLIPVisionConfig(hidden_size=16, intermediate_size=32,
|
|
60
|
+
num_hidden_layers=2, num_attention_heads=2, image_size=8, patch_size=4),
|
|
61
|
+
image_token_id=39, image_seq_length=4, vision_feature_layer=-1)
|
|
62
|
+
else:
|
|
63
|
+
cls = {'llama': hf.LlamaConfig, 'mistral': hf.MistralConfig,
|
|
64
|
+
'gemma2': hf.Gemma2Config}[family]
|
|
65
|
+
extra = {'sliding_window': 4} if family in {'mistral', 'gemma2'} else {}
|
|
66
|
+
if family == 'gemma2':
|
|
67
|
+
extra['attn_logit_softcapping'] = 5.0
|
|
68
|
+
config = cls(**common, **extra)
|
|
69
|
+
config._attn_implementation = 'eager'
|
|
70
|
+
factory = hf.AutoModelForImageTextToText if family == 'llava' else hf.AutoModelForCausalLM
|
|
71
|
+
model = factory.from_config(config, attn_implementation='eager').eval()
|
|
72
|
+
inputs = {'input_ids': torch.tensor([[1, 3, 5, 6, 7, 8, 9, 2, 4, 5, 6]])}
|
|
73
|
+
if family == 'llava':
|
|
74
|
+
inputs = {'input_ids': torch.tensor([[1, 39, 39, 39, 39, 3, 5, 2]]),
|
|
75
|
+
'pixel_values': torch.randn(1, 3, 8, 8)}
|
|
76
|
+
return model, inputs
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@pytest.mark.parametrize('family', ['llama', 'mistral', 'gemma2', 'gpt2', 'llava'])
|
|
80
|
+
def test_hf_task_models_recover_native_attention(family):
|
|
81
|
+
torch.manual_seed(7)
|
|
82
|
+
model, inputs = make_hf_model(family)
|
|
83
|
+
with torch.no_grad():
|
|
84
|
+
expected = model(**inputs, use_cache=False, output_attentions=True)
|
|
85
|
+
weights, checks = reconstruct_attention(model, inputs, layers=[1, 0])
|
|
86
|
+
for output_index, layer in enumerate([1, 0]):
|
|
87
|
+
torch.testing.assert_close(weights[:, output_index], expected.attentions[layer],
|
|
88
|
+
atol=3e-7, rtol=3e-6)
|
|
89
|
+
assert checks['probe_logit_max_abs'] == 0
|
|
90
|
+
assert checks['baseline_logit_max_abs'] == 0
|
|
91
|
+
assert checks['unpatched_logit_max_abs'] == 0
|
|
92
|
+
assert len(checks['modules']) == 2
|
|
93
|
+
assert weights.device.type == 'cpu'
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@pytest.mark.parametrize('family', ['llama', 'gpt2', 'llava'])
|
|
97
|
+
@pytest.mark.parametrize('heads', [1, [1, 0]])
|
|
98
|
+
def test_selected_heads_are_recovered_in_requested_order(family, heads):
|
|
99
|
+
model, inputs = make_hf_model(family)
|
|
100
|
+
expected = model(**inputs, use_cache=False, output_attentions=True).attentions[0]
|
|
101
|
+
chosen = [heads] if isinstance(heads, int) else heads
|
|
102
|
+
weights, checks = reconstruct_attention(model, inputs, layers=[0], heads=heads)
|
|
103
|
+
torch.testing.assert_close(weights[:, 0], expected[:, chosen], atol=3e-7, rtol=3e-6)
|
|
104
|
+
assert weights.shape[2] == len(chosen)
|
|
105
|
+
assert checks['heads'] == chosen
|
|
106
|
+
assert checks['probe_logit_max_abs'] == 0
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@pytest.mark.parametrize('heads', [[], [-1], [4], [0, 0], [True]])
|
|
110
|
+
def test_invalid_head_selection_is_rejected(heads):
|
|
111
|
+
model, inputs = make_hf_model('llama')
|
|
112
|
+
with pytest.raises(ValueError, match='heads'):
|
|
113
|
+
reconstruct_attention(model, inputs, layers=[0], heads=heads)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def test_explicit_attention_module_paths_select_the_named_layer():
|
|
117
|
+
model, inputs = make_hf_model('llama')
|
|
118
|
+
expected = model(**inputs, use_cache=False, output_attentions=True).attentions[1]
|
|
119
|
+
path = 'model.layers.1.self_attn'
|
|
120
|
+
weights, checks = reconstruct_attention(model, inputs, attention_modules=[path], heads=0)
|
|
121
|
+
torch.testing.assert_close(weights[:, 0, 0], expected[:, 0], atol=3e-7, rtol=3e-6)
|
|
122
|
+
assert checks['modules'] == [path]
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def test_unsupported_projection_structure_fails_explicitly():
|
|
126
|
+
from transformers import BertConfig, BertForMaskedLM
|
|
127
|
+
model = BertForMaskedLM(BertConfig(vocab_size=40, hidden_size=16,
|
|
128
|
+
intermediate_size=32, num_hidden_layers=1, num_attention_heads=2)).eval()
|
|
129
|
+
with pytest.raises(ValueError, match='supported attention'):
|
|
130
|
+
reconstruct_attention(model, {'input_ids': torch.tensor([[1, 2]])})
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@pytest.mark.parametrize('family', ['llama', 'llava'])
|
|
134
|
+
def test_public_head_addressing_returns_only_requested_patterns(family):
|
|
135
|
+
import attnread
|
|
136
|
+
model, inputs = make_hf_model(family)
|
|
137
|
+
text = model(**inputs, use_cache=False, output_attentions=True).attentions[1][:, 2]
|
|
138
|
+
selection = [(1, 2)]
|
|
139
|
+
expected = {('text', 1, 2): text}
|
|
140
|
+
if family == 'llava':
|
|
141
|
+
vision = model.model.vision_tower(inputs['pixel_values'], output_attentions=True).attentions[0][:, 1]
|
|
142
|
+
selection.append(('vision', 0, 1))
|
|
143
|
+
expected[('vision', 0, 1)] = vision
|
|
144
|
+
assert callable(getattr(attnread, 'extract_attention', None)), 'missing public extractor'
|
|
145
|
+
patterns, checks = attnread.extract_attention(model, inputs, heads=selection)
|
|
146
|
+
assert list(patterns) == list(expected)
|
|
147
|
+
for key in expected:
|
|
148
|
+
torch.testing.assert_close(patterns[key], expected[key], atol=3e-7, rtol=3e-6)
|
|
149
|
+
assert checks['probe_logit_max_abs'] == 0
|
|
150
|
+
assert checks['unpatched_logit_max_abs'] == 0
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def test_qwen_packed_vision_keeps_image_boundaries_and_preserves_logits():
|
|
154
|
+
import attnread
|
|
155
|
+
from transformers import Qwen3VLConfig, Qwen3VLForConditionalGeneration
|
|
156
|
+
config = Qwen3VLConfig(
|
|
157
|
+
text_config=dict(vocab_size=40, hidden_size=32, intermediate_size=48,
|
|
158
|
+
num_hidden_layers=2, num_attention_heads=2, num_key_value_heads=1,
|
|
159
|
+
head_dim=16, bos_token_id=1, eos_token_id=2, pad_token_id=0,
|
|
160
|
+
rope_parameters={'rope_type': 'default', 'rope_theta': 10000,
|
|
161
|
+
'mrope_section': [2, 3, 3]}),
|
|
162
|
+
vision_config=dict(depth=2, hidden_size=16, intermediate_size=32,
|
|
163
|
+
num_heads=2, patch_size=2, temporal_patch_size=2, spatial_merge_size=2,
|
|
164
|
+
out_hidden_size=32, num_position_embeddings=16, deepstack_visual_indexes=[0, 1]),
|
|
165
|
+
image_token_id=39, video_token_id=38, vision_start_token_id=37, vision_end_token_id=36)
|
|
166
|
+
model = Qwen3VLForConditionalGeneration(config).eval()
|
|
167
|
+
model.set_attn_implementation('eager')
|
|
168
|
+
inputs = {'input_ids': torch.tensor([[1, 37, 39, 37, 39, 2]]),
|
|
169
|
+
'pixel_values': torch.randn(8, 24),
|
|
170
|
+
'mm_token_type_ids': torch.tensor([[0, 0, 1, 0, 1, 0]]),
|
|
171
|
+
'image_grid_thw': torch.tensor([[1, 2, 2], [1, 2, 2]])}
|
|
172
|
+
assert callable(getattr(attnread, 'extract_attention', None)), 'missing public extractor'
|
|
173
|
+
values, outputs = [], []
|
|
174
|
+
attn = model.model.visual.blocks[0].attn
|
|
175
|
+
h1 = attn.qkv.register_forward_hook(lambda m, a, o: values.append(o.detach()))
|
|
176
|
+
h2 = attn.proj.register_forward_pre_hook(lambda m, a: outputs.append(a[0].detach()))
|
|
177
|
+
try:
|
|
178
|
+
with torch.no_grad():
|
|
179
|
+
original = model(**inputs, use_cache=False).logits
|
|
180
|
+
finally:
|
|
181
|
+
h1.remove()
|
|
182
|
+
h2.remove()
|
|
183
|
+
patterns, checks = attnread.extract_attention(model, inputs,
|
|
184
|
+
heads=[('vision', 0, 1), (0, 0)])
|
|
185
|
+
matrix = patterns[('vision', 0, 1)]
|
|
186
|
+
assert matrix.shape == (1, 8, 8) # Packed images share an axis, with disconnected blocks.
|
|
187
|
+
assert torch.count_nonzero(matrix[:, :4, 4:]) == 0
|
|
188
|
+
assert torch.count_nonzero(matrix[:, 4:, :4]) == 0
|
|
189
|
+
torch.testing.assert_close(matrix.sum(-1), torch.ones(1, 8), atol=3e-7, rtol=3e-6)
|
|
190
|
+
v = values[0].reshape(8, 3, 2, 8)[:, 2, 1]
|
|
191
|
+
z = outputs[0].reshape(8, 2, 8)[:, 1]
|
|
192
|
+
torch.testing.assert_close(matrix[0] @ v, z, atol=3e-7, rtol=3e-6)
|
|
193
|
+
assert checks['probe_logit_max_abs'] == 0
|
|
194
|
+
assert checks['unpatched_logit_max_abs'] == 0
|
|
195
|
+
assert checks['modules']['vision'][0]['packed'] is True
|
|
196
|
+
torch.testing.assert_close(model(**inputs, use_cache=False).logits, original, atol=0, rtol=0)
|