subirmansukhani commited on
Commit
140904f
·
1 Parent(s): 707f439

Add percentile thresholding and per-head baseline subtraction for SigLIP heatmaps

Browse files

SigLIP's learned positional embeddings create position-dependent attention
hotspots on image edges that persist across episodes. The existing gray-baseline
subtraction can't fully cancel them because softmax is nonlinear, and the
per-head view had no baseline subtraction at all.

Changes:
- Add --attn-threshold flag (default 0.5) to zero attention values below the
Nth percentile after normalization, suppressing residual positional noise
- Return per-head baseline from compute_positional_baseline() and subtract it
in create_per_head_grid() before heatmap creation
- Save positional_baseline.png diagnostic showing the gray-image attention
pattern that gets subtracted
- Thread threshold through all attention_to_heatmap() call sites (self-attn,
cross-attn, per-head grid)
- Update README with new flag, examples, and pipeline description

Files changed (3) hide show
  1. README.md +11 -3
  2. configs/defaults.yaml +1 -0
  3. inspect_attention.py +81 -17
README.md CHANGED
@@ -24,7 +24,7 @@ Extracts attention maps from two places:
24
  That lets you check whether the model attends to task-relevant regions (gripper, object, goal) or background (walls, table texture) -- useful for debugging overfitting or distribution shift.
25
 
26
  **Input:** A pretrained or fine-tuned SmolVLA policy + a LeRobot dataset (e.g. episodes of pick-and-place).
27
- **Output:** A multi-row grid PNG per episode, optional per-frame PNGs, and an optional per-head attention grid.
28
 
29
  ### Model health diagnostics
30
 
@@ -61,7 +61,7 @@ For a detailed visual walkthrough of the architecture and how it maps to the rep
61
 
62
  4. **Capture cross-attention** (`--cross-attention`) -- SmolVLA's VLM builds a KV cache from the prefix (vision + language + state tokens). The action expert queries that cache. The script monkey-patches `eager_attention_forward()` on the expert layers to intercept the softmax attention when expert Q attends to prefix K. Only columns corresponding to vision tokens are kept, giving a heatmap of which image regions the action decoder reads.
63
 
64
- 5. **Turn attention into spatial heatmaps** -- patch-level importance scores are reshaped into a 2D grid, upsampled with bilinear interpolation to image size, and normalized to [0, 1].
65
 
66
  6. **Visualize** -- the output grid has up to 5 rows per frame:
67
 
@@ -77,7 +77,7 @@ Rows 4-5 only appear when cross-attention is enabled. The co-attention overlay m
77
 
78
  ### Per-head grid
79
 
80
- With `--show-heads`, a separate grid shows each of the 12 SigLIP attention heads individually for the first frame:
81
 
82
  ![Per-head attention grid](assets/example_per_head.png)
83
  *Each subplot is one attention head. Look for specialization -- e.g. one head tracking the gripper, another tracking the object.*
@@ -142,6 +142,12 @@ python inspect_attention.py
142
  # Raw attention without positional baseline subtraction
143
  ./run.sh --raw-attention
144
 
 
 
 
 
 
 
145
  # Model health diagnostics (spectral analysis + entropy + redundancy)
146
  ./run.sh --model-health
147
 
@@ -175,6 +181,7 @@ Results land in `outputs/`.
175
  | `--cross-attention` | `true` | Capture action-expert cross-attention |
176
  | `--show-heads` | `true` | Save per-head attention grid for first frame |
177
  | `--raw-attention` | `false` | Skip positional baseline subtraction |
 
178
 
179
  **Model health diagnostics:**
180
 
@@ -256,6 +263,7 @@ smolvla-inspect/
256
  │ ├── ELI5.md # Plain-language explanation of the interpretability approach
257
  │ └── TESTING.md # CLI test commands and expected output
258
  ├── outputs/ # Generated images and reports (gitignored)
 
259
  │ ├── model_health_report.md
260
  │ └── model_health_report.png
261
  ├── run.sh # Wrapper that sets FFmpeg lib path
 
24
  That lets you check whether the model attends to task-relevant regions (gripper, object, goal) or background (walls, table texture) -- useful for debugging overfitting or distribution shift.
25
 
26
  **Input:** A pretrained or fine-tuned SmolVLA policy + a LeRobot dataset (e.g. episodes of pick-and-place).
