subirmansukhani commited on
Commit
5c3c0ba
·
1 Parent(s): 040a0e4

Add gradient-based attribution (saliency + GradCAM) for SmolVLA

Browse files

Attention maps show where the model looks, but not which image regions
causally influence the predicted action. This adds two gradient methods
behind a new --gradient flag:

- Vanilla saliency (|dA/dpx|) at full pixel resolution
- GradCAM on SigLIP's last encoder layer at patch resolution (32x32)

Key implementation detail: select_action() is decorated with
@torch .no_grad(), blocking all gradient flow. The new gradient module
bypasses this by calling prepare_images/prepare_state/sample_actions
directly under torch.enable_grad(), with a monkey-patch to cast the
Long attention mask to bool (required by torch.where under autograd).

New files: smolvla_inspect/gradient.py
Modified: cli.py (--gradient flag, --gradient-device, --gradient-seed),
viz.py (dynamic row count up to 7, inferno/magma colormaps),
defaults.yaml, README.md (grid row reference table, interpretation guide)

README.md CHANGED
@@ -9,10 +9,11 @@ See what SmolVLA's vision encoder and action expert are looking at when the mode
9
 
10
  ## What this does
11
 
12
- SmolVLA is a **vision-language-action** policy: it takes camera images and a language instruction, then outputs robot actions. This tool has two modes:
13
 
14
  1. **Attention visualization** (default) -- extracts and visualizes attention heatmaps showing where the model looks
15
- 2. **Model health diagnostics** (`--model-health`) -- runs spectral analysis, attention entropy, and head redundancy checks across all model components
 
16
 
17
  ### Attention visualization
18
 
