Spaces:
Sleeping
Add gradient-based attribution (saliency + GradCAM) for SmolVLA
Browse filesAttention 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 +104 -19
- configs/defaults.yaml +5 -0
- smolvla_inspect/cli.py +56 -53
- smolvla_inspect/gradient.py +314 -0
- smolvla_inspect/viz.py +94 -49
|
@@ -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
|
| 13 |
|
| 14 |
1. **Attention visualization** (default) -- extracts and visualizes attention heatmaps showing where the model looks
|
| 15 |
-
2. **
|
|
|
|
| 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 |

|
| 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
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 258 |
├── assets/
|
| 259 |
-
│ ├── architecture.md
|
| 260 |
│ ├── how_it_works_architecture.png
|
| 261 |
│ ├── example_grid.png
|
| 262 |
│ └── example_per_head.png
|
| 263 |
├── configs/
|
| 264 |
-
│ └── defaults.yaml
|
| 265 |
├── docs/
|
| 266 |
-
│ ├── ELI5.md
|
| 267 |
-
│ └── TESTING.md
|
| 268 |
-
├── outputs/
|
| 269 |
│ ├── positional_baseline.png
|
| 270 |
│ ├── model_health_report.md
|
| 271 |
│ └── model_health_report.png
|
| 272 |
-
├── run.sh
|
| 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 |
-
- [
|
| 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 |

|
| 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
|
|
@@ -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
|
|
@@ -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 |
-
|
| 393 |
-
|
| 394 |
-
|
| 395 |
-
|
| 396 |
-
Interpretation: Bright pixels = changing this pixel would change
|
| 397 |
-
the predicted action the most.
|
| 398 |
"""
|
| 399 |
sample = dataset[frame_idx]
|
| 400 |
-
|
| 401 |
-
|
| 402 |
-
|
| 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 |
)
|
|
@@ -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
|
|
@@ -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 |
-
|
| 85 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
if i == 0:
|
| 108 |
-
|
|
|
|
| 109 |
|
| 110 |
# Row 2: Self-attention heatmap
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
if i == 0:
|
| 115 |
-
|
|
|
|
| 116 |
|
| 117 |
# Row 3: Self-attention overlay
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
if i == 0:
|
| 122 |
-
|
|
|
|
| 123 |
|
| 124 |
if has_cross:
|
| 125 |
cross_hm = _resize_heatmap(cross_attn_heatmaps[i], h, w)
|
| 126 |
|
| 127 |
-
#
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
if i == 0:
|
| 132 |
-
|
|
|
|
| 133 |
|
| 134 |
-
#
|
| 135 |
-
co_attn = heatmap_resized * cross_hm
|
| 136 |
-
co_attn = co_attn / (co_attn.max() + 1e-8)
|
| 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 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
if i == 0:
|
| 146 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 147 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 148 |
if has_cross:
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 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")
|