27
+ **Output:** A multi-row grid PNG per episode, optional per-frame PNGs, an optional per-head attention grid, and a positional baseline diagnostic (`positional_baseline.png`) showing the position-dependent attention pattern that gets subtracted.
28
 
29
  ### Model health diagnostics
30
 
 
61
 
62
  4. **Capture cross-attention** (`--cross-attention`) -- SmolVLA's VLM builds a KV cache from the prefix (vision + language + state tokens). The action expert queries that cache. The script monkey-patches `eager_attention_forward()` on the expert layers to intercept the softmax attention when expert Q attends to prefix K. Only columns corresponding to vision tokens are kept, giving a heatmap of which image regions the action decoder reads.
63
 
64
+ 5. **Turn attention into spatial heatmaps** -- patch-level importance scores are reshaped into a 2D grid, upsampled with bilinear interpolation to image size, and normalized to [0, 1]. A percentile threshold (`--attn-threshold`, default 0.5) then zeros out low-attention values to suppress residual positional noise from SigLIP's learned position embeddings, and re-normalizes the remainder.
65
 
66
  6. **Visualize** -- the output grid has up to 5 rows per frame:
67
 
 
77
 
78
  ### Per-head grid
79
 
80
+ With `--show-heads`, a separate grid shows each of the 12 SigLIP attention heads individually for the first frame. Each head has its per-head positional baseline subtracted (computed from a gray-image forward pass) so the patterns reflect content-dependent attention rather than position artifacts.
81
 
82
  ![Per-head attention grid](assets/example_per_head.png)
83
  *Each subplot is one attention head. Look for specialization -- e.g. one head tracking the gripper, another tracking the object.*
 
142
  # Raw attention without positional baseline subtraction
143
  ./run.sh --raw-attention
144
 
145
+ # Higher threshold to suppress more positional noise (default 0.5)
146
+ ./run.sh --attn-threshold 0.7
147
+
148
+ # No threshold (show all baseline-subtracted values)
149
+ ./run.sh --attn-threshold 0
150
+
151
  # Model health diagnostics (spectral analysis + entropy + redundancy)
152
  ./run.sh --model-health
153
 
 
181
  | `--cross-attention` | `true` | Capture action-expert cross-attention |
182
  | `--show-heads` | `true` | Save per-head attention grid for first frame |
183
  | `--raw-attention` | `false` | Skip positional baseline subtraction |
184
+ | `--attn-threshold` | `0.5` | Percentile (0-1) below which attention values are zeroed to suppress positional noise |
185
 
186
  **Model health diagnostics:**
187
 
 
263
  │ ├── ELI5.md # Plain-language explanation of the interpretability approach
264
  │ └── TESTING.md # CLI test commands and expected output
265
  ├── outputs/ # Generated images and reports (gitignored)
266
+ │ ├── positional_baseline.png
267
  │ ├── model_health_report.md
268
  │ └── model_health_report.png
269
  ├── run.sh # Wrapper that sets FFmpeg lib path
configs/defaults.yaml CHANGED
@@ -13,6 +13,7 @@ method: rollout # last-layer | rollout | all-layers
13
  cross_attention: true # true to capture action-expert → vision cross-attention (slower)
14
  show_heads: true # true to save a per-head attention grid for the first frame
15
  raw_attention: false # true to skip positional baseline subtraction
 
16
 
17
  # Model health analysis
18
  model_health: false
 
13
  cross_attention: true # true to capture action-expert → vision cross-attention (slower)
14
  show_heads: true # true to save a per-head attention grid for the first frame
15
  raw_attention: false # true to skip positional baseline subtraction
16
+ attn_threshold: 0.5 # percentile (0-1) below which attention values are zeroed
17
 
18
  # Model health analysis
19
  model_health: false
