attnread 0.1.0__py3-none-any.whl

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/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ """Extract selected attention heads without switching their backend."""
2
+ from ._core import extract_attention
3
+
4
+ __all__ = ["extract_attention"]
attnread/_core.py 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
attnread/py.typed ADDED
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,8 @@
1
+ attnread/__init__.py,sha256=q7PDqt5zmBTn2vhP0GXJ4TTVNmcYMROENKlFtASi-kI,142
2
+ attnread/_core.py,sha256=4GIxsbqapW7_drM3bBDOK2a7sW8vkHyIQQ8f03j8CaY,11438
3
+ attnread/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
+ attnread-0.1.0.dist-info/licenses/LICENSE,sha256=Xi4piPMZc-0ukHN4eM_sq8DGZcEfzPRr8Y2cIo1d8pM,1061
5
+ attnread-0.1.0.dist-info/METADATA,sha256=D48LqpRQnnelzX0LPbDMYhVpb82xQyg0RgILX3sQmk4,7581
6
+ attnread-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
7
+ attnread-0.1.0.dist-info/top_level.txt,sha256=0WrqukxxD6qUq7m_z28avBhVrgWOdeP7lQOFI7sq7yk,9
8
+ attnread-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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 @@
1
+ attnread