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 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.
@@ -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,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"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,4 @@
1
+ """Extract selected attention heads without switching their backend."""
2
+ from ._core import extract_attention
3
+
4
+ __all__ = ["extract_attention"]
@@ -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,8 @@
1
+ nnsight==0.7.0
2
+ torch<2.15,>=2.14
3
+ transformers<5.20,>=5.19
4
+
5
+ [dev]
6
+ pytest<10,>=9
7
+ build<2,>=1.2
8
+ twine<7,>=6
@@ -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)