inspect_attention.py CHANGED
@@ -567,16 +567,21 @@ def compute_padding_patches(input_hw, target_size, patch_size):
567
  return (pad_h // patch_size, pad_w // patch_size)
568
 
569
 
570
- def attention_to_heatmap(attn_weights, grid_size, image_size, content_crop=None):
 
571
  """
572
  Convert attention weights from patch-space to pixel-space heatmap.
573
-
574
  Args:
575
  attn_weights: Tensor of shape (num_patches,) or (H_patches, W_patches)
576
  representing per-patch attention scores
577
  grid_size: (H_patches, W_patches) — the patch grid dimensions
578
  image_size: (H_pixels, W_pixels) — the original image dimensions
579
-
 
 
 
 
580
  Returns:
581
  heatmap: numpy array of shape (H_pixels, W_pixels) normalized to [0, 1]
582
  """
@@ -614,7 +619,15 @@ def attention_to_heatmap(attn_weights, grid_size, image_size, content_crop=None)
614
  hmin, hmax = heatmap.min(), heatmap.max()
615
  if hmax > hmin:
616
  heatmap = (heatmap - hmin) / (hmax - hmin)
617
-
 
 
 
 
 
 
 
 
618
  return heatmap
619
 
620
 
@@ -718,7 +731,9 @@ def compute_positional_baseline(vision_encoder, attn_capture, device, method,
718
  match those in real frames.
719
 
720
  Returns:
721
- Tensor of shape ``(num_patches,)`` per-patch baseline scores.
 
 
722
  """
723
  # Build a mean-gray image at the encoder's expected resolution
724
  img_size = getattr(
@@ -760,20 +775,30 @@ def compute_positional_baseline(vision_encoder, attn_capture, device, method,
760
  vision_encoder(pixel_values=gray, patch_attention_mask=patch_mask)
761
  except Exception as e:
762
  print(f" WARNING: Baseline forward pass failed ({e}), skipping correction")
763
- return None
 
 
 
 
 
 
 
 
 
 
764
 
765
  # Reduce captured attention using the same method as real frames
766
  if method == "rollout":
767
  all_layers = attn_capture.get_all_layer_attentions()
768
  rollout_mat = compute_attention_rollout(all_layers)
769
  if rollout_mat is None:
770
- return None
771
  baseline_scores = rollout_mat.mean(dim=0)
772
  else:
773
  # "last-layer" or "all-layers" both use last-layer for the summary
774
- attn = attn_capture.get_last_layer_attention()
775
- if attn is None:
776
- return None
777
  while attn.dim() > 3:
778
  attn = attn[0]
779
  if attn.dim() == 3:
@@ -781,7 +806,7 @@ def compute_positional_baseline(vision_encoder, attn_capture, device, method,
781
  baseline_scores = compute_patch_attention_scores(attn, method="mean")
782
 
783
  attn_capture.reset_maps()
784
- return baseline_scores
785
 
786
 
787
  # ---------------------------------------------------------------------------
@@ -980,7 +1005,8 @@ def save_individual_frames(frames, heatmaps, output_dir, episode_idx=0):
980
 
981
 
982
  def create_per_head_grid(frame, attn_weights, grid_size, image_size,
983
- output_path="per_head_attention.png", content_crop=None):
 
984
  """
985
  Visualise each attention head's pattern individually for a single
986
  frame. Useful for identifying specialised heads (e.g. one tracking
@@ -993,6 +1019,9 @@ def create_per_head_grid(frame, attn_weights, grid_size, image_size,
993
  grid_size: ``(H_patches, W_patches)``
994
  image_size: ``(H_pixels, W_pixels)``
995
  output_path: where to save the PNG
 
 
 
996
  """
997
  if isinstance(frame, torch.Tensor):
998
  frame_np = frame.permute(1, 2, 0).numpy()
@@ -1017,7 +1046,11 @@ def create_per_head_grid(frame, attn_weights, grid_size, image_size,
1017
  r, c = divmod(h, cols)
1018
  head_attn = attn_weights[h] # (patches, patches)
1019
  scores = head_attn.mean(dim=0) # per-patch importance
1020
- hmap = attention_to_heatmap(scores, grid_size, image_size, content_crop=content_crop)
 
 
 
 
1021
 
1022
  blended = overlay_heatmap(frame_np.copy(), hmap, alpha=0.5)
1023
  axes[r, c].imshow(blended)
@@ -1318,7 +1351,7 @@ def extract_attention_maps(policy, dataset, episode_idx=0, num_frames=8,
1318
  image_key=None, device="cpu",
1319
  method="last-layer", cross_attention=False,
1320
  show_heads=False, output_dir="./outputs",
1321
- raw_attention=False):
1322
  """
1323
  Core function: Run inference and extract attention heatmaps.
1324
 
@@ -1333,6 +1366,8 @@ def extract_attention_maps(policy, dataset, episode_idx=0, num_frames=8,
1333
  *show_heads* is True).
1334
  raw_attention: If *True* skip positional baseline subtraction
1335
  (show raw, uncorrected attention).
 
 
1336
 
1337
  Returns:
1338
  frames: list of image tensors (C, H, W)
@@ -1406,8 +1441,9 @@ def extract_attention_maps(policy, dataset, episode_idx=0, num_frames=8,
1406
  input_hw = (first_img.shape[1], first_img.shape[2])
1407
 
1408
  baseline_scores = None
 
1409
  if not raw_attention:
1410
- baseline_scores = compute_positional_baseline(
1411
  vision_encoder, attn_capture, device, method, input_hw=input_hw,
1412
  )
1413
  if baseline_scores is not None:
@@ -1423,6 +1459,26 @@ def extract_attention_maps(policy, dataset, episode_idx=0, num_frames=8,
1423
  if content_crop != (0, 0):
1424
  print(f" Padding crop: {content_crop[0]} top rows, {content_crop[1]} left cols of patches")
1425
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1426
  # --- Run inference and collect attention ---
1427
  print(f"\n[4/4] Running forward passes and extracting attention (method={method})...")
1428
  frames = []
@@ -1572,7 +1628,8 @@ def extract_attention_maps(policy, dataset, episode_idx=0, num_frames=8,
1572
 
1573
  img_h, img_w = img_tensor.shape[1], img_tensor.shape[2]
1574
  heatmap = attention_to_heatmap(patch_scores, (grid_h, grid_w), (img_h, img_w),
1575
- content_crop=content_crop)
 
1576
  heatmaps.append(heatmap)
1577
 
1578
  print(f" Frame {i}: {n_patches} patches → "
@@ -1586,6 +1643,8 @@ def extract_attention_maps(policy, dataset, episode_idx=0, num_frames=8,
1586
  (grid_h, grid_w), (img_h, img_w),
1587
  output_path=head_path,
1588
  content_crop=content_crop,
 
 
1589
  )
1590
  raw_heads_attn = None # only once
1591
  else:
@@ -1608,7 +1667,8 @@ def extract_attention_maps(policy, dataset, episode_idx=0, num_frames=8,
1608
  cs_h = cs_w = cs_side
1609
 
1610
  img_h, img_w = img_tensor.shape[1], img_tensor.shape[2]
1611
- cross_hm = attention_to_heatmap(cross_scores, (cs_h, cs_w), (img_h, img_w))
 
1612
  cross_attn_heatmaps.append(cross_hm)
1613
  print(f" Frame {i}: Cross-attention captured ({n_vis} vision tokens)")
1614
  else:
@@ -2602,6 +2662,9 @@ Examples:
2602
  parser.add_argument("--raw-attention", action="store_true",
2603
  default=defaults.get("raw_attention", False),
2604
  help="Skip positional baseline subtraction (show raw attention)")
 
 
 
2605
 
2606
  # Model health diagnostics
2607
  parser.add_argument("--model-health", action="store_true",
@@ -2726,6 +2789,7 @@ Examples:
2726
  show_heads=args.show_heads,
2727
  output_dir=args.output_dir,
2728
  raw_attention=args.raw_attention,
 
2729
  )
2730
  except Exception as e:
2731
  print(f"\n Attention extraction failed: {e}")
 
567
  return (pad_h // patch_size, pad_w // patch_size)
568
 
569
 
570
+ def attention_to_heatmap(attn_weights, grid_size, image_size, content_crop=None,
571
+ threshold_pct=0.0):
572
  """
573
  Convert attention weights from patch-space to pixel-space heatmap.
574
+
575
  Args:
576
  attn_weights: Tensor of shape (num_patches,) or (H_patches, W_patches)
577
  representing per-patch attention scores
578
  grid_size: (H_patches, W_patches) — the patch grid dimensions
579
  image_size: (H_pixels, W_pixels) — the original image dimensions
580
+ content_crop: Optional (crop_h, crop_w) to remove padding patches
581
+ threshold_pct: Percentile threshold (0.0–1.0). Values below this
582
+ percentile are zeroed, then the remainder is
583
+ re-normalised to [0, 1]. 0.0 = no thresholding.
584
+
585
  Returns:
586
  heatmap: numpy array of shape (H_pixels, W_pixels) normalized to [0, 1]
587
  """
 
619
  hmin, hmax = heatmap.min(), heatmap.max()
620
  if hmax > hmin:
621
  heatmap = (heatmap - hmin) / (hmax - hmin)
622
+
623
+ # Percentile thresholding: zero out diffuse low-attention noise
624
+ if threshold_pct > 0:
625
+ thresh_val = np.percentile(heatmap, threshold_pct * 100)
626
+ heatmap = np.where(heatmap >= thresh_val, heatmap, 0.0)
627
+ hmax = heatmap.max()
628
+ if hmax > 0:
629
+ heatmap = heatmap / hmax
630
+
631
  return heatmap
632
 
633
 
 
731
  match those in real frames.
732
 
733
  Returns:
734
+ ``(baseline_scores, per_head_baseline)`` where *baseline_scores* has
735
+ shape ``(num_patches,)`` and *per_head_baseline* has shape
736
+ ``(heads, num_patches)`` (or *None* if unavailable).
737
  """
738
  # Build a mean-gray image at the encoder's expected resolution
739
  img_size = getattr(
 
775
  vision_encoder(pixel_values=gray, patch_attention_mask=patch_mask)
776
  except Exception as e:
777
  print(f" WARNING: Baseline forward pass failed ({e}), skipping correction")
778
+ return None, None
779
+
780
+ # Capture per-head baseline from the last layer before aggregation
781
+ per_head_baseline = None
782
+ last_layer_attn = attn_capture.get_last_layer_attention()
783
+ if last_layer_attn is not None:
784
+ head_attn = last_layer_attn
785
+ while head_attn.dim() > 3:
786
+ head_attn = head_attn[0]
787
+ # head_attn: (heads, patches, patches)
788
+ per_head_baseline = head_attn.mean(dim=-2) # (heads, patches)
789
 
790
  # Reduce captured attention using the same method as real frames
791
  if method == "rollout":
792
  all_layers = attn_capture.get_all_layer_attentions()
793
  rollout_mat = compute_attention_rollout(all_layers)
794
  if rollout_mat is None:
795
+ return None, per_head_baseline
796
  baseline_scores = rollout_mat.mean(dim=0)
797
  else:
798
  # "last-layer" or "all-layers" both use last-layer for the summary
799
+ if last_layer_attn is None:
800
+ return None, None
801
+ attn = last_layer_attn
802
  while attn.dim() > 3:
803
  attn = attn[0]
804
  if attn.dim() == 3:
 
806
  baseline_scores = compute_patch_attention_scores(attn, method="mean")
807
 
808
  attn_capture.reset_maps()
809
+ return baseline_scores, per_head_baseline
810
 
811
 
812
  # ---------------------------------------------------------------------------
 
1005
 
1006
 
1007
  def create_per_head_grid(frame, attn_weights, grid_size, image_size,
1008
+ output_path="per_head_attention.png", content_crop=None,
1009
+ baseline_per_head=None, threshold_pct=0.0):
1010
  """
1011
  Visualise each attention head's pattern individually for a single
1012
  frame. Useful for identifying specialised heads (e.g. one tracking
 
1019
  grid_size: ``(H_patches, W_patches)``
1020
  image_size: ``(H_pixels, W_pixels)``
1021
  output_path: where to save the PNG
1022
+ baseline_per_head: Optional tensor ``(heads, patches)`` — per-head
1023
+ positional baseline to subtract before heatmap creation.
1024
+ threshold_pct: Percentile threshold passed to ``attention_to_heatmap``.
1025
  """
1026
  if isinstance(frame, torch.Tensor):
1027
  frame_np = frame.permute(1, 2, 0).numpy()
 
1046
  r, c = divmod(h, cols)
1047
  head_attn = attn_weights[h] # (patches, patches)
1048
  scores = head_attn.mean(dim=0) # per-patch importance
1049
+ if baseline_per_head is not None and h < baseline_per_head.shape[0]:
1050
+ scores = torch.clamp(scores - baseline_per_head[h], min=0)
1051
+ hmap = attention_to_heatmap(scores, grid_size, image_size,
1052
+ content_crop=content_crop,
1053
+ threshold_pct=threshold_pct)
1054
 
1055
  blended = overlay_heatmap(frame_np.copy(), hmap, alpha=0.5)
1056
  axes[r, c].imshow(blended)
 
1351
  image_key=None, device="cpu",
1352
  method="last-layer", cross_attention=False,
1353
  show_heads=False, output_dir="./outputs",
1354
+ raw_attention=False, attn_threshold=0.5):
1355
  """
1356
  Core function: Run inference and extract attention heatmaps.
1357
 
 
1366
  *show_heads* is True).
1367
  raw_attention: If *True* skip positional baseline subtraction
1368
  (show raw, uncorrected attention).
1369
+ attn_threshold: Percentile (0–1) below which attention values are
1370
+ zeroed to suppress residual positional noise.
1371
 
1372
  Returns:
1373
  frames: list of image tensors (C, H, W)
 
1441
  input_hw = (first_img.shape[1], first_img.shape[2])
1442
 
1443
  baseline_scores = None
1444
+ per_head_baseline = None
1445
  if not raw_attention:
1446
+ baseline_scores, per_head_baseline = compute_positional_baseline(
1447
  vision_encoder, attn_capture, device, method, input_hw=input_hw,
1448
  )
1449
  if baseline_scores is not None:
 
1459
  if content_crop != (0, 0):
1460
  print(f" Padding crop: {content_crop[0]} top rows, {content_crop[1]} left cols of patches")
1461
 
1462
+ # --- Save positional baseline diagnostic heatmap ---
1463
+ if baseline_scores is not None:
1464
+ n_bl = baseline_scores.shape[0]
1465
+ bl_side = int(math.sqrt(n_bl))
1466
+ grid_h_bl = target_size // patch_size_cfg if target_size and patch_size_cfg else bl_side
1467
+ grid_w_bl = grid_h_bl
1468
+ img_h_bl, img_w_bl = first_img.shape[1], first_img.shape[2]
1469
+ bl_heatmap = attention_to_heatmap(
1470
+ baseline_scores, (grid_h_bl, grid_w_bl), (img_h_bl, img_w_bl),
1471
+ content_crop=content_crop,
1472
+ )
1473
+ bl_path = os.path.join(output_dir, "positional_baseline.png")
1474
+ fig_bl, ax_bl = plt.subplots(figsize=(6, 6))
1475
+ ax_bl.imshow(bl_heatmap, cmap="jet")
1476
+ ax_bl.set_title("Positional baseline (gray-image attention)", fontsize=11)
1477
+ ax_bl.axis("off")
1478
+ fig_bl.savefig(bl_path, dpi=100, bbox_inches="tight")
1479
+ plt.close(fig_bl)
1480
+ print(f" Saved positional baseline diagnostic: {bl_path}")
1481
+
1482
  # --- Run inference and collect attention ---
1483
  print(f"\n[4/4] Running forward passes and extracting attention (method={method})...")
1484
  frames = []
 
1628
 
1629
  img_h, img_w = img_tensor.shape[1], img_tensor.shape[2]
1630
  heatmap = attention_to_heatmap(patch_scores, (grid_h, grid_w), (img_h, img_w),
1631
+ content_crop=content_crop,
1632
+ threshold_pct=attn_threshold)
1633
  heatmaps.append(heatmap)
1634
 
1635
  print(f" Frame {i}: {n_patches} patches → "
 
1643
  (grid_h, grid_w), (img_h, img_w),
1644
  output_path=head_path,
1645
  content_crop=content_crop,
1646
+ baseline_per_head=per_head_baseline if not raw_attention else None,
1647
+ threshold_pct=attn_threshold,
1648
  )
1649
  raw_heads_attn = None # only once
1650
  else:
 
1667
  cs_h = cs_w = cs_side
1668
 
1669
  img_h, img_w = img_tensor.shape[1], img_tensor.shape[2]
1670
+ cross_hm = attention_to_heatmap(cross_scores, (cs_h, cs_w), (img_h, img_w),
1671
+ threshold_pct=attn_threshold)
1672
  cross_attn_heatmaps.append(cross_hm)
1673
  print(f" Frame {i}: Cross-attention captured ({n_vis} vision tokens)")
1674
  else:
 
2662
  parser.add_argument("--raw-attention", action="store_true",
2663
  default=defaults.get("raw_attention", False),
2664
  help="Skip positional baseline subtraction (show raw attention)")
2665
+ parser.add_argument("--attn-threshold", type=float,
2666
+ default=defaults.get("attn_threshold", 0.5),
2667
+ help="Percentile threshold (0-1) below which attention values are zeroed")
2668
 
2669
  # Model health diagnostics
2670
  parser.add_argument("--model-health", action="store_true",
 
2789
  show_heads=args.show_heads,
2790
  output_dir=args.output_dir,
2791
  raw_attention=args.raw_attention,
2792
+ attn_threshold=args.attn_threshold,
2793
  )
2794
  except Exception as e:
2795
  print(f"\n Attention extraction failed: {e}")