| """Pure-tensor helpers for generation-time SAE feature extraction. |
| |
| No model or transformers imports — all functions take tensors and return |
| tensors so they're testable without GPU. |
| """ |
| from __future__ import annotations |
|
|
| import torch |
|
|
|
|
| def build_position_masks( |
| prompt_real_lens: torch.Tensor, |
| gen_lens: torch.Tensor, |
| n_image_patches: int, |
| seq_len: int, |
| ) -> tuple[torch.Tensor, torch.Tensor]: |
| """Compute prompt-vs-gen position masks for a left-padded teacher-forced batch. |
| |
| LLaVA replaces the single ``<image>`` token with ``n_image_patches`` patch |
| embeddings. After image expansion, row ``i`` has a real region of length |
| ``n_image_patches + prompt_real_lens[i] - 1 + gen_lens[i]`` flush against |
| position ``seq_len - 1`` (left padding puts pads at the start). |
| |
| Returns: |
| prompt_mask: (B, seq_len) bool, True over the image-patch and prompt-text |
| positions of each row's real region. |
| gen_mask: (B, seq_len) bool, True over the last ``gen_lens[i]`` positions |
| of each row. |
| """ |
| B = prompt_real_lens.shape[0] |
| assert gen_lens.shape == (B,) |
| device = prompt_real_lens.device |
| pos = torch.arange(seq_len, device=device).unsqueeze(0).expand(B, -1) |
|
|
| |
| real_lens = n_image_patches + prompt_real_lens - 1 + gen_lens |
| assert (real_lens <= seq_len).all(), \ |
| f"real region overflows seq_len: max real_len={int(real_lens.max())}, seq_len={seq_len}" |
| real_start = (seq_len - real_lens).unsqueeze(1) |
| gen_start = (seq_len - gen_lens).unsqueeze(1) |
| end = torch.full_like(real_start, seq_len) |
|
|
| prompt_mask = (pos >= real_start) & (pos < gen_start) |
| gen_mask = (pos >= gen_start) & (pos < end) |
| return prompt_mask, gen_mask |
|
|
|
|
| def left_pad_collate( |
| rows: list[torch.Tensor], |
| pad_id: int, |
| ) -> tuple[torch.Tensor, torch.Tensor]: |
| """Stack 1-D LongTensors with left padding. |
| |
| Args: |
| rows: list of 1-D tensors with possibly different lengths. |
| pad_id: token id to use for padding. |
| |
| Returns: |
| ids: (B, L_max) LongTensor, left-padded with ``pad_id``. |
| attn: (B, L_max) LongTensor, 1 over real tokens, 0 over pads. |
| """ |
| L_max = max(r.shape[0] for r in rows) |
| B = len(rows) |
| ids = torch.full((B, L_max), pad_id, dtype=rows[0].dtype, device=rows[0].device) |
| attn = torch.zeros((B, L_max), dtype=torch.long, device=rows[0].device) |
| for i, r in enumerate(rows): |
| L = r.shape[0] |
| ids[i, L_max - L :] = r |
| attn[i, L_max - L :] = 1 |
| return ids, attn |
|
|
|
|
| def gather_gen_positions( |
| capture_hidden: dict[int, torch.Tensor], |
| gen_mask: torch.Tensor, |
| ) -> tuple[dict[int, torch.Tensor], torch.Tensor]: |
| """Gather generated-token hidden states into a packed (B, T_max, D) tensor. |
| |
| Unlike ``extract_gen_sae_features`` (which SAE-encodes then max-pools to a |
| single vector per row), this keeps the full token sequence so a downstream |
| sequence model (e.g. an attention-pooling probe) can read every position. |
| |
| The generated region of row ``i`` occupies the last ``gen_mask[i].sum()`` |
| positions of the left-padded sequence. We left-anchor each row's gen tokens |
| at index 0 of the output and mark validity. |
| |
| Args: |
| capture_hidden: l -> (B, S, D) per-layer hidden states. |
| gen_mask: (B, S) bool, True over generated positions. |
| |
| Returns: |
| feats_seq: l -> (B, T_max, D) packed gen-position hidden states; rows |
| shorter than T_max are zero-padded at the tail. |
| valid_mask: (B, T_max) bool, True at real gen positions. Rows whose |
| ``gen_mask`` is all-False yield an all-False ``valid_mask`` row — |
| the caller must filter those out before feeding an attention module |
| (an all-masked row produces NaNs). |
| """ |
| gen_counts = gen_mask.sum(dim=1) |
| B = gen_counts.shape[0] |
| T_max = int(gen_counts.max().item()) if B > 0 else 0 |
| any_h = next(iter(capture_hidden.values())) |
| device = any_h.device |
| valid = ( |
| torch.arange(T_max, device=device).unsqueeze(0) < gen_counts.unsqueeze(1) |
| if T_max > 0 else torch.zeros((B, 0), dtype=torch.bool, device=device) |
| ) |
| out: dict[int, torch.Tensor] = {} |
| for l, h in capture_hidden.items(): |
| S, D = h.shape[1], h.shape[2] |
| if T_max == 0: |
| out[l] = h.new_zeros((B, 0, D)) |
| continue |
| idx = torch.arange(T_max, device=h.device).unsqueeze(0).expand(B, -1) |
| gen_starts = (S - gen_counts).unsqueeze(1) |
| pos = (gen_starts + idx).clamp(max=S - 1) |
| pos_exp = pos.unsqueeze(-1).expand(-1, -1, D) |
| h_gen = torch.gather(h, dim=1, index=pos_exp) |
| out[l] = h_gen.masked_fill(~valid.unsqueeze(-1), 0.0) |
| return out, valid |
|
|
|
|
| def build_scope_mask( |
| scope: str, |
| prompt_mask: torch.Tensor, |
| gen_mask: torch.Tensor, |
| vision_mask: torch.Tensor, |
| ) -> torch.Tensor: |
| """Select which token positions the probe reads, per ``probe_token_scope``. |
| |
| Scopes: |
| gen — generated tokens only (default; surgical, output-aligned) |
| vision — image-patch tokens only |
| prompt — prompt TEXT tokens only (prompt region minus vision patches) |
| prompt_gen — prompt text + generated tokens (excludes vision patches) |
| all — vision patches + prompt text + generated tokens |
| """ |
| prompt_text = prompt_mask & ~vision_mask |
| if scope == "gen": |
| return gen_mask |
| if scope == "vision": |
| return vision_mask |
| if scope == "prompt": |
| return prompt_text |
| if scope == "prompt_gen": |
| return prompt_text | gen_mask |
| if scope == "all": |
| return prompt_mask | gen_mask |
| raise ValueError(f"Unknown probe_token_scope: {scope!r}") |
|
|
|
|
| def gather_masked_positions( |
| capture_hidden: dict[int, torch.Tensor], |
| mask: torch.Tensor, |
| ) -> tuple[dict[int, torch.Tensor], torch.Tensor]: |
| """Pack the True positions of an ARBITRARY ``mask`` into (B, T_max, D), in order. |
| |
| Generalizes ``gather_gen_positions`` (which assumes a tail-flush contiguous |
| region) to any selection — e.g. vision-patch tokens, which form a contiguous |
| block in the MIDDLE of the sequence, or a union of disjoint spans. Sequence |
| order of the selected tokens is preserved. |
| |
| Args: |
| capture_hidden: l -> (B, S, D) per-layer hidden states. |
| mask: (B, S) bool, True at positions to keep. |
| |
| Returns: |
| feats_seq: l -> (B, T_max, D) packed selected hidden states (left-aligned, |
| zero-padded tail). |
| valid_mask: (B, T_max) bool, True at real selected positions. Rows whose |
| ``mask`` is all-False yield an all-False row — filter before feeding an |
| attention module. |
| """ |
| counts = mask.sum(dim=1) |
| B = counts.shape[0] |
| T_max = int(counts.max().item()) if B > 0 else 0 |
| any_h = next(iter(capture_hidden.values())) |
| S = any_h.shape[1] |
| device = any_h.device |
| |
| pos = torch.arange(S, device=device).unsqueeze(0).expand(B, -1) |
| pos_masked = pos.masked_fill(~mask, S) |
| sorted_pos, _ = pos_masked.sort(dim=1) |
| valid = ( |
| torch.arange(T_max, device=device).unsqueeze(0) < counts.unsqueeze(1) |
| if T_max > 0 else torch.zeros((B, 0), dtype=torch.bool, device=device) |
| ) |
| out: dict[int, torch.Tensor] = {} |
| for l, h in capture_hidden.items(): |
| D = h.shape[2] |
| if T_max == 0: |
| out[l] = h.new_zeros((B, 0, D)) |
| continue |
| idx = sorted_pos[:, :T_max].clamp(max=S - 1) |
| h_sel = torch.gather(h, dim=1, index=idx.unsqueeze(-1).expand(-1, -1, D)) |
| out[l] = h_sel.masked_fill(~valid.unsqueeze(-1), 0.0) |
| return out, valid |
|
|
|
|
| def masked_max_pool(h: torch.Tensor, mask: torch.Tensor) -> torch.Tensor: |
| """Per-row max-pool over True positions of ``mask``. |
| |
| Args: |
| h: (B, S, D) hidden states. |
| mask: (B, S) bool, True for positions to include in the pool. |
| |
| Returns: |
| (B, D) max-pooled features. Rows where ``mask`` is all False return |
| -inf (caller is responsible for ensuring at least one True per row, |
| or for masking out empty rows downstream). |
| """ |
| assert h.dim() == 3 and mask.dim() == 2 |
| assert h.shape[:2] == mask.shape |
| |
| masked = h.masked_fill(~mask.unsqueeze(-1), float("-inf")) |
| return masked.max(dim=1).values |
|
|
|
|
| def masked_mean_pool(h: torch.Tensor, mask: torch.Tensor) -> torch.Tensor: |
| """Per-row mean-pool over True positions of ``mask``. |
| |
| Non-redistributable alternative to ``masked_max_pool``: every active |
| position contributes equally, so a downstream suppression objective |
| cannot be satisfied by lowering one peak and raising others. |
| |
| Args: |
| h: (B, S, D) hidden states. |
| mask: (B, S) bool, True for positions to include in the pool. |
| |
| Returns: |
| (B, D) mean-pooled features. Rows where ``mask`` is all False return |
| zeros (caller is responsible for masking those rows out downstream). |
| """ |
| assert h.dim() == 3 and mask.dim() == 2 |
| assert h.shape[:2] == mask.shape |
| m = mask.unsqueeze(-1).to(h.dtype) |
| summed = (h * m).sum(dim=1) |
| count = m.sum(dim=1).clamp(min=1.0) |
| return summed / count |
|
|