@@ -26,6 +27,18 @@ That lets you check whether the model attends to task-relevant regions (gripper,
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
 
31
  Runs three diagnostic checks across all model components (SigLIP vision encoder, VLM text model, action expert, connector, and projection heads):
@@ -66,17 +79,19 @@ For a detailed visual walkthrough of the architecture and how it maps to the rep
66
  ![Positional baseline](assets/example_positional_baseline.png)
67
  *Positional baseline diagnostic: attention pattern from a content-free gray image, showing position-dependent artifacts that get subtracted from real frames.*
68
 
69
- 6. **Visualize** -- the output grid has up to 5 rows per frame:
70
 
71
- | Row | Content | Colormap |
72
- |-----|---------|----------|
73
- | 1 | Original frame | -- |
74
- | 2 | SigLIP self-attention heatmap | jet (blue-to-red) |
75
- | 3 | Self-attention overlay on frame | jet |
76
- | 4 | Action cross-attention heatmap | Greens |
77
- | 5 | Co-attention overlay (self x cross) | cyan (black-cyan-white) |
 
 
78
 
79
- Rows 4-5 only appear when cross-attention is enabled. The co-attention overlay multiplies self-attention and cross-attention element-wise, highlighting regions that are **both** visually salient and action-relevant.
80
 
81
  ### Per-head grid
82
 
@@ -133,6 +148,9 @@ python inspect_attention.py
133
  # More frames, specific episode
134
  ./run.sh --episode 3 --num-frames 12
135
 
 
 
 
136
  # Last-layer method instead of rollout
137
  ./run.sh --method last-layer
138
 
@@ -151,6 +169,15 @@ python inspect_attention.py
151
  # No threshold (show all baseline-subtracted values)
152
  ./run.sh --attn-threshold 0
153
 
 
 
 
 
 
 
 
 
 
154
  # Model health diagnostics (spectral analysis + entropy + redundancy)
155
  ./run.sh --model-health
156
 
@@ -177,6 +204,7 @@ Results land in `outputs/`.
177
  | `--episode` | `0` | Episode index to visualize |
178
  | `--num-frames` | `8` | Number of frames to sample |
179
  | `--image-key` | auto-detected | Dataset image key override |
 
180
  | `--output-dir` | `./outputs` | Output directory |
181
  | `--device` | `auto` | `auto`, `cpu`, `cuda`, or `mps` |
182
  | `--save-individual` | `true` | Save each frame as a separate PNG |
@@ -186,6 +214,14 @@ Results land in `outputs/`.
186
  | `--raw-attention` | `false` | Skip positional baseline subtraction |
187
  | `--attn-threshold` | `0.5` | Percentile (0-1) below which attention values are zeroed to suppress positional noise |
188
 
 
 
 
 
 
 
 
 
189
  **Model health diagnostics:**
190
 
191
  | Flag | Default | Description |
@@ -226,6 +262,45 @@ Defaults can be changed in `configs/defaults.yaml`.
226
 
227
  The cyan overlay highlights regions where **both** the vision encoder and the action expert agree something is important. Bright cyan = high self-attention AND high cross-attention. This is the strongest signal for task-relevant regions.
228
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
229
  ### Per-head patterns
230
 
231
  Look for heads that specialize: one head tracking the gripper, another tracking the object, another attending globally. Specialization is a sign of a well-trained encoder. Heads that all look identical suggest the model hasn't learned diverse attention strategies.
@@ -254,22 +329,32 @@ See **[Architecture Diagrams](assets/architecture.md)** for visual explanations
254
 
255
  ```
256
  smolvla-inspect/
257
- ├── inspect_attention.py # All logic: model loading, hooks, heatmaps, health diagnostics
 
 
 
 
 
 
 
 
 
 
258
  ├── assets/
259
- │ ├── architecture.md # Architecture diagrams and report reference
260
  │ ├── how_it_works_architecture.png
261
  │ ├── example_grid.png
262
  │ └── example_per_head.png
263
  ├── configs/
264
- │ └── defaults.yaml # Default CLI values (model, dataset, method, flags)
265
  ├── docs/
266
- │ ├── ELI5.md # Plain-language explanation of the interpretability approach
267
- │ └── TESTING.md # CLI test commands and expected output
268
- ├── outputs/ # Generated images and reports (gitignored)
269
  │ ├── positional_baseline.png
270
  │ ├── model_health_report.md
271
  │ └── model_health_report.png
272
- ├── run.sh # Wrapper that sets FFmpeg lib path
273
  ├── requirements.txt
274
  └── README.md
275
  ```
@@ -280,7 +365,7 @@ smolvla-inspect/
280
 
281
  Attention maps show where the model allocates compute, but not whether those regions actually drive the output. The following interpretability methods would complement the current tooling:
282
 
283
- - [ ] **Gradient-based attribution** -- compute `d(action) / d(patch_embedding)` via vanilla saliency, GradCAM, or Integrated Gradients to measure which image patches *causally influence* the predicted action (not just where attention points)
284
  - [ ] **Occlusion / perturbation sensitivity** -- mask out image regions or zero out specific prefix tokens (vision, language, state) and measure action MSE change; model-agnostic and directly answers "if I cover the gripper, does the model break?"
285
  - [ ] **Representation probing** -- train small linear classifiers on intermediate layer representations to test what information is encoded at each stage (e.g., can layer N predict object position? does the Expert encode gripper state?)
286
  - [ ] **Causal tracing / activation patching** -- replace activations at specific (layer, token) positions with corrupted versions and measure output change; builds a causal map of information flow through the model
 
9
 
10
  ## What this does
11
 
12
+ SmolVLA is a **vision-language-action** policy: it takes camera images and a language instruction, then outputs robot actions. This tool has three modes:
13
 
14
  1. **Attention visualization** (default) -- extracts and visualizes attention heatmaps showing where the model looks
15
+ 2. **Gradient-based attribution** (`--gradient`) -- computes saliency maps and GradCAM to show which pixels *causally influence* the predicted action
16
+ 3. **Model health diagnostics** (`--model-health`) -- runs spectral analysis, attention entropy, and head redundancy checks across all model components
17
 
18
  ### Attention visualization
19
 
 
27
  **Input:** A pretrained or fine-tuned SmolVLA policy + a LeRobot dataset (e.g. episodes of pick-and-place).
28
  **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.
29
 
30
+ ### Gradient-based attribution
31
+
32
+ Attention maps show where the model *allocates compute*, but not whether those regions actually *drive the output*. Gradient attribution answers that question by backpropagating from the predicted action to the input pixels:
33
+
34
+ 1. **Saliency** (`--gradient saliency`) -- computes `|d(action) / d(pixel)|` at full pixel resolution. Highlights the raw input regions whose changes most affect the predicted action.
35
+ 2. **GradCAM** (`--gradient gradcam`) -- hooks the last SigLIP encoder layer, weights activations by their gradient, and produces a patch-resolution heatmap. Shows which high-level visual features drive the action.
36
+ 3. **Both** (`--gradient` or `--gradient both`) -- runs both methods and adds both rows to the output grid.
37
+
38
+ Since gradient computation requires `.backward()` through the full model (~176 transformer layer passes), it is slower than attention-only mode. MPS backward support is limited for some ops, so you can run attention on MPS and gradients on CPU with `--gradient-device cpu` (see [Split device execution](#split-device-execution)).
39
+
40
+ **Output:** Up to 2 additional rows in the grid PNG -- saliency overlay (inferno colormap) and GradCAM overlay (magma colormap).
41
+
42
  ### Model health diagnostics
43
 
44
  Runs three diagnostic checks across all model components (SigLIP vision encoder, VLM text model, action expert, connector, and projection heads):
 
79
  ![Positional baseline](assets/example_positional_baseline.png)
80
  *Positional baseline diagnostic: attention pattern from a content-free gray image, showing position-dependent artifacts that get subtracted from real frames.*
81
 
82
+ 6. **Visualize** -- the output grid has up to 7 rows per frame:
83
 
84
+ | Row | Content | Colormap | When shown |
85
+ |-----|---------|----------|------------|
86
+ | 1 | Original frame | -- | always |
87
+ | 2 | SigLIP self-attention heatmap | jet (blue-to-red) | always |
88
+ | 3 | Self-attention overlay on frame | jet | always |
89
+ | 4 | Action cross-attention heatmap | Greens | `--cross-attention` |
90
+ | 5 | Co-attention overlay (self x cross) | cyan (black-cyan-white) | `--cross-attention` |
91
+ | 6 | Saliency overlay (\|dA/dpx\|) | inferno | `--gradient saliency` or `both` |
92
+ | 7 | GradCAM overlay (SigLIP last layer) | magma | `--gradient gradcam` or `both` |
93
 
94
+ Rows 4-5 only appear when cross-attention is enabled. The co-attention overlay multiplies self-attention and cross-attention element-wise, highlighting regions that are **both** visually salient and action-relevant. Rows 6-7 only appear when gradient attribution is enabled.
95
 
96
  ### Per-head grid
97
 
 
148
  # More frames, specific episode
149
  ./run.sh --episode 3 --num-frames 12
150
 
151
+ # Override the language instruction (useful for multi-task datasets)
152
+ ./run.sh --task "pick up the red cube"
153
+
154
  # Last-layer method instead of rollout
155
  ./run.sh --method last-layer
156
 
 
169
  # No threshold (show all baseline-subtracted values)
170
  ./run.sh --attn-threshold 0
171
 
172
+ # Gradient-based attribution: saliency + GradCAM
173
+ ./run.sh --gradient
174
+
175
+ # Saliency only
176
+ ./run.sh --gradient saliency
177
+
178
+ # Attention on MPS, gradients on CPU (avoids MPS backward issues)
179
+ ./run.sh --device mps --gradient both --gradient-device cpu
180
+
181
  # Model health diagnostics (spectral analysis + entropy + redundancy)
182
  ./run.sh --model-health
183
 
 
204
  | `--episode` | `0` | Episode index to visualize |
205
  | `--num-frames` | `8` | Number of frames to sample |
206
  | `--image-key` | auto-detected | Dataset image key override |
207
+ | `--task` | from dataset | Override the language instruction |
208
  | `--output-dir` | `./outputs` | Output directory |
209
  | `--device` | `auto` | `auto`, `cpu`, `cuda`, or `mps` |
210
  | `--save-individual` | `true` | Save each frame as a separate PNG |
 
214
  | `--raw-attention` | `false` | Skip positional baseline subtraction |
215
  | `--attn-threshold` | `0.5` | Percentile (0-1) below which attention values are zeroed to suppress positional noise |
216
 
217
+ **Gradient-based attribution:**
218
+
219
+ | Flag | Default | Description |
220
+ |------|---------|-------------|
221
+ | `--gradient` | off | `saliency`, `gradcam`, or `both` (bare `--gradient` means `both`) |
222
+ | `--gradient-device` | same as `--device` | Device for gradient computation (`cpu`, `cuda`, `mps`) |
223
+ | `--gradient-seed` | `42` | Fixed noise seed for reproducible gradient attribution |
224
+
225
  **Model health diagnostics:**
226
 
227
  | Flag | Default | Description |
 
262
 
263
  The cyan overlay highlights regions where **both** the vision encoder and the action expert agree something is important. Bright cyan = high self-attention AND high cross-attention. This is the strongest signal for task-relevant regions.
264
 
265
+ ### Gradient attribution (saliency + GradCAM rows)
266
+
267
+ | Pattern | Interpretation |
268
+ |---------|----------------|
269
+ | Saliency highlights gripper/object edges | **Healthy** -- action predictions are driven by task-relevant pixel changes |
270
+ | GradCAM highlights same regions as self-attention | Attention and causal influence agree -- strong signal |
271
+ | Attention focused but saliency diffuse | Attention points at the right place, but the action doesn't depend on it (possible shortcut) |
272
+ | Saliency focused on unexpected region (e.g. table edge) | Model may be using a visual shortcut rather than task understanding |
273
+ | GradCAM and saliency disagree | GradCAM operates at patch level while saliency is pixel-level -- some divergence is normal, but large disagreement warrants investigation |
274
+
275
+ ### Grid row reference
276
+
277
+ | Row | Name | Question it answers | Resolution | Interpretation of hot spots |
278
+ |-----|------|---------------------|------------|----------------------------|
279
+ | SigLIP self-attn | Vision encoder attention | Which patches attend to each other inside the encoder? | 32x32 patches | Encoder's internal processing focus -- structural, not necessarily action-relevant |
280
+ | Action cross-attn | Action-to-vision cross-attention | Which vision tokens does the action decoder query? | 8x8 tokens (post pixel-shuffle) | Visual regions the action decoder pulls information from |
281
+ | Co-attention | Self x cross product | Which regions are both visually salient and action-queried? | 8x8 upsampled | Strongest attention signal for "what the model looks at to decide what to do" |
282
+ | Saliency \|dA/dpx\| | Input-gradient saliency | If I changed this pixel, would the action change? | Full pixel (480x640) | Pixels that causally influence the predicted action -- fine-grained but noisy |
283
+ | GradCAM SigLIP L-1 | Gradient-weighted activations | Which learned feature regions drive the action? | 32x32 patches | Patch regions whose features most influence the action -- coarser but more semantic |
284
+
285
+ **Attention vs gradient:**
286
+
287
+ | | Attention rows | Gradient rows |
288
+ |--|----------------|---------------|
289
+ | Measures | Correlation ("model looked here") | Causation ("changing here changes the action") |
290
+ | High attn + low gradient | Model looks but doesn't use it for action | -- |
291
+ | Low attn + high gradient | -- | Region subtly influences output without dominating attention |
292
+ | Both high | -- | Strong evidence this region genuinely drives behavior |
293
+
294
+ ### Split device execution
295
+
296
+ Gradient computation requires `.backward()` through the full model, which can fail or be slow on MPS. Use `--gradient-device cpu` to run attention on MPS (fast, forward-only) and gradients on CPU (backward-compatible):
297
+
298
+ ```bash
299
+ ./run.sh --device mps --gradient both --gradient-device cpu
300
+ ```
301
+
302
+ The model is moved to the gradient device after attention extraction finishes. Since gradients run last, there is no need to move it back.
303
+
304
  ### Per-head patterns
305
 
306
  Look for heads that specialize: one head tracking the gripper, another tracking the object, another attending globally. Specialization is a sign of a well-trained encoder. Heads that all look identical suggest the model hasn't learned diverse attention strategies.
 
329
 
330
  ```
331
  smolvla-inspect/
332
+ ├── inspect_attention.py # Thin entry point (delegates to smolvla_inspect)
333
+ ├── smolvla_inspect/ # Main package
334
+ │ ├── __init__.py
335
+ │ ├── cli.py # CLI args, orchestration, attention extraction
336
+ │ ├── capture.py # Attention hook classes (SigLIP, cross-attention)
337
+ │ ├── heatmap.py # Patch scores, rollout, positional baseline, upsampling
338
+ │ ├── gradient.py # Gradient attribution (saliency, GradCAM)
339
+ │ ├── data.py # Dataset helpers, batch building, image key mapping
340
+ │ ├── viz.py # Visualization grid, overlays, per-head grids
341
+ │ ├── health.py # Model health diagnostics (spectral, entropy, redundancy)
342
+ │ └── _compat.py # Resize/pad compatibility helpers
343
  ├── assets/
344
+ │ ├── architecture.md # Architecture diagrams and report reference
345
  │ ├── how_it_works_architecture.png
346
  │ ├── example_grid.png
347
  │ └── example_per_head.png
348
  ├── configs/
349
+ │ └── defaults.yaml # Default CLI values (model, dataset, method, flags)
350
  ├── docs/
351
+ │ ├── ELI5.md # Plain-language explanation of the interpretability approach
352
+ │ └── TESTING.md # CLI test commands and expected output
353
+ ├── outputs/ # Generated images and reports (gitignored)
354
  │ ├── positional_baseline.png
355
  │ ├── model_health_report.md
356
  │ └── model_health_report.png
357
+ ├── run.sh # Wrapper that sets FFmpeg lib path
358
  ├── requirements.txt
359
  └── README.md
360
  ```
 
365
 
366
  Attention maps show where the model allocates compute, but not whether those regions actually drive the output. The following interpretability methods would complement the current tooling:
367
 
368
+ - [x] **Gradient-based attribution** -- vanilla saliency (`|d(action)/d(pixel)|`) and GradCAM on SigLIP's last encoder layer, with split device support (`--gradient-device`) for running gradients on CPU while attention runs on MPS/CUDA
369
  - [ ] **Occlusion / perturbation sensitivity** -- mask out image regions or zero out specific prefix tokens (vision, language, state) and measure action MSE change; model-agnostic and directly answers "if I cover the gripper, does the model break?"
370
  - [ ] **Representation probing** -- train small linear classifiers on intermediate layer representations to test what information is encoded at each stage (e.g., can layer N predict object position? does the Expert encode gripper state?)
371
  - [ ] **Causal tracing / activation patching** -- replace activations at specific (layer, token) positions with corrupted versions and measure output change; builds a causal map of information flow through the model
configs/defaults.yaml CHANGED
@@ -16,6 +16,11 @@ show_heads: true # true to save a per-head attention grid
16
  raw_attention: false # true to skip positional baseline subtraction
17
  attn_threshold: 0.5 # percentile (0-1) below which attention values are zeroed
18
 
 
 
 
 
 
19
  # Model health analysis
20
  model_health: false
21
  health_frames: 5
 
16
  raw_attention: false # true to skip positional baseline subtraction
17
  attn_threshold: 0.5 # percentile (0-1) below which attention values are zeroed
18
 
19
+ # Gradient-based attribution
20
+ gradient: null # null | saliency | gradcam | both
21
+ gradient_device: cpu # null (same as device) | cpu | cuda | mps
22
+ gradient_seed: 42 # fixed noise seed for reproducible gradient attribution
23
+
24
  # Model health analysis
25
  model_health: false
26
  health_frames: 5
smolvla_inspect/cli.py CHANGED
@@ -40,6 +40,7 @@ from .viz import (
40
  overlay_heatmap,
41
  )
42
  from .health import run_model_health_report
 
43
 
44
 
45
  def extract_attention_maps(policy, dataset, episode_idx=0, num_frames=8,
@@ -389,62 +390,15 @@ def gradient_attention_map(policy, dataset, frame_idx, image_key, device="cpu",
389
  """
390
  Compute input-gradient saliency map as a fallback.
391
 
392
- This doesn't require hooking into attention — it directly computes
393
- which input pixels most affect the output actions by backpropagating
394
- through the entire model.
395
-
396
- Interpretation: Bright pixels = changing this pixel would change
397
- the predicted action the most.
398
  """
399
  sample = dataset[frame_idx]
400
- # Build batch using policy-expected image keys (camera1, camera2, ...)
401
- # so we don't get "All image features are missing" when dataset uses up/side.
402
- batch, grad_pkey = build_policy_batch_from_sample(
403
- sample, policy, device, batch_size=1, image_key_for_grad=image_key,
404
- dataset=dataset, task_override=task_override,
405
  )
406
- if grad_pkey is None:
407
- # No policy image key matched; try legacy: use raw sample keys
408
- img = sample[image_key].unsqueeze(0).to(device).float()
409
- img.requires_grad_(True)
410
- batch = {k: v.unsqueeze(0).to(device) if isinstance(v, torch.Tensor) else ([v] if isinstance(v, str) else v) for k, v in sample.items()}
411
- batch[image_key] = img
412
- if "task" not in batch:
413
- batch["task"] = ["pick and place"]
414
- grad_tensor = img
415
- else:
416
- grad_tensor = batch[grad_pkey]
417
-
418
- try:
419
- policy.train() # Need gradients
420
- action = policy.select_action(batch)
421
-
422
- # Backpropagate from action norm
423
- if isinstance(action, dict):
424
- action_tensor = list(action.values())[0]
425
- elif isinstance(action, torch.Tensor):
426
- action_tensor = action
427
- else:
428
- return None
429
-
430
- loss = action_tensor.sum()
431
- loss.backward()
432
-
433
- # Saliency = absolute gradient magnitude across channels
434
- if grad_tensor.grad is None:
435
- return None
436
- saliency = grad_tensor.grad.abs().squeeze(0)
437
- if saliency.dim() == 3:
438
- saliency = saliency.mean(dim=0) # (H, W)
439
- saliency = saliency / (saliency.max() + 1e-8)
440
-
441
- return saliency.detach().cpu().numpy()
442
-
443
- except Exception as e:
444
- print(f" Gradient saliency failed: {e}")
445
- return None
446
- finally:
447
- policy.eval()
448
 
449
 
450
  def load_defaults():
@@ -532,6 +486,19 @@ Examples:
532
  default=defaults.get("redundancy_critical", 0.9),
533
  help="Cosine similarity threshold for 'collapsed' (default: 0.9)")
534
 
 
 
 
 
 
 
 
 
 
 
 
 
 
535
  args = parser.parse_args()
536
 
537
  os.makedirs(args.output_dir, exist_ok=True)
@@ -546,11 +513,17 @@ Examples:
546
  args.device = "cpu"
547
  device = torch.device(args.device)
548
 
 
 
 
 
549
  # -----------------------------------------------------------------------
550
  print("=" * 70)
551
  print("SmolVLA Attention Visualizer")
552
  print("=" * 70)
553
  print(f" Device: {args.device}")
 
 
554
 
555
  # --- Load model ---
556
  print(f"\n[Step 1] Loading model: {args.model}")
@@ -660,6 +633,34 @@ Examples:
660
  print("\nERROR: No frames extracted. Check episode index and dataset.")
661
  sys.exit(1)
662
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
663
  # --- Generate visualizations ---
664
  print(f"\n[Step 4] Generating visualizations...")
665
 
@@ -669,6 +670,8 @@ Examples:
669
  heatmaps=heatmaps,
670
  actions=actions,
671
  cross_attn_heatmaps=cross_attn_heatmaps,
 
 
672
  episode_idx=args.episode,
673
  output_path=grid_path,
674
  )
 
40
  overlay_heatmap,
41
  )
42
  from .health import run_model_health_report
43
+ from .gradient import compute_gradient_maps, compute_saliency_map
44
 
45
 
46
  def extract_attention_maps(policy, dataset, episode_idx=0, num_frames=8,
 
390
  """
391
  Compute input-gradient saliency map as a fallback.
392
 
393
+ Delegates to :func:`gradient.compute_saliency_map` which bypasses the
394
+ ``@torch.no_grad()`` on ``select_action()`` by calling internal model
395
+ methods directly under ``torch.enable_grad()``.
 
 
 
396
  """
397
  sample = dataset[frame_idx]
398
+ return compute_saliency_map(
399
+ policy, sample, dataset, image_key, device,
400
+ task_override=task_override,
 
 
401
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
402
 
403
 
404
  def load_defaults():
 
486
  default=defaults.get("redundancy_critical", 0.9),
487
  help="Cosine similarity threshold for 'collapsed' (default: 0.9)")
488
 
489
+ # Gradient-based attribution
490
+ parser.add_argument("--gradient", nargs="?", const="both",
491
+ default=defaults.get("gradient", None),
492
+ choices=["saliency", "gradcam", "both"],
493
+ help="Gradient attribution method (default: off; bare --gradient means 'both')")
494
+ parser.add_argument("--gradient-device", type=str,
495
+ default=defaults.get("gradient_device", None),
496
+ choices=["cpu", "cuda", "mps"],
497
+ help="Device for gradient attribution (default: same as --device)")
498
+ parser.add_argument("--gradient-seed", type=int,
499
+ default=defaults.get("gradient_seed", 42),
500
+ help="Fixed noise seed for reproducible gradient attribution (default: 42)")
501
+
502
  args = parser.parse_args()
503
 
504
  os.makedirs(args.output_dir, exist_ok=True)
 
513
  args.device = "cpu"
514
  device = torch.device(args.device)
515
 
516
+ # Resolve gradient device (defaults to main device)
517
+ grad_device_str = args.gradient_device or args.device
518
+ grad_device = torch.device(grad_device_str)
519
+
520
  # -----------------------------------------------------------------------
521
  print("=" * 70)
522
  print("SmolVLA Attention Visualizer")
523
  print("=" * 70)
524
  print(f" Device: {args.device}")
525
+ if args.gradient and grad_device_str != args.device:
526
+ print(f" Gradient device: {grad_device_str}")
527
 
528
  # --- Load model ---
529
  print(f"\n[Step 1] Loading model: {args.model}")
 
633
  print("\nERROR: No frames extracted. Check episode index and dataset.")
634
  sys.exit(1)
635
 
636
+ # --- Gradient-based attribution (after attention hooks are cleaned up) ---
637
+ saliency_maps = None
638
+ gradcam_maps = None
639
+ if args.gradient:
640
+ image_key_for_grad = args.image_key or find_image_keys(dataset)[0]
641
+
642
+ # Move model to gradient device if different from main device
643
+ if grad_device != device:
644
+ print(f"\n Moving model from {device} to {grad_device} for gradient computation...")
645
+ policy.to(grad_device)
646
+
647
+ print(f"\n[Step 3b] Computing gradient attribution (method={args.gradient}, device={grad_device_str})...")
648
+ saliency_maps, gradcam_maps = compute_gradient_maps(
649
+ policy=policy,
650
+ dataset=dataset,
651
+ episode_idx=args.episode,
652
+ num_frames=args.num_frames,
653
+ image_key=image_key_for_grad,
654
+ device=grad_device_str,
655
+ method=args.gradient,
656
+ noise_seed=args.gradient_seed,
657
+ task_override=args.task,
658
+ )
659
+ if saliency_maps:
660
+ print(f" Saliency maps: {len(saliency_maps)} frames")
661
+ if gradcam_maps:
662
+ print(f" GradCAM maps: {len(gradcam_maps)} frames")
663
+
664
  # --- Generate visualizations ---
665
  print(f"\n[Step 4] Generating visualizations...")
666
 
 
670
  heatmaps=heatmaps,
671
  actions=actions,
672
  cross_attn_heatmaps=cross_attn_heatmaps,
673
+ saliency_maps=saliency_maps,
674
+ gradcam_maps=gradcam_maps,
675
  episode_idx=args.episode,
676
  output_path=grid_path,
677
  )
smolvla_inspect/gradient.py ADDED
@@ -0,0 +1,314 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Gradient-based attribution — saliency maps and GradCAM for SmolVLA.
3
+
4
+ Bypasses ``select_action()``'s ``@torch.no_grad()`` decorator by calling
5
+ internal methods (``prepare_images``, ``prepare_state``, ``sample_actions``)
6
+ directly under ``torch.enable_grad()``.
7
+ """
8
+
9
+ import contextlib
10
+ import warnings
11
+
12
+ import numpy as np
13
+ import torch
14
+
15
+ from .data import build_policy_batch_from_sample, get_episode_frames, _resolve_task_string
16
+
17
+
18
+ # ---------------------------------------------------------------------------
19
+ # Monkey-patch: cast Long attention masks to bool for torch.where under grad
20
+ # ---------------------------------------------------------------------------
21
+
22
+ @contextlib.contextmanager
23
+ def _patch_eager_attention_bool_mask(policy):
24
+ """
25
+ ``eager_attention_forward`` uses ``torch.where(mask, ...)`` where
26
+ *mask* is Long. Under ``torch.no_grad()`` PyTorch silently casts it,
27
+ but with gradients enabled the dtype mismatch raises. This context
28
+ manager monkey-patches the method to add ``.bool()`` on the mask.
29
+ """
30
+ vlm_expert = getattr(policy.model, "vlm_with_expert", None)
31
+ if vlm_expert is None:
32
+ yield
33
+ return
34
+
35
+ orig_fn = vlm_expert.eager_attention_forward
36
+
37
+ def _patched(attention_mask, *args, **kwargs):
38
+ if attention_mask.dtype != torch.bool:
39
+ attention_mask = attention_mask.bool()
40
+ return orig_fn(attention_mask, *args, **kwargs)
41
+
42
+ vlm_expert.eager_attention_forward = _patched
43
+ try:
44
+ yield
45
+ finally:
46
+ vlm_expert.eager_attention_forward = orig_fn
47
+
48
+
49
+ # ---------------------------------------------------------------------------
50
+ # Internal: forward pass with gradient graph retained
51
+ # ---------------------------------------------------------------------------
52
+
53
+ def _run_forward_with_grad(policy, batch, device, noise_seed=42):
54
+ """
55
+ Replicate ``SmolVLAPolicy._get_action_chunk()`` without the
56
+ ``@torch.no_grad()`` wrapper so that gradients flow back to inputs.
57
+
58
+ Returns:
59
+ action_scalar: scalar tensor suitable for ``.backward()``
60
+ (the sum of the first predicted action step)
61
+ """
62
+ with torch.enable_grad(), _patch_eager_attention_bool_mask(policy):
63
+ images, img_masks = policy.prepare_images(batch)
64
+ state = policy.prepare_state(batch)
65
+
66
+ lang_tokens = batch["observation.language.tokens"]
67
+ lang_masks = batch["observation.language.attention_mask"]
68
+
69
+ # Fixed noise for reproducibility across frames
70
+ bsize = state.shape[0]
71
+ actions_shape = (
72
+ bsize,
73
+ policy.model.config.chunk_size,
74
+ policy.model.config.max_action_dim,
75
+ )
76
+ gen = torch.Generator(device=device)
77
+ gen.manual_seed(noise_seed)
78
+ noise = torch.randn(actions_shape, device=device, generator=gen, dtype=torch.float32)
79
+
80
+ actions = policy.model.sample_actions(
81
+ images, img_masks, lang_tokens, lang_masks, state, noise=noise,
82
+ )
83
+
84
+ # Backprop target: sum of the immediate next action (step 0)
85
+ action_scalar = actions[:, 0, :].sum()
86
+
87
+ return action_scalar
88
+
89
+
90
+ # ---------------------------------------------------------------------------
91
+ # Saliency map (vanilla input-gradient)
92
+ # ---------------------------------------------------------------------------
93
+
94
+ def compute_saliency_map(policy, sample, dataset, image_key, device,
95
+ noise_seed=42, task_override=None):
96
+ """
97
+ Compute ``|d(action)/d(pixel)|`` at pixel resolution.
98
+
99
+ Returns:
100
+ numpy array ``(H, W)`` in ``[0, 1]``, or *None* on failure.
101
+ """
102
+ batch, grad_pkey = build_policy_batch_from_sample(
103
+ sample, policy, device, batch_size=1,
104
+ image_key_for_grad=image_key, dataset=dataset,
105
+ task_override=task_override,
106
+ )
107
+ if grad_pkey is None:
108
+ print(" WARNING: Could not identify gradient image tensor")
109
+ return None
110
+
111
+ grad_tensor = batch[grad_pkey]
112
+
113
+ try:
114
+ policy.reset()
115
+ action_scalar = _run_forward_with_grad(policy, batch, device, noise_seed)
116
+ action_scalar.backward()
117
+
118
+ if grad_tensor.grad is None:
119
+ print(" WARNING: grad is None — gradient did not flow to input pixels")
120
+ return None
121
+
122
+ saliency = grad_tensor.grad.abs().squeeze(0) # (C, H, W)
123
+ if saliency.dim() == 3:
124
+ saliency = saliency.mean(dim=0) # (H, W)
125
+ saliency = saliency / (saliency.max() + 1e-8)
126
+ return saliency.detach().cpu().numpy()
127
+
128
+ except torch.cuda.OutOfMemoryError:
129
+ torch.cuda.empty_cache()
130
+ print(" WARNING: CUDA OOM during saliency — skipping this frame")
131
+ return None
132
+ except RuntimeError as e:
133
+ if "MPS" in str(e) or "mps" in str(e):
134
+ print(f" WARNING: MPS backward error: {e}")
135
+ print(" Consider using --gradient-device cpu")
136
+ else:
137
+ print(f" WARNING: Saliency computation failed: {e}")
138
+ return None
139
+
140
+
141
+ # ---------------------------------------------------------------------------
142
+ # GradCAM on SigLIP last encoder layer
143
+ # ---------------------------------------------------------------------------
144
+
145
+ def compute_gradcam_map(policy, sample, dataset, image_key, device,
146
+ noise_seed=42, task_override=None):
147
+ """
148
+ Gradient-weighted class activation map at patch resolution (32x32).
149
+
150
+ Hooks the last SigLIP encoder layer to capture activations and their
151
+ gradients, then computes GradCAM weights.
152
+
153
+ Returns:
154
+ numpy array ``(grid_h, grid_w)`` in ``[0, 1]``, or *None* on failure.
155
+ """
156
+ from .data import find_vision_encoder
157
+
158
+ vision_encoder = find_vision_encoder(policy)
159
+ if vision_encoder is None:
160
+ print(" WARNING: Could not find vision encoder for GradCAM")
161
+ return None
162
+
163
+ # Hook last encoder layer
164
+ try:
165
+ last_layer = vision_encoder.encoder.layers[-1]
166
+ except (AttributeError, IndexError):
167
+ print(" WARNING: Could not access last encoder layer for GradCAM")
168
+ return None
169
+
170
+ activations = {}
171
+ gradients = {}
172
+
173
+ def fwd_hook(module, input, output):
174
+ # output is typically a tuple; first element is hidden states
175
+ out = output[0] if isinstance(output, tuple) else output
176
+ activations["value"] = out
177
+
178
+ def bwd_hook(module, grad_input, grad_output):
179
+ gradients["value"] = grad_output[0]
180
+
181
+ fwd_handle = last_layer.register_forward_hook(fwd_hook)
182
+ bwd_handle = last_layer.register_full_backward_hook(bwd_hook)
183
+
184
+ try:
185
+ batch, _ = build_policy_batch_from_sample(
186
+ sample, policy, device, batch_size=1,
187
+ image_key_for_grad=image_key, dataset=dataset,
188
+ task_override=task_override,
189
+ )
190
+
191
+ policy.reset()
192
+ action_scalar = _run_forward_with_grad(policy, batch, device, noise_seed)
193
+ action_scalar.backward()
194
+
195
+ if "value" not in activations or "value" not in gradients:
196
+ print(" WARNING: GradCAM hooks did not fire")
197
+ return None
198
+
199
+ A = activations["value"] # (B, n_patches, hidden_dim)
200
+ dA = gradients["value"] # same shape
201
+
202
+ # GAP over patches → per-channel weight
203
+ alpha = dA.mean(dim=1, keepdim=True) # (B, 1, hidden_dim)
204
+
205
+ # Weighted combination + ReLU
206
+ cam = (alpha * A).sum(dim=-1) # (B, n_patches)
207
+ cam = torch.relu(cam)
208
+ cam = cam.squeeze(0) # (n_patches,)
209
+
210
+ # Reshape to spatial grid
211
+ n_patches = cam.shape[0]
212
+ grid_side = int(n_patches ** 0.5)
213
+ if grid_side * grid_side != n_patches:
214
+ # Non-square — try to infer from vision encoder config
215
+ img_size = getattr(getattr(vision_encoder, "config", None), "image_size", None) or 512
216
+ patch_size = getattr(getattr(vision_encoder, "config", None), "patch_size", None) or 16
217
+ grid_h = img_size // patch_size
218
+ grid_w = grid_h
219
+ else:
220
+ grid_h = grid_w = grid_side
221
+
222
+ cam_2d = cam[:grid_h * grid_w].reshape(grid_h, grid_w)
223
+ cam_2d = cam_2d / (cam_2d.max() + 1e-8)
224
+ return cam_2d.detach().float().cpu().numpy()
225
+
226
+ except torch.cuda.OutOfMemoryError:
227
+ torch.cuda.empty_cache()
228
+ print(" WARNING: CUDA OOM during GradCAM — skipping this frame")
229
+ return None
230
+ except RuntimeError as e:
231
+ if "MPS" in str(e) or "mps" in str(e):
232
+ print(f" WARNING: MPS backward error: {e}")
233
+ print(" Consider using --gradient-device cpu")
234
+ else:
235
+ print(f" WARNING: GradCAM computation failed: {e}")
236
+ return None
237
+ finally:
238
+ fwd_handle.remove()
239
+ bwd_handle.remove()
240
+
241
+
242
+ # ---------------------------------------------------------------------------
243
+ # Top-level: compute gradient maps for all frames
244
+ # ---------------------------------------------------------------------------
245
+
246
+ def compute_gradient_maps(policy, dataset, episode_idx, num_frames, image_key,
247
+ device, method="both", noise_seed=42,
248
+ task_override=None):
249
+ """
250
+ Compute saliency and/or GradCAM maps for a set of episode frames.
251
+
252
+ Args:
253
+ method: ``"saliency"``, ``"gradcam"``, or ``"both"``
254
+
255
+ Returns:
256
+ ``(saliency_maps, gradcam_maps)`` — each is a list of numpy arrays
257
+ or *None* if that method was not requested / all frames failed.
258
+ """
259
+ # MPS warning
260
+ if str(device) == "mps":
261
+ print(" NOTE: MPS backward support is limited. If gradient computation "
262
+ "fails, try --gradient-device cpu")
263
+
264
+ do_saliency = method in ("saliency", "both")
265
+ do_gradcam = method in ("gradcam", "both")
266
+
267
+ frame_pairs = get_episode_frames(dataset, episode_idx, num_frames, image_key)
268
+
269
+ saliency_maps = [] if do_saliency else None
270
+ gradcam_maps = [] if do_gradcam else None
271
+
272
+ for i, (frame_idx, img_tensor) in enumerate(frame_pairs):
273
+ sample = dataset[frame_idx]
274
+
275
+ if do_saliency:
276
+ policy.zero_grad()
277
+ smap = compute_saliency_map(
278
+ policy, sample, dataset, image_key, device,
279
+ noise_seed=noise_seed, task_override=task_override,
280
+ )
281
+ if smap is not None:
282
+ saliency_maps.append(smap)
283
+ print(f" Frame {i}: Saliency computed ({smap.shape})")
284
+ else:
285
+ # Uniform fallback
286
+ h, w = img_tensor.shape[1], img_tensor.shape[2]
287
+ saliency_maps.append(np.ones((h, w)) * 0.5)
288
+ print(f" Frame {i}: Saliency failed, using uniform")
289
+
290
+ if do_gradcam:
291
+ policy.zero_grad()
292
+ gcam = compute_gradcam_map(
293
+ policy, sample, dataset, image_key, device,
294
+ noise_seed=noise_seed, task_override=task_override,
295
+ )
296
+ if gcam is not None:
297
+ gradcam_maps.append(gcam)
298
+ print(f" Frame {i}: GradCAM computed ({gcam.shape})")
299
+ else:
300
+ h, w = img_tensor.shape[1], img_tensor.shape[2]
301
+ gradcam_maps.append(np.ones((h, w)) * 0.5)
302
+ print(f" Frame {i}: GradCAM failed, using uniform")
303
+
304
+ # If all frames failed, return None instead of list of uniforms
305
+ if saliency_maps is not None and all(
306
+ (m == 0.5).all() if isinstance(m, np.ndarray) else False for m in saliency_maps
307
+ ):
308
+ saliency_maps = None
309
+ if gradcam_maps is not None and all(
310
+ (m == 0.5).all() if isinstance(m, np.ndarray) else False for m in gradcam_maps
311
+ ):
312
+ gradcam_maps = None
313
+
314
+ return saliency_maps, gradcam_maps
smolvla_inspect/viz.py CHANGED
@@ -72,22 +72,32 @@ def _frame_to_np(frame):
72
 
73
  def create_visualization_grid(frames, heatmaps, actions=None,
74
  cross_attn_heatmaps=None,
 
75
  episode_idx=0, output_path="attention_grid.png"):
76
  """
77
  Create a grid visualization showing original frames, heatmaps, and overlays.
78
 
79
- Layout per frame:
80
  Row 1: Original image
81
  Row 2: Vision encoder self-attention heatmap (colorized)
82
  Row 3: Overlay (image + self-attention heatmap blended)
83
-
84
- When *cross_attn_heatmaps* is provided two extra rows are added:
85
- Row 4: Action→Vision cross-attention heatmap
86
- Row 5: Dual-color overlay (self-attn blue, cross-attn red)
87
  """
88
  n_frames = len(frames)
89
  has_cross = cross_attn_heatmaps is not None and len(cross_attn_heatmaps) == n_frames
90
- n_rows = 5 if has_cross else 3
 
 
 
 
 
 
 
 
 
 
91
 
92
  fig = plt.figure(figsize=(4 * n_frames, 4 * n_rows))
93
  gs = gridspec.GridSpec(n_rows, n_frames, hspace=0.3, wspace=0.05)
@@ -99,71 +109,106 @@ def create_visualization_grid(frames, heatmaps, actions=None,
99
  heatmap_resized = _resize_heatmap(heatmap, h, w)
100
  overlay = overlay_heatmap(frame_np, heatmap_resized, alpha=0.45)
101
 
 
 
102
  # Row 1: Original
103
- ax1 = fig.add_subplot(gs[0, i])
104
- ax1.imshow(frame_np)
105
- ax1.set_title(f"Frame {i}", fontsize=10)
106
- ax1.axis("off")
107
  if i == 0:
108
- ax1.set_ylabel("Original", fontsize=11, rotation=0, labelpad=60, va="center")
 
109
 
110
  # Row 2: Self-attention heatmap
111
- ax2 = fig.add_subplot(gs[1, i])
112
- ax2.imshow(heatmap_resized, cmap="jet", vmin=0, vmax=1)
113
- ax2.axis("off")
114
  if i == 0:
115
- ax2.set_ylabel("SigLIP\nself-attn", fontsize=11, rotation=0, labelpad=60, va="center")
 
116
 
117
  # Row 3: Self-attention overlay
118
- ax3 = fig.add_subplot(gs[2, i])
119
- ax3.imshow(overlay)
120
- ax3.axis("off")
121
  if i == 0:
122
- ax3.set_ylabel("Self-attn\noverlay", fontsize=11, rotation=0, labelpad=60, va="center")
 
123
 
124
  if has_cross:
125
  cross_hm = _resize_heatmap(cross_attn_heatmaps[i], h, w)
126
 
127
- # Row 4: Cross-attention heatmap
128
- ax4 = fig.add_subplot(gs[3, i])
129
- ax4.imshow(cross_hm, cmap="Greens", vmin=0, vmax=1)
130
- ax4.axis("off")
131
  if i == 0:
132
- ax4.set_ylabel("Action\ncross-attn", fontsize=11, rotation=0, labelpad=60, va="center")
 
133
 
134
- # Row 5: Co-attention overlay (self-attn × cross-attn)
135
- co_attn = heatmap_resized * cross_hm # element-wise product
136
- co_attn = co_attn / (co_attn.max() + 1e-8) # renormalize to [0, 1]
137
  co_overlay = frame_np.copy()
138
  co_overlay = (0.5 * co_overlay.astype(np.float32)
139
  + 0.5 * _CYAN_CMAP(co_attn)[:, :, :3] * 255)
140
  co_overlay = np.clip(co_overlay, 0, 255).astype(np.uint8)
141
 
142
- ax5 = fig.add_subplot(gs[4, i])
143
- ax5.imshow(co_overlay)
144
- ax5.axis("off")
145
  if i == 0:
146
- ax5.set_ylabel("Co-attention\noverlay", fontsize=11, rotation=0, labelpad=60, va="center")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
147
 
 
 
 
 
 
 
 
 
 
 
 
 
 
148
  if has_cross:
149
- legend = ("Row 1: Original | Row 2: SigLIP self-attn heatmap | Row 3: Self-attn overlay | "
150
- "Row 4: Action cross-attn heatmap | Row 5: Co-attention (self × cross)")
151
- dual_legend = "Co-attention: self-attn × cross-attn — bright regions are both visually salient and action-relevant"
152
- else:
153
- legend = "Row 1: Original | Row 2: SigLIP self-attn heatmap | Row 3: Overlay (heatmap on frame)"
154
- dual_legend = None
155
-
156
- if dual_legend:
157
- title = (
158
- f"SmolVLA Attention — Episode {episode_idx}\n\n"
159
- f"{legend}\n\n"
160
- f"{dual_legend}"
161
- )
162
- else:
163
- title = (
164
- f"SmolVLA Attention Episode {episode_idx}\n\n"
165
- f"{legend}"
166
- )
167
  fig.suptitle(title, fontsize=14, fontweight="bold", y=0.98)
168
 
169
  plt.savefig(output_path, dpi=150, bbox_inches="tight", facecolor="white")
 
72
 
73
  def create_visualization_grid(frames, heatmaps, actions=None,
74
  cross_attn_heatmaps=None,
75
+ saliency_maps=None, gradcam_maps=None,
76
  episode_idx=0, output_path="attention_grid.png"):
77
  """
78
  Create a grid visualization showing original frames, heatmaps, and overlays.
79
 
80
+ Layout per frame (dynamic rows):
81
  Row 1: Original image
82
  Row 2: Vision encoder self-attention heatmap (colorized)
83
  Row 3: Overlay (image + self-attention heatmap blended)
84
+ Row 4-5 (optional): Cross-attention heatmap + co-attention overlay
85
+ +1 row (optional): Saliency overlay (|dA/dpx|)
86
+ +1 row (optional): GradCAM overlay (SigLIP last layer)
 
87
  """
88
  n_frames = len(frames)
89
  has_cross = cross_attn_heatmaps is not None and len(cross_attn_heatmaps) == n_frames
90
+ has_saliency = saliency_maps is not None and len(saliency_maps) == n_frames
91
+ has_gradcam = gradcam_maps is not None and len(gradcam_maps) == n_frames
92
+
93
+ # Dynamic row count: base 3 + 2 cross + 1 saliency + 1 gradcam = 7 max
94
+ n_rows = 3
95
+ if has_cross:
96
+ n_rows += 2
97
+ if has_saliency:
98
+ n_rows += 1
99
+ if has_gradcam:
100
+ n_rows += 1
101
 
102
  fig = plt.figure(figsize=(4 * n_frames, 4 * n_rows))
103
  gs = gridspec.GridSpec(n_rows, n_frames, hspace=0.3, wspace=0.05)
 
109
  heatmap_resized = _resize_heatmap(heatmap, h, w)
110
  overlay = overlay_heatmap(frame_np, heatmap_resized, alpha=0.45)
111
 
112
+ row = 0
113
+
114
  # Row 1: Original
115
+ ax = fig.add_subplot(gs[row, i])
116
+ ax.imshow(frame_np)
117
+ ax.set_title(f"Frame {i}", fontsize=10)
118
+ ax.axis("off")
119
  if i == 0:
120
+ ax.set_ylabel("Original", fontsize=11, rotation=0, labelpad=60, va="center")
121
+ row += 1
122
 
123
  # Row 2: Self-attention heatmap
124
+ ax = fig.add_subplot(gs[row, i])
125
+ ax.imshow(heatmap_resized, cmap="jet", vmin=0, vmax=1)
126
+ ax.axis("off")
127
  if i == 0:
128
+ ax.set_ylabel("SigLIP\nself-attn", fontsize=11, rotation=0, labelpad=60, va="center")
129
+ row += 1
130
 
131
  # Row 3: Self-attention overlay
132
+ ax = fig.add_subplot(gs[row, i])
133
+ ax.imshow(overlay)
134
+ ax.axis("off")
135
  if i == 0:
136
+ ax.set_ylabel("Self-attn\noverlay", fontsize=11, rotation=0, labelpad=60, va="center")
137
+ row += 1
138
 
139
  if has_cross:
140
  cross_hm = _resize_heatmap(cross_attn_heatmaps[i], h, w)
141
 
142
+ # Cross-attention heatmap
143
+ ax = fig.add_subplot(gs[row, i])
144
+ ax.imshow(cross_hm, cmap="Greens", vmin=0, vmax=1)
145
+ ax.axis("off")
146
  if i == 0:
147
+ ax.set_ylabel("Action\ncross-attn", fontsize=11, rotation=0, labelpad=60, va="center")
148
+ row += 1
149
 
150
+ # Co-attention overlay (self-attn × cross-attn)
151
+ co_attn = heatmap_resized * cross_hm
152
+ co_attn = co_attn / (co_attn.max() + 1e-8)
153
  co_overlay = frame_np.copy()
154
  co_overlay = (0.5 * co_overlay.astype(np.float32)
155
  + 0.5 * _CYAN_CMAP(co_attn)[:, :, :3] * 255)
156
  co_overlay = np.clip(co_overlay, 0, 255).astype(np.uint8)
157
 
158
+ ax = fig.add_subplot(gs[row, i])
159
+ ax.imshow(co_overlay)
160
+ ax.axis("off")
161
  if i == 0:
162
+ ax.set_ylabel("Co-attention\noverlay", fontsize=11, rotation=0, labelpad=60, va="center")
163
+ row += 1
164
+
165
+ if has_saliency:
166
+ sal_hm = _resize_heatmap(saliency_maps[i], h, w)
167
+ sal_overlay = overlay_heatmap(frame_np, sal_hm, alpha=0.45, colormap="inferno")
168
+
169
+ ax = fig.add_subplot(gs[row, i])
170
+ ax.imshow(sal_overlay)
171
+ ax.axis("off")
172
+ if i == 0:
173
+ ax.set_ylabel("Saliency\n|dA/dpx|", fontsize=11, rotation=0, labelpad=60, va="center")
174
+ row += 1
175
+
176
+ if has_gradcam:
177
+ gc_hm = _resize_heatmap(gradcam_maps[i], h, w)
178
+ gc_overlay = overlay_heatmap(frame_np, gc_hm, alpha=0.45, colormap="magma")
179
 
180
+ ax = fig.add_subplot(gs[row, i])
181
+ ax.imshow(gc_overlay)
182
+ ax.axis("off")
183
+ if i == 0:
184
+ ax.set_ylabel("GradCAM\nSigLIP L-1", fontsize=11, rotation=0, labelpad=60, va="center")
185
+ row += 1
186
+
187
+ # --- Build legend string dynamically ---
188
+ legend_parts = [
189
+ "Original",
190
+ "SigLIP self-attn heatmap",
191
+ "Self-attn overlay",
192
+ ]
193
  if has_cross:
194
+ legend_parts.append("Action cross-attn heatmap")
195
+ legend_parts.append("Co-attention (self \u00d7 cross)")
196
+ if has_saliency:
197
+ legend_parts.append("Saliency |dA/dpx|")
198
+ if has_gradcam:
199
+ legend_parts.append("GradCAM SigLIP L-1")
200
+
201
+ legend = " | ".join(f"Row {j+1}: {lbl}" for j, lbl in enumerate(legend_parts))
202
+
203
+ extra_lines = []
204
+ if has_cross:
205
+ extra_lines.append("Co-attention: self-attn \u00d7 cross-attn \u2014 bright regions are both visually salient and action-relevant")
206
+ if has_saliency or has_gradcam:
207
+ extra_lines.append("Gradient rows show which image regions causally influence the predicted action")
208
+
209
+ title = f"SmolVLA Attention \u2014 Episode {episode_idx}\n\n{legend}"
210
+ if extra_lines:
211
+ title += "\n\n" + "\n".join(extra_lines)
212
  fig.suptitle(title, fontsize=14, fontweight="bold", y=0.98)
213
 
214
  plt.savefig(output_path, dpi=150, bbox_inches="tight", facecolor="white")